fabro/lib/crates/fabro-cli/tests/it/workflow/acp.rs
fabro-sh-0530[bot] 7e6052bec3
Surface reasoning_effort + speed in stage badge end-to-end (#363)
## Summary

The run page stage badge showed only the model name. This PR plumbs
`reasoning_effort` and `speed` from the LLM call site all the way
through the event stream, store projection, API, and UI so the badge now
renders `gpt-5.5 · high`.

### Plan Summary

- **Event props** — `AgentSessionActivatedProps` and `StagePromptProps`
gain `reasoning_effort: Option<ReasoningEffort>` and `speed:
Option<Speed>` with `serde(default, skip_serializing_if)` for
back-compat.
- **Typed projection** — `provider_used: Option<serde_json::Value>` is
replaced by `Option<StageModelUsage>`, a proper struct in `fabro-types`
with factory methods (`from_prompt_props`,
`from_agent_session_activated`). The freeform JSON bag is gone.
- **Emission sites** — `ActivationLeaseOptions` carries the new fields;
`emit_stage_prompt()` (new shared helper) resolves
`EffectiveRequestControls` via the backend and stamps them on
`Event::Prompt`. `AgentHandler` and `PromptHandler` both call this
helper instead of building the event inline.
- **ACP path** — `AgentAcpStarted` no longer writes `provider_used`; the
canonical source is the later `AgentSessionActivated` event, which is
already emitted for ACP steering sessions. Runs without a hub
legitimately leave `provider_used` unset.
- **OpenAPI** — new `StageModelUsage` and `ReasoningEffort` schemas
replace the `object | null` bag; `build.rs` maps both to the canonical
Rust types; a new `stage_model_usage_round_trip` integration test
enforces the parity requirement.
- **UI** — `extractStageModel` (event-scanning heuristic) is deleted;
replaced by `formatStageModelUsageLabel` and `stageModelUsageTitle` that
read directly off `selectedStage.providerUsed`. `parseFanInOutcome` now
sources the reducer model from `stage.prompt` instead of
`prompt.completed`.

### Key design decisions

**No type sprawl**: `fabro_model::ReasoningEffort` and `Speed` are
reused verbatim via `with_replacement` in `build.rs` — no parallel
enums.

**ACP behavior change**: previously `AgentAcpStarted` wrote a bespoke
`provider_used` blob and a later `AgentSessionActivated` would be
ignored for ACP sessions. Now `AgentSessionActivated` is the single
write path for all modes; ACP runs that never activate a steering hub
correctly leave `provider_used = null`. The integration test (`acp.rs`)
is updated to assert the new shape, and the unit test is renamed
`agent_acp_started_alone_leaves_stage_provider_used_unset` to document
intent.

**`emit_stage_prompt` helper**: both `AgentHandler` and the existing
prompt path share one function to avoid the two call sites drifting
apart again.


### Fabro Details

<details>
<summary>Ran 9 stages in 98m 44s for $65.70</summary>

| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| toolchain | 2s | – | 0 |
| preflight_compile | 2m 15s | – | 0 |
| preflight_lint | 2m 30s | – | 0 |
| implement | 42m 19s | $26.85 | 0 |
| simplify_opus | 38m 3s | $35.17 | 0 |
| simplify_gpt | 9m 20s | $3.68 | 0 |
| verify | 3m 38s | – | 0 |
| fmt | 3s | – | 0 |
| **Total** | **98m 44s** | **$65.70** | **0** |

</details>

<details>
<summary>Ran <code>ImplementPlan.fabro</code> (12 nodes and 15
edges)</summary>

```dot
digraph ImplementPlan {
    graph [
        goal="Implement and simplify",
        model_stylesheet="
            * { model: claude-opus-4-7; }
        "
    ]
    rankdir=LR

    start [shape=Mdiamond, label="Start"]
    exit  [shape=Msquare, label="Exit"]

    toolchain         [label="Toolchain", shape=parallelogram, script="command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", max_retries=0]
    preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check -q --workspace 2>&1", max_retries=0]
    preflight_lint    [label="Preflight Lint", shape=parallelogram, script="cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1", max_retries=0]
    fix_lints         [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3]
    implement         [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD.", model="gpt-55", reasoning_effort="xhigh"]
    simplify_opus     [label="Simplify (Opus)", prompt="@prompts/simplify.md"]
    simplify_gpt      [label="Simplify (GPT-55)", prompt="@prompts/simplify.md", model="gpt-55"]
    verify            [label="Verify", shape=parallelogram, script="cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --cargo-quiet --workspace --status-level fail 2>&1 && cargo dev docs refresh 2>&1 && cargo dev docs check 2>&1", goal_gate=true, retry_target="fixup"]
    fixup             [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all clippy lint warnings, test failures, and generated docs errors.", max_visits=3]
    fmt               [label="Format", shape=parallelogram, script="cargo +nightly-2026-04-14 fmt --all 2>&1", max_retries=0]

    start -> toolchain
    toolchain -> preflight_compile [condition="outcome=succeeded"]
    toolchain -> exit
    preflight_compile -> preflight_lint [condition="outcome=succeeded"]
    preflight_compile -> exit
    preflight_lint -> implement [condition="outcome=succeeded"]
    preflight_lint -> fix_lints
    fix_lints -> preflight_lint
    implement -> simplify_opus -> simplify_gpt -> verify
    verify -> fmt   [condition="outcome=succeeded"]
    verify -> fixup
    fixup -> verify
    fmt -> exit
}

```

</details>

⚒️ Generated with [Fabro](https://fabro.sh)

---------

Co-authored-by: Fabro <noreply@fabro.sh>
Co-authored-by: Bryan Helmkamp <bryan@brynary.com>
Co-authored-by: Bryan Helmkamp <bhelmkamp@users.noreply.github.com>
2026-05-23 13:14:55 -04:00

325 lines
10 KiB
Rust

#![expect(
clippy::disallowed_methods,
reason = "integration test initializes an isolated git repository with the system git binary"
)]
use fabro_acp::test_support::fake_acp_agent_script;
use fabro_config::Storage;
use fabro_test::test_context;
use fabro_types::EventBody;
use fabro_vault::{SecretType, Vault};
use super::{find_run_dir, has_event, read_conclusion, run_events, run_id_for, run_state};
use crate::cmd::support::output_stdout;
#[test]
fn acp_backend_workflow() {
let mut context = test_context!();
context.write_home(
".fabro/settings.toml",
"[server.auth]\nmethods = [\"dev-token\"]\n",
);
context.isolated_server();
let fake_agent = write_fake_acp_agent(&context);
let acp_config = fake_acp_config_attr(&fake_agent);
let workflow = context.temp_dir.join("acp_backend.fabro");
context.write_temp(
"acp_backend.fabro",
format!(
r#"digraph ACP {{
graph [goal="Exercise ACP backend"]
start [shape=Mdiamond]
work [type="agent", backend="acp", prompt="write hello.txt", acp.config={acp_config}]
exit [shape=Msquare]
start -> work
work -> exit
}}"#
),
);
init_git_repo(&context.temp_dir);
context
.run_cmd()
.args(["--auto-approve", "--environment", "local"])
.arg(&workflow)
.assert()
.success();
let run_dir = find_run_dir(&context);
let conclusion = read_conclusion(&run_dir);
assert_eq!(conclusion["status"].as_str(), Some("succeeded"));
let events = run_events(&run_dir);
assert!(has_event(&run_dir, "agent.acp.started"));
assert!(has_event(&run_dir, "agent.acp.completed"));
let completed = events
.iter()
.find_map(|event| match &event.event.body {
EventBody::StageCompleted(props) if event.event.node_id.as_deref() == Some("work") => {
Some(props)
}
_ => None,
})
.expect("work stage should complete");
assert_eq!(completed.response.as_deref(), Some("hello from acp"));
assert!(
completed
.files_touched
.iter()
.any(|file| file == "hello.txt"),
"files_touched should include hello.txt: {:?}",
completed.files_touched
);
let state = serde_json::to_value(run_state(&run_dir)).expect("run state should serialize");
let stages = state["stages"]
.as_object()
.expect("run state should contain stages");
assert!(
stages.values().any(|stage| {
stage["provider_used"]["mode"] == "acp"
&& stage["provider_used"]["provider"] == "acp"
&& stage["provider_used"]["model"] == "fake"
&& stage["provider_used"].get("reasoning_effort").is_none()
&& stage["provider_used"].get("speed").is_none()
}),
"run projection should include ACP model usage metadata: {stages:?}"
);
}
#[test]
fn acp_backend_does_not_inject_registered_provider_credentials() {
let mut context = test_context!();
context.write_home(
".fabro/settings.toml",
"[server.auth]\nmethods = [\"dev-token\"]\n",
);
context.isolated_server();
seed_anthropic_vault(&context.storage_dir);
let fake_agent = write_fake_acp_agent(&context);
let env_record = context.temp_dir.join("acp-env.json");
let acp_config = fake_acp_config_attr_recording_env(&fake_agent, &env_record);
let workflow = context.temp_dir.join("acp_provider_env.fabro");
context.write_temp(
"acp_provider_env.fabro",
format!(
r#"digraph ACP {{
graph [goal="Exercise ACP backend with stored provider credentials"]
start [shape=Mdiamond]
work [type="agent", backend="acp", prompt="write hello.txt", acp.config={acp_config}]
exit [shape=Msquare]
start -> work
work -> exit
}}"#
),
);
init_git_repo(&context.temp_dir);
context
.run_cmd()
.env_remove("ANTHROPIC_API_KEY")
.env_remove("OPENAI_API_KEY")
.env_remove("GEMINI_API_KEY")
.args(["--auto-approve", "--environment", "local"])
.arg(&workflow)
.assert()
.success();
let run_dir = find_run_dir(&context);
let conclusion = read_conclusion(&run_dir);
assert_eq!(conclusion["status"].as_str(), Some("succeeded"));
let recorded_env: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(&env_record)
.expect("fake ACP agent should record its environment"),
)
.expect("fake ACP environment record should be valid JSON");
assert_eq!(recorded_env, serde_json::json!({}));
}
#[test]
fn acp_artifacts_are_listed_when_touched_file_mtime_precedes_attempt_start() {
let mut context = test_context!();
context.write_home(
".fabro/settings.toml",
"[server.auth]\nmethods = [\"dev-token\"]\n",
);
context.isolated_server();
let fake_agent = write_fake_acp_agent(&context);
let acp_config = fake_acp_config_attr_with_env(&fake_agent, vec![
serde_json::json!({
"name": "ACP_WRITE_PATH",
"value": "verification-artifacts/report.md",
}),
serde_json::json!({
"name": "ACP_WRITE_CONTENT",
"value": "verified\n",
}),
serde_json::json!({
"name": "ACP_WRITE_MTIME_EPOCH",
"value": "946684800",
}),
]);
context.write_temp(
"acp_artifact_collection.fabro",
format!(
r#"digraph ACPArtifacts {{
graph [goal="Capture verification artifacts"]
start [shape=Mdiamond]
verify [type="agent", backend="acp", prompt="write verification artifact", acp.config={acp_config}]
exit [shape=Msquare]
start -> verify
verify -> exit
}}"#
),
);
context.write_temp(
"run.toml",
r#"_version = 1
[workflow]
graph = "acp_artifact_collection.fabro"
[run]
goal = "Capture verification artifacts"
[run.checkpoint]
exclude_globs = ["verification-artifacts/**"]
[run.artifacts]
include = ["verification-artifacts/**"]
"#,
);
init_git_repo(&context.temp_dir);
context
.run_cmd()
.args(["--auto-approve", "--environment", "local"])
.arg(context.temp_dir.join("run.toml"))
.assert()
.success();
let run_dir = find_run_dir(&context);
let run_id = run_id_for(&run_dir);
let events = run_events(&run_dir);
let completed = events
.iter()
.find_map(|event| match &event.event.body {
EventBody::StageCompleted(props)
if event.event.node_id.as_deref() == Some("verify") =>
{
Some(props)
}
_ => None,
})
.expect("verify stage should complete");
assert!(
completed
.files_touched
.iter()
.any(|file| file == "verification-artifacts/report.md"),
"files_touched should include the artifact path: {:?}",
completed.files_touched
);
let output = context
.command()
.args(["--json", "artifact", "list", &run_id])
.output()
.expect("artifact list should execute");
assert!(
output.status.success(),
"artifact list failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let artifacts: Vec<serde_json::Value> =
serde_json::from_str(&output_stdout(&output)).expect("artifact list JSON should parse");
assert!(
artifacts.iter().any(|artifact| {
artifact["node_slug"] == "verify"
&& artifact["relative_path"] == "verification-artifacts/report.md"
}),
"artifact list should include the touched verification artifact: {artifacts:?}"
);
let completed_run = events
.iter()
.find_map(|event| match &event.event.body {
EventBody::RunCompleted(props) => Some(props),
_ => None,
})
.expect("run.completed should be emitted");
assert!(
completed_run.artifact_count > 0,
"run.completed should report captured artifacts"
);
}
fn write_fake_acp_agent(context: &fabro_test::TestContext) -> std::path::PathBuf {
context.write_temp("fake_acp_agent.py", fake_acp_agent_script());
context.temp_dir.join("fake_acp_agent.py")
}
fn fake_acp_config_attr(script_path: &std::path::Path) -> String {
fake_acp_config_attr_with_env(script_path, Vec::new())
}
fn fake_acp_config_attr_recording_env(
script_path: &std::path::Path,
env_record: &std::path::Path,
) -> String {
fake_acp_config_attr_with_env(script_path, vec![
serde_json::json!({"name": "ACP_ENV_RECORD", "value": env_record.to_string_lossy()}),
serde_json::json!({
"name": "ACP_ENV_RECORD_KEYS",
"value": "ANTHROPIC_API_KEY,OPENAI_API_KEY,GEMINI_API_KEY",
}),
])
}
fn fake_acp_config_attr_with_env(
script_path: &std::path::Path,
extra_env: Vec<serde_json::Value>,
) -> String {
let mut env = vec![serde_json::json!({"name": "ACP_MODE", "value": "write_file"})];
env.extend(extra_env);
let config = serde_json::json!({
"type": "stdio",
"name": "fake",
"command": "python3",
"args": [script_path.to_string_lossy()],
"env": env,
})
.to_string();
format!("{config:?}")
}
fn seed_anthropic_vault(storage_dir: &std::path::Path) {
let mut vault =
Vault::load(Storage::new(storage_dir).secrets_path()).expect("test vault should load");
vault
.set(
"ANTHROPIC_API_KEY",
"vault-anthropic-key",
SecretType::Token,
None,
)
.expect("Anthropic credential should store in test vault");
}
fn init_git_repo(dir: &std::path::Path) {
let output = std::process::Command::new("git")
.args(["init", "-q"])
.current_dir(dir)
.output()
.expect("git init should run");
assert!(
output.status.success(),
"git init failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}