Add ACP backend support (#237)

## Summary
Implemented ACP support as a first-class Fabro backend alongside `api`
and `cli`. This adds a new `fabro-acp` crate using the official ACP Rust
crates, routes `backend=\"acp\"` for agent and prompt nodes, adds
sandbox stdio support for local/Docker/test-support paths, emits ACP
workflow events/projections, updates server steerability handling,
validation, documentation, and black-box CLI coverage.

## Test Plan
Passed strict non-live verification:
- `ulimit -n 4096 && cargo nextest run -p fabro-workflow --run-ignored
all --no-fail-fast` — 1162 passed, 0 skipped.
- `ulimit -n 4096 && cargo nextest run -p fabro-acp -p fabro-sandbox -p
fabro-workflow -p fabro-validate -p fabro-store -p fabro-server -p
fabro-cli --run-ignored all --no-fail-fast -E 'not
test(daytona_streaming_live_smoke)'` — 3125 passed.
- `cargo build --workspace` — passed.
- `ulimit -n 4096 && cargo nextest run --workspace --run-ignored all
--no-fail-fast -E 'not test(daytona_streaming_live_smoke)'` — 5666
passed.
- `cargo +nightly-2026-04-14 fmt --check --all` — passed.
- `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D
warnings` — passed.

Live-environment tests skipped/excluded under explicit user override:
- `daytona_streaming_live_smoke` was excluded from final nextest runs
because it requires live Daytona infrastructure and `DAYTONA_API_KEY`.
- Confirmed with `env -u DAYTONA_API_KEY cargo test -p fabro-sandbox
--features daytona --test daytona_streaming_live
daytona_streaming_live::daytona_streaming_live_smoke -- --ignored
--exact --nocapture`: failed fast with `DAYTONA_API_KEY must be set to
run this live smoke test`.
This commit is contained in:
Bryan Helmkamp 2026-05-11 20:39:43 -07:00 • committed by GitHub
parent a19f6dd03a
commit 234bd5663e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
81 changed files with 7854 additions and 1278 deletions

View file

@ -15,6 +15,10 @@ leak-timeout = "500ms"
filter = "package(fabro-server) & test(all_spec_routes_are_routable)"
slow-timeout = { period = "15s", terminate-after = 4 }
[[profile.default.overrides]]
filter = "package(fabro-devcontainer) & test(resolve_features_integration)"
slow-timeout = { period = "10s", terminate-after = 3 }
[[profile.default.overrides]]
filter = "package(fabro-workflow)"
slow-timeout = { period = "2s", terminate-after = 3 }

View file

@ -11,7 +11,7 @@ auto_stop_interval = 30
repo = "fabro-sh/fabro"
[run.sandbox.daytona.snapshot]
name = "fabro-v8"
name = "fabro-v9"
cpu = 8
memory = "16GB"
disk = "20GB"
@ -20,6 +20,8 @@ FROM ubuntu:24.04
RUN apt-get update && apt-get install -y --no-install-recommends \
curl git ca-certificates build-essential pkg-config libssl-dev unzip python3 \
xvfb xfce4 xfce4-terminal x11vnc novnc dbus-x11 \
libx11-6 libxrandr2 libxext6 libxrender1 libxfixes3 libxss1 libxtst6 libxi6 \
&& rm -rf /var/lib/apt/lists/*
# GitHub CLI

191
Cargo.lock generated
View file

@ -58,6 +58,72 @@ dependencies = [
"subtle",
]
[[package]]
name = "agent-client-protocol"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af62fb84df2af0f933d8f5fd78b843fa5eb0ec5a48fa1b528c41951d0bbe36c"
dependencies = [
"agent-client-protocol-derive",
"agent-client-protocol-schema",
"anyhow",
"futures",
"futures-concurrency",
"jsonrpcmsg",
"rmcp",
"rustc-hash",
"schemars 1.2.1",
"serde",
"serde_json",
"thiserror 2.0.18",
"tokio",
"tokio-util",
"tracing",
"uuid",
]
[[package]]
name = "agent-client-protocol-derive"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce42c2d3c048c12897eef2e577dfff1e3355c632c9f1625cc953b9df48b44631"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "agent-client-protocol-schema"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49bae57dad1c28a362fbdcf7bab0583316a02b45a70792109fced55780a3b63c"
dependencies = [
"anyhow",
"derive_more",
"schemars 1.2.1",
"serde",
"serde_json",
"serde_with",
"strum",
"tracing",
]
[[package]]
name = "agent-client-protocol-tokio"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e1572b219f22c4b3be0f20f934c8b6f1d1457126ce72923c4f6608f96153b65"
dependencies = [
"agent-client-protocol",
"futures",
"serde",
"serde_json",
"shell-words",
"tokio",
"tokio-util",
]
[[package]]
name = "ahash"
version = "0.8.12"
@ -594,6 +660,15 @@ version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c"
[[package]]
name = "bs58"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4"
dependencies = [
"tinyvec",
]
[[package]]
name = "bstr"
version = "1.12.1"
@ -1088,16 +1163,6 @@ dependencies = [
"darling_macro 0.14.4",
]
[[package]]
name = "darling"
version = "0.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0"
dependencies = [
"darling_core 0.21.3",
"darling_macro 0.21.3",
]
[[package]]
name = "darling"
version = "0.23.0"
@ -1122,20 +1187,6 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "darling_core"
version = "0.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4"
dependencies = [
"fnv",
"ident_case",
"proc-macro2",
"quote",
"strsim 0.11.1",
"syn 2.0.117",
]
[[package]]
name = "darling_core"
version = "0.23.0"
@ -1160,17 +1211,6 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "darling_macro"
version = "0.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81"
dependencies = [
"darling_core 0.21.3",
"quote",
"syn 2.0.117",
]
[[package]]
name = "darling_macro"
version = "0.23.0"
@ -1309,6 +1349,7 @@ dependencies = [
"quote",
"rustc_version",
"syn 2.0.117",
"unicode-xid",
]
[[package]]
@ -1537,6 +1578,28 @@ dependencies = [
"libc",
]
[[package]]
name = "fabro-acp"
version = "0.230.0-nightly.0"
dependencies = [
"agent-client-protocol",
"agent-client-protocol-tokio",
"bytes",
"fabro-model",
"fabro-sandbox",
"fabro-types",
"fabro-util",
"futures",
"serde",
"serde_json",
"tempfile",
"thiserror 2.0.18",
"tokio",
"tokio-util",
"tracing",
"uuid",
]
[[package]]
name = "fabro-agent"
version = "0.230.0-nightly.0"
@ -1661,6 +1724,7 @@ dependencies = [
"dialoguer",
"dirs",
"dotenvy",
"fabro-acp",
"fabro-agent",
"fabro-api",
"fabro-auth",
@ -2433,6 +2497,7 @@ version = "0.230.0-nightly.0"
dependencies = [
"fabro-graphviz",
"fabro-model",
"fabro-types",
"serde",
"thiserror 2.0.18",
]
@ -2460,6 +2525,7 @@ dependencies = [
"bytes",
"chrono",
"dirs",
"fabro-acp",
"fabro-agent",
"fabro-auth",
"fabro-checkpoint",
@ -2573,6 +2639,12 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "fixedbitset"
version = "0.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
[[package]]
name = "flatbuffers"
version = "25.12.19"
@ -2843,6 +2915,19 @@ dependencies = [
"futures-sink",
]
[[package]]
name = "futures-concurrency"
version = "7.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "175cd8cca9e1d45b87f18ffa75088f2099e3c4fe5e2f83e42de112560bea8ea6"
dependencies = [
"fixedbitset",
"futures-core",
"futures-lite",
"pin-project",
"smallvec",
]
[[package]]
name = "futures-core"
version = "0.3.32"
@ -2866,6 +2951,19 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
[[package]]
name = "futures-lite"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
dependencies = [
"fastrand",
"futures-core",
"futures-io",
"parking",
"pin-project-lite",
]
[[package]]
name = "futures-macro"
version = "0.3.32"
@ -3732,6 +3830,16 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "jsonrpcmsg"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d833a15225c779251e13929203518c2ff26e2fe0f322d584b213f4f4dad37bd"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "jsonschema"
version = "0.42.2"
@ -5630,6 +5738,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2231b2c085b371c01bc90c0e6c1cab8834711b6394533375bdbf870b0166d419"
dependencies = [
"async-trait",
"base64",
"chrono",
"futures",
"http",
@ -6177,11 +6286,12 @@ dependencies = [
[[package]]
name = "serde_with"
version = "3.17.0"
version = "3.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "381b283ce7bc6b476d903296fb59d0d36633652b633b27f64db4fb46dcbfc3b9"
checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2"
dependencies = [
"base64",
"bs58",
"chrono",
"hex",
"indexmap 1.9.3",
@ -6196,11 +6306,11 @@ dependencies = [
[[package]]
name = "serde_with_macros"
version = "3.17.0"
version = "3.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6d4e30573c8cb306ed6ab1dca8423eec9a463ea0e155f45399455e0368b27e0"
checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac"
dependencies = [
"darling 0.21.3",
"darling 0.23.0",
"proc-macro2",
"quote",
"syn 2.0.117",
@ -6931,6 +7041,7 @@ checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
dependencies = [
"bytes",
"futures-core",
"futures-io",
"futures-sink",
"futures-util",
"hashbrown 0.15.5",

View file

@ -9,6 +9,8 @@ version = "0.230.0-nightly.0"
license = "MIT"
[workspace.dependencies]
agent-client-protocol = { version = "0.11.1", features = ["unstable_session_usage"] }
agent-client-protocol-tokio = "0.11.1"
anyhow = "1"
axum = { version = "0.8" }
axum-extra = { version = "0.10", features = ["cookie-private"] }

View file

@ -1952,6 +1952,8 @@ Emitted when an image or snapshot ensure step fails.
## CLI ensure events
These legacy events may appear in older run logs. Current CLI backend runs do not emit them because Fabro no longer installs or prepares provider CLIs at stage runtime.
### `cli.ensure.started`
```json

View file

@ -401,7 +401,7 @@ V2 keeps the current durable family surface broadly intact.
- `sandbox.*`
- `setup.*`
- `cli.ensure.*`
- `cli.ensure.*` (legacy only)
- `command.*`
- `agent.cli.*`
- `devcontainer.*`

View file

@ -0,0 +1,326 @@
# ACP Backend Test Plan
The accepted testing strategy still holds, with scoped additions from the implementation plan: ACP prompt nodes are explicitly supported, backend validation becomes strict, sandbox stdio is part of the public contract, and ACP events affect run projection, fork replay, and server steerability. These additions do not require paid services or materially change the agreed scope because all high-value ACP checks can run against deterministic fake agents and local/unit harnesses.
## Harness Requirements
1. **Fake ACP agent harness**
- What it does: runs a deterministic ACP agent over stdio, records observed JSON-RPC method order, emits configurable `session/update` messages, writes optional files in cwd, responds to permission requests, and simulates cancellation, malformed JSON, early exit, timeout, and stop reasons.
- Exposes: a checked-in fixture binary or script plus crate-local helpers in `fabro-acp::test_support` using `agent-client-protocol` schema types where practical.
- Complexity: medium. It is the main substitute for paid/live ACP agents.
- Tests depending on it: 7, 8, 9, 10, 11, 12, 13, 26, 27.
2. **Sandbox stdio process harness**
- What it does: exercises `Sandbox::spawn_stdio_process` with a line-oriented subprocess, captures stdout/stderr separately, terminates the process, and validates Docker exec option construction without requiring live Docker.
- Exposes: local sandbox round-trip tests, Docker option-builder/control-wrapper tests, Daytona unsupported-provider assertion, and decorator/test-support forwarding assertions.
- Complexity: medium because Docker stdio is multiplexed and cancellation needs an explicit control path.
- Tests depending on it: 5, 6, 8, 12, 26.
3. **Workflow ACP runner harness**
- What it does: runs a real Fabro workflow with `backend="acp"` and node-level `acp_command` pointing at the fake ACP agent, then inspects persisted run events/projection through existing CLI workflow helpers.
- Exposes: user-visible `fabro run` result, run events, stage response, `files_touched`, and `provider_used`.
- Complexity: low once the fake ACP agent exists.
- Tests depending on it: 26, 27.
4. **Server event-state harness extension**
- What it does: uses existing server test fixtures to insert a running run, apply ACP events, and call `POST /runs/{id}/steer`.
- Exposes: HTTP status and JSON error codes through the Axum test router.
- Complexity: low.
- Tests depending on it: 23, 24.
## Test Plan
1. **Existing CLI backend behavior remains intact**
- Type: regression
- Disposition: existing
- Harness: existing `fabro-workflow` router/CLI tests from the accepted strategy.
- Preconditions: current repository before ACP changes; no ACP-specific code required.
- Actions: run `ulimit -n 4096 && cargo nextest run -p fabro-workflow -E 'test(router_uses_cli_for_backend_attr) | test(router_uses_api_by_default) | test(backend_router_delegates_to_cli_for_cli_node) | test(backend_router_delegates_to_api_for_normal_node) | test(backend_router_delegates_to_cli_for_backend_attr) | test(full_pipeline_with_cli_backend_node) | test(stylesheet_backend_property_routes_to_cli) | test(cli_backend_run_writes_prompt_and_calls_exec) | test(cli_backend_run_with_codex_provider) | test(parse_real_codex_ndjson)'`.
- Expected outcome: all tests pass; `backend="cli"` still routes agent nodes to CLI, default routing remains API, stylesheet `backend: cli` still works, CLI output parsing remains unchanged. Source of truth: user request to keep `api`, `cli`, and `acp` as three backends for now; implementation plan User-Visible Behavior for legacy CLI compatibility.
- Interactions: router, CLI backend, stylesheet import, sandbox command execution, CLI event emission.
2. **Existing stdio JSON-RPC precedent remains intact**
- Type: regression
- Disposition: existing
- Harness: existing `fabro-mcp` stdio integration tests.
- Preconditions: Python is available; no live MCP service required.
- Actions: run `ulimit -n 4096 && cargo nextest run -p fabro-mcp -E 'test(stdio_client_initialize_and_list_tools) | test(stdio_client_call_tool_echo) | test(connection_manager_stdio_roundtrip)'`.
- Expected outcome: all tests pass; Fabro can still spawn a stdio JSON-RPC collaborator, initialize it, list capabilities, and call it. Source of truth: accepted strategy listed these as relevant stdio precedent.
- Interactions: child process stdio, JSON-RPC framing, local subprocess lifecycle.
3. **ACP default command mapping matches provider families**
- Type: unit
- Disposition: new
- Harness: `fabro-acp` command mapping tests.
- Preconditions: `fabro-acp` crate exists with `agent-client-protocol-tokio = 0.11.1`.
- Actions: call `default_acp_command` for Anthropic, OpenAI, Kimi, Zai, Minimax, Inception, OpenAI-compatible, and Gemini.
- Expected outcome: Anthropic maps to `npx -y @zed-industries/claude-code-acp@latest`; OpenAI-compatible family maps to `npx -y @zed-industries/codex-acp@latest`; Gemini maps to `npx -y -- @google/gemini-cli@latest --experimental-acp`. Source of truth: implementation plan User-Visible Behavior default ACP command mapping.
- Interactions: provider enum coverage and ACP Tokio parser defaults.
4. **ACP command overrides are parsed as stdio commands, not raw shell**
- Type: boundary
- Disposition: new
- Harness: `fabro-acp` command parsing tests using `agent_client_protocol_tokio::AcpAgent::from_str`.
- Preconditions: no sandbox required.
- Actions: resolve `acp_command` values for a shell-word command, a blank string, a JSON stdio config with args/env, and a non-stdio JSON config.
- Expected outcome: shell-word and JSON stdio commands expose parsed program/args/env; blank overrides fail with `acp_command must not be empty`; HTTP/SSE configs fail with `only stdio ACP commands are supported`; rendered sandbox command uses parsed parts with shell quoting. Source of truth: implementation plan command override contract and shell quoting invariant in `AGENTS.md`.
- Interactions: ACP Tokio parser, command rendering, env merge inputs.
5. **Local sandbox stdio round-trips without a PTY**
- Type: integration
- Disposition: new
- Harness: `fabro-sandbox` local stdio process harness.
- Preconditions: temp local sandbox workspace; Python or a POSIX shell command available.
- Actions: spawn a line-oriented process with `spawn_stdio_process`, write `abc\n` to stdin, read one stdout line, then terminate and wait.
- Expected outcome: stdout returns the transformed line, stderr remains separately collectible, and `terminate()` completes without leaking the process. Source of truth: implementation plan Contracts And Invariants requiring sandbox-backed, bidirectional, non-PTY stdio.
- Interactions: local process groups, env filtering, async IO, cancellation cleanup.
6. **Sandbox providers preserve or reject ACP stdio capability correctly**
- Type: invariant
- Disposition: new
- Harness: `fabro-sandbox` provider/decorator tests.
- Preconditions: local sandbox, read/write guard, worktree/decorator wrappers, test-support sandbox, and Daytona provider stub are available.
- Actions: call `spawn_stdio_process` through each wrapper around a supporting sandbox; call it on Daytona; construct Docker exec stdio options.
- Expected outcome: wrappers forward to the inner sandbox; Daytona returns `ACP backend requires bidirectional stdio; the Daytona sandbox provider does not support it yet`; Docker create/start options attach stdin/stdout/stderr and set `tty=false`; Docker termination uses the stop-file/control path. Source of truth: implementation plan provider support and PTY corruption risk.
- Interactions: decorator macro, worktree path resolution, Docker option builder, Daytona provider boundary.
7. **ACP lifecycle initializes, creates a session, sends a prompt, and aggregates text**
- Type: integration
- Disposition: new
- Harness: `fabro-acp` fake ACP agent harness.
- Preconditions: fake ACP agent configured to emit two text `agent_message_chunk` updates and return `stopReason: "end_turn"`.
- Actions: call `run_acp_turn` with a prompt and cwd.
- Expected outcome: fake agent observes `initialize`, `session/new`, `session/prompt` in order; result text is the concatenation of text chunks; stop reason is `EndTurn`. Source of truth: ACP initialization/session/prompt docs and docs.rs quick-start lifecycle.
- Interactions: official ACP SDK client, sandbox stdio transport, JSON-RPC ordering.
8. **ACP runs inside the active sandbox and sees the workflow cwd**
- Type: integration
- Disposition: new
- Harness: `fabro-acp` fake agent plus local sandbox stdio.
- Preconditions: temp sandbox workspace; fake agent writes `hello.txt` in its cwd during `session/prompt`.
- Actions: call `run_acp_turn`, then inspect the sandbox workspace for `hello.txt`.
- Expected outcome: file exists inside the sandbox workspace, not the host process cwd; `session/new` cwd matches `sandbox.working_directory()`. Source of truth: implementation plan Contracts And Invariants requiring ACP processes to run inside the active Fabro sandbox.
- Interactions: sandbox cwd resolution, command launch, file mutation visibility.
9. **ACP permission requests auto-select an allow option**
- Type: integration
- Disposition: new
- Harness: `fabro-acp` fake ACP agent harness.
- Preconditions: fake agent sends `session/request_permission` with `AllowAlways`, `AllowOnce`, and reject options before completing the prompt.
- Actions: call `run_acp_turn` and record the client response.
- Expected outcome: client responds with the `AllowAlways` option id when present, then the turn continues and returns text. Source of truth: implementation plan permission handling contract; ACP supports agent-to-client permission requests.
- Interactions: ACP client request handler, cancellation token state, prompt turn progress.
10. **ACP cancellation sends session cancel and returns cancellation**
- Type: boundary
- Disposition: new
- Harness: `fabro-acp` fake ACP agent harness.
- Preconditions: fake agent has created a session and is holding `session/prompt` open.
- Actions: start `run_acp_turn`, cancel the token before completion, and let the fake agent record incoming notifications.
- Expected outcome: client sends `session/cancel` for the active session, terminates if the agent does not finish within grace, and returns `AcpError::Cancelled`. If a permission request arrives after cancellation, the response is `RequestPermissionOutcome::Cancelled`. Source of truth: implementation plan cancellation contract and ACP prompt lifecycle stop reasons.
- Interactions: cancel token, JSON-RPC notification, process termination.
11. **ACP timeout terminates the process and reports timeout**
- Type: boundary
- Disposition: new
- Harness: `fabro-acp` fake ACP agent harness.
- Preconditions: fake agent never responds to `session/prompt`; request timeout is short.
- Actions: call `run_acp_turn`.
- Expected outcome: process is terminated, stderr tail is available if emitted, and error is `AcpError::TimedOut`. Source of truth: implementation plan timeout contract using node timeout like CLI mode.
- Interactions: watchdog activity, process handle termination, stderr collector.
12. **ACP protocol failures include diagnostic stderr without losing typed errors**
- Type: boundary
- Disposition: new
- Harness: `fabro-acp` fake ACP agent harness.
- Preconditions: fake agents for malformed JSON-RPC and early nonzero exit.
- Actions: call `run_acp_turn` for each failure mode.
- Expected outcome: malformed JSON returns a protocol error; early exit includes exit status and stderr tail; error source chains remain inspectable where applicable. Source of truth: implementation plan malformed/early-exit behavior and error-handling strategy.
- Interactions: ACP SDK error propagation, stderr tail collection, process wait.
13. **ACP stop reasons map to Fabro backend outcomes**
- Type: boundary
- Disposition: new
- Harness: `fabro-acp` fake agent plus workflow `AgentAcpBackend` adapter tests.
- Preconditions: fake agent can return `EndTurn`, `Refusal`, `Cancelled`, `MaxTokens`, and `MaxTurnRequests`.
- Actions: run an ACP backend turn for each stop reason.
- Expected outcome: `EndTurn` and `Refusal` return text; `Cancelled` maps to `Error::Cancelled`; `MaxTokens` and `MaxTurnRequests` return handler errors containing the stop reason and partial output. Source of truth: implementation plan Stop reason handling.
- Interactions: protocol result mapping, workflow error conversion, event terminal paths.
14. **ACP backend adapter prepares credentials, env, Node runtime, and changed files**
- Type: integration
- Disposition: new
- Harness: `fabro-workflow` ACP adapter tests with fake credential resolver and fake sandbox.
- Preconditions: node uses `backend="acp"`; fake resolver can provide env vars and login command; sandbox records commands and git status before/after.
- Actions: call `AgentAcpBackend::run`.
- Expected outcome: login command runs before ACP; tool env overlays command env; default `npx` commands trigger Node/npm/npx bootstrap; explicit `acp_command` does not install provider CLIs; `files_touched` excludes pre-existing dirty files and includes new changed/untracked files. Source of truth: implementation plan env preparation, Node bootstrap, and changed-file semantics.
- Interactions: credential resolver, workflow tool env, sandbox exec, Git diff helper.
15. **ACP one-shot prompt nodes use sandboxed ACP and combine system prompt correctly**
- Type: integration
- Disposition: new
- Harness: `fabro-workflow` `PromptHandler` and `AgentAcpBackend::one_shot` tests.
- Preconditions: prompt node has `backend="acp"`; project memory can produce a system prompt; fake backend captures sandbox pointer and cancellation token.
- Actions: execute the prompt handler.
- Expected outcome: `PromptHandler` passes the active sandbox and run cancel token into `CodergenBackend::one_shot`; ACP one-shot sends `System:\n{system_prompt}\n\nUser:\n{prompt}` when system prompt exists and only the prompt when absent; no host process is used. Source of truth: implementation plan User-Visible Behavior for prompt/one_shot ACP support.
- Interactions: prompt handler, memory discovery, backend trait signature, run services.
16. **Backend router selects api, cli, and acp explicitly**
- Type: integration
- Disposition: extend
- Harness: `fabro-workflow` router tests.
- Preconditions: router has API, CLI, and ACP test backends with distinguishable responses.
- Actions: run agent nodes with absent backend, `backend="api"`, `backend="cli"`, `backend="acp"`, and `backend="codex"`.
- Expected outcome: absent and `api` use API; `cli` uses CLI; `acp` uses ACP; unknown backend fails with `unsupported LLM backend "codex"; expected one of: api, cli, acp`. Source of truth: implementation plan three-way router selection and strict validation requirement.
- Interactions: node attributes, model fallback, handler errors.
17. **Prompt router keeps legacy cli one-shot fallback but routes acp to ACP**
- Type: regression
- Disposition: extend
- Harness: `fabro-workflow` router one-shot tests.
- Preconditions: router has API and ACP one-shot test backends.
- Actions: call `one_shot` for prompt nodes with absent backend, `backend="api"`, `backend="cli"`, and `backend="acp"`.
- Expected outcome: absent, `api`, and legacy `cli` prompt nodes use API; `acp` uses ACP. Source of truth: implementation plan compatibility note for prompt nodes with `backend="cli"` and explicit ACP prompt support.
- Interactions: backend routing, prompt handler behavior, backward compatibility.
18. **Workflow validation accepts only supported backend values**
- Type: boundary
- Disposition: new
- Harness: `fabro-validate` `backend_valid` rule tests and CLI validate coverage if practical.
- Preconditions: graphs with absent backend and with `api`, `cli`, `acp`, and `codex`.
- Actions: run `fabro_validate::validate` against each graph; optionally run `fabro validate` against an invalid fixture.
- Expected outcome: absent, `api`, `cli`, and `acp` have no backend diagnostic; `codex` returns an error diagnostic containing `unsupported LLM backend "codex"; expected one of: api, cli, acp`. Source of truth: implementation plan User-Visible Behavior for unknown backend values.
- Interactions: validation registry, parser, CLI diagnostic rendering.
19. **Imported workflow placeholders propagate acp_command**
- Type: regression
- Disposition: extend
- Harness: `fabro-workflow` import transform tests.
- Preconditions: host workflow has an import placeholder with `backend="acp"` and `acp_command="python fake_agent.py"`; imported workflow has LLM nodes.
- Actions: run `ImportTransform` and inspect imported node attrs.
- Expected outcome: imported LLM nodes receive `backend="acp"` and the placeholder `acp_command`; unsupported placeholder attributes still poison the placeholder. Source of truth: implementation plan file list and import transform requirement.
- Interactions: graph transform, default attribute propagation, import validation.
20. **ACP events serialize with stage-scoped metadata**
- Type: integration
- Disposition: new
- Harness: `fabro-workflow` event conversion tests.
- Preconditions: construct `Event::AgentAcpStarted`, `AgentAcpCompleted`, `AgentAcpCancelled`, and `AgentAcpTimedOut` with a `StageScope`.
- Actions: convert each event through `to_run_event`.
- Expected outcome: event names are `agent.acp.started`, `agent.acp.completed`, `agent.acp.cancelled`, and `agent.acp.timed_out`; envelope includes `node_id`, stage id/visit-derived fields, and no prompt/env/credential contents. Source of truth: events strategy and implementation plan ACP event contract.
- Interactions: event naming, stored fields, `fabro-types` event body serde.
21. **Run projection records ACP provider metadata and terminal output**
- Type: integration
- Disposition: new
- Harness: `fabro-store` run projection tests.
- Preconditions: event sequence has stage start, `agent.acp.started`, terminal ACP event, and stage completion/failure.
- Actions: apply events to `RunProjection`.
- Expected outcome: `stage.provider_used.mode == "acp"` with provider, model, and command; completed output contains aggregated text/stderr payload; cancelled and timed-out terminal events set `CommandTermination::Cancelled` and `CommandTermination::TimedOut`. Source of truth: implementation plan run projection support.
- Interactions: stored event fields, stage lookup by visit, projection terminal data.
22. **Fork replay preserves ACP stage metadata**
- Type: regression
- Disposition: new
- Harness: `fabro-workflow` fork replay tests.
- Preconditions: source run history includes ACP started/cancelled/timed-out events before a checkpoint.
- Actions: call fork replay filtering or run a lower-level fork projection test.
- Expected outcome: `AgentAcpStarted`, `AgentAcpCancelled`, and `AgentAcpTimedOut` are replayed into the fork projection; `AgentAcpCompleted` follows the existing CLI completed replay policy. Source of truth: implementation plan fork replay requirement.
- Interactions: historical event filtering, forked run projection.
23. **ACP running stages are not steerable through the server API**
- Type: scenario
- Disposition: new
- Harness: server event-state harness extension.
- Preconditions: a running managed run with worker control channel; no active API-mode agent session; active stage marker has been set by `agent.acp.started`.
- Actions: call `POST /runs/{id}/steer` with a plain steer request and with interrupt+steer.
- Expected outcome: response is `409 CONFLICT` with a clear non-steerable-agent error code/message; no worker control message is enqueued as if an API session might appear. Source of truth: implementation plan server steerability tracking.
- Interactions: run manager event reducer, HTTP handler, worker control queue.
24. **ACP non-steerable marker clears on all terminal paths**
- Type: invariant
- Disposition: new
- Harness: server event-state harness extension.
- Preconditions: a running managed run with active ACP stage and no active API-mode stage.
- Actions: apply each clearing event independently: `agent.acp.completed`, `agent.acp.cancelled`, `agent.acp.timed_out`, `stage.completed`, and `stage.failed`; then call `POST /runs/{id}/steer` with a plain steer request.
- Expected outcome: plain steer is accepted/buffered after each terminal event because no non-steerable active agent remains. Source of truth: implementation plan server steerability clearing rules.
- Interactions: event reducer backstops, HTTP handler, stage lifecycle.
25. **Pipeline initialization wires ACP into real workflow handlers**
- Type: integration
- Disposition: new
- Harness: `fabro-workflow` pipeline initialization tests.
- Preconditions: graph contains `backend="acp"` LLM node; credentials are supplied through a stub/env source; dry-run and non-dry-run cases are both available.
- Actions: call `initialize`/`build_registry` and execute or resolve the node through the initialized registry using a fake ACP runner.
- Expected outcome: non-dry-run registry constructs a router with ACP; dry-run still builds no real backend and simulates LLM handlers; ACP does not fall back to host env when a resolver exists. Source of truth: implementation plan pipeline initialization task.
- Interactions: credential source, handler registry, dry-run path.
26. **Black-box `fabro run` executes an ACP-backed agent workflow**
- Type: scenario
- Disposition: new
- Harness: workflow ACP runner harness in `fabro-cli/tests/it/workflow`.
- Preconditions: temp workflow has an agent node with `backend="acp"`, `provider="openai"`, `model="fake-acp"`, and `acp_command` pointing to the checked-in fake ACP agent; local sandbox is used.
- Actions: run the workflow through the CLI test command, then read run state/events through existing workflow helpers.
- Expected outcome: run succeeds; stage response contains concatenated chunks; `hello.txt` is included in `files_touched`; run projection has `provider_used.mode == "acp"`; `agent.acp.started` and `agent.acp.completed` events are present. Source of truth: user request for first-class `backend="acp"` and implementation plan black-box workflow coverage.
- Interactions: CLI command, parser, validation, pipeline initialization, sandbox stdio, ACP protocol, run store.
27. **Black-box ACP prompt workflow uses ACP instead of API**
- Type: scenario
- Disposition: new
- Harness: workflow ACP runner harness.
- Preconditions: temp workflow has a prompt/one_shot node with `backend="acp"` and fake ACP command.
- Actions: run the workflow through the CLI test command and inspect stage response/events.
- Expected outcome: prompt node succeeds through ACP, response is fake ACP text, and `agent.acp.*` provider metadata appears; no API-mode `agent.session.activated` event is needed for the prompt. Source of truth: implementation plan User-Visible Behavior for `backend="acp"` on prompt/one_shot nodes.
- Interactions: prompt handler, one-shot routing, pipeline initialization, run projection.
28. **Documentation examples and backend references include ACP without stale CLI prompt claims**
- Type: regression
- Disposition: extend
- Harness: documentation grep plus existing docs build if normally run in CI.
- Preconditions: docs have been updated.
- Actions: run `rg -n "backend=.*cli|backend: cli|backend.*api|CLI backend|cli mode|ACP" docs/public lib/crates -g '*.md' -g '*.mdx'` and `cd apps/marketing && bun run build` only if the touched docs are built by that package.
- Expected outcome: docs mention valid backend values `api`, `cli`, `acp`; `cli` is described as legacy; ACP sandbox and Daytona limitations are documented; no stale claim remains that prompt nodes use CLI mode. Source of truth: implementation plan documentation task.
- Interactions: public docs, marketing/docs build pipeline.
29. **Final targeted ACP verification passes**
- Type: invariant
- Disposition: new
- Harness: repository test suites named by the implementation plan.
- Preconditions: all implementation tasks complete.
- Actions: run:
`ulimit -n 4096 && cargo nextest run -p fabro-acp --run-ignored all --no-fail-fast`;
`ulimit -n 4096 && cargo nextest run -p fabro-sandbox --run-ignored all --no-fail-fast`;
`ulimit -n 4096 && cargo nextest run -p fabro-workflow --run-ignored all --no-fail-fast`;
`ulimit -n 4096 && cargo nextest run -p fabro-validate --run-ignored all --no-fail-fast`;
`ulimit -n 4096 && cargo nextest run -p fabro-store --run-ignored all --no-fail-fast`;
`ulimit -n 4096 && cargo nextest run -p fabro-server --run-ignored all --no-fail-fast`;
`ulimit -n 4096 && cargo nextest run -p fabro-cli --run-ignored all --no-fail-fast`.
- Expected outcome: every suite passes without skipped tests or live provider credentials. Source of truth: accepted strategy final verification and implementation plan Task 10.
- Interactions: all changed crates and user-visible workflow/server surfaces.
30. **Workspace-wide build, formatting, and lint gates pass**
- Type: invariant
- Disposition: existing
- Harness: repository-wide Cargo/rustfmt/clippy commands.
- Preconditions: targeted tests pass.
- Actions: run `cargo build --workspace`, `ulimit -n 4096 && cargo nextest run --workspace --run-ignored all --no-fail-fast`, `cargo +nightly-2026-04-14 fmt --check --all`, and `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings`.
- Expected outcome: build, workspace tests, formatting, and clippy all pass with zero skipped tests. Source of truth: repository `AGENTS.md` build/test commands and the no-skipped-tests final-run requirement.
- Interactions: full workspace dependency graph, feature flags, generated code boundaries.
## Coverage Summary
Covered action space:
- Workflow authoring: `backend` absent, `api`, `cli`, `acp`, invalid values, stylesheet/import propagation, `acp_command` shell-word and JSON stdio overrides.
- Execution surfaces: agent nodes, prompt/one_shot nodes, local sandbox ACP execution, default command selection, explicit override execution, credentials/env, Node bootstrap, changed-file reporting, cancellation, timeout, and stop reason handling.
- Protocol behavior: `initialize`, `session/new`, `session/prompt`, `session/update` text aggregation, permission requests, `session/cancel`, malformed JSON-RPC, and early process exit.
- Provider/sandbox boundaries: local stdio, Docker non-PTY stdio option/control behavior, Daytona unsupported error, decorator forwarding.
- Product-visible state: ACP events, run projection `provider_used.mode == "acp"`, terminal output/termination, fork replay, CLI workflow run state, and server steerability API behavior.
- Regression protection: existing CLI routing/CLI parsing tests, existing MCP stdio tests, dry-run initialization, repository build/fmt/clippy.
Explicit exclusions:
- Live Anthropic/OpenAI/Gemini ACP adapter calls are excluded; fake ACP agents provide deterministic coverage without paid credentials. Risk: vendor-specific adapter quirks may escape until optional/live tests are added.
- Full live Docker ACP workflow execution is not required unless the existing test environment already provides Docker. Unit-level Docker exec option/control tests cover the non-PTY and termination contract. Risk: daemon-specific stream behavior could still differ from Bollard option construction.
- Remote ACP transports are excluded because the implementation plan supports only stdio in this cutover. Risk: none for the agreed scope.
- ACP client filesystem and terminal capabilities are excluded because Fabro intentionally advertises none in this cutover. Risk: agents requiring those client APIs will fail as documented rather than silently using unsafe host capabilities.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,762 @@
# Sandbox Real-Agent Smoke QA Plan
## Purpose
Manually smoke test the `add-acp-backend` branch with real LLM-backed agents across the full sandbox/agent matrix:
| Sandbox provider | Claude | Codex | Gemini |
| --- | --- | --- | --- |
| Local | Required | Required | Required |
| Docker | Required | Required | Required |
| Daytona | Required | Required | Required |
The key branch constraint is intentional: ACP requires bidirectional raw stdio and is supported by local and Docker in this cutover, but not by Daytona. This QA plan proves ACP works through local and Docker sandboxes for Claude, Codex, and Gemini; uses Daytona CLI execution for the same three agents as the positive Daytona coverage; and finally verifies ACP-on-Daytona fails clearly instead of falling back to host execution or PTY transport.
## Scope
In scope:
- A real Claude ACP-backed agent running through the local sandbox provider with no container.
- A real Codex ACP-backed agent running through the local sandbox provider with no container.
- A real Gemini ACP-backed agent running through the local sandbox provider with no container.
- A real Claude ACP-backed agent running in a Docker sandbox.
- A real Codex ACP-backed agent running in a Docker sandbox.
- A real Gemini ACP-backed agent running in a Docker sandbox.
- A real Claude CLI-backed agent running in a Daytona sandbox.
- A real Codex CLI-backed agent running in a Daytona sandbox.
- A real Gemini CLI-backed agent running in a Daytona sandbox.
- Optional Daytona API backend control coverage after the required 3x3 matrix.
- An ACP-backed node on Daytona returning the expected unsupported-provider failure.
- Evidence capture through `inspect`, `events`, `dump`, and optional preserved-sandbox SSH.
Out of scope:
- Automated nextest coverage.
- ACP positive execution on Daytona.
- Full regression of Docker or local ACP behavior beyond this real-agent smoke.
- Snapshot creation performance tuning beyond what is needed to run the smoke.
## Preconditions
- Current branch is `add-acp-backend`.
- Local host has Node/npx available for the no-container ACP smoke.
- Docker is available for the Docker sandbox smoke.
- Daytona API key is available with sandbox/snapshot scopes.
- Real LLM credentials are available for all three required agents.
- GitHub access is configured if the operator chooses not to use `skip_clone = true`.
- Network access from the host, Docker container, and Daytona sandbox allows installing CLI packages.
Recommended environment:
```bash
cargo build -p fabro-cli
export FABRO=./target/debug/fabro
set -a
source .env
set +a
$FABRO doctor -v
```
Required environment variables for the full matrix:
- `DAYTONA_API_KEY`
- `ANTHROPIC_API_KEY`
- `OPENAI_API_KEY`
- `GEMINI_API_KEY`
## Test Data Setup
Create a scratch directory for manual smoke files:
```bash
mkdir -p smoke tmp
```
Docker and Daytona smoke configs use `skip_clone = true` to avoid depending on pushed branch state. This keeps the test focused on runtime behavior and real agent execution. The local smoke intentionally runs directly in the current working tree because `provider = "local"` has no container boundary; remove `smoke_local_acp_*_result.txt`, `.fabro-smoke-*-acp`, and `.fabro-smoke-home/` during cleanup if they remain.
Agent definitions for the required matrix:
| Agent | Provider | Model | Credential | ACP command | Daytona CLI install |
| --- | --- | --- | --- | --- | --- |
| Claude | `anthropic` | `claude-haiku-4-5` | `ANTHROPIC_API_KEY` | `npx -y @zed-industries/claude-code-acp@latest` | `npm install -g @anthropic-ai/claude-code`; binary: `claude` |
| Codex | `openai` | `gpt-5.3-codex` | `OPENAI_API_KEY` | `npx -y @zed-industries/codex-acp@latest` | `npm install -g @openai/codex`; binary: `codex` |
| Gemini | `gemini` | `gemini-3.1-pro-preview` | `GEMINI_API_KEY` | `npx -y -- @google/gemini-cli@latest --experimental-acp` | `npm install -g @google/gemini-cli`; binary: `gemini` |
## Smoke 1: Local ACP Backend Matrix
### Goal
Prove real Claude, Codex, and Gemini ACP-backed agents can run through the local sandbox provider without a container, use bidirectional raw stdio, and mutate the local workflow filesystem.
Required local combinations:
| Agent | Workflow | Result file | Expected content |
| --- | --- | --- | --- |
| Claude | `smoke/local_acp_claude.toml` | `smoke_local_acp_claude_result.txt` | `local-acp-claude-ok` |
| Codex | `smoke/local_acp_codex.toml` | `smoke_local_acp_codex_result.txt` | `local-acp-codex-ok` |
| Gemini | `smoke/local_acp_gemini.toml` | `smoke_local_acp_gemini_result.txt` | `local-acp-gemini-ok` |
### Files
Create one graph per agent. The Claude graph is:
```dot
digraph LocalAcpClaudeSmoke {
graph [goal="Local ACP Claude backend smoke"]
start [shape=Mdiamond]
setup [shape=parallelogram, script="rm -f smoke_local_acp_claude_result.txt"]
work [type="agent", backend="acp", provider="anthropic", model="claude-haiku-4-5", acp_command="/bin/bash .fabro-smoke-claude-acp", prompt="Create a file named smoke_local_acp_claude_result.txt containing exactly: local-acp-claude-ok"]
verify [shape=parallelogram, script="test \"$(cat smoke_local_acp_claude_result.txt)\" = \"local-acp-claude-ok\" && cat smoke_local_acp_claude_result.txt"]
exit [shape=Msquare]
start -> setup -> work -> verify -> exit
}
```
Create matching Codex and Gemini graphs with these substitutions:
| Agent | Graph file | Provider | Model | ACP wrapper | Result file | Expected content |
| --- | --- | --- | --- | --- | --- | --- |
| Codex | `smoke/local_acp_codex.fabro` | `openai` | `gpt-5.3-codex` | `.fabro-smoke-codex-acp` | `smoke_local_acp_codex_result.txt` | `local-acp-codex-ok` |
| Gemini | `smoke/local_acp_gemini.fabro` | `gemini` | `gemini-3.1-pro-preview` | `.fabro-smoke-gemini-acp` | `smoke_local_acp_gemini_result.txt` | `local-acp-gemini-ok` |
Create `smoke/local_acp_claude.toml`:
```toml
_version = 1
[workflow]
graph = "local_acp_claude.fabro"
[run.sandbox]
provider = "local"
[[run.prepare.steps]]
script = '''
set -eu
NODE_DIR="$(dirname "$(command -v node)")"
NPX_PATH="$(command -v npx)"
cat > .fabro-smoke-claude-acp <<SH
set -eu
export HOME="\${HOME:-\$PWD/.fabro-smoke-home}"
mkdir -p "\$HOME"
export PATH="$NODE_DIR:/usr/local/bin:/usr/bin:/bin:\${PATH:-}"
exec "$NPX_PATH" -y @zed-industries/claude-code-acp@latest
SH
chmod +x .fabro-smoke-claude-acp
'''
```
Create matching Codex and Gemini TOML files:
- `smoke/local_acp_codex.toml` uses `graph = "local_acp_codex.fabro"`, writes `.fabro-smoke-codex-acp`, and the wrapper ends with `exec "$NPX_PATH" -y @zed-industries/codex-acp@latest`.
- `smoke/local_acp_gemini.toml` uses `graph = "local_acp_gemini.fabro"`, writes `.fabro-smoke-gemini-acp`, and the wrapper ends with `exec "$NPX_PATH" -y -- @google/gemini-cli@latest --experimental-acp`.
### Run
```bash
$FABRO run --auto-approve smoke/local_acp_claude.toml
$FABRO run --auto-approve smoke/local_acp_codex.toml
$FABRO run --auto-approve smoke/local_acp_gemini.toml
```
### Pass Criteria
- Each of the three local runs exits successfully.
- Each `verify` stage prints its expected `local-acp-<agent>-ok` content.
- `fabro events <run-id> --tail 200` for each run includes:
- `agent.acp.started`
- `agent.acp.completed`
- `stage.completed` for `work`
- `stage.completed` for `verify`
- Events for each run do not include `agent.session.activated` for the `work` stage.
- Events for each run do not include `agent.cli.started` for the `work` stage.
- `fabro inspect <run-id>` shows each run succeeded.
- The local working tree contains each expected `smoke_local_acp_<agent>_result.txt` file with exactly the expected content.
### Failure Notes
- Missing `node` or `npx` on the host is a setup failure for this no-container smoke.
- A successful run with API or CLI events instead of ACP events is a branch failure because ACP silently fell back to another backend.
- The local provider writes directly into the current working tree; check `git status` before cleanup.
## Smoke 2: Docker ACP Backend Matrix
### Goal
Prove real Claude, Codex, and Gemini ACP-backed agents can run inside Docker sandboxes, use bidirectional non-PTY stdio through Docker exec, and mutate only the container workspace.
Required Docker combinations:
| Agent | Workflow | Result file | Expected content |
| --- | --- | --- | --- |
| Claude | `smoke/docker_acp_claude.toml` | `smoke_docker_acp_claude_result.txt` | `docker-acp-claude-ok` |
| Codex | `smoke/docker_acp_codex.toml` | `smoke_docker_acp_codex_result.txt` | `docker-acp-codex-ok` |
| Gemini | `smoke/docker_acp_gemini.toml` | `smoke_docker_acp_gemini_result.txt` | `docker-acp-gemini-ok` |
### Files
Create one graph per agent. The Claude graph is:
```dot
digraph DockerAcpClaudeSmoke {
graph [goal="Docker ACP Claude backend smoke"]
start [shape=Mdiamond]
setup [shape=parallelogram, script="rm -f smoke_docker_acp_claude_result.txt"]
work [type="agent", backend="acp", provider="anthropic", model="claude-haiku-4-5", acp_command="/bin/bash .fabro-smoke-claude-acp", prompt="Create a file named smoke_docker_acp_claude_result.txt containing exactly: docker-acp-claude-ok"]
verify [shape=parallelogram, script="test \"$(cat smoke_docker_acp_claude_result.txt)\" = \"docker-acp-claude-ok\" && cat smoke_docker_acp_claude_result.txt"]
exit [shape=Msquare]
start -> setup -> work -> verify -> exit
}
```
Create matching Codex and Gemini graphs with these substitutions:
| Agent | Graph file | Provider | Model | ACP wrapper | Result file | Expected content |
| --- | --- | --- | --- | --- | --- | --- |
| Codex | `smoke/docker_acp_codex.fabro` | `openai` | `gpt-5.3-codex` | `.fabro-smoke-codex-acp` | `smoke_docker_acp_codex_result.txt` | `docker-acp-codex-ok` |
| Gemini | `smoke/docker_acp_gemini.fabro` | `gemini` | `gemini-3.1-pro-preview` | `.fabro-smoke-gemini-acp` | `smoke_docker_acp_gemini_result.txt` | `docker-acp-gemini-ok` |
Create `smoke/docker_acp_claude.toml`:
```toml
_version = 1
[workflow]
graph = "docker_acp_claude.fabro"
[run.sandbox]
provider = "docker"
preserve = true
[run.sandbox.docker]
image = "buildpack-deps:noble"
network_mode = "bridge"
memory_limit = "4GB"
cpu_quota = 200000
skip_clone = true
[[run.prepare.steps]]
script = '''
set -eu
mkdir -p "$HOME/.local"
if ! command -v node >/dev/null 2>&1; then
curl -fsSL https://nodejs.org/dist/v22.14.0/node-v22.14.0-linux-x64.tar.gz | tar -xz --strip-components=1 -C "$HOME/.local"
fi
export PATH="$HOME/.local/bin:$PATH"
npm --version
npx --version
NODE_DIR="$(dirname "$(command -v node)")"
NPX_PATH="$(command -v npx)"
cat > .fabro-smoke-claude-acp <<SH
set -eu
export HOME="\${HOME:-\$PWD/.fabro-smoke-home}"
mkdir -p "\$HOME"
export PATH="$NODE_DIR:/usr/local/bin:/usr/bin:/bin:\${PATH:-}"
exec "$NPX_PATH" -y @zed-industries/claude-code-acp@latest
SH
chmod +x .fabro-smoke-claude-acp
'''
```
Create matching Codex and Gemini TOML files:
- `smoke/docker_acp_codex.toml` uses `graph = "docker_acp_codex.fabro"`, writes `.fabro-smoke-codex-acp`, and the wrapper ends with `exec "$NPX_PATH" -y @zed-industries/codex-acp@latest`.
- `smoke/docker_acp_gemini.toml` uses `graph = "docker_acp_gemini.fabro"`, writes `.fabro-smoke-gemini-acp`, and the wrapper ends with `exec "$NPX_PATH" -y -- @google/gemini-cli@latest --experimental-acp`.
### Run
```bash
$FABRO run --auto-approve smoke/docker_acp_claude.toml
$FABRO run --auto-approve smoke/docker_acp_codex.toml
$FABRO run --auto-approve smoke/docker_acp_gemini.toml
```
### Pass Criteria
- Each of the three Docker runs exits successfully.
- Each `verify` stage prints its expected `docker-acp-<agent>-ok` content.
- `fabro events <run-id> --tail 200` for each run includes:
- `sandbox.ready`
- `setup.started`
- `setup.completed`
- `agent.acp.started`
- `agent.acp.completed`
- `stage.completed` for `verify`
- Events for each run do not include `agent.session.activated` for the `work` stage.
- Events for each run do not include `agent.cli.started` for the `work` stage.
- `fabro inspect <run-id>` shows each run succeeded.
- Each preserved Docker sandbox contains its expected `smoke_docker_acp_<agent>_result.txt` file with exactly the expected content.
### Failure Notes
- Docker daemon, image pull, or package-install failures are setup failures unless the error indicates ACP stdio or sandbox routing broke.
- A successful run with API or CLI events instead of ACP events is a branch failure because ACP silently fell back to another backend.
## Smoke 3: Daytona API Backend Control
### Goal
Optionally prove a real provider API agent can use Fabro-managed tools inside the Daytona sandbox and mutate the sandbox filesystem. This is a backend control smoke, not part of the required 3x3 external-agent matrix.
### Files
Create `smoke/daytona_api.fabro`:
```dot
digraph DaytonaApiSmoke {
graph [goal="Daytona API backend smoke"]
start [shape=Mdiamond]
setup [shape=parallelogram, script="rm -f smoke_api_result.txt"]
work [type="agent", backend="api", provider="anthropic", model="claude-haiku-4-5", prompt="Create a file named smoke_api_result.txt containing exactly: daytona-api-ok"]
verify [shape=parallelogram, script="test \"$(cat smoke_api_result.txt)\" = \"daytona-api-ok\" && cat smoke_api_result.txt"]
exit [shape=Msquare]
start -> setup -> work -> verify -> exit
}
```
Create `smoke/daytona_api.toml`:
```toml
_version = 1
[workflow]
graph = "daytona_api.fabro"
[run.sandbox]
provider = "daytona"
preserve = true
[run.sandbox.daytona]
skip_clone = true
auto_stop_interval = 60
```
### Run
```bash
$FABRO run --auto-approve smoke/daytona_api.toml
```
### Pass Criteria
- Run exits successfully.
- The `verify` stage prints `daytona-api-ok`.
- `fabro events <run-id> --tail 200` includes:
- `sandbox.ready`
- `agent.session.activated`
- `stage.completed` for `work`
- `stage.completed` for `verify`
- `fabro inspect <run-id>` shows the run succeeded.
### Failure Notes
- Provider authentication failures are setup failures unless the error indicates sandbox routing or missing Daytona state.
- Missing `smoke_api_result.txt` after a successful agent stage is a failure.
## Smoke 4: Daytona CLI Backend Matrix
### Goal
Prove the branch runs real Claude, Codex, and Gemini external CLI agents inside Daytona when the CLI is preinstalled by the workflow environment. This also confirms Fabro no longer installs CLIs implicitly at stage runtime.
Required Daytona combinations:
| Agent | Workflow | Result file | Expected content | CLI binary |
| --- | --- | --- | --- | --- |
| Claude | `smoke/daytona_cli_claude.toml` | `smoke_daytona_cli_claude_result.txt` | `daytona-cli-claude-ok` | `claude` |
| Codex | `smoke/daytona_cli_codex.toml` | `smoke_daytona_cli_codex_result.txt` | `daytona-cli-codex-ok` | `codex` |
| Gemini | `smoke/daytona_cli_gemini.toml` | `smoke_daytona_cli_gemini_result.txt` | `daytona-cli-gemini-ok` | `gemini` |
### Files
Create one graph per agent. The Claude graph is:
```dot
digraph DaytonaCliClaudeSmoke {
graph [goal="Daytona CLI Claude backend smoke"]
start [shape=Mdiamond]
setup [shape=parallelogram, script="rm -f smoke_daytona_cli_claude_result.txt"]
work [type="agent", backend="cli", provider="anthropic", model="claude-haiku-4-5", prompt="Create a file named smoke_daytona_cli_claude_result.txt containing exactly: daytona-cli-claude-ok"]
verify [shape=parallelogram, script="test \"$(cat smoke_daytona_cli_claude_result.txt)\" = \"daytona-cli-claude-ok\" && cat smoke_daytona_cli_claude_result.txt"]
exit [shape=Msquare]
start -> setup -> work -> verify -> exit
}
```
Create matching Codex and Gemini graphs with these substitutions:
| Agent | Graph file | Provider | Model | Result file | Expected content |
| --- | --- | --- | --- | --- | --- |
| Codex | `smoke/daytona_cli_codex.fabro` | `openai` | `gpt-5.3-codex` | `smoke_daytona_cli_codex_result.txt` | `daytona-cli-codex-ok` |
| Gemini | `smoke/daytona_cli_gemini.fabro` | `gemini` | `gemini-3.1-pro-preview` | `smoke_daytona_cli_gemini_result.txt` | `daytona-cli-gemini-ok` |
Create `smoke/daytona_cli_claude.toml`:
```toml
_version = 1
[workflow]
graph = "daytona_cli_claude.fabro"
[run.sandbox]
provider = "daytona"
preserve = true
[run.sandbox.daytona]
skip_clone = true
auto_stop_interval = 60
[[run.prepare.steps]]
script = '''
set -eu
mkdir -p "$HOME/.local"
if ! command -v node >/dev/null 2>&1; then
curl -fsSL https://nodejs.org/dist/v22.14.0/node-v22.14.0-linux-x64.tar.gz | tar -xz --strip-components=1 -C "$HOME/.local"
fi
export PATH="$HOME/.local/bin:$PATH"
npm config set prefix "$HOME/.local"
command -v claude >/dev/null 2>&1 || npm install -g @anthropic-ai/claude-code
claude --version
'''
```
Create matching Codex and Gemini TOML files:
- `smoke/daytona_cli_codex.toml` uses `graph = "daytona_cli_codex.fabro"`, installs `@openai/codex` when `codex` is missing, and prints `codex --version`.
- `smoke/daytona_cli_gemini.toml` uses `graph = "daytona_cli_gemini.fabro"`, installs `@google/gemini-cli` when `gemini` is missing, and prints `gemini --version`.
### Run
```bash
$FABRO run --auto-approve smoke/daytona_cli_claude.toml
$FABRO run --auto-approve smoke/daytona_cli_codex.toml
$FABRO run --auto-approve smoke/daytona_cli_gemini.toml
```
### Pass Criteria
- Each of the three Daytona CLI runs exits successfully.
- Each `verify` stage prints its expected `daytona-cli-<agent>-ok` content.
- `fabro events <run-id> --tail 200` for each run includes:
- `setup.started`
- `setup.completed`
- `agent.cli.started`
- `agent.cli.completed`
- `stage.completed` for `verify`
- Events for each run do not include new `cli.ensure.started`, `cli.ensure.completed`, or `cli.ensure.failed` entries for this branch's runtime path.
- Each preserved Daytona sandbox contains its expected `smoke_daytona_cli_<agent>_result.txt` file with exactly the expected content.
### Failure Notes
- `CLI backend requires '<binary>' to be installed in the sandbox PATH` means the prepare step did not install the agent CLI where the backend expects it. Treat this as environment/setup failure unless the prepare logs prove `claude`, `codex`, or `gemini` was installed in `$HOME/.local/bin`.
- CLI package-install failures may be caused by Daytona network policy or npm registry availability.
## Smoke 5: Daytona ACP Backend Expected Unsupported Failure
### Goal
Prove ACP on Daytona fails explicitly because Daytona lacks bidirectional raw stdio support. This provider-boundary check is run once with Claude because the failure must happen before any agent-specific command is launched. This test guards against unsafe fallbacks such as running ACP on the host or over a PTY.
### Files
Create `smoke/daytona_acp_unsupported.fabro`:
```dot
digraph DaytonaAcpUnsupportedSmoke {
graph [goal="ACP Daytona unsupported smoke"]
start [shape=Mdiamond]
work [type="agent", backend="acp", provider="anthropic", model="claude-haiku-4-5", acp_command="npx -y @zed-industries/claude-code-acp@latest", prompt="Create smoke_acp_result.txt"]
exit [shape=Msquare]
start -> work -> exit
}
```
Create `smoke/daytona_acp_unsupported.toml`:
```toml
_version = 1
[workflow]
graph = "daytona_acp_unsupported.fabro"
[run.sandbox]
provider = "daytona"
preserve = true
[run.sandbox.daytona]
skip_clone = true
auto_stop_interval = 60
```
### Run
```bash
$FABRO run --auto-approve smoke/daytona_acp_unsupported.toml
```
### Pass Criteria
- Run fails.
- The failure text contains:
- `ACP backend requires bidirectional stdio`
- `Daytona sandbox provider does not support it yet`
- Events include `agent.acp.started`.
- Events do not include `agent.acp.completed`.
- The preserved Daytona sandbox does not contain `smoke_acp_result.txt`.
### Failure Notes
- If the run succeeds, that is a failure for this branch because ACP should not execute on Daytona.
- If the failure is about `acp_command` missing, the workflow file is wrong.
- If the failure is about `npx` missing before the Daytona unsupported error, inspect the code path: the smoke should prove the sandbox stdio provider boundary, not package availability.
## Required 3x3 Matrix Record
The plan is incomplete until all nine sandbox/agent combinations below have a run ID and evidence:
| Sandbox | Agent | Backend | Workflow | Run ID |
| --- | --- | --- | --- | --- |
| Local | Claude | ACP | `smoke/local_acp_claude.toml` | `________________` |
| Local | Codex | ACP | `smoke/local_acp_codex.toml` | `________________` |
| Local | Gemini | ACP | `smoke/local_acp_gemini.toml` | `________________` |
| Docker | Claude | ACP | `smoke/docker_acp_claude.toml` | `________________` |
| Docker | Codex | ACP | `smoke/docker_acp_codex.toml` | `________________` |
| Docker | Gemini | ACP | `smoke/docker_acp_gemini.toml` | `________________` |
| Daytona | Claude | CLI | `smoke/daytona_cli_claude.toml` | `________________` |
| Daytona | Codex | CLI | `smoke/daytona_cli_codex.toml` | `________________` |
| Daytona | Gemini | CLI | `smoke/daytona_cli_gemini.toml` | `________________` |
## Verification Checklist
Use this checklist as the operator-facing record for the smoke. Fill in run IDs and notes as each step completes.
### Preconditions
- [ ] Current branch is `add-acp-backend`.
- [ ] `cargo build -p fabro-cli` completed successfully.
- [ ] `FABRO=./target/debug/fabro` is exported for the shell running the smoke.
- [ ] `.env` is loaded.
- [ ] `$FABRO doctor -v` completed without a blocking environment error.
- [ ] Host `node` is available for the local ACP smoke.
- [ ] Host `npx` is available for the local ACP smoke.
- [ ] Docker is available for the Docker sandbox smoke.
- [ ] `DAYTONA_API_KEY` is present and has sandbox/snapshot scopes.
- [ ] `ANTHROPIC_API_KEY` is present for Claude smokes.
- [ ] `OPENAI_API_KEY` is present for Codex smokes.
- [ ] `GEMINI_API_KEY` is present for Gemini smokes.
- [ ] Host, Docker, and Daytona network paths can install CLI packages.
- [ ] `smoke/` and `tmp/` directories exist.
### Smoke 1: Local ACP Backend Matrix
- [ ] Created `smoke/local_acp_claude.fabro` and `smoke/local_acp_claude.toml`.
- [ ] Created `smoke/local_acp_codex.fabro` and `smoke/local_acp_codex.toml`.
- [ ] Created `smoke/local_acp_gemini.fabro` and `smoke/local_acp_gemini.toml`.
- [ ] All three local configs use `provider = "local"`.
- [ ] Prepare steps create `.fabro-smoke-claude-acp`, `.fabro-smoke-codex-acp`, and `.fabro-smoke-gemini-acp`.
- [ ] Ran `$FABRO run --auto-approve smoke/local_acp_claude.toml`.
- [ ] Recorded local Claude ACP run ID: `________________`.
- [ ] Ran `$FABRO run --auto-approve smoke/local_acp_codex.toml`.
- [ ] Recorded local Codex ACP run ID: `________________`.
- [ ] Ran `$FABRO run --auto-approve smoke/local_acp_gemini.toml`.
- [ ] Recorded local Gemini ACP run ID: `________________`.
- [ ] Each local ACP run exited successfully.
- [ ] Verify stages printed `local-acp-claude-ok`, `local-acp-codex-ok`, and `local-acp-gemini-ok`.
- [ ] Each local run's events include `agent.acp.started`.
- [ ] Each local run's events include `agent.acp.completed`.
- [ ] Each local run's events include `stage.completed` for `work`.
- [ ] Each local run's events include `stage.completed` for `verify`.
- [ ] Each local run's events do not include `agent.session.activated` for the `work` stage.
- [ ] Each local run's events do not include `agent.cli.started` for the `work` stage.
- [ ] `fabro inspect <run-id>` shows each local run succeeded.
- [ ] Local working tree contains the three expected `smoke_local_acp_<agent>_result.txt` files with exact contents.
### Smoke 2: Docker ACP Backend Matrix
- [ ] Created `smoke/docker_acp_claude.fabro` and `smoke/docker_acp_claude.toml`.
- [ ] Created `smoke/docker_acp_codex.fabro` and `smoke/docker_acp_codex.toml`.
- [ ] Created `smoke/docker_acp_gemini.fabro` and `smoke/docker_acp_gemini.toml`.
- [ ] All three Docker configs use Docker with `preserve = true`.
- [ ] All three Docker configs use `skip_clone = true`.
- [ ] Prepare steps install or verify Node.
- [ ] Prepare steps verify `npx`.
- [ ] Prepare steps create `.fabro-smoke-claude-acp`, `.fabro-smoke-codex-acp`, and `.fabro-smoke-gemini-acp`.
- [ ] Ran `$FABRO run --auto-approve smoke/docker_acp_claude.toml`.
- [ ] Recorded Docker Claude ACP run ID: `________________`.
- [ ] Ran `$FABRO run --auto-approve smoke/docker_acp_codex.toml`.
- [ ] Recorded Docker Codex ACP run ID: `________________`.
- [ ] Ran `$FABRO run --auto-approve smoke/docker_acp_gemini.toml`.
- [ ] Recorded Docker Gemini ACP run ID: `________________`.
- [ ] Each Docker ACP run exited successfully.
- [ ] Verify stages printed `docker-acp-claude-ok`, `docker-acp-codex-ok`, and `docker-acp-gemini-ok`.
- [ ] Each Docker run's events include `sandbox.ready`.
- [ ] Each Docker run's events include `setup.started`.
- [ ] Each Docker run's events include `setup.completed`.
- [ ] Each Docker run's events include `agent.acp.started`.
- [ ] Each Docker run's events include `agent.acp.completed`.
- [ ] Each Docker run's events include `stage.completed` for `verify`.
- [ ] Each Docker run's events do not include `agent.session.activated` for the `work` stage.
- [ ] Each Docker run's events do not include `agent.cli.started` for the `work` stage.
- [ ] `fabro inspect <run-id>` shows each Docker run succeeded.
- [ ] Preserved Docker sandboxes contain their expected `smoke_docker_acp_<agent>_result.txt` files with exact contents.
### Smoke 3: Daytona API Backend Control
- [ ] Created `smoke/daytona_api.fabro`.
- [ ] Created `smoke/daytona_api.toml`.
- [ ] Config uses Daytona with `preserve = true`.
- [ ] Config uses `skip_clone = true`.
- [ ] Ran `$FABRO run --auto-approve smoke/daytona_api.toml`.
- [ ] Recorded Daytona API smoke run ID: `________________`.
- [ ] Run exited successfully.
- [ ] `verify` stage printed `daytona-api-ok`.
- [ ] Events include `sandbox.ready`.
- [ ] Events include `agent.session.activated`.
- [ ] Events include `stage.completed` for `work`.
- [ ] Events include `stage.completed` for `verify`.
- [ ] `fabro inspect <run-id>` shows the run succeeded.
- [ ] Preserved Daytona sandbox contains `smoke_api_result.txt` with exactly `daytona-api-ok`.
### Smoke 4: Daytona CLI Backend Matrix
- [ ] Created `smoke/daytona_cli_claude.fabro` and `smoke/daytona_cli_claude.toml`.
- [ ] Created `smoke/daytona_cli_codex.fabro` and `smoke/daytona_cli_codex.toml`.
- [ ] Created `smoke/daytona_cli_gemini.fabro` and `smoke/daytona_cli_gemini.toml`.
- [ ] All three Daytona CLI configs use Daytona with `preserve = true`.
- [ ] All three Daytona CLI configs use `skip_clone = true`.
- [ ] Prepare steps install or verify Node.
- [ ] Prepare steps install or verify `claude`, `codex`, and `gemini` in the sandbox PATH.
- [ ] Prepare steps print `claude --version`, `codex --version`, and `gemini --version`.
- [ ] Ran `$FABRO run --auto-approve smoke/daytona_cli_claude.toml`.
- [ ] Recorded Daytona Claude CLI run ID: `________________`.
- [ ] Ran `$FABRO run --auto-approve smoke/daytona_cli_codex.toml`.
- [ ] Recorded Daytona Codex CLI run ID: `________________`.
- [ ] Ran `$FABRO run --auto-approve smoke/daytona_cli_gemini.toml`.
- [ ] Recorded Daytona Gemini CLI run ID: `________________`.
- [ ] Each Daytona CLI run exited successfully.
- [ ] Verify stages printed `daytona-cli-claude-ok`, `daytona-cli-codex-ok`, and `daytona-cli-gemini-ok`.
- [ ] Each Daytona CLI run's events include `setup.started`.
- [ ] Each Daytona CLI run's events include `setup.completed`.
- [ ] Each Daytona CLI run's events include `agent.cli.started`.
- [ ] Each Daytona CLI run's events include `agent.cli.completed`.
- [ ] Each Daytona CLI run's events include `stage.completed` for `verify`.
- [ ] Each Daytona CLI run's events do not include `cli.ensure.started`.
- [ ] Each Daytona CLI run's events do not include `cli.ensure.completed`.
- [ ] Each Daytona CLI run's events do not include `cli.ensure.failed`.
- [ ] Preserved Daytona sandboxes contain their expected `smoke_daytona_cli_<agent>_result.txt` files with exact contents.
### Smoke 5: Daytona ACP Unsupported Failure
- [ ] Created `smoke/daytona_acp_unsupported.fabro`.
- [ ] Created `smoke/daytona_acp_unsupported.toml`.
- [ ] Config uses Daytona with `preserve = true`.
- [ ] Config uses `skip_clone = true`.
- [ ] Ran `$FABRO run --auto-approve smoke/daytona_acp_unsupported.toml`.
- [ ] Recorded Daytona ACP smoke run ID: `________________`.
- [ ] Run failed.
- [ ] Failure text contains `ACP backend requires bidirectional stdio`.
- [ ] Failure text contains `Daytona sandbox provider does not support it yet`.
- [ ] Events include `agent.acp.started`.
- [ ] Events do not include `agent.acp.completed`.
- [ ] Preserved Daytona sandbox does not contain `smoke_acp_result.txt`.
- [ ] No evidence shows ACP ran on the host.
- [ ] No evidence shows ACP used a PTY fallback.
- [ ] No evidence shows ACP silently fell back to API or CLI.
### Evidence Capture
For each required run:
- [ ] Captured `$FABRO inspect <run-id>`.
- [ ] Captured `$FABRO events <run-id> --tail 200`.
- [ ] Captured `$FABRO dump --output tmp/<run-id>-dump <run-id>`.
- [ ] Recorded command used.
- [ ] Recorded final status.
- [ ] Recorded relevant event names.
- [ ] Recorded any external-provider or sandbox infrastructure errors.
For provider-specific filesystem checks:
- [ ] Inspected local working tree files directly.
- [ ] Inspected preserved Docker filesystem with `$FABRO sandbox ssh <run-id>`.
- [ ] Inspected preserved Daytona filesystem with `$FABRO sandbox ssh <run-id>`.
- [ ] Recorded whether expected files exist in the expected provider workspace.
### Cleanup And Final Acceptance
- [ ] Removed each run with `$FABRO rm -f <run-id>` after evidence capture.
- [ ] Removed local smoke artifacts: `smoke_local_acp_*_result.txt`, `.fabro-smoke-*-acp`, and `.fabro-smoke-home/`.
- [ ] Verified preserved Docker sandbox is gone with Docker or `fabro inspect <run-id>`.
- [ ] Verified preserved Daytona sandboxes are gone from the Daytona dashboard or `fabro inspect <run-id>`.
- [ ] Local ACP backend smoke succeeded with Claude, Codex, and Gemini and no API/CLI fallback.
- [ ] Docker ACP backend smoke succeeded with Claude, Codex, and Gemini and no API/CLI fallback.
- [ ] Daytona CLI backend smoke succeeded with Claude, Codex, and Gemini after explicit CLI installation in prepare steps.
- [ ] Optional Daytona API backend control result was recorded if run.
- [ ] Daytona ACP smoke failed with the expected unsupported bidirectional-stdio message.
- [ ] Captured events and dumps are sufficient to diagnose any failure without rerunning immediately.
## Evidence Capture Commands
For each run, capture:
```bash
$FABRO inspect <run-id>
$FABRO events <run-id> --tail 200
$FABRO dump --output tmp/<run-id>-dump <run-id>
```
For the local smoke, inspect the local filesystem. For preserved Docker and Daytona sandboxes, inspect the provider filesystem:
```bash
cat smoke_local_acp_claude_result.txt 2>/dev/null || true
cat smoke_local_acp_codex_result.txt 2>/dev/null || true
cat smoke_local_acp_gemini_result.txt 2>/dev/null || true
$FABRO sandbox ssh <run-id>
pwd
ls -la
cat smoke_docker_acp_claude_result.txt 2>/dev/null || true
cat smoke_docker_acp_codex_result.txt 2>/dev/null || true
cat smoke_docker_acp_gemini_result.txt 2>/dev/null || true
cat smoke_api_result.txt 2>/dev/null || true
cat smoke_daytona_cli_claude_result.txt 2>/dev/null || true
cat smoke_daytona_cli_codex_result.txt 2>/dev/null || true
cat smoke_daytona_cli_gemini_result.txt 2>/dev/null || true
cat smoke_acp_result.txt 2>/dev/null || true
exit
```
Record for each run:
- Run ID.
- Command used.
- Final status.
- Relevant event names.
- Whether expected files exist in the expected local, Docker, or Daytona workspace.
- Any external-provider or sandbox infrastructure errors.
## Cleanup
After evidence capture:
```bash
$FABRO rm -f <run-id>
rm -f smoke_local_acp_*_result.txt .fabro-smoke-*-acp
rm -rf .fabro-smoke-home
```
Verify preserved Docker and Daytona sandboxes are gone from Docker/Daytona or by rerunning `fabro inspect <run-id>` and confirming no active sandbox remains.
## Final Acceptance Criteria
The branch passes this manual QA plan when:
1. The local sandbox provider succeeds with real Claude, Codex, and Gemini ACP-backed agents.
2. The Docker sandbox provider succeeds with real Claude, Codex, and Gemini ACP-backed agents.
3. The Daytona sandbox provider succeeds with real Claude, Codex, and Gemini CLI-backed agents after explicit CLI installation in prepare steps.
4. The optional Daytona API control smoke, if run, succeeds or has a clearly recorded external-provider/setup failure.
5. The ACP Daytona smoke fails with the expected unsupported bidirectional-stdio message.
6. No evidence shows ACP-on-Daytona ran on the host, used a PTY fallback, or silently fell back to API/CLI.
7. Captured run events and dumps are sufficient to diagnose any failure without rerunning immediately.

View file

@ -127,7 +127,7 @@ The tracked paths are stored as `files_touched` on the stage outcome:
For the **API backend**, Fabro subscribes to agent session events. When a `ToolCallStarted` event fires for `write_file` or `edit_file`, Fabro records the `file_path` argument as pending. When the corresponding `ToolCallCompleted` arrives without an error, the path is confirmed as touched. Failed tool calls are discarded.
For the **CLI backend**, Fabro takes a different approach: it runs `git diff --name-only` and `git ls-files --others --exclude-standard` before and after the agent session, then computes the difference. Any files that appear in the "after" snapshot but not "before" are recorded as touched.
For the **CLI** and **ACP** backends, Fabro takes a different approach: it runs `git diff --name-only` and `git ls-files --others --exclude-standard` before and after the external agent session, then computes the difference. Any files that appear in the "after" snapshot but not "before" are recorded as touched.
## Artifact offloading

View file

@ -6,7 +6,7 @@ description: "Delegate subtasks to child agent sessions"
An agent can spawn **sub-agents** to delegate work to independent child sessions. Each sub-agent gets its own LLM session and tool access, runs concurrently with the parent, and returns its result when finished.
<Note>
Sub-agents are only available with the [API backend](/core-concepts/agents#api-backend-default) (the default). Agents using the [CLI backend](/core-concepts/agents#cli-backend) cannot spawn sub-agents.
Sub-agents are only available with the [API backend](/core-concepts/agents#api-backend-default) (the default). Agents using the [CLI backend](/core-concepts/agents#cli-backend) or [ACP backend](/core-concepts/agents#acp-backend) cannot spawn Fabro sub-agents.
</Note>
## Tools

View file

@ -6,7 +6,7 @@ description: "Built-in tools for file I/O, shell commands, search, and web acces
Every agent in Fabro has access to a set of built-in tools for interacting with the codebase and environment. Tools execute inside the agent's [sandbox](/execution/environments) — whether that's the local machine, a Docker container, or a Daytona VM — so the same tool calls work identically regardless of provider.
<Note>
The tools described on this page apply to the **API backend** (the default). When using the [CLI backend](/core-concepts/agents#cli-backend), the external CLI tool (`claude`, `codex`, or `gemini`) provides its own tools — Fabro's built-in tools are not used.
The tools described on this page apply to the **API backend** (the default). When using the [CLI backend](/core-concepts/agents#cli-backend) or [ACP backend](/core-concepts/agents#acp-backend), the external agent process provides its own tools — Fabro's built-in tools are not used.
</Note>
## Core tools

View file

@ -13,11 +13,11 @@ Two new built-in tools bring real-time information into workflow decisions. `web
## CLI backends
Individual workflow nodes can now delegate work to external AI coding assistants. Set the backend to `claude-code`, `codex`, or `gemini-cli` and the node will use that CLI tool instead of the built-in agent loop.
Individual workflow nodes can delegate work to external AI coding assistants. Set `backend="cli"` and choose a provider; Fabro selects `claude`, `codex`, or `gemini` for the node instead of the built-in API agent loop. Current backend values are `api`, `cli`, and `acp`.
```dot
implement [handler=codergen, cli_backend=codex]
review [handler=codergen, cli_backend=claude-code]
implement [type="agent", backend="cli", provider="openai"]
review [type="agent", backend="cli", provider="anthropic"]
```
This means each stage in a workflow can use a different AI tool — use Codex for implementation and Claude Code for review, for example.

View file

@ -18,7 +18,7 @@ This loop continues until the model stops calling tools, indicating it considers
## Backends
Every agent node uses a **backend** that determines how Fabro interacts with the LLM. There are two options:
Every agent and prompt node uses a **backend** that determines how Fabro interacts with the LLM. There are three options:
### API backend (default)
@ -31,7 +31,7 @@ Fabro manages the agent loop directly — it calls the LLM provider's API, execu
### CLI backend
Fabro delegates execution to an external coding assistant CLI. The CLI tool manages its own tool loop internally — Fabro sends the prompt, waits for the CLI to finish, and tracks file changes via `git diff` before and after execution.
Fabro delegates execution to a legacy external coding assistant CLI. The CLI tool manages its own tool loop internally — Fabro sends the prompt, waits for the CLI to finish, and tracks file changes via `git diff` before and after execution.
The CLI is selected automatically based on the node's provider:
@ -41,6 +41,8 @@ The CLI is selected automatically based on the node's provider:
| OpenAI | `codex` |
| Gemini | `gemini` |
Fabro does not install these CLIs at runtime. Install the selected CLI in the sandbox image or run setup steps before the workflow reaches a `backend="cli"` node.
Set the CLI backend on a node with `backend="cli"` or via a [model stylesheet](/workflows/stylesheets):
```dot
@ -52,15 +54,29 @@ implement [label="Implement", backend="cli"]
* { backend: cli; }
```
### ACP backend
Fabro can also run Agent Client Protocol (ACP) stdio agents with `backend="acp"`. ACP agents run inside the active Fabro sandbox, so local and Docker runs keep the same workspace isolation, secret forwarding, cancellation, and file-change tracking behavior as other agent stages.
Set ACP on a node with `backend="acp"` and an explicit `acp_command`:
```dot
implement [label="Implement", backend="acp", acp_command="python3 tools/fake_acp_agent.py"]
```
Fabro does not install ACP agents, Node.js, npm, or `npx` at runtime. The command must already be available in the sandbox image, repository, or setup steps. You can use `npx ...@latest` as an explicit `acp_command` if that is the behavior you want, but Fabro will treat it like any other user-supplied command.
ACP v1 does not have a portable model-selection request. Fabro records the selected provider and model in events and run projections, but model-specific ACP behavior must be encoded in the chosen command for now. ACP is supported with local and Docker sandboxes; Daytona does not expose bidirectional stdio yet, so ACP nodes fail there with an explicit unsupported-provider error.
### Comparison
| Capability | API backend | CLI backend |
|---|---|---|
| Tools | Fabro built-in tools + MCP | CLI's own tool set |
| Session caching | Supported (`fidelity` + `thread_id`) | Not supported |
| Sub-agents | Supported | Not supported |
| Provider failover | Supported | Not supported |
| File tracking | Tool call events | `git diff` before/after |
| Capability | API backend | CLI backend | ACP backend |
|---|---|---|---|
| Tools | Fabro built-in tools + MCP | CLI's own tool set | ACP agent's own tool set |
| Session caching | Supported (`fidelity` + `thread_id`) | Not supported | Agent-dependent |
| Sub-agents | Supported | Not supported | Not supported through Fabro tools |
| Provider failover | Supported | Not supported | Not supported |
| File tracking | Tool call events | `git diff` before/after | `git diff` before/after |
### When to use the CLI backend
@ -68,6 +84,12 @@ implement [label="Implement", backend="cli"]
- **CLI-only models** — use models that are only available through a CLI tool, not via API
- **Existing workflows** — integrate a CLI tool you already depend on without rewriting its configuration
### When to use the ACP backend
- **Protocol adapters** — run ACP-compatible coding agents through a stable stdio protocol
- **Sandbox parity** — keep agent process execution inside Fabro's local or Docker sandbox
- **Custom agents** — use `acp_command` for a checked-in or preinstalled ACP adapter
## Tools
Agents have access to a set of built-in tools for interacting with the codebase and environment:

View file

@ -206,7 +206,8 @@ Start nodes can also be identified by ID (`start` or `Start`). Exit nodes can be
| `model` | String | Explicit model ID (overrides stylesheet) |
| `provider` | String | Explicit provider name (overrides stylesheet). Auto-inferred from the model catalog when omitted. |
| `project_memory` | Boolean | When `true` (default), prompt nodes discover and include project docs (`AGENTS.md`, `CLAUDE.md`, etc.) as a system prompt. Set to `false` to disable. |
| `backend` | String | Agent execution backend. `api` (default): Fabro calls the LLM API directly and runs its own tool loop. `cli`: Fabro delegates to an external CLI tool (`claude`, `codex`, or `gemini` based on provider). See [Agents — Backends](/core-concepts/agents#backends). |
| `backend` | String | Agent execution backend: `api` (default), `cli`, or `acp`. `api` runs Fabro's tool loop through provider APIs; `cli` delegates to the legacy provider CLI; `acp` runs an Agent Client Protocol stdio agent inside the active sandbox. See [Agents — Backends](/core-concepts/agents#backends). |
| `acp_command` | String | Required for nodes with `backend="acp"`. The value must be a stdio ACP command available in the sandbox. Fabro records model selection but does not send it through stable ACP v1. |
### Command nodes

View file

@ -88,7 +88,7 @@ Stylesheets support four properties:
| `model` | Model ID or alias (e.g. `claude-sonnet-4-5`, `opus`, `gemini-pro`) |
| `provider` | Provider name (optional — auto-inferred from the model catalog when omitted) |
| `reasoning_effort` | `low`, `medium`, or `high` |
| `backend` | `api` (default) or `cli` |
| `backend` | `api` (default), `cli`, or `acp` |
## Why route models?

View file

@ -68,7 +68,7 @@ Stylesheets support four properties:
| `provider` | Provider name (optional — auto-inferred from the model catalog when omitted) | `anthropic`, `openai`, `gemini` |
| `reasoning_effort` | Reasoning effort level | `low`, `medium`, `high` |
| `speed` | Output speed mode. `fast` enables Anthropic's fast mode for up to 2.5x faster output at higher cost. | `fast` |
| `backend` | Agent execution backend — `api` (default) runs Fabro's own tool loop, `cli` delegates to an external CLI tool. See [Backends](/core-concepts/agents#backends). | `cli`, `api` |
| `backend` | Agent execution backend — `api` (default) runs Fabro's own tool loop, `cli` delegates to a legacy external CLI tool, and `acp` runs an Agent Client Protocol stdio agent in the active sandbox. See [Backends](/core-concepts/agents#backends). | `api`, `cli`, `acp` |
See [Models](/core-concepts/models) for the full list of model IDs and aliases.

View file

@ -0,0 +1,37 @@
[package]
name = "fabro-acp"
edition.workspace = true
version.workspace = true
publish = false
license.workspace = true
description = "Agent Client Protocol backend support for Fabro"
[features]
test-support = []
[lib]
doctest = false
[lints]
workspace = true
[dependencies]
agent-client-protocol.workspace = true
agent-client-protocol-tokio.workspace = true
fabro-model = { path = "../fabro-model" }
fabro-sandbox = { path = "../fabro-sandbox" }
fabro-types = { path = "../fabro-types" }
fabro-util = { path = "../fabro-util" }
bytes.workspace = true
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
tokio.workspace = true
tokio-util = { workspace = true, features = ["compat", "io"] }
futures.workspace = true
uuid.workspace = true
tracing.workspace = true
[dev-dependencies]
fabro-sandbox = { path = "../fabro-sandbox", features = ["test-support"] }
tempfile = "3"

View file

@ -0,0 +1,190 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use agent_client_protocol::schema::McpServer;
use agent_client_protocol_tokio::AcpAgent;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AcpCommand {
display: String,
program: PathBuf,
args: Vec<String>,
env: HashMap<String, String>,
}
impl AcpCommand {
#[must_use]
pub fn program(&self) -> &Path {
&self.program
}
#[must_use]
pub fn args(&self) -> &[String] {
&self.args
}
#[must_use]
pub fn env(&self) -> &HashMap<String, String> {
&self.env
}
#[must_use]
pub fn display(&self) -> &str {
&self.display
}
#[must_use]
pub fn to_shell_command(&self) -> String {
render_command(&self.program, &self.args)
}
}
impl std::fmt::Display for AcpCommand {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.display)
}
}
#[derive(Debug, thiserror::Error)]
pub enum AcpCommandError {
#[error("acp_command must not be empty")]
EmptyOverride,
#[error(
"acp_command is required for backend=\"acp\" because Fabro does not install ACP agents"
)]
MissingOverride,
#[error("only stdio ACP commands are supported")]
UnsupportedTransport,
#[error("failed to parse acp_command")]
Parse(#[source] agent_client_protocol::Error),
}
impl From<agent_client_protocol::Error> for AcpCommandError {
fn from(error: agent_client_protocol::Error) -> Self {
Self::Parse(error)
}
}
pub fn resolve_acp_command(override_command: Option<&str>) -> Result<AcpCommand, AcpCommandError> {
if let Some(raw) = override_command {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(AcpCommandError::EmptyOverride);
}
return parse_acp_command(trimmed);
}
Err(AcpCommandError::MissingOverride)
}
fn parse_acp_command(raw: &str) -> Result<AcpCommand, AcpCommandError> {
reject_non_stdio_json_transport(raw)?;
let agent = AcpAgent::from_str(raw)?;
let McpServer::Stdio(stdio) = agent.into_server() else {
return Err(AcpCommandError::UnsupportedTransport);
};
let program = stdio.command;
let args = stdio.args;
let display = render_command(&program, &args);
Ok(AcpCommand {
display,
program,
args,
env: stdio
.env
.into_iter()
.map(|env| (env.name, env.value))
.collect(),
})
}
fn render_command(program: &Path, args: &[String]) -> String {
std::iter::once(program.to_string_lossy().into_owned())
.chain(args.iter().cloned())
.map(|part| fabro_sandbox::shell_quote(&part))
.collect::<Vec<_>>()
.join(" ")
}
fn reject_non_stdio_json_transport(raw: &str) -> Result<(), AcpCommandError> {
let trimmed = raw.trim_start();
if !trimmed.starts_with('{') {
return Ok(());
}
let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) else {
return Ok(());
};
match value.get("type").and_then(serde_json::Value::as_str) {
Some("stdio") | None => Ok(()),
Some(_) => Err(AcpCommandError::UnsupportedTransport),
}
}
#[cfg(test)]
mod tests {
use std::path::Path;
use super::*;
#[test]
fn missing_acp_command_is_rejected() {
let err = resolve_acp_command(None).unwrap_err();
assert!(
err.to_string()
.contains("acp_command is required for backend=\"acp\"")
);
}
#[test]
fn explicit_acp_command_overrides_provider_default() {
let command = resolve_acp_command(Some("python fake_agent.py")).unwrap();
assert_eq!(command.to_string(), "python fake_agent.py");
assert_eq!(command.program(), Path::new("python"));
assert_eq!(command.args(), &["fake_agent.py".to_string()]);
}
#[test]
fn blank_acp_command_is_rejected() {
let err = resolve_acp_command(Some(" ")).unwrap_err();
assert!(err.to_string().contains("acp_command must not be empty"));
}
#[test]
fn json_stdio_acp_command_is_supported() {
let raw = r#"{"type":"stdio","name":"fake","command":"python","args":["fake agent.py"],"env":[{"name":"MODE","value":"test"}]}"#;
let command = resolve_acp_command(Some(raw)).unwrap();
assert_eq!(command.program(), Path::new("python"));
assert_eq!(command.args(), &["fake agent.py".to_string()]);
assert_eq!(command.env().get("MODE").map(String::as_str), Some("test"));
}
#[test]
fn json_stdio_acp_command_display_omits_env_contents() {
let raw = r#"{"type":"stdio","name":"fake","command":"agent","args":["--flag","two words"],"env":[{"name":"OPENAI_API_KEY","value":"secret-key"}]}"#;
let command = resolve_acp_command(Some(raw)).unwrap();
assert_eq!(
command.env().get("OPENAI_API_KEY").map(String::as_str),
Some("secret-key")
);
assert_eq!(command.to_string(), "agent --flag 'two words'");
assert!(!command.to_string().contains("secret-key"));
assert!(!command.to_string().contains("OPENAI_API_KEY"));
}
#[test]
fn non_stdio_acp_command_is_rejected() {
let raw = r#"{"type":"http","name":"remote","url":"https://example.test/acp"}"#;
let err = resolve_acp_command(Some(raw)).unwrap_err();
assert!(
err.to_string()
.contains("only stdio ACP commands are supported")
);
}
}

View file

@ -0,0 +1,31 @@
use crate::command::AcpCommandError;
#[derive(Debug, thiserror::Error)]
pub enum AcpError {
#[error(transparent)]
Command(#[from] AcpCommandError),
#[error(transparent)]
Sandbox(#[from] fabro_sandbox::Error),
#[error("ACP protocol error")]
Protocol(#[source] agent_client_protocol::Error),
#[error("ACP turn was cancelled")]
Cancelled,
#[error("ACP turn timed out")]
TimedOut { stderr: String },
#[error("ACP prompt stopped with {stop_reason}: {text}")]
StopReason {
stop_reason: String,
text: String,
},
}
impl From<agent_client_protocol::Error> for AcpError {
fn from(error: agent_client_protocol::Error) -> Self {
Self::Protocol(error)
}
}

View file

@ -0,0 +1,12 @@
pub mod command;
pub mod error;
pub mod session;
#[cfg(any(test, feature = "test-support"))]
pub mod test_support;
mod transport;
pub use command::{AcpCommand, AcpCommandError, resolve_acp_command};
pub use error::AcpError;
pub use session::{AcpRunRequest, AcpRunResult, render_stop_reason, run_acp_turn};

View file

@ -0,0 +1,268 @@
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use agent_client_protocol::schema::{
CancelNotification, ContentBlock, ContentChunk, InitializeRequest, PermissionOptionKind,
ProtocolVersion, RequestPermissionOutcome, RequestPermissionRequest, RequestPermissionResponse,
SelectedPermissionOutcome, SessionNotification, SessionUpdate, StopReason,
};
use agent_client_protocol::util::MatchDispatch;
use agent_client_protocol::{ActiveSession, Agent, Client, Error as ProtocolError, SessionMessage};
use fabro_sandbox::Sandbox;
use fabro_util::time::elapsed_ms;
use tokio::time::{sleep, timeout};
use tokio_util::sync::CancellationToken;
use crate::command::AcpCommand;
use crate::error::AcpError;
use crate::transport::{SandboxAcpTransport, TransportState};
pub struct AcpRunRequest {
pub command: AcpCommand,
pub prompt: String,
pub cwd: String,
pub timeout_ms: Option<u64>,
pub env: HashMap<String, String>,
pub sandbox: Arc<dyn Sandbox>,
pub cancel_token: CancellationToken,
pub on_activity: Option<Arc<dyn Fn() + Send + Sync>>,
}
#[derive(Debug)]
pub struct AcpRunResult {
pub text: String,
pub stop_reason: StopReason,
pub stderr: String,
pub duration_ms: u64,
}
pub async fn run_acp_turn(request: AcpRunRequest) -> Result<AcpRunResult, AcpError> {
let AcpRunRequest {
command,
prompt,
cwd,
timeout_ms,
env,
sandbox,
cancel_token,
on_activity,
} = request;
let start = std::time::Instant::now();
let state = TransportState::new();
let read_cancel_token = cancel_token.clone();
let run_cancel_token = cancel_token.clone();
let permission_cancel_token = cancel_token.clone();
let state_for_run = state.clone();
let transport = SandboxAcpTransport::new(command, cwd.clone(), env, sandbox, state.clone());
let run = Client
.builder()
.name("fabro")
.on_receive_request(
async move |request: RequestPermissionRequest, responder, _connection| {
let outcome = if permission_cancel_token.is_cancelled() {
RequestPermissionOutcome::Cancelled
} else {
select_permission_outcome(&request)
};
responder.respond(RequestPermissionResponse::new(outcome))
},
agent_client_protocol::on_receive_request!(),
)
.connect_with(transport, async move |cx| {
cx.send_request(InitializeRequest::new(ProtocolVersion::V1))
.block_task()
.await?;
cx.build_session(&cwd)
.block_task()
.run_until(async |mut session| {
session.send_prompt(prompt)?;
read_turn(
&mut session,
&read_cancel_token,
on_activity.as_ref(),
&state_for_run,
)
.await
})
.await
});
let cancel_deadline_token = cancel_token.clone();
let run_outcome = async {
match timeout_ms {
Some(timeout_ms) => {
if let Ok(result) = timeout(Duration::from_millis(timeout_ms), run).await {
Ok(result)
} else {
state.terminate().await?;
if run_cancel_token.is_cancelled() {
return Err(AcpError::Cancelled);
}
Err(AcpError::TimedOut {
stderr: state.stderr_tail().await,
})
}
}
None => Ok(run.await),
}
};
let outcome = tokio::select! {
result = run_outcome => result?,
() = async {
cancel_deadline_token.cancelled().await;
sleep(Duration::from_millis(500)).await;
} => {
state.terminate().await?;
return Err(AcpError::Cancelled);
}
};
let (text, stop_reason) = match outcome {
Ok(result) => result,
Err(_) if run_cancel_token.is_cancelled() => {
state.terminate().await?;
return Err(AcpError::Cancelled);
}
Err(error) => {
state.terminate().await?;
if let Some(startup_error) = state.take_startup_error().await {
return Err(AcpError::Sandbox(startup_error));
}
return Err(map_protocol_error(error));
}
};
match stop_reason {
StopReason::EndTurn | StopReason::Refusal => {}
StopReason::Cancelled => {
state.terminate().await?;
return Err(AcpError::Cancelled);
}
_ => {
state.terminate().await?;
return Err(AcpError::StopReason {
stop_reason: render_stop_reason(&stop_reason),
text,
});
}
}
state.terminate().await?;
let stderr = state.stderr_tail().await;
Ok(AcpRunResult {
text,
stop_reason,
stderr,
duration_ms: elapsed_ms(start),
})
}
fn map_protocol_error(error: ProtocolError) -> AcpError {
AcpError::Protocol(error)
}
fn select_permission_outcome(request: &RequestPermissionRequest) -> RequestPermissionOutcome {
let selected = request
.options
.iter()
.find(|option| option.kind == PermissionOptionKind::AllowAlways)
.or_else(|| {
request
.options
.iter()
.find(|option| option.kind == PermissionOptionKind::AllowOnce)
})
.or_else(|| {
request.options.iter().find(|option| {
!matches!(
option.kind,
PermissionOptionKind::RejectOnce | PermissionOptionKind::RejectAlways
)
})
});
selected.map_or(RequestPermissionOutcome::Cancelled, |option| {
RequestPermissionOutcome::Selected(SelectedPermissionOutcome::new(option.option_id.clone()))
})
}
async fn read_turn(
session: &mut ActiveSession<'_, Agent>,
cancel_token: &CancellationToken,
on_activity: Option<&Arc<dyn Fn() + Send + Sync>>,
state: &TransportState,
) -> Result<(String, StopReason), ProtocolError> {
let mut text = String::new();
let mut cancel_sent = false;
loop {
tokio::select! {
update = session.read_update() => {
if let Some(on_activity) = on_activity {
on_activity();
}
match update? {
SessionMessage::SessionMessage(dispatch) => {
MatchDispatch::new(dispatch)
.if_notification(async |notification: SessionNotification| {
if let SessionUpdate::AgentMessageChunk(ContentChunk {
content: ContentBlock::Text(text_chunk),
..
}) = notification.update {
text.push_str(&text_chunk.text);
}
Ok(())
})
.await
.otherwise_ignore()?;
}
SessionMessage::StopReason(stop_reason) => {
return Ok((text, stop_reason));
}
_ => {}
}
}
() = cancel_token.cancelled(), if !cancel_sent => {
cancel_sent = true;
session.connection().send_notification_to(
Agent,
CancelNotification::new(session.session_id().clone()),
)?;
}
() = sleep(Duration::from_millis(500)), if cancel_sent => {
state.terminate().await.map_err(ProtocolError::into_internal_error)?;
return Ok((text, StopReason::Cancelled));
}
}
}
}
#[must_use]
pub fn render_stop_reason(stop_reason: &StopReason) -> String {
serde_json::to_value(stop_reason)
.ok()
.and_then(|value| value.as_str().map(str::to_string))
.unwrap_or_else(|| format!("{stop_reason:?}"))
}
#[cfg(test)]
mod tests {
use agent_client_protocol::schema::SessionNotification;
#[test]
fn codex_usage_update_session_notification_deserializes() {
let notification = serde_json::json!({
"sessionId": "session-1",
"update": {
"sessionUpdate": "usage_update",
"used": 26128,
"size": 258_400
}
});
serde_json::from_value::<SessionNotification>(notification)
.expect("Codex ACP usage_update notifications should be ignored, not fatal");
}
}

View file

@ -0,0 +1,139 @@
use agent_client_protocol::schema::{
ContentBlock, ContentChunk, SessionNotification, SessionUpdate,
};
use serde_json::json;
pub const SESSION_ID: &str = "sess-1";
pub fn agent_message_chunk(session_id: &str, text: &str) -> SessionNotification {
SessionNotification::new(
session_id.to_string(),
SessionUpdate::AgentMessageChunk(ContentChunk::new(ContentBlock::from(text.to_string()))),
)
}
pub fn agent_message_chunk_json(session_id: &str, text: &str) -> serde_json::Value {
json!({
"jsonrpc": "2.0",
"method": "session/update",
"params": agent_message_chunk(session_id, text),
})
}
pub fn fake_acp_agent_script() -> &'static str {
r#"
import json
import os
import signal
import sys
import time
methods = []
session_id = "sess-1"
if os.environ.get("ACP_PID_RECORD"):
with open(os.environ["ACP_PID_RECORD"], "w", encoding="utf-8") as record:
record.write(str(os.getpid()))
def handle_sigterm(signum, frame):
if os.environ.get("ACP_LINGER_TERMINATED"):
with open(os.environ["ACP_LINGER_TERMINATED"], "w", encoding="utf-8") as record:
record.write("terminated\n")
sys.exit(0)
signal.signal(signal.SIGTERM, handle_sigterm)
def send(message):
print(json.dumps(message), flush=True)
def respond(message, result):
send({"jsonrpc": "2.0", "id": message["id"], "result": result})
def record_methods():
if os.environ.get("ACP_RECORD"):
with open(os.environ["ACP_RECORD"], "w", encoding="utf-8") as record:
record.write("\n".join(methods) + "\n")
for line in sys.stdin:
message = json.loads(line)
method = message.get("method")
methods.append(method)
if method == "initialize":
if os.environ.get("ACP_MODE") == "slow_initialize":
time.sleep(60)
respond(message, {"protocolVersion": 1, "agentCapabilities": {}})
elif method == "session/new":
if os.environ.get("ACP_SESSION_NEW_PARAMS"):
with open(os.environ["ACP_SESSION_NEW_PARAMS"], "w", encoding="utf-8") as record:
record.write(json.dumps(message.get("params", {}), separators=(",", ":")))
respond(message, {"sessionId": session_id})
elif method == "session/prompt":
if os.environ.get("ACP_PROMPT_RECORD"):
with open(os.environ["ACP_PROMPT_RECORD"], "w", encoding="utf-8") as record:
record.write(json.dumps(message.get("params", {})))
mode = os.environ.get("ACP_MODE", "normal")
if mode == "timeout":
time.sleep(60)
if mode == "malformed":
print("malformed json", file=sys.stderr, flush=True)
print("{not-json", flush=True)
break
if mode == "early_exit":
print("early boom", file=sys.stderr, flush=True)
sys.exit(2)
if mode == "write_file":
with open("hello.txt", "w", encoding="utf-8") as file:
file.write("hello from sandbox\n")
if mode == "cancel":
for cancel_line in sys.stdin:
cancel_message = json.loads(cancel_line)
if cancel_message.get("method") == "session/cancel":
with open(os.environ["ACP_CANCEL_RECORD"], "w", encoding="utf-8") as record:
record.write("session/cancel\n")
respond(message, {"stopReason": "cancelled"})
sys.exit(0)
if mode == "permission":
send({
"jsonrpc": "2.0",
"id": "permission-1",
"method": "session/request_permission",
"params": {
"sessionId": session_id,
"toolCall": {"toolCallId": "tool-1"},
"options": [
{"optionId": "reject", "name": "Reject", "kind": "reject_once"},
{"optionId": "once", "name": "Allow once", "kind": "allow_once"},
{"optionId": "always", "name": "Allow always", "kind": "allow_always"}
]
}
})
permission_response = json.loads(sys.stdin.readline())
with open(os.environ["ACP_PERMISSION"], "w", encoding="utf-8") as permission:
permission.write(json.dumps(permission_response.get("result", {}), separators=(",", ":")))
for text in ["hello ", "from acp"]:
send({
"jsonrpc": "2.0",
"method": "session/update",
"params": {
"sessionId": session_id,
"update": {
"sessionUpdate": "agent_message_chunk",
"content": {"type": "text", "text": text}
}
}
})
record_methods()
respond(message, {"stopReason": os.environ.get("ACP_STOP_REASON", "end_turn")})
if mode == "linger_after_response":
while True:
time.sleep(1)
break
else:
send({
"jsonrpc": "2.0",
"id": message.get("id"),
"error": {"code": -32601, "message": "method not found"}
})
"#
}

View file

@ -0,0 +1,156 @@
use std::collections::HashMap;
use std::io::Result as IoResult;
use std::pin::Pin;
use std::sync::Arc;
use std::time::Duration;
use agent_client_protocol::util::internal_error;
use agent_client_protocol::{
Agent, Client, ConnectTo, Error as ProtocolError, Lines, Result as AcpProtocolResult,
};
use fabro_sandbox::{
Error as SandboxError, Result as SandboxResult, Sandbox, StderrCollector, StdioProcessHandle,
};
use futures::io::BufReader;
use futures::sink::unfold;
use futures::{AsyncBufReadExt, AsyncWriteExt, Stream};
use tokio::sync::Mutex as TokioMutex;
use tokio::time::timeout;
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
use crate::command::AcpCommand;
#[derive(Clone)]
pub(crate) struct TransportState {
handle: Arc<TokioMutex<Option<StdioProcessHandle>>>,
stderr: Arc<TokioMutex<Option<StderrCollector>>>,
startup_error: Arc<TokioMutex<Option<SandboxError>>>,
}
impl TransportState {
pub(crate) fn new() -> Self {
Self {
handle: Arc::new(TokioMutex::new(None)),
stderr: Arc::new(TokioMutex::new(None)),
startup_error: Arc::new(TokioMutex::new(None)),
}
}
async fn set_process(&self, handle: StdioProcessHandle, stderr: StderrCollector) {
*self.handle.lock().await = Some(handle);
*self.stderr.lock().await = Some(stderr);
}
async fn set_startup_error(&self, error: SandboxError) {
*self.startup_error.lock().await = Some(error);
}
pub(crate) async fn take_startup_error(&self) -> Option<SandboxError> {
self.startup_error.lock().await.take()
}
pub(crate) async fn terminate(&self) -> SandboxResult<()> {
if let Some(handle) = self.handle.lock().await.as_ref().cloned() {
handle.terminate().await?;
}
Ok(())
}
pub(crate) async fn stderr_tail(&self) -> String {
if let Some(stderr) = self.stderr.lock().await.as_ref().cloned() {
return stderr.tail_string().await;
}
String::new()
}
}
pub(crate) struct SandboxAcpTransport {
command: AcpCommand,
cwd: String,
env: HashMap<String, String>,
sandbox: Arc<dyn Sandbox>,
state: TransportState,
}
impl SandboxAcpTransport {
pub(crate) fn new(
command: AcpCommand,
cwd: String,
env: HashMap<String, String>,
sandbox: Arc<dyn Sandbox>,
state: TransportState,
) -> Self {
Self {
command,
cwd,
env,
sandbox,
state,
}
}
}
impl ConnectTo<Client> for SandboxAcpTransport {
async fn connect_to(self, client: impl ConnectTo<Agent>) -> AcpProtocolResult<()> {
let mut env = self.command.env().clone();
env.extend(self.env);
let process = match self
.sandbox
.spawn_stdio_process(
&self.command.to_shell_command(),
Some(&self.cwd),
Some(&env),
None,
)
.await
{
Ok(process) => process,
Err(error) => {
self.state.set_startup_error(error).await;
return Err(internal_error("ACP process failed to start"));
}
};
let handle = process.handle.clone();
let stderr = process.stderr.clone();
self.state.set_process(handle.clone(), stderr.clone()).await;
let incoming_lines = Box::pin(BufReader::new(process.stdout.compat()).lines())
as Pin<Box<dyn Stream<Item = IoResult<String>> + Send>>;
let outgoing_sink = Box::pin(unfold(
process.stdin.compat_write(),
async move |mut writer, line: String| {
let mut bytes = line.into_bytes();
bytes.push(b'\n');
writer.write_all(&bytes).await?;
Ok::<_, std::io::Error>(writer)
},
));
let protocol = agent_client_protocol::ConnectTo::<Client>::connect_to(
Lines::new(outgoing_sink, incoming_lines),
client,
);
tokio::select! {
result = protocol => {
if let Err(err) = handle.terminate().await {
tracing::warn!(error = %err, "Failed to terminate ACP process after protocol completion");
}
let _ = timeout(Duration::from_millis(500), handle.wait()).await;
result
}
termination = handle.wait() => {
let termination = termination.map_err(ProtocolError::into_internal_error)?;
let stderr = stderr.tail_string().await;
let exit_code = termination
.exit_code
.map_or_else(|| "unknown".to_string(), |code| code.to_string());
Err(internal_error(format!(
"ACP process exited before protocol completed: termination={}, exit_code={exit_code}, stderr={stderr}",
termination.termination,
)))
}
}
}
}

View file

@ -0,0 +1,447 @@
use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use agent_client_protocol::schema::StopReason;
use fabro_acp::{AcpError, AcpRunRequest, AcpRunResult, resolve_acp_command, run_acp_turn};
use fabro_sandbox::test_support::MockSandbox;
use fabro_sandbox::{LocalSandbox, Sandbox, shell_quote};
use fabro_util::error::collect_chain;
use tokio::fs::{metadata, read_to_string, write};
use tokio::process::Command;
use tokio::time::{Instant, sleep};
use tokio_util::sync::CancellationToken;
const ACP_TEST_TIMEOUT_MS: u64 = 30_000;
#[allow(
unused,
unreachable_pub,
reason = "integration test imports the shared test fixture source as a private module"
)]
#[path = "../src/test_support.rs"]
mod test_support;
use test_support::fake_acp_agent_script;
#[tokio::test]
async fn stdio_spawn_failure_returns_sandbox_error() {
const SANDBOX_FAILURE: &str = "ACP backend requires bidirectional stdio; the Daytona sandbox provider does not support it yet";
let command = resolve_acp_command(Some("fake-acp-agent")).expect("resolve ACP command");
let mut sandbox = MockSandbox::linux();
sandbox.stdio_process_error = Some(SANDBOX_FAILURE.to_string());
let sandbox: Arc<dyn Sandbox> = Arc::new(sandbox);
let result = run_acp_turn(AcpRunRequest {
command,
prompt: "hello".to_string(),
cwd: "/workspace".to_string(),
timeout_ms: Some(ACP_TEST_TIMEOUT_MS),
env: HashMap::new(),
sandbox,
cancel_token: CancellationToken::new(),
on_activity: None,
})
.await;
let Err(error) = result else {
panic!("stdio spawn failure should fail");
};
assert!(
matches!(error, AcpError::Sandbox(_)),
"expected sandbox error, got {error:?}"
);
let chain = collect_chain(&error);
assert!(
chain.iter().any(|cause| cause == SANDBOX_FAILURE),
"cause chain should contain sandbox failure, got: {chain:?}"
);
}
#[tokio::test]
async fn session_lifecycle_initializes_sends_prompt_and_aggregates_text() {
let tempdir = tempfile::tempdir().expect("create tempdir");
let script_path = tempdir.path().join("fake_acp_agent.py");
let record_path = tempdir.path().join("methods.txt");
write(&script_path, fake_acp_agent_script())
.await
.expect("write fake ACP agent");
let raw_command = format!("python3 {}", shell_quote(&script_path.to_string_lossy()));
let command = resolve_acp_command(Some(&raw_command)).expect("resolve ACP command");
let sandbox: Arc<dyn Sandbox> = Arc::new(LocalSandbox::new(tempdir.path().to_path_buf()));
let result = run_acp_turn(AcpRunRequest {
command,
prompt: "hello".to_string(),
cwd: tempdir.path().to_string_lossy().into_owned(),
timeout_ms: Some(ACP_TEST_TIMEOUT_MS),
env: HashMap::from([(
"ACP_RECORD".to_string(),
record_path.to_string_lossy().into_owned(),
)]),
sandbox,
cancel_token: CancellationToken::new(),
on_activity: None,
})
.await
.expect("run ACP turn");
assert_eq!(result.text, "hello from acp");
assert_eq!(result.stop_reason, StopReason::EndTurn);
assert_eq!(
read_to_string(record_path)
.await
.expect("read method record"),
"initialize\nsession/new\nsession/prompt\n"
);
}
#[tokio::test]
async fn permission_request_selects_allow_always() {
let tempdir = tempfile::tempdir().expect("create tempdir");
let permission_path = tempdir.path().join("permission.json");
let result = run_fake_agent(
tempdir.path(),
HashMap::from([
("ACP_MODE".to_string(), "permission".to_string()),
(
"ACP_PERMISSION".to_string(),
permission_path.to_string_lossy().into_owned(),
),
]),
Some(ACP_TEST_TIMEOUT_MS),
CancellationToken::new(),
)
.await
.expect("run ACP turn");
assert_eq!(result.text, "hello from acp");
let permission = read_to_string(permission_path)
.await
.expect("read permission record");
assert!(permission.contains(r#""outcome":"selected""#));
assert!(permission.contains(r#""optionId":"always""#));
}
#[tokio::test]
async fn runs_inside_sandbox_and_uses_requested_cwd() {
let tempdir = tempfile::tempdir().expect("create tempdir");
let cwd_path = tempdir.path().join("session_new.json");
let result = run_fake_agent(
tempdir.path(),
HashMap::from([
("ACP_MODE".to_string(), "write_file".to_string()),
(
"ACP_SESSION_NEW_PARAMS".to_string(),
cwd_path.to_string_lossy().into_owned(),
),
]),
Some(ACP_TEST_TIMEOUT_MS),
CancellationToken::new(),
)
.await
.expect("run ACP turn");
assert_eq!(result.text, "hello from acp");
assert_eq!(
read_to_string(tempdir.path().join("hello.txt"))
.await
.expect("read sandbox output file"),
"hello from sandbox\n"
);
assert!(
read_to_string(cwd_path)
.await
.expect("read session/new params")
.contains(&tempdir.path().to_string_lossy().into_owned())
);
}
#[tokio::test]
async fn cancellation_sends_session_cancel_and_returns_cancelled() {
let tempdir = tempfile::tempdir().expect("create tempdir");
let cancel_path = tempdir.path().join("cancel.txt");
let prompt_path = tempdir.path().join("prompt.json");
let tempdir_path = tempdir.path().to_path_buf();
let cancel_path_for_task = cancel_path.clone();
let prompt_path_for_task = prompt_path.clone();
let cancel_token = CancellationToken::new();
let cancel_for_task = cancel_token.clone();
let task = tokio::spawn(async move {
run_fake_agent(
&tempdir_path,
HashMap::from([
("ACP_MODE".to_string(), "cancel".to_string()),
(
"ACP_CANCEL_RECORD".to_string(),
cancel_path_for_task.to_string_lossy().into_owned(),
),
(
"ACP_PROMPT_RECORD".to_string(),
prompt_path_for_task.to_string_lossy().into_owned(),
),
]),
Some(ACP_TEST_TIMEOUT_MS),
cancel_for_task,
)
.await
});
wait_for_file(&prompt_path, Duration::from_secs(10)).await;
cancel_token.cancel();
let err = task
.await
.expect("join cancellation task")
.expect_err("cancelled turn should error");
assert!(matches!(err, AcpError::Cancelled));
assert_eq!(
read_to_string(cancel_path)
.await
.expect("read cancel record"),
"session/cancel\n"
);
}
#[tokio::test]
async fn pre_session_cancellation_returns_cancelled() {
let tempdir = tempfile::tempdir().expect("create tempdir");
let cancel_token = CancellationToken::new();
cancel_token.cancel();
let err = run_fake_agent(
tempdir.path(),
HashMap::from([("ACP_MODE".to_string(), "slow_initialize".to_string())]),
Some(1_000),
cancel_token,
)
.await
.expect_err("pre-session cancellation should error");
assert!(matches!(err, AcpError::Cancelled));
}
#[tokio::test]
async fn successful_turn_terminates_lingering_agent_process() {
let tempdir = tempfile::tempdir().expect("create tempdir");
let pid_path = tempdir.path().join("agent.pid");
let result = run_fake_agent(
tempdir.path(),
HashMap::from([
("ACP_MODE".to_string(), "linger_after_response".to_string()),
(
"ACP_PID_RECORD".to_string(),
pid_path.to_string_lossy().into_owned(),
),
]),
Some(ACP_TEST_TIMEOUT_MS),
CancellationToken::new(),
)
.await
.expect("run ACP turn");
sleep(Duration::from_millis(100)).await;
let pid = read_to_string(&pid_path).await.expect("read agent pid");
let still_running = process_is_running(pid.trim()).await;
if still_running {
let _ = Command::new("kill")
.arg("-TERM")
.arg(pid.trim())
.status()
.await;
}
assert_eq!(result.text, "hello from acp");
assert!(
!still_running,
"successful ACP turn should not leave lingering agent process"
);
}
#[tokio::test]
async fn refusal_stop_reason_returns_text() {
let tempdir = tempfile::tempdir().expect("create tempdir");
let result = run_fake_agent(
tempdir.path(),
HashMap::from([("ACP_STOP_REASON".to_string(), "refusal".to_string())]),
Some(ACP_TEST_TIMEOUT_MS),
CancellationToken::new(),
)
.await
.expect("run ACP turn");
assert_eq!(result.text, "hello from acp");
assert_eq!(result.stop_reason, StopReason::Refusal);
}
#[tokio::test]
async fn max_tokens_stop_reason_returns_partial_text_error() {
let tempdir = tempfile::tempdir().expect("create tempdir");
let err = run_fake_agent(
tempdir.path(),
HashMap::from([("ACP_STOP_REASON".to_string(), "max_tokens".to_string())]),
Some(ACP_TEST_TIMEOUT_MS),
CancellationToken::new(),
)
.await
.expect_err("max_tokens should return stop reason error");
let AcpError::StopReason { stop_reason, text } = err else {
panic!("expected stop reason error");
};
assert_eq!(stop_reason, "max_tokens");
assert_eq!(text, "hello from acp");
}
#[tokio::test]
async fn max_turn_requests_stop_reason_returns_partial_text_error() {
let tempdir = tempfile::tempdir().expect("create tempdir");
let err = run_fake_agent(
tempdir.path(),
HashMap::from([(
"ACP_STOP_REASON".to_string(),
"max_turn_requests".to_string(),
)]),
Some(ACP_TEST_TIMEOUT_MS),
CancellationToken::new(),
)
.await
.expect_err("max_turn_requests should return stop reason error");
let AcpError::StopReason { stop_reason, text } = err else {
panic!("expected stop reason error");
};
assert_eq!(stop_reason, "max_turn_requests");
assert_eq!(text, "hello from acp");
}
#[tokio::test]
async fn timeout_terminates_process_and_returns_timeout() {
let tempdir = tempfile::tempdir().expect("create tempdir");
let err = run_fake_agent(
tempdir.path(),
HashMap::from([("ACP_MODE".to_string(), "timeout".to_string())]),
Some(100),
CancellationToken::new(),
)
.await
.expect_err("timeout should error");
assert!(matches!(err, AcpError::TimedOut { .. }));
}
#[tokio::test]
async fn malformed_json_returns_protocol_error() {
let tempdir = tempfile::tempdir().expect("create tempdir");
let err = run_fake_agent(
tempdir.path(),
HashMap::from([("ACP_MODE".to_string(), "malformed".to_string())]),
Some(ACP_TEST_TIMEOUT_MS),
CancellationToken::new(),
)
.await
.expect_err("malformed JSON should error");
assert!(matches!(err, AcpError::Protocol(_)));
}
#[tokio::test]
async fn early_exit_returns_protocol_error_with_stderr() {
let tempdir = tempfile::tempdir().expect("create tempdir");
let err = run_fake_agent(
tempdir.path(),
HashMap::from([("ACP_MODE".to_string(), "early_exit".to_string())]),
Some(ACP_TEST_TIMEOUT_MS),
CancellationToken::new(),
)
.await
.expect_err("early exit should error");
let AcpError::Protocol(error) = err else {
panic!("expected protocol error");
};
let message = error.to_string();
assert!(
message.contains("exit_code=2"),
"early exit should include exit code in diagnostic: {message}"
);
assert!(
message.contains("early boom"),
"early exit should include stderr tail in diagnostic: {message}"
);
}
async fn run_fake_agent(
tempdir: &Path,
env: HashMap<String, String>,
timeout_ms: Option<u64>,
cancel_token: CancellationToken,
) -> Result<AcpRunResult, AcpError> {
let script_path = tempdir.join("fake_acp_agent.py");
write(&script_path, fake_acp_agent_script())
.await
.expect("write fake ACP agent");
let raw_command = format!("python3 {}", shell_quote(&script_path.to_string_lossy()));
let command = resolve_acp_command(Some(&raw_command)).expect("resolve ACP command");
let sandbox: Arc<dyn Sandbox> = Arc::new(LocalSandbox::new(tempdir.to_path_buf()));
run_acp_turn(AcpRunRequest {
command,
prompt: "hello".to_string(),
cwd: tempdir.to_string_lossy().into_owned(),
timeout_ms,
env,
sandbox,
cancel_token,
on_activity: None,
})
.await
}
async fn wait_for_file(path: &Path, max_wait: Duration) {
let deadline = Instant::now() + max_wait;
while Instant::now() < deadline {
if metadata(path).await.is_ok() {
return;
}
sleep(Duration::from_millis(25)).await;
}
panic!("timed out waiting for {}", path.display());
}
async fn process_is_running(pid: &str) -> bool {
let Ok(status) = Command::new("kill").arg("-0").arg(pid).status().await else {
return false;
};
if !status.success() {
return false;
}
let Ok(output) = Command::new("ps")
.args(["-ww", "-o", "stat=", "-p", pid])
.output()
.await
else {
return true;
};
if !output.status.success() {
return false;
}
String::from_utf8_lossy(&output.stdout)
.chars()
.find(|ch| !ch.is_whitespace())
.is_none_or(|state| !matches!(state, 'Z' | 'z'))
}

View file

@ -41,8 +41,9 @@ pub use profiles::{AnthropicProfile, EnvContext, GeminiProfile, OpenAiProfile};
pub use read_before_write_sandbox::ReadBeforeWriteSandbox;
pub use sandbox::{
CommandOutputCallback, DirEntry, ExecResult, ExecStreamingResult, GrepOptions, Sandbox,
SandboxEvent, SandboxEventCallback, WorktreeEvent, WorktreeEventCallback, WorktreeOptions,
WorktreeSandbox, format_lines_numbered, shell_quote,
SandboxEvent, SandboxEventCallback, StderrCollector, StdioProcess, StdioProcessHandle,
WorktreeEvent, WorktreeEventCallback, WorktreeOptions, WorktreeSandbox, format_lines_numbered,
shell_quote,
};
pub use session::{
CompletionCoordinator, Session, SessionControlHandle, StaticEnvProvider, SteeringItem,

View file

@ -3,6 +3,7 @@
// `crate::delegate_sandbox!` invocations continue to work.
pub use fabro_sandbox::{
CommandOutputCallback, DirEntry, ExecResult, ExecStreamingResult, GrepOptions, Sandbox,
SandboxEvent, SandboxEventCallback, WorktreeEvent, WorktreeEventCallback, WorktreeOptions,
SandboxEvent, SandboxEventCallback, StderrCollector, StdioProcess, StdioProcessHandle,
StdioProcessTermination, WorktreeEvent, WorktreeEventCallback, WorktreeOptions,
WorktreeSandbox, delegate_sandbox, format_lines_numbered, shell_quote,
};

View file

@ -115,6 +115,7 @@ chrono = { workspace = true }
[dev-dependencies]
assert_cmd = "2"
fabro-acp = { path = "../fabro-acp", features = ["test-support"] }
fabro-build-support = { path = "../build-support" }
fabro-server = { path = "../fabro-server", features = ["test-support"] }
insta = { workspace = true, features = ["filters"] }

View file

@ -472,6 +472,7 @@ mod tests {
use fabro_agent::{AgentEvent, SandboxEvent};
use fabro_llm::types::TokenCounts;
use fabro_model::{ModelRef, Provider};
use fabro_types::run_event::CliEnsureCompletedProps;
use fabro_types::{
MetadataSnapshotFailureKind, MetadataSnapshotPhase, ParallelBranchId, SandboxProvider,
StageId, fixtures,
@ -527,6 +528,24 @@ mod tests {
ui.handle_event(&stored);
}
fn emit_body(ui: &mut ProgressUI, body: fabro_types::EventBody) {
ui.handle_event(&RunEvent {
id: "evt_legacy".to_string(),
ts: Utc::now(),
run_id: fixtures::RUN_1,
node_id: None,
node_label: None,
stage_id: None,
parallel_group_id: None,
parallel_branch_id: None,
session_id: None,
parent_session_id: None,
tool_call_id: None,
actor: None,
body,
});
}
fn agent_event(stage: &str, event: AgentEvent) -> Event {
Event::Agent {
stage: stage.into(),
@ -860,13 +879,16 @@ mod tests {
});
emit(&mut ui, Event::SetupStarted { command_count: 2 });
emit(&mut ui, Event::SetupCompleted { duration_ms: 8200 });
emit(&mut ui, Event::CliEnsureCompleted {
cli_name: "gh".into(),
provider: "github".into(),
already_installed: false,
node_installed: false,
duration_ms: 600,
});
emit_body(
&mut ui,
fabro_types::EventBody::CliEnsureCompleted(CliEnsureCompletedProps {
cli_name: "gh".into(),
provider: "github".into(),
already_installed: false,
node_installed: false,
duration_ms: 600,
}),
);
emit(&mut ui, Event::DevcontainerResolved {
dockerfile_lines: 24,
environment_count: 3,

View file

@ -5,7 +5,11 @@
use std::process::Output;
use fabro_auth::{AuthCredential, AuthDetails};
use fabro_config::Storage;
use fabro_model::Provider;
use fabro_test::{fabro_snapshot, test_context, twin_openai};
use fabro_vault::{SecretType, Vault};
async fn run_success_output(mut cmd: assert_cmd::Command) -> Output {
tokio::task::spawn_blocking(move || cmd.assert().success().get_output().clone())
@ -13,6 +17,35 @@ async fn run_success_output(mut cmd: assert_cmd::Command) -> Output {
.expect("blocking command task should complete")
}
fn toml_path(path: &std::path::Path) -> String {
path.display()
.to_string()
.replace('\\', "\\\\")
.replace('"', "\\\"")
}
fn seed_openai_vault(storage_dir: &std::path::Path, base_url: &str, api_key: &str) {
let mut vault =
Vault::load(Storage::new(storage_dir).secrets_path()).expect("test vault should load");
vault
.set(
"openai",
&serde_json::to_string(&AuthCredential {
provider: Provider::OpenAi,
details: AuthDetails::ApiKey {
key: api_key.to_string(),
},
})
.expect("OpenAI test credential should serialize"),
SecretType::Credential,
None,
)
.expect("OpenAI credential should store in test vault");
vault
.set("OPENAI_BASE_URL", base_url, SecretType::Environment, None)
.expect("OpenAI base URL should store in test vault");
}
#[test]
fn help() {
let context = test_context!();
@ -64,9 +97,28 @@ fn live_doctor() {
#[fabro_macros::e2e_test(twin)]
async fn twin_doctor() {
let context = test_context!();
let mut context = test_context!();
let twin = twin_openai().await;
let namespace = format!("{}::{}", module_path!(), line!());
let storage_dir = context.temp_dir.join("doctor-server-storage");
context.write_home(
".fabro/settings.toml",
format!(
r#"[server.storage]
root = "{}"
[server.auth]
methods = ["dev-token"]
[server.integrations.github]
strategy = "app"
"#,
toml_path(&storage_dir)
),
);
seed_openai_vault(&storage_dir, &twin.base_url, &namespace);
context.isolated_server();
let mut cmd = context.doctor();
cmd.arg("--verbose");
cmd.env_clear();

View file

@ -26,14 +26,17 @@ fn local_run_lifecycle() {
};
// 1. Run a workflow
cmd(&[
"run",
"--auto-approve",
"--sandbox",
"local",
fixture("command_pipeline.fabro").to_str().unwrap(),
])
.success();
context
.run_cmd()
.args([
"--auto-approve",
"--sandbox",
"local",
fixture("command_pipeline.fabro").to_str().unwrap(),
])
.timeout(timeout_for("local"))
.assert()
.success();
// 2. ps -a --json — should list exactly one run
let label = context.test_case_label();

View file

@ -0,0 +1,208 @@
#![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_auth::{AuthCredential, AuthDetails};
use fabro_config::Storage;
use fabro_model::Provider;
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_state};
#[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();
seed_openai_vault(&context.storage_dir);
let fake_agent = write_fake_acp_agent(&context);
let acp_command = fake_acp_command_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", provider="openai", model="fake-acp", prompt="write hello.txt", acp_command={acp_command}]
exit [shape=Msquare]
start -> work
work -> exit
}}"#
),
);
init_git_repo(&context.temp_dir);
context
.run_cmd()
.args(["--auto-approve", "--sandbox", "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"] == "openai"
}),
"run projection should include ACP provider metadata: {stages:?}"
);
}
#[test]
fn acp_prompt_workflow_uses_acp_backend() {
let mut context = test_context!();
context.write_home(
".fabro/settings.toml",
"[server.auth]\nmethods = [\"dev-token\"]\n",
);
context.isolated_server();
seed_openai_vault(&context.storage_dir);
let fake_agent = write_fake_acp_agent(&context);
let acp_command = fake_acp_command_attr(&fake_agent);
let workflow = context.temp_dir.join("acp_prompt_backend.fabro");
context.write_temp(
"acp_prompt_backend.fabro",
format!(
r#"digraph ACP {{
graph [goal="Exercise ACP prompt backend"]
start [shape=Mdiamond]
prompt [type="prompt", backend="acp", provider="openai", model="fake-acp", project_memory=false, prompt="write hello.txt", acp_command={acp_command}]
exit [shape=Msquare]
start -> prompt
prompt -> exit
}}"#
),
);
init_git_repo(&context.temp_dir);
context
.run_cmd()
.args(["--auto-approve", "--sandbox", "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"));
assert!(
!has_event(&run_dir, "agent.session.activated"),
"ACP prompt should not activate an API-mode agent session"
);
let completed = events
.iter()
.find_map(|event| match &event.event.body {
EventBody::StageCompleted(props)
if event.event.node_id.as_deref() == Some("prompt") =>
{
Some(props)
}
_ => None,
})
.expect("prompt stage should complete");
assert_eq!(completed.response.as_deref(), Some("hello from acp"));
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"] == "openai"
}),
"run projection should include ACP provider metadata: {stages:?}"
);
}
fn seed_openai_vault(storage_dir: &std::path::Path) {
let mut vault =
Vault::load(Storage::new(storage_dir).secrets_path()).expect("test vault should load");
vault
.set(
"openai",
&serde_json::to_string(&AuthCredential {
provider: Provider::OpenAi,
details: AuthDetails::ApiKey {
key: "test-openai-key".to_string(),
},
})
.expect("OpenAI test credential should serialize"),
SecretType::Credential,
None,
)
.expect("OpenAI credential should store in test vault");
}
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_command_attr(script_path: &std::path::Path) -> String {
let command = serde_json::json!({
"type": "stdio",
"name": "fake",
"command": "python3",
"args": [script_path.to_string_lossy()],
"env": [{"name": "ACP_MODE", "value": "write_file"}],
})
.to_string();
format!("{command:?}")
}
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)
);
}

View file

@ -49,8 +49,8 @@ fn scenario_command_agent_mixed(sandbox: &str) {
let export_dir = dump_export(&context, &run_id_for(&run_dir));
let stdout =
std::fs::read_to_string(stage_dump_dir(&export_dir, "verify@1").join("stdout.log"))
.expect("verify stdout.log should exist");
std::fs::read_to_string(stage_dump_dir(&export_dir, "verify@1").join("output.log"))
.expect("verify output.log should exist");
assert!(
stdout.contains("SCENARIO_FLAG_42"),
"verify stdout should contain SCENARIO_FLAG_42, got: {stdout}"

View file

@ -49,8 +49,8 @@ fn scenario_command_pipeline(sandbox: &str) {
let export_dir = dump_export(&context, &run_id_for(&run_dir));
let stdout1 =
std::fs::read_to_string(stage_dump_dir(&export_dir, "step1@1").join("stdout.log"))
.expect("step1 stdout.log should exist");
std::fs::read_to_string(stage_dump_dir(&export_dir, "step1@1").join("output.log"))
.expect("step1 output.log should exist");
assert!(
stdout1.contains("hello-from-step1"),
"step1 stdout should contain hello-from-step1, got: {stdout1}"

View file

@ -74,8 +74,8 @@ fn scenario_full_stack(sandbox: &str) {
// Verify node stdout should contain PASS
let export_dir = dump_export(&context, &run_id_for(&run_dir));
let stdout =
std::fs::read_to_string(stage_dump_dir(&export_dir, "verify@1").join("stdout.log"))
.expect("verify stdout.log should exist");
std::fs::read_to_string(stage_dump_dir(&export_dir, "verify@1").join("output.log"))
.expect("verify output.log should exist");
assert!(
stdout.contains("PASS"),
"verify stdout should contain PASS, got: {stdout}"

View file

@ -11,9 +11,15 @@
use std::process::Output;
use fabro_test::{TestMode, TwinScenario, TwinScenarios, TwinToolCall, test_context, twin_openai};
use fabro_auth::{AuthCredential, AuthDetails};
use fabro_config::Storage;
use fabro_model::Provider;
use fabro_test::{
TestMode, TwinOpenAi, TwinScenario, TwinScenarios, TwinToolCall, test_context, twin_openai,
};
use fabro_vault::{SecretType, Vault};
use super::{find_run_dir, read_conclusion};
use super::read_conclusion;
async fn run_success_output(mut cmd: assert_cmd::Command) -> Output {
tokio::task::spawn_blocking(move || cmd.assert().success().get_output().clone())
@ -51,6 +57,73 @@ fn stage_provider() -> &'static str {
}
}
fn toml_path(path: &std::path::Path) -> String {
path.display()
.to_string()
.replace('\\', "\\\\")
.replace('"', "\\\"")
}
fn twin_server_storage_dir(context: &fabro_test::TestContext) -> std::path::PathBuf {
context.temp_dir.join("hook-server-storage")
}
fn settings_with_hook(context: &fabro_test::TestContext, hook: &str) -> String {
if TestMode::from_env().is_twin() {
format!(
r#"[server.storage]
root = "{}"
[server.auth]
methods = ["dev-token"]
{hook}"#,
toml_path(&twin_server_storage_dir(context)),
)
} else {
hook.to_string()
}
}
fn write_hook_settings(context: &fabro_test::TestContext, hook: &str) {
let settings = settings_with_hook(context, hook);
if settings.trim().is_empty() {
return;
}
context.write_home(".fabro/settings.toml", settings);
}
fn seed_openai_vault(storage_dir: &std::path::Path, base_url: &str, api_key: &str) {
let mut vault =
Vault::load(Storage::new(storage_dir).secrets_path()).expect("test vault should load");
vault
.set(
"openai",
&serde_json::to_string(&AuthCredential {
provider: Provider::OpenAi,
details: AuthDetails::ApiKey {
key: api_key.to_string(),
},
})
.expect("OpenAI test credential should serialize"),
SecretType::Credential,
None,
)
.expect("OpenAI credential should store in test vault");
vault
.set("OPENAI_BASE_URL", base_url, SecretType::Environment, None)
.expect("OpenAI base URL should store in test vault");
}
fn configure_twin_server(
context: &mut fabro_test::TestContext,
twin: &TwinOpenAi,
namespace: &str,
) {
seed_openai_vault(&twin_server_storage_dir(context), &twin.base_url, namespace);
context.isolated_server();
}
fn write_workflow(context: &fabro_test::TestContext, name: &str, dot: &str) -> std::path::PathBuf {
context.write_temp(name, dot);
context.temp_dir.join(name)
@ -69,25 +142,28 @@ fn configure_hook_env(cmd: &mut assert_cmd::Command, hook_model: &str) {
cmd.arg("--model").arg(hook_model);
}
fn conclusion_status(context: &fabro_test::TestContext) -> String {
let run_dir = find_run_dir(&context);
read_conclusion(&run_dir)["status"]
.as_str()
.expect("conclusion should include a string status")
.to_string()
async fn conclusion_status(context: &fabro_test::TestContext) -> String {
let run_dir = context.single_run_dir();
tokio::task::spawn_blocking(move || {
read_conclusion(&run_dir)["status"]
.as_str()
.expect("conclusion should include a string status")
.to_string()
})
.await
.expect("conclusion status task should complete")
}
#[fabro_macros::e2e_test(twin, live("ANTHROPIC_API_KEY"))]
async fn hook_prompt_proceed_allows_run() {
let context = test_context!();
context.write_home(
".fabro/settings.toml",
let mut context = test_context!();
write_hook_settings(
&context,
&format!(
r#"
[[hooks]]
[[run.hooks]]
name = "prompt-proceed"
event = "run_start"
type = "prompt"
prompt = "A workflow is starting. Always approve. Respond with {{\"ok\": true}}."
model = "{model}"
"#,
@ -111,6 +187,7 @@ model = "{model}"
.scenario(TwinScenario::responses("gpt-5.4-mini").text(r#"{"ok":true}"#))
.load(twin)
.await;
configure_twin_server(&mut context, twin, &namespace);
let mut cmd = context.run_cmd();
configure_hook_env(&mut cmd, stage_model());
twin.configure_command(&mut cmd, &namespace);
@ -123,20 +200,19 @@ model = "{model}"
run_success_output(cmd).await;
}
assert_eq!(conclusion_status(&context), "succeeded");
assert_eq!(conclusion_status(&context).await, "succeeded");
}
#[fabro_macros::e2e_test(twin, live("ANTHROPIC_API_KEY"))]
async fn hook_prompt_block_prevents_run() {
let context = test_context!();
context.write_home(
".fabro/settings.toml",
let mut context = test_context!();
write_hook_settings(
&context,
&format!(
r#"
[[hooks]]
[[run.hooks]]
name = "prompt-block"
event = "run_start"
type = "prompt"
prompt = "Check: is 2+2 equal to 5? If the statement is true, respond {{\"ok\": true}}. If false, respond {{\"ok\": false, \"reason\": \"math check failed\"}}."
model = "{model}"
"#,
@ -163,6 +239,7 @@ model = "{model}"
)
.load(twin)
.await;
configure_twin_server(&mut context, twin, &namespace);
let mut cmd = context.run_cmd();
configure_hook_env(&mut cmd, stage_model());
twin.configure_command(&mut cmd, &namespace);
@ -184,18 +261,18 @@ model = "{model}"
#[fabro_macros::e2e_test(twin, live("ANTHROPIC_API_KEY"))]
async fn hook_agent_proceed_allows_run() {
let context = test_context!();
context.write_home(
".fabro/settings.toml",
let mut context = test_context!();
write_hook_settings(
&context,
&format!(
r#"
[[hooks]]
[[run.hooks]]
name = "agent-proceed"
event = "run_start"
type = "agent"
prompt = "A workflow is starting. Always approve. Respond with {{\"ok\": true}}. Do not use any tools."
model = "{model}"
max_tool_rounds = 1
agent = "enabled"
"#,
model = hook_model()
),
@ -217,6 +294,7 @@ max_tool_rounds = 1
.scenario(TwinScenario::responses("gpt-5.4-mini").text(r#"{"ok":true}"#))
.load(twin)
.await;
configure_twin_server(&mut context, twin, &namespace);
let mut cmd = context.run_cmd();
configure_hook_env(&mut cmd, stage_model());
twin.configure_command(&mut cmd, &namespace);
@ -229,25 +307,25 @@ max_tool_rounds = 1
run_success_output(cmd).await;
}
assert_eq!(conclusion_status(&context), "succeeded");
assert_eq!(conclusion_status(&context).await, "succeeded");
}
#[fabro_macros::e2e_test(twin, live("ANTHROPIC_API_KEY"))]
async fn hook_agent_with_tool_use() {
let context = test_context!();
let mut context = test_context!();
let marker = context.temp_dir.join("hook_check.txt");
std::fs::write(&marker, "READY").unwrap();
context.write_home(
".fabro/settings.toml",
write_hook_settings(
&context,
&format!(
r#"
[[hooks]]
[[run.hooks]]
name = "agent-tools"
event = "run_start"
type = "agent"
prompt = "Read the file at {path} using the read_file tool. If it contains 'READY', respond with {{\"ok\": true}}. Otherwise respond with {{\"ok\": false, \"reason\": \"not ready\"}}."
model = "{model}"
max_tool_rounds = 5
agent = "enabled"
"#,
path = marker.display(),
model = hook_model()
@ -274,6 +352,7 @@ max_tool_rounds = 5
.scenario(TwinScenario::responses("gpt-5.4-mini").text(r#"{"ok":true}"#))
.load(twin)
.await;
configure_twin_server(&mut context, twin, &namespace);
let mut cmd = context.run_cmd();
configure_hook_env(&mut cmd, stage_model());
twin.configure_command(&mut cmd, &namespace);
@ -286,12 +365,13 @@ max_tool_rounds = 5
run_success_output(cmd).await;
}
assert_eq!(conclusion_status(&context), "succeeded");
assert_eq!(conclusion_status(&context).await, "succeeded");
}
#[fabro_macros::e2e_test(twin, live("ANTHROPIC_API_KEY"))]
async fn arc_e2e_with_real_llm() {
let context = test_context!();
let mut context = test_context!();
write_hook_settings(&context, "");
let hello = context.temp_dir.join("hello.txt");
let workflow = write_workflow(
&context,
@ -328,6 +408,7 @@ async fn arc_e2e_with_real_llm() {
)
.load(twin)
.await;
configure_twin_server(&mut context, twin, &namespace);
let mut cmd = context.run_cmd();
configure_hook_env(&mut cmd, stage_model());
twin.configure_command(&mut cmd, &namespace);
@ -345,5 +426,5 @@ async fn arc_e2e_with_real_llm() {
"Hello from LLM",
"workflow should create the expected file"
);
assert_eq!(conclusion_status(&context), "succeeded");
assert_eq!(conclusion_status(&context).await, "succeeded");
}

View file

@ -3,6 +3,7 @@
reason = "This test module prefers explicit type paths over extra imports."
)]
mod acp;
mod agent_linear;
mod command_agent_mixed;
mod command_pipeline;

View file

@ -1,11 +1,10 @@
use std::sync::Arc;
use std::time::Duration;
use fabro_graphviz::graph::{AttrValue, Node};
use fabro_llm::provider::Provider;
use fabro_workflow::context::Context;
use fabro_workflow::event::Emitter;
use fabro_workflow::handler::agent::{CodergenBackend, CodergenResult};
use fabro_workflow::handler::agent::{CodergenBackend, CodergenResult, CodergenRunRequest};
use fabro_workflow::handler::llm::cli::AgentCliBackend;
/// Run a real CLI tool via LocalSandbox and verify the full flow.
@ -14,8 +13,7 @@ async fn run_real_cli_test(provider: Provider, model: &str) {
let env: Arc<dyn fabro_agent::Sandbox> = Arc::new(fabro_agent::LocalSandbox::new(
workspace.path().to_path_buf(),
));
let backend = AgentCliBackend::new_from_env(model.to_string(), provider)
.with_poll_interval(Duration::from_millis(10));
let backend = AgentCliBackend::new_from_env(model.to_string(), provider);
let mut node = Node::new("real_cli_test");
node.attrs.insert(
@ -26,16 +24,16 @@ async fn run_real_cli_test(provider: Provider, model: &str) {
let context = Context::new();
let emitter = Arc::new(Emitter::default());
let result = backend
.run(
&node,
"What is 2+2? Reply with just the number.",
&context,
None,
&emitter,
&env,
None,
tokio_util::sync::CancellationToken::new(),
)
.run(CodergenRunRequest {
node: &node,
prompt: "What is 2+2? Reply with just the number.",
context: &context,
thread_id: None,
emitter: &emitter,
sandbox: &env,
tool_hooks: None,
cancel_token: tokio_util::sync::CancellationToken::new(),
})
.await
.unwrap_or_else(|_| panic!("CLI backend ({provider}/{model}) should succeed"));

View file

@ -24,7 +24,7 @@ anyhow.workspace = true
async-trait.workspace = true
thiserror.workspace = true
tokio.workspace = true
tokio-util.workspace = true
tokio-util = { workspace = true, features = ["compat"] }
serde.workspace = true
serde_json.workspace = true
strum.workspace = true

View file

@ -29,7 +29,7 @@ use crate::redact::redact_auth_url;
use crate::sandbox::{optional_timeout, resolve_path};
use crate::{
CommandOutputCallback, DirEntry, ExecResult, ExecStreamingResult, GrepOptions, Sandbox,
SandboxEvent, SandboxEventCallback, format_lines_numbered, shell_quote,
SandboxEvent, SandboxEventCallback, StdioProcess, format_lines_numbered, shell_quote,
};
const WORKING_DIRECTORY: &str = "/home/daytona/workspace";
@ -1535,6 +1535,18 @@ impl Sandbox for DaytonaSandbox {
})
}
async fn spawn_stdio_process(
&self,
_command: &str,
_working_dir: Option<&str>,
_env_vars: Option<&HashMap<String, String>>,
_cancel_token: Option<CancellationToken>,
) -> crate::Result<StdioProcess> {
Err(crate::Error::message(
"ACP backend requires bidirectional stdio; the Daytona sandbox provider does not support it yet",
))
}
async fn grep(
&self,
pattern: &str,

View file

@ -1,6 +1,7 @@
use std::collections::BTreeMap;
use anyhow::Result;
#[cfg(any(feature = "docker", feature = "daytona"))]
use chrono::{DateTime, Utc};
use fabro_types::{
RunId, RunSandbox, SandboxDetails, SandboxProvider, SandboxResources, SandboxState,
@ -55,6 +56,7 @@ fn local_details(record: &RunSandbox) -> SandboxDetails {
}
}
#[cfg(any(feature = "docker", feature = "daytona"))]
fn parse_rfc3339_utc(value: &str) -> Option<DateTime<Utc>> {
DateTime::parse_from_rfc3339(value)
.ok()

View file

@ -1,7 +1,8 @@
use std::collections::HashMap;
use std::fmt::Write as _;
use std::io::Cursor;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::time::{Instant, SystemTime, UNIX_EPOCH};
use async_trait::async_trait;
@ -12,23 +13,25 @@ use bollard::container::{
UploadToContainerOptions,
};
use bollard::errors::Error as DockerError;
use bollard::exec::{CreateExecOptions, StartExecResults};
use bollard::exec::{CreateExecOptions, StartExecOptions, StartExecResults};
use bollard::image::CreateImageOptions;
use bollard::models::HostConfig;
use fabro_github::GitHubCredentials;
use fabro_types::{CommandOutputStream, CommandTermination, RunId};
use fabro_util::time::elapsed_ms;
use futures::StreamExt;
use tokio::sync::OnceCell;
use tokio::io::{AsyncWriteExt, duplex};
use tokio::sync::{Mutex as TokioMutex, Notify, OnceCell};
use tokio::{fs, time};
use tokio_util::sync::CancellationToken;
use crate::clone_source::{self, CloneDecision, EmptyWorkspaceReason};
use crate::redact::redact_auth_url;
use crate::sandbox::{optional_timeout, resolve_path};
use crate::sandbox::{StdioProcessControl, optional_timeout, resolve_path};
use crate::{
CommandOutputCallback, DirEntry, ExecResult, ExecStreamingResult, GrepOptions, Sandbox,
SandboxEvent, SandboxEventCallback, format_lines_numbered, shell_quote,
CommandOutputCallback, DEFAULT_EXEC_OUTPUT_TAIL_BYTES, DirEntry, ExecResult,
ExecStreamingResult, GrepOptions, Sandbox, SandboxEvent, SandboxEventCallback, StderrCollector,
StdioProcess, StdioProcessHandle, StdioProcessTermination, format_lines_numbered, shell_quote,
};
const WORKING_DIRECTORY: &str = "/workspace";
@ -216,17 +219,15 @@ impl DockerSandbox {
..Default::default()
};
let exec_instance = self
.docker
.create_exec(container_id, exec_opts)
.await
.map_err(|e| crate::Error::context("Failed to create exec", e))?;
let start_result = self
.docker
.start_exec(&exec_instance.id, None)
.await
.map_err(|e| crate::Error::context("Failed to start exec", e))?;
let (exec_id, start_result) = create_and_start_exec(
&self.docker,
container_id,
exec_opts,
None,
"Failed to create exec",
"Failed to start exec",
)
.await?;
let mut stdout = String::new();
let mut stderr = String::new();
@ -250,7 +251,7 @@ impl DockerSandbox {
let inspect = self
.docker
.inspect_exec(&exec_instance.id)
.inspect_exec(&exec_id)
.await
.map_err(|e| crate::Error::context("Failed to inspect exec", e))?;
@ -278,15 +279,15 @@ impl DockerSandbox {
..Default::default()
};
let exec_instance = docker
.create_exec(&container_id, exec_opts)
.await
.map_err(|e| crate::Error::context("Failed to create exec", e))?;
let start_result = docker
.start_exec(&exec_instance.id, None)
.await
.map_err(|e| crate::Error::context("Failed to start exec", e))?;
let (exec_id, start_result) = create_and_start_exec(
&docker,
&container_id,
exec_opts,
None,
"Failed to create exec",
"Failed to start exec",
)
.await?;
let mut stdout = Vec::new();
let mut stderr = Vec::new();
@ -311,7 +312,7 @@ impl DockerSandbox {
}
let inspect = docker
.inspect_exec(&exec_instance.id)
.inspect_exec(&exec_id)
.await
.map_err(|e| crate::Error::context("Failed to inspect exec", e))?;
@ -451,20 +452,7 @@ impl DockerSandbox {
}
async fn request_docker_exec_stop(&self, stop_file: &str) -> crate::Result<()> {
let command = format!("touch {}", shell_quote(stop_file));
let (stdout, stderr, exit_code) = self
.docker_exec(
vec!["/bin/bash".to_string(), "-lc".to_string(), command.clone()],
Some("/"),
None,
)
.await?;
if exit_code != 0 {
return Err(crate::Error::message(format!(
"Failed to request Docker exec stop (exit {exit_code}): {stderr}{stdout}"
)));
}
Ok(())
request_docker_exec_stop_with(&self.docker, self.container_id()?, stop_file).await
}
async fn ensure_image(&self) -> crate::Result<EnsureImageOutcome> {
@ -769,6 +757,7 @@ fn docker_controlled_shell_command(command: &str, stop_file: &str, pid_file: &st
stop_file={stop_file}; \
pid_file={pid_file}; \
user_command={command}; \
exec 3<&0; \
rm -f \"$pid_file\"; \
if [ -e \"$stop_file\" ]; then \
rm -f \"$stop_file\" \"$pid_file\"; \
@ -783,11 +772,12 @@ fi; \
kill -KILL \"-$child\" 2>/dev/null || kill -KILL \"$child\" 2>/dev/null || true; \
) & watcher=$!; \
if command -v setsid >/dev/null 2>&1; then \
setsid /bin/bash -lc \"$user_command\" & \
setsid /bin/bash -lc \"$user_command\" <&3 & \
else \
/bin/bash -lc \"$user_command\" & \
/bin/bash -lc \"$user_command\" <&3 & \
fi; \
child=$!; \
exec 3<&-; \
echo \"$child\" > \"$pid_file\"; \
wait \"$child\"; \
status=$?; \
@ -804,6 +794,213 @@ exit \"$status\"\
)
}
fn docker_stdio_exec_options(
command: String,
working_dir: String,
env: Option<Vec<String>>,
) -> (CreateExecOptions<String>, StartExecOptions) {
(
CreateExecOptions {
attach_stdin: Some(true),
attach_stdout: Some(true),
attach_stderr: Some(true),
tty: Some(false),
cmd: Some(vec!["/bin/bash".to_string(), "-lc".to_string(), command]),
working_dir: Some(working_dir),
env,
..Default::default()
},
StartExecOptions {
detach: false,
tty: false,
output_capacity: None,
},
)
}
async fn create_and_start_exec(
docker: &Docker,
container_id: &str,
exec_options: CreateExecOptions<String>,
start_options: Option<StartExecOptions>,
create_context: &'static str,
start_context: &'static str,
) -> crate::Result<(String, StartExecResults)> {
let exec_instance = docker
.create_exec(container_id, exec_options)
.await
.map_err(|err| crate::Error::context(create_context, err))?;
let exec_id = exec_instance.id;
let start_result = docker
.start_exec(&exec_id, start_options)
.await
.map_err(|err| crate::Error::context(start_context, err))?;
Ok((exec_id, start_result))
}
async fn request_docker_exec_stop_with(
docker: &Docker,
container_id: &str,
stop_file: &str,
) -> crate::Result<()> {
let command = format!("touch {}", shell_quote(stop_file));
let exec_opts = CreateExecOptions {
cmd: Some(vec!["/bin/bash".to_string(), "-lc".to_string(), command]),
attach_stdout: Some(true),
attach_stderr: Some(true),
working_dir: Some("/".to_string()),
..Default::default()
};
let (exec_id, start_result) = create_and_start_exec(
docker,
container_id,
exec_opts,
None,
"Failed to create Docker exec stop request",
"Failed to start Docker exec stop request",
)
.await?;
let mut stdout = String::new();
let mut stderr = String::new();
if let StartExecResults::Attached { mut output, .. } = start_result {
while let Some(chunk) = output.next().await {
match chunk {
Ok(LogOutput::StdOut { message }) => {
stdout.push_str(&String::from_utf8_lossy(&message));
}
Ok(LogOutput::StdErr { message }) => {
stderr.push_str(&String::from_utf8_lossy(&message));
}
Ok(_) => {}
Err(e) => {
return Err(crate::Error::context(
"Error reading stop request output",
e,
));
}
}
}
}
let inspect = docker
.inspect_exec(&exec_id)
.await
.map_err(|e| crate::Error::context("Failed to inspect Docker exec stop request", e))?;
let exit_code = inspect
.exit_code
.and_then(|code| i32::try_from(code).ok())
.unwrap_or(-1);
if exit_code != 0 {
return Err(crate::Error::message(format!(
"Failed to request Docker exec stop (exit {exit_code}): {stderr}{stdout}"
)));
}
Ok(())
}
struct DockerStdioProcessControl {
docker: Docker,
container_id: String,
exec_id: String,
stop_file: String,
state: Arc<DockerStdioProcessState>,
}
#[derive(Default)]
struct DockerStdioProcessState {
stop_requested: AtomicBool,
termination: TokioMutex<Option<StdioProcessTermination>>,
termination_notify: Notify,
}
impl DockerStdioProcessState {
async fn cached_termination(&self) -> Option<StdioProcessTermination> {
*self.termination.lock().await
}
async fn request_stop_once(&self) -> bool {
self.cached_termination().await.is_none()
&& !self.stop_requested.swap(true, Ordering::AcqRel)
}
async fn cache_termination(&self, termination: StdioProcessTermination) {
let mut cached = self.termination.lock().await;
if cached.is_none() {
*cached = Some(termination);
self.termination_notify.notify_waiters();
}
}
async fn wait_for_cached_termination(&self) -> StdioProcessTermination {
loop {
if let Some(termination) = self.cached_termination().await {
return termination;
}
self.termination_notify.notified().await;
}
}
}
#[async_trait]
impl StdioProcessControl for DockerStdioProcessControl {
async fn terminate(&self) -> crate::Result<()> {
if !self.state.request_stop_once().await {
return Ok(());
}
request_docker_exec_stop_with(&self.docker, &self.container_id, &self.stop_file).await?;
Ok(())
}
async fn wait(&self) -> crate::Result<StdioProcessTermination> {
if let Some(termination) = self.state.cached_termination().await {
return Ok(termination);
}
let mut poll_interval = time::interval(std::time::Duration::from_secs(1));
loop {
if let Some(termination) = self.state.cached_termination().await {
return Ok(termination);
}
let inspect = self
.docker
.inspect_exec(&self.exec_id)
.await
.map_err(|e| crate::Error::context("Failed to inspect Docker stdio exec", e))?;
if inspect.running != Some(true) {
let exit_code = inspect.exit_code.and_then(|code| i32::try_from(code).ok());
let termination = StdioProcessTermination::exited(exit_code);
self.state.cache_termination(termination).await;
return Ok(termination);
}
tokio::select! {
termination = self.state.wait_for_cached_termination() => return Ok(termination),
_ = poll_interval.tick() => {}
}
}
}
}
async fn cache_docker_stdio_completion(
docker: Docker,
exec_id: String,
state: Arc<DockerStdioProcessState>,
) {
match docker.inspect_exec(&exec_id).await {
Ok(inspect) if inspect.running != Some(true) => {
let exit_code = inspect.exit_code.and_then(|code| i32::try_from(code).ok());
state
.cache_termination(StdioProcessTermination::exited(exit_code))
.await;
}
Ok(_) => {}
Err(err) => {
tracing::warn!(error = %err, "Failed to inspect completed Docker stdio exec");
}
}
}
fn git_clone_command(clone_url: &str, branch: Option<&str>) -> String {
let mut command = "git -c maintenance.auto=0 -c gc.auto=0 clone".to_string();
if let Some(branch) = branch {
@ -1333,6 +1530,98 @@ impl Sandbox for DockerSandbox {
.await
}
async fn spawn_stdio_process(
&self,
command: &str,
working_dir: Option<&str>,
env_vars: Option<&HashMap<String, String>>,
cancel_token: Option<CancellationToken>,
) -> crate::Result<StdioProcess> {
let effective_dir = working_dir.map_or_else(
|| WORKING_DIRECTORY.to_string(),
Self::resolve_container_path,
);
let env: Option<Vec<String>> =
env_vars.map(|vars| vars.iter().map(|(k, v)| format!("{k}={v}")).collect());
let (stop_file, pid_file) = docker_exec_control_paths();
let controlled_command = docker_controlled_shell_command(command, &stop_file, &pid_file);
let (create_opts, start_opts) =
docker_stdio_exec_options(controlled_command, effective_dir, env);
let container_id = self.container_id()?.to_string();
let (exec_id, start_result) = create_and_start_exec(
&self.docker,
&container_id,
create_opts,
Some(start_opts),
"Failed to create Docker stdio exec",
"Failed to start Docker stdio exec",
)
.await?;
let StartExecResults::Attached { mut output, input } = start_result else {
return Err(crate::Error::message(
"Docker stdio exec started detached unexpectedly",
));
};
let stderr_collector = StderrCollector::new(DEFAULT_EXEC_OUTPUT_TAIL_BYTES);
let stderr_for_output = stderr_collector.clone();
let (mut stdout_writer, stdout_reader) = duplex(64 * 1024);
let state = Arc::new(DockerStdioProcessState::default());
let state_for_output = Arc::clone(&state);
let docker_for_output = self.docker.clone();
let exec_id_for_output = exec_id.clone();
tokio::spawn(async move {
while let Some(chunk) = output.next().await {
match chunk {
Ok(LogOutput::StdOut { message }) => {
if let Err(err) = stdout_writer.write_all(&message).await {
tracing::warn!(error = %err, "Failed to forward Docker stdio stdout");
break;
}
}
Ok(LogOutput::StdErr { message }) => {
stderr_for_output.push(&message).await;
}
Ok(_) => {}
Err(err) => {
let message = format!("Docker stdio output stream error: {err}");
stderr_for_output.push(message.as_bytes()).await;
break;
}
}
}
cache_docker_stdio_completion(docker_for_output, exec_id_for_output, state_for_output)
.await;
});
let handle = StdioProcessHandle::new(DockerStdioProcessControl {
docker: self.docker.clone(),
container_id,
exec_id,
stop_file,
state,
});
if let Some(token) = cancel_token {
let handle_for_cancel = handle.clone();
tokio::spawn(async move {
token.cancelled().await;
if let Err(err) = handle_for_cancel.terminate().await {
tracing::warn!(error = %err, "Failed to terminate cancelled Docker stdio exec");
}
});
}
Ok(StdioProcess {
stdin: input,
stdout: Box::pin(stdout_reader),
stderr: stderr_collector,
handle,
})
}
async fn read_file(
&self,
path: &str,
@ -1666,8 +1955,10 @@ mod tests {
reason = "unit test reads an in-memory tar entry synchronously"
)]
use std::io::Read as _;
use std::process::Stdio;
use std::time::Duration;
use tokio::io::AsyncWriteExt as _;
use tokio::process::Command;
use super::*;
@ -1744,6 +2035,33 @@ mod tests {
);
}
#[test]
fn stdio_exec_options_attach_streams_without_tty() {
let (create, start) = docker_stdio_exec_options(
"python fake_agent.py".to_string(),
WORKING_DIRECTORY.to_string(),
Some(vec!["MODE=test".to_string()]),
);
assert_eq!(create.attach_stdin, Some(true));
assert_eq!(create.attach_stdout, Some(true));
assert_eq!(create.attach_stderr, Some(true));
assert_eq!(create.tty, Some(false));
assert_eq!(create.working_dir.as_deref(), Some(WORKING_DIRECTORY));
assert_eq!(create.env, Some(vec!["MODE=test".to_string()]));
assert_eq!(
create.cmd,
Some(vec![
"/bin/bash".to_string(),
"-lc".to_string(),
"python fake_agent.py".to_string()
])
);
assert!(!start.detach);
assert!(!start.tty);
assert_eq!(start.output_capacity, None);
}
#[tokio::test]
async fn controlled_shell_command_honors_stop_requested_before_pid_file_exists() {
let tempdir = tempfile::tempdir().expect("tempdir should be created");
@ -1788,6 +2106,59 @@ mod tests {
);
}
#[tokio::test]
async fn controlled_shell_command_preserves_stdin_for_user_command() {
let tempdir = tempfile::tempdir().expect("tempdir should be created");
let stop_file = tempdir.path().join("stop");
let pid_file = tempdir.path().join("pid");
let stop_file = stop_file.to_string_lossy().into_owned();
let pid_file = pid_file.to_string_lossy().into_owned();
let command = docker_controlled_shell_command("cat", &stop_file, &pid_file);
let mut child = Command::new("/bin/bash")
.arg("-lc")
.arg(command)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.kill_on_drop(true)
.spawn()
.expect("controlled shell command should spawn");
let mut stdin = child
.stdin
.take()
.expect("controlled shell command stdin should be piped");
stdin
.write_all(b"abc\n")
.await
.expect("stdin should be written");
drop(stdin);
let output = time::timeout(Duration::from_secs(5), child.wait_with_output())
.await
.expect("controlled shell command should not hang")
.expect("controlled shell command should run");
assert!(
output.status.success(),
"controlled shell command should exit successfully: {output:?}"
);
assert_eq!(output.stdout, b"abc\n");
}
#[tokio::test]
async fn docker_stdio_process_state_does_not_cache_cancelled_on_stop_request() {
let state = DockerStdioProcessState::default();
assert!(state.request_stop_once().await);
assert_eq!(state.cached_termination().await, None);
assert!(!state.request_stop_once().await);
let termination = StdioProcessTermination::exited(Some(143));
state.cache_termination(termination).await;
assert_eq!(state.cached_termination().await, Some(termination));
assert!(!state.request_stop_once().await);
}
#[tokio::test]
async fn controlled_shell_command_skips_user_command_when_stop_already_requested() {
let tempdir = tempfile::tempdir().expect("tempdir should be created");

View file

@ -41,7 +41,8 @@ pub use reconnect::{reconnect, reconnect_for_run, reconnect_for_run_with_callbac
pub use sandbox::{
CommandOutputCallback, DEFAULT_EXEC_OUTPUT_TAIL_BYTES, DirEntry, ExecResult,
ExecStreamingResult, GitRunInfo, GitSetupIntent, GrepOptions, Sandbox, SandboxEvent,
SandboxEventCallback, format_lines_numbered, git_push_via_exec, redacted_output_tail,
SandboxEventCallback, StderrCollector, StdioProcess, StdioProcessHandle,
StdioProcessTermination, format_lines_numbered, git_push_via_exec, redacted_output_tail,
setup_git_via_exec, shell_quote,
};
pub use sandbox_spec::SandboxSpec;

View file

@ -7,14 +7,16 @@ use fabro_types::{CommandOutputStream, CommandTermination};
use fabro_util::time::elapsed_ms;
use tokio::io::{AsyncRead, AsyncReadExt};
use tokio::process::{Child, Command};
use tokio::sync::watch;
use tokio::task::spawn_blocking;
use tokio::{fs, time};
use tokio_util::sync::CancellationToken;
use crate::sandbox::optional_timeout;
use crate::sandbox::{StdioProcessControl, optional_timeout};
use crate::{
CommandOutputCallback, DirEntry, ExecResult, ExecStreamingResult, GrepOptions, Sandbox,
SandboxEvent, SandboxEventCallback, format_lines_numbered,
CommandOutputCallback, DEFAULT_EXEC_OUTPUT_TAIL_BYTES, DirEntry, ExecResult,
ExecStreamingResult, GrepOptions, Sandbox, SandboxEvent, SandboxEventCallback, StderrCollector,
StdioProcess, StdioProcessHandle, StdioProcessTermination, format_lines_numbered,
};
pub struct LocalSandbox {
@ -128,6 +130,34 @@ fn process_env_vars() -> Vec<(String, String)> {
std::env::vars().collect()
}
#[derive(Debug, Clone, Copy)]
enum ExplicitEnvPolicy {
FilterSensitive,
TrustCaller,
}
fn filtered_env_vars(
env_vars: Option<&std::collections::HashMap<String, String>>,
explicit_policy: ExplicitEnvPolicy,
) -> Vec<(String, String)> {
let mut filtered_env: Vec<(String, String)> = process_env_vars()
.into_iter()
.filter(|(key, _)| !LocalSandbox::should_filter_env_var(key))
.collect();
if let Some(extra) = env_vars {
for (key, value) in extra {
if matches!(explicit_policy, ExplicitEnvPolicy::TrustCaller)
|| !LocalSandbox::should_filter_env_var(key)
{
filtered_env.push((key.clone(), value.clone()));
}
}
}
filtered_env
}
async fn drain_pipe<R>(mut pipe: Option<R>, stream: CommandOutputStream) -> String
where
R: AsyncRead + Unpin,
@ -141,6 +171,77 @@ where
buf
}
type LocalStdioOutcome = Result<StdioProcessTermination, String>;
struct LocalStdioProcessControl {
terminate_tx: watch::Sender<bool>,
termination_rx: watch::Receiver<Option<LocalStdioOutcome>>,
}
impl LocalStdioProcessControl {
fn new(mut child: Child) -> Self {
let (terminate_tx, mut terminate_rx) = watch::channel(false);
let (termination_tx, termination_rx) = watch::channel(None);
tokio::spawn(async move {
let outcome = tokio::select! {
status = child.wait() => {
status
.map(|status| StdioProcessTermination::exited(status.code()))
.map_err(|err| format!("Failed to wait for stdio process: {err}"))
}
changed = terminate_rx.changed() => {
if changed.is_err() || !*terminate_rx.borrow() {
child.wait()
.await
.map(|status| StdioProcessTermination::exited(status.code()))
.map_err(|err| format!("Failed to wait for stdio process: {err}"))
} else {
sigterm_then_kill(&mut child).await;
Ok(StdioProcessTermination::cancelled())
}
}
};
let _ = termination_tx.send(Some(outcome));
});
Self {
terminate_tx,
termination_rx,
}
}
async fn wait_for_termination(&self) -> crate::Result<StdioProcessTermination> {
let mut termination_rx = self.termination_rx.clone();
loop {
if let Some(outcome) = termination_rx.borrow().clone() {
return outcome.map_err(crate::Error::message);
}
termination_rx.changed().await.map_err(|_| {
crate::Error::message(
"stdio process supervisor stopped before reporting termination",
)
})?;
}
}
}
#[async_trait]
impl StdioProcessControl for LocalStdioProcessControl {
async fn terminate(&self) -> crate::Result<()> {
if self.termination_rx.borrow().is_some() {
return Ok(());
}
self.terminate_tx.send_replace(true);
self.wait_for_termination().await.map(|_| ())
}
async fn wait(&self) -> crate::Result<StdioProcessTermination> {
self.wait_for_termination().await
}
}
#[async_trait]
impl Sandbox for LocalSandbox {
async fn read_file(
@ -252,18 +353,7 @@ impl Sandbox for LocalSandbox {
) -> crate::Result<ExecResult> {
let start = Instant::now();
let mut filtered_env: Vec<(String, String)> = process_env_vars()
.into_iter()
.filter(|(key, _)| !Self::should_filter_env_var(key))
.collect();
if let Some(extra) = env_vars {
for (k, v) in extra {
if !Self::should_filter_env_var(k) {
filtered_env.push((k.clone(), v.clone()));
}
}
}
let filtered_env = filtered_env_vars(env_vars, ExplicitEnvPolicy::FilterSensitive);
let effective_dir =
working_dir.map_or_else(|| self.working_directory.clone(), std::path::PathBuf::from);
@ -340,18 +430,7 @@ impl Sandbox for LocalSandbox {
) -> crate::Result<ExecStreamingResult> {
let start = Instant::now();
let mut filtered_env: Vec<(String, String)> = process_env_vars()
.into_iter()
.filter(|(key, _)| !Self::should_filter_env_var(key))
.collect();
if let Some(extra) = env_vars {
for (k, v) in extra {
if !Self::should_filter_env_var(k) {
filtered_env.push((k.clone(), v.clone()));
}
}
}
let filtered_env = filtered_env_vars(env_vars, ExplicitEnvPolicy::FilterSensitive);
let effective_dir =
working_dir.map_or_else(|| self.working_directory.clone(), std::path::PathBuf::from);
@ -424,6 +503,71 @@ impl Sandbox for LocalSandbox {
})
}
async fn spawn_stdio_process(
&self,
command: &str,
working_dir: Option<&str>,
env_vars: Option<&std::collections::HashMap<String, String>>,
cancel_token: Option<CancellationToken>,
) -> crate::Result<StdioProcess> {
let filtered_env = filtered_env_vars(env_vars, ExplicitEnvPolicy::TrustCaller);
let effective_dir =
working_dir.map_or_else(|| self.working_directory.clone(), std::path::PathBuf::from);
let mut cmd = Command::new("/bin/bash");
cmd.arg("-lc")
.arg(format!("exec {command}"))
.current_dir(&effective_dir)
.env_clear()
.envs(filtered_env)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
#[cfg(unix)]
fabro_proc::pre_exec_setpgid(cmd.as_std_mut());
let mut child = cmd
.spawn()
.map_err(|e| crate::Error::context("Failed to spawn stdio process", e))?;
let stdin = child
.stdin
.take()
.ok_or_else(|| crate::Error::message("Failed to open stdio process stdin"))?;
let stdout = child
.stdout
.take()
.ok_or_else(|| crate::Error::message("Failed to open stdio process stdout"))?;
let stderr = child
.stderr
.take()
.ok_or_else(|| crate::Error::message("Failed to open stdio process stderr"))?;
let stderr_collector = StderrCollector::new(DEFAULT_EXEC_OUTPUT_TAIL_BYTES);
stderr_collector.spawn_reader(stderr);
let handle = StdioProcessHandle::new(LocalStdioProcessControl::new(child));
if let Some(token) = cancel_token {
let handle_for_cancel = handle.clone();
tokio::spawn(async move {
token.cancelled().await;
if let Err(err) = handle_for_cancel.terminate().await {
tracing::warn!(error = %err, "Failed to terminate cancelled stdio process");
}
});
}
Ok(StdioProcess {
stdin: Box::pin(stdin),
stdout: Box::pin(stdout),
stderr: stderr_collector,
handle,
})
}
async fn grep(
&self,
pattern: &str,
@ -740,7 +884,7 @@ mod tests {
use std::pin::Pin;
use std::task::{Context as TaskContext, Poll};
use tokio::io::ReadBuf;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, ReadBuf};
use super::*;
@ -876,6 +1020,58 @@ mod tests {
std::fs::remove_dir_all(&dir).unwrap();
}
#[tokio::test]
async fn stdio_process_round_trips_lines() {
let dir = temp_dir();
let sandbox = LocalSandbox::new(dir.clone());
let process = sandbox
.spawn_stdio_process(
"python3 -u -c 'import sys; [print(line.strip()[::-1], flush=True) for line in sys.stdin]'",
None,
None,
None,
)
.await
.unwrap();
let mut stdin = process.stdin;
let mut stdout = BufReader::new(process.stdout);
stdin.write_all(b"abc\n").await.unwrap();
stdin.flush().await.unwrap();
let mut line = String::new();
stdout.read_line(&mut line).await.unwrap();
assert_eq!(line.trim_end(), "cba");
process.handle.terminate().await.unwrap();
std::fs::remove_dir_all(&dir).unwrap();
}
#[tokio::test]
async fn stdio_process_forwards_explicit_provider_credentials() {
let dir = temp_dir();
let sandbox = LocalSandbox::new(dir.clone());
let env = HashMap::from([("OPENAI_API_KEY".to_string(), "test-key".to_string())]);
let process = sandbox
.spawn_stdio_process(
"python3 -u -c 'import os; print(os.environ.get(\"OPENAI_API_KEY\", \"missing\"), flush=True)'",
None,
Some(&env),
None,
)
.await
.unwrap();
let mut stdout = BufReader::new(process.stdout);
let mut line = String::new();
stdout.read_line(&mut line).await.unwrap();
assert_eq!(line.trim_end(), "test-key");
process.handle.wait().await.unwrap();
std::fs::remove_dir_all(&dir).unwrap();
}
#[tokio::test]
async fn exec_command_exit_code() {
let dir = temp_dir();

View file

@ -277,4 +277,22 @@ mod tests {
assert!(result.is_ok());
}
#[tokio::test]
async fn stdio_process_forwards_to_inner_sandbox() {
let mock = Arc::new(MockSandbox::linux());
let env = ReadBeforeWriteSandbox::new(mock.clone());
env.spawn_stdio_process("python fake_agent.py", Some("/work/sub"), None, None)
.await
.unwrap();
assert_eq!(
*mock.captured_command.lock().unwrap(),
Some("python fake_agent.py".to_string())
);
assert_eq!(*mock.captured_working_dirs.lock().unwrap(), vec![Some(
"/work/sub".to_string()
)]);
}
}

View file

@ -9,6 +9,9 @@ use std::time::Duration;
use async_trait::async_trait;
use fabro_types::{CommandOutputStream, CommandTermination};
use serde::{Deserialize, Serialize};
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite};
use tokio::sync::Mutex as TokioMutex;
use tokio::task::JoinHandle;
use tokio::time;
use tokio_util::sync::CancellationToken;
@ -121,6 +124,18 @@ macro_rules! delegate_sandbox {
.await
}
async fn spawn_stdio_process(
&self,
command: &str,
working_dir: Option<&str>,
env_vars: Option<&std::collections::HashMap<String, String>>,
cancel_token: Option<tokio_util::sync::CancellationToken>,
) -> $crate::Result<$crate::StdioProcess> {
self.$field
.spawn_stdio_process(command, working_dir, env_vars, cancel_token)
.await
}
async fn glob(&self, pattern: &str, path: Option<&str>) -> $crate::Result<Vec<String>> {
self.$field.glob(pattern, path).await
}
@ -666,6 +681,114 @@ pub type CommandOutputCallback = Arc<
+ Sync,
>;
pub struct StdioProcess {
pub stdin: Pin<Box<dyn AsyncWrite + Send>>,
pub stdout: Pin<Box<dyn AsyncRead + Send>>,
pub stderr: StderrCollector,
pub handle: StdioProcessHandle,
}
#[derive(Debug, Clone)]
pub struct StderrCollector {
inner: Arc<TokioMutex<Vec<u8>>>,
max_bytes: usize,
}
impl StderrCollector {
#[must_use]
pub fn new(max_bytes: usize) -> Self {
Self {
inner: Arc::new(TokioMutex::new(Vec::new())),
max_bytes,
}
}
pub async fn push(&self, bytes: &[u8]) {
let mut tail = self.inner.lock().await;
tail.extend_from_slice(bytes);
if tail.len() > self.max_bytes {
let excess = tail.len() - self.max_bytes;
tail.drain(..excess);
}
}
pub async fn tail_string(&self) -> String {
let tail = self.inner.lock().await;
String::from_utf8_lossy(&tail).into_owned()
}
pub fn spawn_reader<R>(&self, mut reader: R) -> JoinHandle<()>
where
R: AsyncRead + Unpin + Send + 'static,
{
let collector = self.clone();
tokio::spawn(async move {
let mut buf = [0_u8; 8192];
loop {
match reader.read(&mut buf).await {
Ok(0) => return,
Ok(read) => collector.push(&buf[..read]).await,
Err(err) => {
tracing::warn!(error = %err, "Failed to read stdio process stderr");
return;
}
}
}
})
}
}
#[derive(Clone)]
pub struct StdioProcessHandle {
control: Arc<dyn StdioProcessControl>,
}
impl StdioProcessHandle {
pub(crate) fn new(control: impl StdioProcessControl + 'static) -> Self {
Self {
control: Arc::new(control),
}
}
pub async fn terminate(&self) -> crate::Result<()> {
self.control.terminate().await
}
pub async fn wait(&self) -> crate::Result<StdioProcessTermination> {
self.control.wait().await
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct StdioProcessTermination {
pub termination: CommandTermination,
pub exit_code: Option<i32>,
}
impl StdioProcessTermination {
#[must_use]
pub fn exited(exit_code: Option<i32>) -> Self {
Self {
termination: CommandTermination::Exited,
exit_code,
}
}
#[must_use]
pub fn cancelled() -> Self {
Self {
termination: CommandTermination::Cancelled,
exit_code: None,
}
}
}
#[async_trait]
pub(crate) trait StdioProcessControl: Send + Sync {
async fn terminate(&self) -> crate::Result<()>;
async fn wait(&self) -> crate::Result<StdioProcessTermination>;
}
#[derive(Debug, Clone)]
pub struct DirEntry {
pub name: String,
@ -752,6 +875,19 @@ pub trait Sandbox: Send + Sync {
live_streaming: false,
})
}
async fn spawn_stdio_process(
&self,
_command: &str,
_working_dir: Option<&str>,
_env_vars: Option<&HashMap<String, String>>,
_cancel_token: Option<CancellationToken>,
) -> crate::Result<StdioProcess> {
Err(crate::Error::message(
"ACP backend requires bidirectional stdio; this sandbox provider does not support it",
))
}
async fn grep(
&self,
pattern: &str,

View file

@ -4,9 +4,15 @@ use std::sync::Mutex;
use async_trait::async_trait;
use fabro_types::CommandTermination;
use tokio::fs;
use tokio::io::duplex;
use tokio_util::sync::CancellationToken;
use crate::{DirEntry, ExecResult, GrepOptions, Sandbox, SandboxEvent, SandboxEventCallback};
use crate::sandbox::StdioProcessControl;
use crate::{
DEFAULT_EXEC_OUTPUT_TAIL_BYTES, DirEntry, ExecResult, GrepOptions, Sandbox, SandboxEvent,
SandboxEventCallback, StderrCollector, StdioProcess, StdioProcessHandle,
StdioProcessTermination,
};
// --- MockSandbox ---
@ -36,6 +42,7 @@ pub struct MockSandbox {
pub stop_calls: Mutex<u32>,
pub delete_calls: Mutex<u32>,
pub event_callback: Option<SandboxEventCallback>,
pub stdio_process_error: Option<String>,
}
impl MockSandbox {
@ -100,10 +107,24 @@ impl Default for MockSandbox {
stop_calls: Mutex::new(0),
delete_calls: Mutex::new(0),
event_callback: None,
stdio_process_error: None,
}
}
}
struct MockStdioProcessControl;
#[async_trait]
impl StdioProcessControl for MockStdioProcessControl {
async fn terminate(&self) -> crate::Result<()> {
Ok(())
}
async fn wait(&self) -> crate::Result<StdioProcessTermination> {
Ok(StdioProcessTermination::exited(Some(0)))
}
}
#[async_trait]
impl Sandbox for MockSandbox {
async fn read_file(
@ -184,6 +205,44 @@ impl Sandbox for MockSandbox {
Ok(self.exec_result.clone())
}
async fn spawn_stdio_process(
&self,
command: &str,
working_dir: Option<&str>,
env_vars: Option<&std::collections::HashMap<String, String>>,
_cancel_token: Option<CancellationToken>,
) -> crate::Result<StdioProcess> {
*self
.captured_command
.lock()
.expect("captured_command lock poisoned") = Some(command.to_string());
self.captured_commands
.lock()
.expect("captured_commands lock poisoned")
.push(command.to_string());
self.captured_working_dirs
.lock()
.expect("captured_working_dirs lock poisoned")
.push(working_dir.map(String::from));
*self
.captured_env_vars
.lock()
.expect("captured_env_vars lock poisoned") = env_vars.cloned();
if let Some(error) = &self.stdio_process_error {
return Err(crate::Error::message(error.clone()));
}
let (stdin, _stdin_read) = duplex(1024);
let (_stdout_write, stdout) = duplex(1024);
Ok(StdioProcess {
stdin: Box::pin(stdin),
stdout: Box::pin(stdout),
stderr: StderrCollector::new(DEFAULT_EXEC_OUTPUT_TAIL_BYTES),
handle: StdioProcessHandle::new(MockStdioProcessControl),
})
}
async fn grep(
&self,
_pattern: &str,

View file

@ -8,7 +8,7 @@ use tokio_util::sync::CancellationToken;
use crate::sandbox::fetch_source_run_ref;
use crate::{
CommandOutputCallback, DirEntry, ExecResult, ExecStreamingResult, GitRunInfo, GitSetupIntent,
GrepOptions, Sandbox, shell_quote,
GrepOptions, Sandbox, StdioProcess, shell_quote,
};
/// Git command prefix that disables background maintenance.
@ -267,6 +267,19 @@ impl Sandbox for WorktreeSandbox {
.await
}
async fn spawn_stdio_process(
&self,
command: &str,
working_dir: Option<&str>,
env_vars: Option<&HashMap<String, String>>,
cancel_token: Option<CancellationToken>,
) -> crate::Result<StdioProcess> {
let wd = working_dir.unwrap_or(&self.config.worktree_path);
self.inner
.spawn_stdio_process(command, Some(wd), env_vars, cancel_token)
.await
}
// --- Delegated methods ---
async fn read_file(
@ -676,6 +689,23 @@ mod tests {
);
}
#[tokio::test]
async fn stdio_process_none_working_dir_defaults_to_worktree_path() {
let (inner, mock) = make_mock();
let wt = WorktreeSandbox::new(inner, make_config("/tmp/wt"));
wt.spawn_stdio_process("python fake_agent.py", None, None, None)
.await
.unwrap();
let wdirs = mock.captured_working_dirs.lock().unwrap().clone();
assert_eq!(
wdirs.last(),
Some(&Some("/tmp/wt".to_string())),
"None working_dir should be replaced with worktree path"
);
}
// -----------------------------------------------------------------------
// Accessors
// -----------------------------------------------------------------------

View file

@ -189,30 +189,30 @@ impl<T: serde::Serialize> ListResponse<T> {
/// Snapshot of a managed run.
struct ManagedRun {
dot_source: String,
status: RunStatus,
error: Option<String>,
created_at: chrono::DateTime<chrono::Utc>,
enqueued_at: Instant,
dot_source: String,
status: RunStatus,
error: Option<String>,
created_at: chrono::DateTime<chrono::Utc>,
enqueued_at: Instant,
// Populated when running:
answer_transport: Option<RunAnswerTransport>,
answer_transport: Option<RunAnswerTransport>,
accepted_questions: HashSet<String>,
/// Stage IDs of currently steerable API-mode (SDK) agent sessions,
/// keyed to the session id that owns the active lease. Used by the
/// steerability predicate.
active_api_stages: HashMap<StageId, String>,
/// Stage IDs of currently running CLI-mode agent sessions, observed
/// from `agent.cli.started/completed` plus `stage.completed`/
active_api_stages: HashMap<StageId, String>,
/// Stage IDs of currently running non-steerable agent sessions, observed
/// from CLI/ACP start/completion events plus `stage.completed`/
/// `stage.failed` backstops.
active_cli_stages: HashSet<StageId>,
event_tx: Option<broadcast::Sender<RunEvent>>,
checkpoint: Option<Checkpoint>,
cancel_tx: Option<oneshot::Sender<()>>,
cancel_token: Option<CancellationToken>,
worker_pid: Option<u32>,
worker_pgid: Option<u32>,
run_dir: Option<std::path::PathBuf>,
execution_mode: RunExecutionMode,
active_non_steerable_agent_stages: HashSet<StageId>,
event_tx: Option<broadcast::Sender<RunEvent>>,
checkpoint: Option<Checkpoint>,
cancel_tx: Option<oneshot::Sender<()>>,
cancel_token: Option<CancellationToken>,
worker_pid: Option<u32>,
worker_pgid: Option<u32>,
run_dir: Option<std::path::PathBuf>,
execution_mode: RunExecutionMode,
}
#[derive(Clone, Copy)]
@ -1953,7 +1953,7 @@ fn clear_live_run_state(run: &mut ManagedRun) {
run.answer_transport = None;
run.accepted_questions.clear();
run.active_api_stages.clear();
run.active_cli_stages.clear();
run.active_non_steerable_agent_stages.clear();
run.event_tx = None;
run.cancel_tx = None;
run.cancel_token = None;
@ -2291,7 +2291,7 @@ fn managed_run(
answer_transport: None,
accepted_questions: HashSet::new(),
active_api_stages: HashMap::new(),
active_cli_stages: HashSet::new(),
active_non_steerable_agent_stages: HashSet::new(),
event_tx: None,
checkpoint: None,
cancel_tx: None,
@ -2397,7 +2397,7 @@ fn update_live_run_from_event(state: &AppState, run_id: RunId, event: &RunEvent)
};
managed_run.error = None;
managed_run.active_api_stages.clear();
managed_run.active_cli_stages.clear();
managed_run.active_non_steerable_agent_stages.clear();
}
EventBody::RunFailed(props) => {
managed_run.status = RunStatus::Failed {
@ -2405,7 +2405,7 @@ fn update_live_run_from_event(state: &AppState, run_id: RunId, event: &RunEvent)
};
managed_run.error = Some(props.error.clone());
managed_run.active_api_stages.clear();
managed_run.active_cli_stages.clear();
managed_run.active_non_steerable_agent_stages.clear();
}
// Track API-mode steerable sessions. Activated/deactivated are
// leased by session id so stale deactivations cannot clear a newer
@ -2434,17 +2434,24 @@ fn update_live_run_from_event(state: &AppState, run_id: RunId, event: &RunEvent)
}
}
}
// Track CLI-mode agent stages. CLI started/completed are coarser
// and sometimes fail to emit `completed` on error paths — the
// stage.completed/stage.failed handler below is the backstop.
EventBody::AgentCliStarted(_) => {
// Track non-steerable agent stages. CLI/ACP started/completed are
// coarser and sometimes fail to emit terminal events on error paths;
// stage.completed/stage.failed below are the backstops.
EventBody::AgentCliStarted(_) | EventBody::AgentAcpStarted(_) => {
if let Some(stage_id) = event.stage_id.as_ref() {
managed_run.active_cli_stages.insert(stage_id.clone());
managed_run
.active_non_steerable_agent_stages
.insert(stage_id.clone());
}
}
EventBody::AgentCliCompleted(_) => {
EventBody::AgentCliCompleted(_)
| EventBody::AgentAcpCompleted(_)
| EventBody::AgentAcpCancelled(_)
| EventBody::AgentAcpTimedOut(_) => {
if let Some(stage_id) = &event.stage_id {
managed_run.active_cli_stages.remove(stage_id);
managed_run
.active_non_steerable_agent_stages
.remove(stage_id);
}
}
// Stage lifecycle backstop: cover both completion and failure
@ -2452,7 +2459,9 @@ fn update_live_run_from_event(state: &AppState, run_id: RunId, event: &RunEvent)
EventBody::StageCompleted(_) | EventBody::StageFailed(_) => {
if let Some(stage_id) = &event.stage_id {
managed_run.active_api_stages.remove(stage_id);
managed_run.active_cli_stages.remove(stage_id);
managed_run
.active_non_steerable_agent_stages
.remove(stage_id);
}
}
_ => {}

View file

@ -116,14 +116,14 @@ async fn control_run(
// - If at least one API-mode session is active → forward.
// - Else if no agent stages are active at all → forward (worker hub buffers
// for the next session).
// - Else (active agents exist but all are CLI-mode) → 409.
// - Else (active agents exist but all are non-steerable) → 409.
if managed_run.active_api_stages.is_empty()
&& !managed_run.active_cli_stages.is_empty()
&& !managed_run.active_non_steerable_agent_stages.is_empty()
{
return ApiError::with_code(
StatusCode::CONFLICT,
"All currently running agent stages are CLI-mode and cannot be steered.",
"cli_agent_not_steerable",
"All currently running agent stages use a non-steerable backend.",
"agent_not_steerable",
)
.into_response();
}

View file

@ -7231,6 +7231,150 @@ fn active_api_stage_projection_ignores_stale_deactivation() {
);
}
fn acp_event_for_stage(run_id: &RunId, event: &workflow_event::Event) -> fabro_types::RunEvent {
workflow_event::to_run_event_at(
run_id,
event,
Utc::now(),
Some(&workflow_event::StageScope {
node_id: "agent".to_string(),
visit: 1,
parallel_group_id: None,
parallel_branch_id: None,
}),
)
}
#[tokio::test]
async fn steer_with_active_acp_stage_returns_non_steerable_conflict() {
let state = test_app_state();
let app = crate::test_support::build_test_router(Arc::clone(&state));
let run_id = fixtures::RUN_1;
let (control_tx, _control_rx) = tokio::sync::mpsc::channel(1);
let _temp_dir = insert_running_control_run(
&state,
run_id,
Some(RunAnswerTransport::Subprocess { control_tx }),
);
let started = acp_event_for_stage(&run_id, &workflow_event::Event::AgentAcpStarted {
node_id: "agent".to_string(),
visit: 1,
mode: "acp".to_string(),
provider: "openai".to_string(),
model: "fake-acp".to_string(),
command: "python fake_agent.py".to_string(),
});
update_live_run_from_event(&state, run_id, &started);
let req = Request::builder()
.method("POST")
.uri(api(&format!("/runs/{run_id}/steer")))
.header("content-type", "application/json")
.body(Body::from(r#"{"text":"try again"}"#))
.unwrap();
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::CONFLICT);
let body = body_json(response.into_body()).await;
assert_eq!(body["errors"][0]["code"], "agent_not_steerable");
}
#[tokio::test]
async fn active_acp_stage_marker_clears_on_terminal_paths() {
let terminal_events: Vec<workflow_event::Event> = vec![
workflow_event::Event::AgentAcpCompleted {
node_id: "agent".to_string(),
stdout: "done".to_string(),
stderr: String::new(),
stop_reason: "end_turn".to_string(),
duration_ms: 42,
},
workflow_event::Event::AgentAcpCancelled {
node_id: "agent".to_string(),
stdout: "partial".to_string(),
stderr: "cancelled".to_string(),
duration_ms: 7,
},
workflow_event::Event::AgentAcpTimedOut {
node_id: "agent".to_string(),
stdout: "partial".to_string(),
stderr: "timeout".to_string(),
duration_ms: 99,
},
workflow_event::Event::StageCompleted {
node_id: "agent".to_string(),
name: "agent".to_string(),
index: 0,
duration_ms: 1,
status: "success".to_string(),
preferred_label: None,
suggested_next_ids: Vec::new(),
billing: None,
failure: None,
notes: None,
files_touched: Vec::new(),
context_updates: None,
jump_to_node: None,
context_values: None,
node_visits: None,
loop_failure_signatures: None,
restart_failure_signatures: None,
response: None,
attempt: 1,
max_attempts: 1,
},
workflow_event::Event::StageFailed {
node_id: "agent".to_string(),
name: "agent".to_string(),
index: 0,
failure: FailureDetail::new("failed", FailureCategory::Deterministic),
will_retry: false,
duration_ms: 1,
billing: None,
actor: None,
},
];
for terminal_event in terminal_events {
let state = test_app_state();
let app = crate::test_support::build_test_router(Arc::clone(&state));
let run_id = fixtures::RUN_1;
let (control_tx, mut control_rx) = tokio::sync::mpsc::channel(1);
let _temp_dir = insert_running_control_run(
&state,
run_id,
Some(RunAnswerTransport::Subprocess { control_tx }),
);
let started = acp_event_for_stage(&run_id, &workflow_event::Event::AgentAcpStarted {
node_id: "agent".to_string(),
visit: 1,
mode: "acp".to_string(),
provider: "openai".to_string(),
model: "fake-acp".to_string(),
command: "python fake_agent.py".to_string(),
});
update_live_run_from_event(&state, run_id, &started);
let terminal = acp_event_for_stage(&run_id, &terminal_event);
update_live_run_from_event(&state, run_id, &terminal);
let req = Request::builder()
.method("POST")
.uri(api(&format!("/runs/{run_id}/steer")))
.header("content-type", "application/json")
.body(Body::from(r#"{"text":"try again"}"#))
.unwrap();
let response = app.oneshot(req).await.unwrap();
assert_status!(response, StatusCode::ACCEPTED).await;
let envelope = control_rx.recv().await.unwrap();
assert!(matches!(
envelope.message,
WorkerControlMessage::Steer { ref text, .. } if text == "try again"
));
}
}
#[tokio::test]
async fn get_graph_returns_svg() {
let state = test_app_state();

View file

@ -3,8 +3,9 @@ use std::str::FromStr;
use chrono::{DateTime, Utc};
use fabro_types::run_event::{
AgentCliStartedProps, AgentSessionActivatedProps, CheckpointCompletedProps, RunCompletedProps,
RunFailedProps, StageCompletedProps, StagePromptProps,
AgentAcpStartedProps, AgentCliStartedProps, AgentSessionActivatedProps,
CheckpointCompletedProps, RunCompletedProps, RunFailedProps, StageCompletedProps,
StagePromptProps,
};
use fabro_types::settings::run::RunSandboxSettings;
use fabro_types::{
@ -371,6 +372,13 @@ impl RunProjectionReducer for RunProjection {
};
stage.provider_used = Some(provider_used_from_agent_cli_started(props));
}
EventBody::AgentAcpStarted(props) => {
let Some(stage) = stage_at_stored_or_visit(self, stored, props.visit, event.seq)
else {
return Ok(());
};
stage.provider_used = Some(provider_used_from_agent_acp_started(props));
}
EventBody::CommandStarted(props) => {
let script_invocation = serde_json::to_value(props).map_err(|err| {
Error::InvalidEvent(format!("invalid command.started payload: {err}"))
@ -397,7 +405,8 @@ impl RunProjectionReducer for RunProjection {
let Some(stage) = stage_at_current_visit(self, stored, event.seq) else {
return Ok(());
};
apply_agent_cli_terminal(
apply_agent_terminal(
"agent.cli",
stage,
props,
merge_agent_cli_output(&props.stdout, &props.stderr),
@ -408,7 +417,8 @@ impl RunProjectionReducer for RunProjection {
let Some(stage) = stage_at_current_visit(self, stored, event.seq) else {
return Ok(());
};
apply_agent_cli_terminal(
apply_agent_terminal(
"agent.cli",
stage,
props,
merge_agent_cli_output(&props.stdout, &props.stderr),
@ -419,7 +429,44 @@ impl RunProjectionReducer for RunProjection {
let Some(stage) = stage_at_current_visit(self, stored, event.seq) else {
return Ok(());
};
apply_agent_cli_terminal(
apply_agent_terminal(
"agent.cli",
stage,
props,
merge_agent_cli_output(&props.stdout, &props.stderr),
CommandTermination::TimedOut,
)?;
}
EventBody::AgentAcpCompleted(props) => {
let Some(stage) = stage_at_current_visit(self, stored, event.seq) else {
return Ok(());
};
apply_agent_terminal(
"agent.acp",
stage,
props,
merge_agent_cli_output(&props.stdout, &props.stderr),
CommandTermination::Exited,
)?;
}
EventBody::AgentAcpCancelled(props) => {
let Some(stage) = stage_at_current_visit(self, stored, event.seq) else {
return Ok(());
};
apply_agent_terminal(
"agent.acp",
stage,
props,
merge_agent_cli_output(&props.stdout, &props.stderr),
CommandTermination::Cancelled,
)?;
}
EventBody::AgentAcpTimedOut(props) => {
let Some(stage) = stage_at_current_visit(self, stored, event.seq) else {
return Ok(());
};
apply_agent_terminal(
"agent.acp",
stage,
props,
merge_agent_cli_output(&props.stdout, &props.stderr),
@ -837,25 +884,37 @@ fn provider_used_from_agent_session_activated(props: &AgentSessionActivatedProps
}
fn provider_used_from_agent_cli_started(props: &AgentCliStartedProps) -> Value {
provider_used_from_agent_process_started("cli", &props.provider, &props.model, &props.command)
}
fn provider_used_from_agent_acp_started(props: &AgentAcpStartedProps) -> Value {
provider_used_from_agent_process_started("acp", &props.provider, &props.model, &props.command)
}
fn provider_used_from_agent_process_started(
mode: &str,
provider: &str,
model: &str,
command: &str,
) -> Value {
let mut provider_used = serde_json::Map::new();
provider_used.insert("mode".to_string(), Value::String("cli".to_string()));
provider_used.insert(
"provider".to_string(),
Value::String(props.provider.clone()),
);
provider_used.insert("model".to_string(), Value::String(props.model.clone()));
provider_used.insert("command".to_string(), Value::String(props.command.clone()));
provider_used.insert("mode".to_string(), Value::String(mode.to_string()));
provider_used.insert("provider".to_string(), Value::String(provider.to_string()));
provider_used.insert("model".to_string(), Value::String(model.to_string()));
provider_used.insert("command".to_string(), Value::String(command.to_string()));
Value::Object(provider_used)
}
fn apply_agent_cli_terminal(
fn apply_agent_terminal(
event_prefix: &str,
stage: &mut StageProjection,
props: &impl serde::Serialize,
output: String,
termination: CommandTermination,
) -> Result<()> {
let script_timing = serde_json::to_value(props)
.map_err(|err| Error::InvalidEvent(format!("invalid agent.cli terminal payload: {err}")))?;
let script_timing = serde_json::to_value(props).map_err(|err| {
Error::InvalidEvent(format!("invalid {event_prefix} terminal payload: {err}"))
})?;
stage.output = Some(output);
stage.termination = Some(termination);
stage.script_timing = Some(script_timing);
@ -878,11 +937,13 @@ mod tests {
use chrono::Utc;
use fabro_types::run_event::run::RunFailedProps;
use fabro_types::run_event::{
AgentCliCancelledProps, AgentCliCompletedProps, AgentCliTimedOutProps, AgentMessageProps,
AgentSessionActivatedProps, AgentSessionEndedProps, AgentSessionStartedProps,
CheckpointCompletedProps, InterviewCompletedProps, InterviewOption, InterviewStartedProps,
RunControlEffectProps, StageCompletedProps, StageFailedProps, StagePromptProps,
StageRetryingProps, StageStartedProps,
AgentAcpCancelledProps, AgentAcpCompletedProps, AgentAcpStartedProps,
AgentAcpTimedOutProps, AgentCliCancelledProps, AgentCliCompletedProps,
AgentCliTimedOutProps, AgentMessageProps, AgentSessionActivatedProps,
AgentSessionEndedProps, AgentSessionStartedProps, CheckpointCompletedProps,
InterviewCompletedProps, InterviewOption, InterviewStartedProps, RunControlEffectProps,
StageCompletedProps, StageFailedProps, StagePromptProps, StageRetryingProps,
StageStartedProps,
};
use fabro_types::{
BilledModelUsage, BilledTokenCounts, BlockedReason, Checkpoint, CheckpointRecord,
@ -1287,6 +1348,109 @@ mod tests {
assert!(stage.provider_used.is_none());
}
#[test]
fn agent_acp_started_updates_stage_provider_used() {
let mut state = initialized_projection();
let stage_id = StageId::new("code", 1);
start_stage(&mut state, &stage_id);
state
.apply_event(&test_stage_event(
4,
EventBody::AgentAcpStarted(AgentAcpStartedProps {
visit: 1,
mode: "acp".to_string(),
provider: "openai".to_string(),
model: "fake-acp".to_string(),
command: "python fake_agent.py".to_string(),
}),
stage_id.clone(),
))
.unwrap();
let stage = state.stage(&stage_id).unwrap();
assert_eq!(
stage.provider_used.as_ref().unwrap(),
&json!({
"mode": "acp",
"provider": "openai",
"model": "fake-acp",
"command": "python fake_agent.py"
})
);
}
#[test]
fn agent_acp_completed_updates_stage_output_projection() {
let mut state = initialized_projection();
let stage_id = StageId::new("code", 1);
start_stage(&mut state, &stage_id);
state
.apply_event(&test_stage_event(
4,
EventBody::AgentAcpCompleted(AgentAcpCompletedProps {
stdout: "done".to_string(),
stderr: "warn".to_string(),
stop_reason: "end_turn".to_string(),
duration_ms: 42,
}),
stage_id.clone(),
))
.unwrap();
let stage = state.stage(&stage_id).unwrap();
assert_eq!(stage.output.as_deref(), Some("done\nwarn"));
assert_eq!(stage.termination, Some(CommandTermination::Exited));
assert_eq!(
stage.script_timing.as_ref().unwrap()["stop_reason"],
serde_json::json!("end_turn")
);
}
#[test]
fn agent_acp_cancelled_and_timed_out_update_terminal_projection() {
let mut cancelled = initialized_projection();
let cancelled_stage_id = StageId::new("cancelled", 1);
start_stage(&mut cancelled, &cancelled_stage_id);
cancelled
.apply_event(&test_stage_event(
4,
EventBody::AgentAcpCancelled(AgentAcpCancelledProps {
stdout: "partial".to_string(),
stderr: "cancelled".to_string(),
duration_ms: 7,
}),
cancelled_stage_id.clone(),
))
.unwrap();
let stage = cancelled.stage(&cancelled_stage_id).unwrap();
assert_eq!(stage.output.as_deref(), Some("partial\ncancelled"));
assert_eq!(stage.termination, Some(CommandTermination::Cancelled));
let mut timed_out = initialized_projection();
let timed_out_stage_id = StageId::new("timed_out", 1);
start_stage(&mut timed_out, &timed_out_stage_id);
timed_out
.apply_event(&test_stage_event(
4,
EventBody::AgentAcpTimedOut(AgentAcpTimedOutProps {
stdout: "partial".to_string(),
stderr: "timeout".to_string(),
duration_ms: 99,
}),
timed_out_stage_id.clone(),
))
.unwrap();
let stage = timed_out.stage(&timed_out_stage_id).unwrap();
assert_eq!(stage.output.as_deref(), Some("partial\ntimeout"));
assert_eq!(stage.termination, Some(CommandTermination::TimedOut));
}
#[test]
fn agent_cli_completed_updates_stage_output_projection() {
let mut state = initialized_projection();

View file

@ -3,6 +3,8 @@ use std::time::Duration;
use serde::{Deserialize, Serialize};
use crate::LlmBackend;
/// Typed attribute values for nodes, edges, and graph-level attributes.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum AttrValue {
@ -266,6 +268,16 @@ impl Node {
self.str_attr("backend")
}
#[must_use]
pub fn llm_backend(&self) -> Option<Result<LlmBackend, strum::ParseError>> {
self.backend().map(str::parse)
}
#[must_use]
pub fn acp_command(&self) -> Option<&str> {
self.str_attr("acp_command")
}
#[must_use]
pub fn selection(&self) -> &str {
self.str_attr("selection").unwrap_or("deterministic")

View file

@ -13,6 +13,7 @@ pub mod event_envelope;
pub mod failure_signature;
pub mod graph;
pub mod interview;
pub mod llm_backend;
pub mod outcome;
pub mod principal;
pub mod pull_request;
@ -57,6 +58,7 @@ pub use graph::{
shape_to_handler_type,
};
pub use interview::{InterviewQuestionRecord, QuestionType};
pub use llm_backend::LlmBackend;
pub use outcome::{
FailureCategory, FailureDetail, NodeResult, Outcome, OutcomeMeta, StageOutcome, StageState,
};

View file

@ -0,0 +1,32 @@
use serde::{Deserialize, Serialize};
use strum::{Display, EnumString, IntoStaticStr, VariantArray, VariantNames};
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
Hash,
Serialize,
Deserialize,
Display,
EnumString,
IntoStaticStr,
VariantArray,
VariantNames,
)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum LlmBackend {
Api,
Cli,
Acp,
}
impl LlmBackend {
#[must_use]
pub fn expected_values() -> String {
<Self as VariantNames>::VARIANTS.join(", ")
}
}

View file

@ -335,6 +335,37 @@ pub struct AgentCliTimedOutProps {
pub duration_ms: u64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AgentAcpStartedProps {
pub visit: u32,
pub mode: String,
pub provider: String,
pub model: String,
pub command: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AgentAcpCompletedProps {
pub stdout: String,
pub stderr: String,
pub stop_reason: String,
pub duration_ms: u64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AgentAcpCancelledProps {
pub stdout: String,
pub stderr: String,
pub duration_ms: u64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AgentAcpTimedOutProps {
pub stdout: String,
pub stderr: String,
pub duration_ms: u64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PullRequestCreatedProps {
pub pr_url: String,

View file

@ -290,6 +290,14 @@ pub enum EventBody {
AgentCliCancelled(AgentCliCancelledProps),
#[serde(rename = "agent.cli.timed_out")]
AgentCliTimedOut(AgentCliTimedOutProps),
#[serde(rename = "agent.acp.started")]
AgentAcpStarted(AgentAcpStartedProps),
#[serde(rename = "agent.acp.completed")]
AgentAcpCompleted(AgentAcpCompletedProps),
#[serde(rename = "agent.acp.cancelled")]
AgentAcpCancelled(AgentAcpCancelledProps),
#[serde(rename = "agent.acp.timed_out")]
AgentAcpTimedOut(AgentAcpTimedOutProps),
#[serde(rename = "pull_request.created")]
PullRequestCreated(PullRequestCreatedProps),
#[serde(rename = "pull_request.failed")]
@ -484,6 +492,10 @@ impl EventBody {
Self::AgentCliCompleted(_) => "agent.cli.completed",
Self::AgentCliCancelled(_) => "agent.cli.cancelled",
Self::AgentCliTimedOut(_) => "agent.cli.timed_out",
Self::AgentAcpStarted(_) => "agent.acp.started",
Self::AgentAcpCompleted(_) => "agent.acp.completed",
Self::AgentAcpCancelled(_) => "agent.acp.cancelled",
Self::AgentAcpTimedOut(_) => "agent.acp.timed_out",
Self::PullRequestCreated(_) => "pull_request.created",
Self::PullRequestFailed(_) => "pull_request.failed",
Self::DevcontainerResolved(_) => "devcontainer.resolved",
@ -629,6 +641,12 @@ fn is_known_event_name(event: &str) -> bool {
| "command.completed"
| "agent.cli.started"
| "agent.cli.completed"
| "agent.cli.cancelled"
| "agent.cli.timed_out"
| "agent.acp.started"
| "agent.acp.completed"
| "agent.acp.cancelled"
| "agent.acp.timed_out"
| "pull_request.created"
| "pull_request.failed"
| "devcontainer.resolved"

View file

@ -15,5 +15,6 @@ workspace = true
[dependencies]
fabro-graphviz = { path = "../fabro-graphviz" }
fabro-model = { path = "../fabro-model" }
fabro-types = { path = "../fabro-types" }
serde = { workspace = true }
thiserror = { workspace = true }
thiserror = { workspace = true }

View file

@ -0,0 +1,140 @@
use fabro_graphviz::graph::{Graph, Node};
use fabro_types::LlmBackend;
use crate::{Diagnostic, LintRule, Severity};
pub(super) fn rule() -> Box<dyn LintRule> {
Box::new(Rule)
}
struct Rule;
impl LintRule for Rule {
fn name(&self) -> &'static str {
"backend_valid"
}
fn apply(&self, graph: &Graph) -> Vec<Diagnostic> {
let mut diagnostics = Vec::new();
for node in graph.nodes.values() {
if let Some(backend) = node.backend() {
match node.llm_backend() {
Some(Err(_)) => {
let expected = LlmBackend::expected_values();
diagnostics.push(Diagnostic {
rule: self.name().to_string(),
severity: Severity::Error,
message: format!(
"unsupported LLM backend \"{backend}\"; expected one of: {expected}"
),
node_id: Some(node.id.clone()),
edge: None,
fix: Some(format!("Use one of: {expected}")),
});
}
Some(Ok(LlmBackend::Acp)) if acp_command_missing(node) => {
diagnostics.push(Diagnostic {
rule: self.name().to_string(),
severity: Severity::Error,
message: "backend=\"acp\" requires acp_command because Fabro does \
not install ACP agents"
.to_string(),
node_id: Some(node.id.clone()),
edge: None,
fix: Some(
"Set acp_command to a stdio ACP command available in the sandbox"
.to_string(),
),
});
}
Some(Ok(_)) | None => {}
}
}
}
diagnostics
}
}
fn acp_command_missing(node: &Node) -> bool {
match node.acp_command() {
Some(command) => command.trim().is_empty(),
None => true,
}
}
#[cfg(test)]
mod tests {
use fabro_graphviz::graph::{AttrValue, Node};
use super::Rule;
use crate::rules::test_support::minimal_graph;
use crate::{LintRule, Severity};
#[test]
fn backend_valid_accepts_absent_api_and_cli() {
for backend in [None, Some("api"), Some("cli")] {
let mut graph = minimal_graph();
let mut node = Node::new("work");
if let Some(backend) = backend {
node.attrs.insert(
"backend".to_string(),
AttrValue::String(backend.to_string()),
);
}
graph.nodes.insert("work".to_string(), node);
assert!(Rule.apply(&graph).is_empty(), "backend: {backend:?}");
}
}
#[test]
fn backend_valid_rejects_unknown_backend() {
let mut graph = minimal_graph();
let mut node = Node::new("work");
node.attrs.insert(
"backend".to_string(),
AttrValue::String("codex".to_string()),
);
graph.nodes.insert("work".to_string(), node);
let diagnostics = Rule.apply(&graph);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].severity, Severity::Error);
assert!(
diagnostics[0]
.message
.contains("unsupported LLM backend \"codex\"; expected one of: api, cli, acp")
);
}
#[test]
fn backend_valid_requires_acp_command_for_acp_backend() {
let mut graph = minimal_graph();
let mut node = Node::new("work");
node.attrs
.insert("backend".to_string(), AttrValue::String("acp".to_string()));
graph.nodes.insert("work".to_string(), node);
let diagnostics = Rule.apply(&graph);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].severity, Severity::Error);
assert!(diagnostics[0].message.contains(
"backend=\"acp\" requires acp_command because Fabro does not install ACP agents"
));
}
#[test]
fn backend_valid_accepts_acp_backend_with_acp_command() {
let mut graph = minimal_graph();
let mut node = Node::new("work");
node.attrs
.insert("backend".to_string(), AttrValue::String("acp".to_string()));
node.attrs.insert(
"acp_command".to_string(),
AttrValue::String("agent-acp".to_string()),
);
graph.nodes.insert("work".to_string(), node);
assert!(Rule.apply(&graph).is_empty());
}
}

View file

@ -1,4 +1,5 @@
mod all_conditional_edges;
mod backend_valid;
mod condition_syntax;
mod direction_valid;
mod edge_target_exists;
@ -43,6 +44,7 @@ pub fn built_in_rules() -> Vec<Box<dyn LintRule>> {
condition_syntax::rule(),
stylesheet_syntax::rule(),
type_known::rule(),
backend_valid::rule(),
fidelity_valid::rule(),
retry_target_exists::rule(),
goal_gate_has_retry::rule(),

View file

@ -19,6 +19,7 @@ workspace = true
[dependencies]
anyhow.workspace = true
fabro-auth = { path = "../fabro-auth" }
fabro-acp = { path = "../fabro-acp" }
fabro-agent = { path = "../fabro-agent" }
fabro-config = { path = "../fabro-config" }
fabro-graphviz = { path = "../fabro-graphviz" }
@ -70,7 +71,8 @@ toml.workspace = true
fabro-vault = { path = "../fabro-vault" }
[dev-dependencies]
base64.workspace = true
fabro-sandbox = { path = "../fabro-sandbox", features = ["daytona", "test-support"] }
fabro-acp = { path = "../fabro-acp", features = ["test-support"] }
fabro-sandbox = { path = "../fabro-sandbox", features = ["daytona", "docker", "test-support"] }
fabro-mcp = { path = "../fabro-mcp" }
tokio = { workspace = true, features = ["test-util", "macros"] }
object_store.workspace = true

View file

@ -986,38 +986,6 @@ fn event_body_from_event(event: &Event) -> EventBody {
to_model: to_model.clone(),
error: error.clone(),
}),
Event::CliEnsureStarted { cli_name, provider } => {
EventBody::CliEnsureStarted(fabro_types::CliEnsureStartedProps {
cli_name: cli_name.clone(),
provider: provider.clone(),
})
}
Event::CliEnsureCompleted {
cli_name,
provider,
already_installed,
node_installed,
duration_ms,
} => EventBody::CliEnsureCompleted(fabro_types::CliEnsureCompletedProps {
cli_name: cli_name.clone(),
provider: provider.clone(),
already_installed: *already_installed,
node_installed: *node_installed,
duration_ms: *duration_ms,
}),
Event::CliEnsureFailed {
cli_name,
provider,
error,
duration_ms,
exec_output_tail,
} => EventBody::CliEnsureFailed(fabro_types::CliEnsureFailedProps {
cli_name: cli_name.clone(),
provider: provider.clone(),
error: error.clone(),
duration_ms: *duration_ms,
exec_output_tail: exec_output_tail.clone(),
}),
Event::CommandStarted {
script,
command,
@ -1134,6 +1102,52 @@ fn event_body_from_event(event: &Event) -> EventBody {
stderr: stderr.clone(),
duration_ms: *duration_ms,
}),
Event::AgentAcpStarted {
visit,
mode,
provider,
model,
command,
..
} => EventBody::AgentAcpStarted(fabro_types::AgentAcpStartedProps {
visit: *visit,
mode: mode.clone(),
provider: provider.clone(),
model: model.clone(),
command: command.clone(),
}),
Event::AgentAcpCompleted {
stdout,
stderr,
stop_reason,
duration_ms,
..
} => EventBody::AgentAcpCompleted(fabro_types::AgentAcpCompletedProps {
stdout: stdout.clone(),
stderr: stderr.clone(),
stop_reason: stop_reason.clone(),
duration_ms: *duration_ms,
}),
Event::AgentAcpCancelled {
stdout,
stderr,
duration_ms,
..
} => EventBody::AgentAcpCancelled(fabro_types::AgentAcpCancelledProps {
stdout: stdout.clone(),
stderr: stderr.clone(),
duration_ms: *duration_ms,
}),
Event::AgentAcpTimedOut {
stdout,
stderr,
duration_ms,
..
} => EventBody::AgentAcpTimedOut(fabro_types::AgentAcpTimedOutProps {
stdout: stdout.clone(),
stderr: stderr.clone(),
duration_ms: *duration_ms,
}),
Event::PullRequestCreated {
pr_url,
pr_number,
@ -2009,6 +2023,110 @@ mod tests {
}
}
#[test]
fn agent_acp_events_map_to_event_bodies_with_stage_scope() {
let scope = StageScope {
node_id: "code".to_string(),
visit: 2,
parallel_group_id: Some(StageId::new("fanout", 1)),
parallel_branch_id: Some(ParallelBranchId::new(StageId::new("fanout", 1), 0)),
};
let started = to_run_event_at(
&fixtures::RUN_1,
&Event::AgentAcpStarted {
node_id: "code".to_string(),
visit: 2,
mode: "acp".to_string(),
provider: "openai".to_string(),
model: "fake-acp".to_string(),
command: "python fake_agent.py".to_string(),
},
Utc::now(),
Some(&scope),
);
assert_eq!(started.event_name(), "agent.acp.started");
assert_eq!(started.node_id.as_deref(), Some("code"));
assert_eq!(started.stage_id, Some(StageId::new("code", 2)));
assert_eq!(started.parallel_group_id, scope.parallel_group_id);
assert_eq!(started.parallel_branch_id, scope.parallel_branch_id);
match &started.body {
EventBody::AgentAcpStarted(props) => {
assert_eq!(props.visit, 2);
assert_eq!(props.mode, "acp");
assert_eq!(props.provider, "openai");
assert_eq!(props.model, "fake-acp");
assert_eq!(props.command, "python fake_agent.py");
}
other => panic!("expected AgentAcpStarted, got {other:?}"),
}
let completed = to_run_event_at(
&fixtures::RUN_1,
&Event::AgentAcpCompleted {
node_id: "code".to_string(),
stdout: "done".to_string(),
stderr: "warn".to_string(),
stop_reason: "end_turn".to_string(),
duration_ms: 42,
},
Utc::now(),
Some(&scope),
);
assert_eq!(completed.event_name(), "agent.acp.completed");
match &completed.body {
EventBody::AgentAcpCompleted(props) => {
assert_eq!(props.stdout, "done");
assert_eq!(props.stderr, "warn");
assert_eq!(props.stop_reason, "end_turn");
assert_eq!(props.duration_ms, 42);
}
other => panic!("expected AgentAcpCompleted, got {other:?}"),
}
let cancelled = to_run_event_at(
&fixtures::RUN_1,
&Event::AgentAcpCancelled {
node_id: "code".to_string(),
stdout: "partial".to_string(),
stderr: "cancelled".to_string(),
duration_ms: 7,
},
Utc::now(),
Some(&scope),
);
assert_eq!(cancelled.event_name(), "agent.acp.cancelled");
assert_eq!(cancelled.stage_id, Some(StageId::new("code", 2)));
assert!(matches!(
cancelled.body,
EventBody::AgentAcpCancelled(fabro_types::AgentAcpCancelledProps {
duration_ms: 7,
..
})
));
let timed_out = to_run_event_at(
&fixtures::RUN_1,
&Event::AgentAcpTimedOut {
node_id: "code".to_string(),
stdout: "partial".to_string(),
stderr: "timeout".to_string(),
duration_ms: 99,
},
Utc::now(),
Some(&scope),
);
assert_eq!(timed_out.event_name(), "agent.acp.timed_out");
assert_eq!(timed_out.stage_id, Some(StageId::new("code", 2)));
assert!(matches!(
timed_out.body,
EventBody::AgentAcpTimedOut(fabro_types::AgentAcpTimedOutProps {
duration_ms: 99,
..
})
));
}
#[test]
fn stall_watchdog_timeout_populates_watchdog_actor() {
let stored = to_run_event(&fixtures::RUN_1, &Event::StallWatchdogTimeout {

View file

@ -495,25 +495,6 @@ pub enum Event {
to_model: String,
error: String,
},
CliEnsureStarted {
cli_name: String,
provider: String,
},
CliEnsureCompleted {
cli_name: String,
provider: String,
already_installed: bool,
node_installed: bool,
duration_ms: u64,
},
CliEnsureFailed {
cli_name: String,
provider: String,
error: String,
duration_ms: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
exec_output_tail: Option<fabro_types::ExecOutputTail>,
},
CommandStarted {
node_id: String,
script: String,
@ -621,6 +602,33 @@ pub enum Event {
stderr: String,
duration_ms: u64,
},
AgentAcpStarted {
node_id: String,
visit: u32,
mode: String,
provider: String,
model: String,
command: String,
},
AgentAcpCompleted {
node_id: String,
stdout: String,
stderr: String,
stop_reason: String,
duration_ms: u64,
},
AgentAcpCancelled {
node_id: String,
stdout: String,
stderr: String,
duration_ms: u64,
},
AgentAcpTimedOut {
node_id: String,
stdout: String,
stderr: String,
duration_ms: u64,
},
PullRequestCreated {
pr_url: String,
pr_number: u64,
@ -1241,48 +1249,6 @@ impl Event {
"LLM provider failover"
);
}
Self::CliEnsureStarted {
cli_name, provider, ..
} => {
debug!(cli_name, provider, "CLI ensure started");
}
Self::CliEnsureCompleted {
cli_name,
provider,
already_installed,
node_installed,
duration_ms,
} => {
info!(
cli_name,
provider,
already_installed,
node_installed,
duration_ms,
"CLI ensure completed"
);
}
Self::CliEnsureFailed {
cli_name,
provider,
error,
duration_ms,
exec_output_tail,
} => {
let tail = fabro_types::ExecOutputTail::trace_summary(exec_output_tail.as_ref());
error!(
cli_name,
provider,
error,
duration_ms,
exec_output_tail_present = tail.present,
exec_stdout_tail_bytes = tail.stdout_bytes,
exec_stderr_tail_bytes = tail.stderr_bytes,
exec_stdout_truncated = tail.stdout_truncated,
exec_stderr_truncated = tail.stderr_truncated,
"CLI ensure failed"
);
}
Self::CommandStarted {
node_id,
language,
@ -1378,6 +1344,36 @@ impl Event {
} => {
debug!(node_id, duration_ms, "Agent CLI timed out");
}
Self::AgentAcpStarted {
node_id,
provider,
model,
..
} => {
debug!(node_id, provider, model, "Agent ACP started");
}
Self::AgentAcpCompleted {
node_id,
stop_reason,
duration_ms,
..
} => {
debug!(node_id, stop_reason, duration_ms, "Agent ACP completed");
}
Self::AgentAcpCancelled {
node_id,
duration_ms,
..
} => {
debug!(node_id, duration_ms, "Agent ACP cancelled");
}
Self::AgentAcpTimedOut {
node_id,
duration_ms,
..
} => {
debug!(node_id, duration_ms, "Agent ACP timed out");
}
Self::PullRequestCreated {
pr_url,
pr_number,

View file

@ -121,9 +121,6 @@ pub fn event_name(event: &Event) -> &'static str {
Event::ArtifactCaptured { .. } => "artifact.captured",
Event::SshAccessReady { .. } => "ssh.ready",
Event::Failover { .. } => "agent.failover",
Event::CliEnsureStarted { .. } => "cli.ensure.started",
Event::CliEnsureCompleted { .. } => "cli.ensure.completed",
Event::CliEnsureFailed { .. } => "cli.ensure.failed",
Event::CommandStarted { .. } => "command.started",
Event::CommandCompleted { .. } => "command.completed",
Event::AgentCliStarted { .. } => "agent.cli.started",
@ -137,6 +134,10 @@ pub fn event_name(event: &Event) -> &'static str {
Event::AgentSteerDropped { .. } => "agent.steer.dropped",
Event::AgentCliCancelled { .. } => "agent.cli.cancelled",
Event::AgentCliTimedOut { .. } => "agent.cli.timed_out",
Event::AgentAcpStarted { .. } => "agent.acp.started",
Event::AgentAcpCompleted { .. } => "agent.acp.completed",
Event::AgentAcpCancelled { .. } => "agent.acp.cancelled",
Event::AgentAcpTimedOut { .. } => "agent.acp.timed_out",
Event::PullRequestCreated { .. } => "pull_request.created",
Event::PullRequestFailed { .. } => "pull_request.failed",
Event::DevcontainerResolved { .. } => "devcontainer.resolved",

View file

@ -122,7 +122,20 @@ fn stored_event_fields_for_variant(event: &Event) -> StoredEventFields {
| Event::AgentCliStarted { node_id, .. }
| Event::AgentCliCompleted { node_id, .. }
| Event::AgentCliCancelled { node_id, .. }
| Event::AgentCliTimedOut { node_id, .. } => node_stored_fields(Some(node_id.clone())),
| Event::AgentCliTimedOut { node_id, .. }
| Event::AgentAcpCompleted { node_id, .. }
| Event::AgentAcpCancelled { node_id, .. }
| Event::AgentAcpTimedOut { node_id, .. } => node_stored_fields(Some(node_id.clone())),
Event::AgentAcpStarted { node_id, visit, .. } => {
let node_id_str = node_id.clone();
let node_label = default_node_label(Some(&node_id_str), None);
StoredEventFields {
node_id: Some(node_id_str.clone()),
node_label,
stage_id: Some(StageId::new(node_id_str, *visit)),
..StoredEventFields::default()
}
}
Event::AgentSessionStarted {
session_id,
parent_session_id,

View file

@ -28,35 +28,35 @@ pub enum CodergenResult {
Full(Outcome),
}
pub struct CodergenRunRequest<'a> {
pub node: &'a Node,
pub prompt: &'a str,
pub context: &'a Context,
pub thread_id: Option<&'a str>,
pub emitter: &'a Arc<Emitter>,
pub sandbox: &'a Arc<dyn Sandbox>,
pub tool_hooks: Option<Arc<dyn fabro_agent::ToolHookCallback>>,
pub cancel_token: CancellationToken,
}
pub struct OneShotRequest<'a> {
pub node: &'a Node,
pub prompt: &'a str,
pub system_prompt: Option<&'a str>,
pub emitter: &'a Arc<Emitter>,
pub stage_scope: &'a StageScope,
pub sandbox: &'a Arc<dyn Sandbox>,
pub cancel_token: CancellationToken,
}
/// Backend interface for LLM execution in codergen nodes.
#[allow(
clippy::too_many_arguments,
reason = "Codergen backends need the node, prompt, context, and runtime handles separately."
)]
#[async_trait]
pub trait CodergenBackend: Send + Sync {
/// Run a multi-turn agent loop (the default codergen mode).
async fn run(
&self,
node: &Node,
prompt: &str,
context: &Context,
thread_id: Option<&str>,
emitter: &Arc<Emitter>,
sandbox: &Arc<dyn Sandbox>,
tool_hooks: Option<Arc<dyn fabro_agent::ToolHookCallback>>,
cancel_token: CancellationToken,
) -> Result<CodergenResult, Error>;
async fn run(&self, request: CodergenRunRequest<'_>) -> Result<CodergenResult, Error>;
/// Run a single LLM call with no tools (one_shot mode).
async fn one_shot(
&self,
_node: &Node,
_prompt: &str,
_system_prompt: Option<&str>,
_emitter: &Arc<Emitter>,
_stage_scope: &StageScope,
) -> Result<CodergenResult, Error> {
async fn one_shot(&self, _request: OneShotRequest<'_>) -> Result<CodergenResult, Error> {
Err(Error::Validation(
"one_shot mode not supported by this backend".into(),
))
@ -301,16 +301,16 @@ impl Handler for AgentHandler {
let (response_text, stage_usage, backend_files_touched, last_file_touched) =
if let Some(backend) = &self.backend {
let result = backend
.run(
.run(CodergenRunRequest {
node,
&prompt,
prompt: &prompt,
context,
thread_id.as_deref(),
&services.run.emitter,
&services.run.sandbox,
thread_id: thread_id.as_deref(),
emitter: &services.run.emitter,
sandbox: &services.run.sandbox,
tool_hooks,
services.run.cancel_token(),
)
cancel_token: services.run.cancel_token(),
})
.await;
match result {
Ok(CodergenResult::Full(outcome)) => return Ok(outcome),
@ -429,7 +429,6 @@ mod tests {
use tempfile::TempDir;
use super::*;
use crate::event::Emitter;
fn make_services() -> EngineServices {
EngineServices::test_default()
@ -641,25 +640,13 @@ mod tests {
#[tokio::test]
async fn codergen_handler_prefers_response_text_over_status_json() {
use std::sync::Arc;
// Backend returns response text with routing directives — status.json
// in the sandbox should be ignored.
struct DirectiveBackend;
#[async_trait]
impl CodergenBackend for DirectiveBackend {
async fn run(
&self,
_node: &Node,
_prompt: &str,
_context: &Context,
_thread_id: Option<&str>,
_emitter: &Arc<Emitter>,
_sandbox: &Arc<dyn fabro_agent::Sandbox>,
_tool_hooks: Option<Arc<dyn fabro_agent::ToolHookCallback>>,
_cancel_token: CancellationToken,
) -> Result<CodergenResult, Error> {
async fn run(&self, _request: CodergenRunRequest<'_>) -> Result<CodergenResult, Error> {
Ok(CodergenResult::Text {
text:
r#"Done. {"outcome": "succeeded", "preferred_next_label": "approve"}"#
@ -704,23 +691,11 @@ mod tests {
#[tokio::test]
async fn codergen_handler_extracts_status_from_last_file_touched() {
use std::sync::Arc;
struct LastFileBackend;
#[async_trait]
impl CodergenBackend for LastFileBackend {
async fn run(
&self,
_node: &Node,
_prompt: &str,
_context: &Context,
_thread_id: Option<&str>,
_emitter: &Arc<Emitter>,
_sandbox: &Arc<dyn fabro_agent::Sandbox>,
_tool_hooks: Option<Arc<dyn fabro_agent::ToolHookCallback>>,
_cancel_token: CancellationToken,
) -> Result<CodergenResult, Error> {
async fn run(&self, _request: CodergenRunRequest<'_>) -> Result<CodergenResult, Error> {
Ok(CodergenResult::Text {
text: "Done writing results.".to_string(),
usage: None,
@ -772,21 +747,11 @@ mod tests {
#[async_trait]
impl CodergenBackend for ProviderEventBackend {
async fn run(
&self,
node: &Node,
_prompt: &str,
context: &Context,
_thread_id: Option<&str>,
emitter: &Arc<Emitter>,
_sandbox: &Arc<dyn fabro_agent::Sandbox>,
_tool_hooks: Option<Arc<dyn fabro_agent::ToolHookCallback>>,
_cancel_token: CancellationToken,
) -> Result<CodergenResult, Error> {
let scope = StageScope::for_handler(context, &node.id);
emitter.emit_scoped(
async fn run(&self, request: CodergenRunRequest<'_>) -> Result<CodergenResult, Error> {
let scope = StageScope::for_handler(request.context, &request.node.id);
request.emitter.emit_scoped(
&crate::event::Event::AgentSessionActivated {
node_id: node.id.clone(),
node_id: request.node.id.clone(),
visit: scope.visit,
session_id: "session_123".to_string(),
thread_id: None,
@ -883,18 +848,9 @@ mod tests {
#[async_trait]
impl CodergenBackend for ThreadCapturingBackend {
async fn run(
&self,
_node: &Node,
_prompt: &str,
_context: &Context,
thread_id: Option<&str>,
_emitter: &Arc<Emitter>,
_sandbox: &Arc<dyn Sandbox>,
_tool_hooks: Option<Arc<dyn fabro_agent::ToolHookCallback>>,
_cancel_token: CancellationToken,
) -> Result<CodergenResult, Error> {
*self.captured_thread_id.lock().unwrap() = Some(thread_id.map(String::from));
async fn run(&self, request: CodergenRunRequest<'_>) -> Result<CodergenResult, Error> {
*self.captured_thread_id.lock().unwrap() =
Some(request.thread_id.map(String::from));
Ok(CodergenResult::Text {
text: "ok".to_string(),
usage: None,
@ -936,18 +892,9 @@ mod tests {
#[async_trait]
impl CodergenBackend for ThreadCapturingBackend {
async fn run(
&self,
_node: &Node,
_prompt: &str,
_context: &Context,
thread_id: Option<&str>,
_emitter: &Arc<Emitter>,
_sandbox: &Arc<dyn Sandbox>,
_tool_hooks: Option<Arc<dyn fabro_agent::ToolHookCallback>>,
_cancel_token: CancellationToken,
) -> Result<CodergenResult, Error> {
*self.captured_thread_id.lock().unwrap() = Some(thread_id.map(String::from));
async fn run(&self, request: CodergenRunRequest<'_>) -> Result<CodergenResult, Error> {
*self.captured_thread_id.lock().unwrap() =
Some(request.thread_id.map(String::from));
Ok(CodergenResult::Text {
text: "ok".to_string(),
usage: None,
@ -984,17 +931,7 @@ mod tests {
#[async_trait]
impl CodergenBackend for FailingBackend {
async fn run(
&self,
_node: &Node,
_prompt: &str,
_context: &Context,
_thread_id: Option<&str>,
_emitter: &Arc<Emitter>,
_sandbox: &Arc<dyn Sandbox>,
_tool_hooks: Option<Arc<dyn fabro_agent::ToolHookCallback>>,
_cancel_token: CancellationToken,
) -> Result<CodergenResult, Error> {
async fn run(&self, _request: CodergenRunRequest<'_>) -> Result<CodergenResult, Error> {
Err(Error::handler("Request timed out".to_string()))
}
}
@ -1132,17 +1069,7 @@ Some text in between.
#[async_trait]
impl CodergenBackend for ValidationFailBackend {
async fn run(
&self,
_node: &Node,
_prompt: &str,
_context: &Context,
_thread_id: Option<&str>,
_emitter: &Arc<Emitter>,
_sandbox: &Arc<dyn Sandbox>,
_tool_hooks: Option<Arc<dyn fabro_agent::ToolHookCallback>>,
_cancel_token: CancellationToken,
) -> Result<CodergenResult, Error> {
async fn run(&self, _request: CodergenRunRequest<'_>) -> Result<CodergenResult, Error> {
Err(Error::Validation("bad config".to_string()))
}
}
@ -1173,18 +1100,8 @@ Some text in between.
#[async_trait]
impl CodergenBackend for PromptCapturingBackend {
async fn run(
&self,
_node: &Node,
prompt: &str,
_context: &Context,
_thread_id: Option<&str>,
_emitter: &Arc<Emitter>,
_sandbox: &Arc<dyn Sandbox>,
_tool_hooks: Option<Arc<dyn fabro_agent::ToolHookCallback>>,
_cancel_token: CancellationToken,
) -> Result<CodergenResult, Error> {
*self.captured_prompt.lock().unwrap() = Some(prompt.to_string());
async fn run(&self, request: CodergenRunRequest<'_>) -> Result<CodergenResult, Error> {
*self.captured_prompt.lock().unwrap() = Some(request.prompt.to_string());
Ok(CodergenResult::Text {
text: "ok".to_string(),
usage: None,
@ -1243,18 +1160,8 @@ Some text in between.
#[async_trait]
impl CodergenBackend for PromptCapturingBackend {
async fn run(
&self,
_node: &Node,
prompt: &str,
_context: &Context,
_thread_id: Option<&str>,
_emitter: &Arc<Emitter>,
_sandbox: &Arc<dyn Sandbox>,
_tool_hooks: Option<Arc<dyn fabro_agent::ToolHookCallback>>,
_cancel_token: CancellationToken,
) -> Result<CodergenResult, Error> {
*self.captured_prompt.lock().unwrap() = Some(prompt.to_string());
async fn run(&self, request: CodergenRunRequest<'_>) -> Result<CodergenResult, Error> {
*self.captured_prompt.lock().unwrap() = Some(request.prompt.to_string());
Ok(CodergenResult::Text {
text: "ok".to_string(),
usage: None,

View file

@ -6,7 +6,7 @@ use fabro_agent::Sandbox;
use fabro_graphviz::graph::{Graph, Node};
use tokio_util::sync::CancellationToken;
use super::agent::{CodergenBackend, CodergenResult};
use super::agent::{CodergenBackend, CodergenResult, CodergenRunRequest};
use super::{EngineServices, Handler};
use crate::context::{Context, keys};
use crate::error::Error;
@ -260,16 +260,16 @@ async fn llm_evaluate(
// Fan-in evaluation runs outside a thread context, so pass None
match backend
.run(
&eval_node,
&full_prompt,
.run(CodergenRunRequest {
node: &eval_node,
prompt: &full_prompt,
context,
None,
thread_id: None,
emitter,
sandbox,
None,
tool_hooks: None,
cancel_token,
)
})
.await
{
Ok(CodergenResult::Full(outcome)) => {
@ -469,23 +469,13 @@ mod tests {
async fn fan_in_with_backend_llm_eval() {
use tempfile::TempDir;
use crate::handler::agent::CodergenBackend;
use crate::handler::agent::{CodergenBackend, CodergenRunRequest};
struct MockBackend;
#[async_trait]
impl CodergenBackend for MockBackend {
async fn run(
&self,
_node: &Node,
_prompt: &str,
_context: &Context,
_thread_id: Option<&str>,
_emitter: &Arc<Emitter>,
_sandbox: &Arc<dyn Sandbox>,
_tool_hooks: Option<Arc<dyn fabro_agent::ToolHookCallback>>,
_cancel_token: CancellationToken,
) -> Result<CodergenResult, Error> {
async fn run(&self, _request: CodergenRunRequest<'_>) -> Result<CodergenResult, Error> {
// Return text that contains the ID "branch_b"
Ok(CodergenResult::Text {
text: "The best candidate is branch_b".to_string(),

View file

@ -0,0 +1,638 @@
//! Workflow adapter for ACP-backed LLM stages.
use std::collections::HashMap;
use std::sync::Arc;
use async_trait::async_trait;
use fabro_acp::{
AcpCommandError, AcpError, AcpRunRequest, render_stop_reason, resolve_acp_command,
};
use fabro_agent::{Sandbox, StaticEnvProvider, ToolEnvProvider};
use fabro_auth::CredentialResolver;
use fabro_graphviz::graph::Node;
use fabro_model::Provider;
use fabro_util::time::elapsed_ms;
use tokio_util::sync::CancellationToken;
use super::super::agent::{CodergenBackend, CodergenResult, CodergenRunRequest, OneShotRequest};
use super::changed_files;
use super::cli::AgentCli;
use super::launch_env::{AgentLaunchEnvRequest, resolve_agent_launch_env};
use crate::error::Error;
use crate::event::{Emitter, Event, StageScope};
pub struct AgentAcpBackend {
model: String,
provider: Provider,
tool_env: Option<Arc<dyn ToolEnvProvider>>,
github_token_refresh_managed: bool,
resolver: Option<CredentialResolver>,
}
impl AgentAcpBackend {
#[must_use]
pub fn new(model: String, provider: Provider, resolver: CredentialResolver) -> Self {
Self {
model,
provider,
tool_env: None,
github_token_refresh_managed: false,
resolver: Some(resolver),
}
}
#[must_use]
pub fn new_from_env(model: String, provider: Provider) -> Self {
Self {
model,
provider,
tool_env: None,
github_token_refresh_managed: false,
resolver: None,
}
}
#[must_use]
pub fn with_env(mut self, env: HashMap<String, String>) -> Self {
self.tool_env = Some(Arc::new(StaticEnvProvider(env)));
self
}
#[must_use]
pub fn with_tool_env_provider(
mut self,
provider: Arc<dyn ToolEnvProvider>,
github_token_refresh_managed: bool,
) -> Self {
self.tool_env = Some(provider);
self.github_token_refresh_managed = github_token_refresh_managed;
self
}
async fn run_turn(
&self,
node: &Node,
prompt: String,
emitter: &Arc<Emitter>,
stage_scope: &StageScope,
sandbox: &Arc<dyn Sandbox>,
cancel_token: CancellationToken,
) -> Result<CodergenResult, Error> {
let files_before = changed_files::detect_changed_files(sandbox).await;
let model = node.model().unwrap_or(&self.model);
let provider = node
.provider()
.and_then(|value| value.parse::<Provider>().ok())
.unwrap_or(self.provider);
let command =
resolve_acp_command(node.acp_command()).map_err(acp_command_error_to_workflow)?;
let launch_env = resolve_agent_launch_env(AgentLaunchEnvRequest {
provider,
cli: AgentCli::for_provider(provider),
resolver: self.resolver.as_ref(),
tool_env: self.tool_env.as_ref(),
github_token_refresh_managed: self.github_token_refresh_managed,
stage_label: "ACP",
emitter,
sandbox,
cancel_token: &cancel_token,
})
.await?;
let on_activity = {
let emitter = Arc::clone(emitter);
Arc::new(move || emitter.touch()) as Arc<dyn Fn() + Send + Sync>
};
let command_display = command.to_string();
emitter.emit_scoped(
&Event::AgentAcpStarted {
node_id: node.id.clone(),
visit: stage_scope.visit,
mode: "acp".to_string(),
provider: provider.to_string(),
model: model.to_string(),
command: command_display,
},
stage_scope,
);
let launch_start = std::time::Instant::now();
let result = match fabro_acp::run_acp_turn(AcpRunRequest {
command,
prompt,
cwd: sandbox.working_directory().to_string(),
timeout_ms: node.timeout().map(crate::millis_u64),
env: launch_env,
sandbox: Arc::clone(sandbox),
cancel_token: cancel_token.child_token(),
on_activity: Some(on_activity),
})
.await
{
Ok(result) => {
emitter.emit_scoped(
&Event::AgentAcpCompleted {
node_id: node.id.clone(),
stdout: result.text.clone(),
stderr: result.stderr.clone(),
stop_reason: render_stop_reason(&result.stop_reason),
duration_ms: result.duration_ms,
},
stage_scope,
);
result
}
Err(AcpError::Cancelled) => {
emitter.emit_scoped(
&Event::AgentAcpCancelled {
node_id: node.id.clone(),
stdout: String::new(),
stderr: String::new(),
duration_ms: elapsed_ms(launch_start),
},
stage_scope,
);
return Err(Error::Cancelled);
}
Err(AcpError::TimedOut { stderr }) => {
emitter.emit_scoped(
&Event::AgentAcpTimedOut {
node_id: node.id.clone(),
stdout: String::new(),
stderr: stderr.clone(),
duration_ms: elapsed_ms(launch_start),
},
stage_scope,
);
return Err(acp_error_to_workflow(AcpError::TimedOut { stderr }));
}
Err(AcpError::StopReason { stop_reason, text }) => {
emitter.emit_scoped(
&Event::AgentAcpCompleted {
node_id: node.id.clone(),
stdout: text.clone(),
stderr: String::new(),
stop_reason: stop_reason.clone(),
duration_ms: elapsed_ms(launch_start),
},
stage_scope,
);
return Err(acp_error_to_workflow(AcpError::StopReason {
stop_reason,
text,
}));
}
Err(error) => return Err(acp_error_to_workflow(error)),
};
let (files_touched, last_file_touched) =
changed_files::files_touched_since(sandbox, &files_before).await;
Ok(CodergenResult::Text {
text: result.text,
usage: None,
files_touched,
last_file_touched,
})
}
}
#[async_trait]
impl CodergenBackend for AgentAcpBackend {
async fn run(&self, request: CodergenRunRequest<'_>) -> Result<CodergenResult, Error> {
let stage_scope = StageScope::for_handler(request.context, &request.node.id);
self.run_turn(
request.node,
request.prompt.to_string(),
request.emitter,
&stage_scope,
request.sandbox,
request.cancel_token,
)
.await
}
async fn one_shot(&self, request: OneShotRequest<'_>) -> Result<CodergenResult, Error> {
let prompt = match request.system_prompt.filter(|prompt| !prompt.is_empty()) {
Some(system_prompt) => format!("System:\n{system_prompt}\n\nUser:\n{}", request.prompt),
None => request.prompt.to_string(),
};
self.run_turn(
request.node,
prompt,
request.emitter,
request.stage_scope,
request.sandbox,
request.cancel_token,
)
.await
}
}
fn acp_command_error_to_workflow(error: AcpCommandError) -> Error {
match error {
AcpCommandError::EmptyOverride => Error::handler("acp_command must not be empty"),
AcpCommandError::MissingOverride => Error::handler(
"acp_command is required for backend=\"acp\" because Fabro does not install ACP agents",
),
AcpCommandError::UnsupportedTransport => {
Error::handler("only stdio ACP commands are supported")
}
AcpCommandError::Parse(source) => {
Error::handler_with_source("Failed to resolve ACP command", &source)
}
}
}
fn acp_error_to_workflow(error: AcpError) -> Error {
match error {
AcpError::Cancelled => Error::Cancelled,
AcpError::TimedOut { stderr } => {
if stderr.is_empty() {
Error::handler("ACP turn timed out")
} else {
Error::handler(format!("ACP turn timed out: {stderr}"))
}
}
AcpError::StopReason { stop_reason, text } => {
Error::handler(format!("ACP prompt stopped with {stop_reason}: {text}"))
}
AcpError::Sandbox(source) => Error::handler_with_source("ACP turn failed", &source),
other => Error::handler_with_source("ACP turn failed", &other),
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use fabro_acp::test_support::fake_acp_agent_script;
use fabro_agent::{LocalSandbox, Sandbox, shell_quote};
use fabro_graphviz::graph::{AttrValue, Node};
use fabro_model::Provider;
use fabro_sandbox::test_support::MockSandbox;
use fabro_types::EventBody;
use tokio_util::sync::CancellationToken;
use super::AgentAcpBackend;
use crate::context::Context;
use crate::event::{Emitter, StageScope};
use crate::handler::agent::{
CodergenBackend, CodergenResult, CodergenRunRequest, OneShotRequest,
};
#[tokio::test]
async fn acp_backend_run_sends_prompt_and_returns_text() {
let tempdir = tempfile::tempdir().unwrap();
init_git(tempdir.path());
let script_path = tempdir.path().join("fake_acp_agent.py");
tokio::fs::write(&script_path, fake_acp_agent_script())
.await
.unwrap();
let mut node = Node::new("work");
node.attrs.insert(
"provider".to_string(),
AttrValue::String("openai".to_string()),
);
node.attrs.insert(
"model".to_string(),
AttrValue::String("fake-acp".to_string()),
);
node.attrs
.insert("backend".to_string(), AttrValue::String("acp".to_string()));
node.attrs.insert(
"acp_command".to_string(),
AttrValue::String(format!(
"python3 {}",
shell_quote(&script_path.to_string_lossy())
)),
);
let backend =
AgentAcpBackend::new_from_env("fake-acp".to_string(), Provider::OpenAi).with_env(
HashMap::from([("ACP_MODE".to_string(), "write_file".to_string())]),
);
let sandbox: Arc<dyn Sandbox> = Arc::new(LocalSandbox::new(tempdir.path().to_path_buf()));
let emitter = Arc::new(Emitter::default());
let context = Context::new();
let result = backend
.run(CodergenRunRequest {
node: &node,
prompt: "write hello",
context: &context,
thread_id: None,
emitter: &emitter,
sandbox: &sandbox,
tool_hooks: None,
cancel_token: CancellationToken::new(),
})
.await
.unwrap();
let CodergenResult::Text {
text,
files_touched,
..
} = result
else {
panic!("expected text result");
};
assert_eq!(text, "hello from acp");
assert_eq!(files_touched, vec!["hello.txt"]);
}
#[tokio::test]
async fn acp_backend_one_shot_combines_system_prompt_and_uses_passed_sandbox() {
let tempdir = tempfile::tempdir().unwrap();
let script_path = tempdir.path().join("fake_acp_agent.py");
let prompt_record_path = tempdir.path().join("prompt.json");
tokio::fs::write(&script_path, fake_acp_agent_script())
.await
.unwrap();
let mut node = Node::new("prompt");
node.attrs.insert(
"provider".to_string(),
AttrValue::String("openai".to_string()),
);
node.attrs
.insert("backend".to_string(), AttrValue::String("acp".to_string()));
node.attrs.insert(
"acp_command".to_string(),
AttrValue::String(format!(
"python3 {}",
shell_quote(&script_path.to_string_lossy())
)),
);
let backend = AgentAcpBackend::new_from_env("fake-acp".to_string(), Provider::OpenAi)
.with_env(HashMap::from([
(
"ACP_PROMPT_RECORD".to_string(),
prompt_record_path.to_string_lossy().into_owned(),
),
("ACP_MODE".to_string(), "write_file".to_string()),
]));
let sandbox: Arc<dyn Sandbox> = Arc::new(LocalSandbox::new(tempdir.path().to_path_buf()));
let emitter = Arc::new(Emitter::default());
let context = Context::new();
let stage_scope = StageScope::for_handler(&context, "prompt");
let result = backend
.one_shot(OneShotRequest {
node: &node,
prompt: "User prompt",
system_prompt: Some("System prompt"),
emitter: &emitter,
stage_scope: &stage_scope,
sandbox: &sandbox,
cancel_token: CancellationToken::new(),
})
.await
.unwrap();
assert!(matches!(result, CodergenResult::Text { .. }));
let recorded = tokio::fs::read_to_string(prompt_record_path).await.unwrap();
assert!(recorded.contains("System:\\nSystem prompt\\n\\nUser:\\nUser prompt"));
assert_eq!(
tokio::fs::read_to_string(tempdir.path().join("hello.txt"))
.await
.unwrap(),
"hello from sandbox\n"
);
}
#[tokio::test]
async fn acp_backend_cancelled_stop_reason_maps_to_cancelled_error() {
let tempdir = tempfile::tempdir().unwrap();
let script_path = tempdir.path().join("fake_acp_agent.py");
tokio::fs::write(&script_path, fake_acp_agent_script())
.await
.unwrap();
let mut node = Node::new("work");
node.attrs.insert(
"provider".to_string(),
AttrValue::String("openai".to_string()),
);
node.attrs.insert(
"acp_command".to_string(),
AttrValue::String(format!(
"python3 {}",
shell_quote(&script_path.to_string_lossy())
)),
);
let backend =
AgentAcpBackend::new_from_env("fake-acp".to_string(), Provider::OpenAi).with_env(
HashMap::from([("ACP_STOP_REASON".to_string(), "cancelled".to_string())]),
);
let sandbox: Arc<dyn Sandbox> = Arc::new(LocalSandbox::new(tempdir.path().to_path_buf()));
let emitter = Arc::new(Emitter::default());
let context = Context::new();
let result = backend
.run(CodergenRunRequest {
node: &node,
prompt: "cancel",
context: &context,
thread_id: None,
emitter: &emitter,
sandbox: &sandbox,
tool_hooks: None,
cancel_token: CancellationToken::new(),
})
.await;
let Err(err) = result else {
panic!("expected cancellation error");
};
assert!(matches!(err, crate::error::Error::Cancelled));
}
#[tokio::test]
async fn acp_started_event_omits_json_command_env_values() {
let tempdir = tempfile::tempdir().unwrap();
let script_path = tempdir.path().join("fake_acp_agent.py");
tokio::fs::write(&script_path, fake_acp_agent_script())
.await
.unwrap();
let raw_command = serde_json::json!({
"type": "stdio",
"name": "fake",
"command": "python3",
"args": [script_path.to_string_lossy()],
"env": [
{"name": "OPENAI_API_KEY", "value": "secret-key"}
],
})
.to_string();
let mut node = Node::new("work");
node.attrs.insert(
"provider".to_string(),
AttrValue::String("openai".to_string()),
);
node.attrs
.insert("backend".to_string(), AttrValue::String("acp".to_string()));
node.attrs
.insert("acp_command".to_string(), AttrValue::String(raw_command));
let backend = AgentAcpBackend::new_from_env("fake-acp".to_string(), Provider::OpenAi);
let sandbox: Arc<dyn Sandbox> = Arc::new(LocalSandbox::new(tempdir.path().to_path_buf()));
let emitter = Arc::new(Emitter::default());
let events = Arc::new(Mutex::new(Vec::new()));
emitter.on_event({
let events = Arc::clone(&events);
move |event| events.lock().unwrap().push(event.clone())
});
let context = Context::new();
backend
.run(CodergenRunRequest {
node: &node,
prompt: "write hello",
context: &context,
thread_id: None,
emitter: &emitter,
sandbox: &sandbox,
tool_hooks: None,
cancel_token: CancellationToken::new(),
})
.await
.unwrap();
let events = events.lock().unwrap();
let command = events
.iter()
.find_map(|event| match &event.body {
EventBody::AgentAcpStarted(props) => Some(props.command.as_str()),
_ => None,
})
.expect("ACP started event should be emitted");
assert!(command.contains("python3"));
assert!(command.contains("fake_acp_agent.py"));
assert!(!command.contains("OPENAI_API_KEY"));
assert!(!command.contains("secret-key"));
}
#[tokio::test]
async fn acp_backend_requires_explicit_acp_command() {
let sandbox = MockSandbox::linux();
let sandbox = Arc::new(sandbox);
let sandbox_dyn: Arc<dyn Sandbox> = sandbox.clone();
let mut node = Node::new("work");
node.attrs.insert(
"provider".to_string(),
AttrValue::String("openai".to_string()),
);
node.attrs
.insert("backend".to_string(), AttrValue::String("acp".to_string()));
let backend = AgentAcpBackend::new_from_env("fake-acp".to_string(), Provider::OpenAi);
let emitter = Arc::new(Emitter::default());
let context = Context::new();
let result = backend
.run(CodergenRunRequest {
node: &node,
prompt: "write hello",
context: &context,
thread_id: None,
emitter: &emitter,
sandbox: &sandbox_dyn,
tool_hooks: None,
cancel_token: CancellationToken::new(),
})
.await;
let Err(err) = result else {
panic!("ACP without acp_command should fail");
};
assert!(
err.to_string()
.contains("acp_command is required for backend=\"acp\"")
);
assert!(
sandbox
.captured_env_vars
.lock()
.expect("captured env lock poisoned")
.is_none(),
"ACP process should not launch when acp_command is missing"
);
}
#[tokio::test]
async fn acp_backend_stdio_spawn_failure_preserves_sandbox_cause() {
const DAYTONA_UNSUPPORTED_ACP: &str = "ACP backend requires bidirectional stdio; the Daytona sandbox provider does not support it yet";
let mut sandbox = MockSandbox::linux();
sandbox.stdio_process_error = Some(DAYTONA_UNSUPPORTED_ACP.to_string());
let sandbox = Arc::new(sandbox);
let sandbox_dyn: Arc<dyn Sandbox> = sandbox.clone();
let mut node = Node::new("work");
node.attrs.insert(
"provider".to_string(),
AttrValue::String("openai".to_string()),
);
node.attrs
.insert("backend".to_string(), AttrValue::String("acp".to_string()));
node.attrs.insert(
"acp_command".to_string(),
AttrValue::String("fake-acp-agent".to_string()),
);
let backend =
AgentAcpBackend::new_from_env("fake-acp".to_string(), Provider::OpenAi).with_env(
HashMap::from([("OPENAI_API_KEY".to_string(), "test-key".to_string())]),
);
let emitter = Arc::new(Emitter::default());
let context = Context::new();
let result = backend
.run(CodergenRunRequest {
node: &node,
prompt: "write hello",
context: &context,
thread_id: None,
emitter: &emitter,
sandbox: &sandbox_dyn,
tool_hooks: None,
cancel_token: CancellationToken::new(),
})
.await;
let Err(err) = result else {
panic!("stdio spawn failure should fail the ACP turn");
};
let rendered = err.display_with_causes();
assert!(
rendered.contains("ACP turn failed"),
"rendered error should keep ACP context: {rendered}"
);
assert!(
err.causes()
.iter()
.any(|cause| cause == DAYTONA_UNSUPPORTED_ACP),
"cause chain should include sandbox failure, got: {rendered}"
);
assert_eq!(
err.failure_category(),
crate::error::FailureCategory::Deterministic
);
}
#[expect(
clippy::disallowed_methods,
reason = "unit test initializes an isolated git repository with the system git binary"
)]
fn init_git(path: &std::path::Path) {
let output = std::process::Command::new("git")
.arg("init")
.current_dir(path)
.output()
.unwrap();
assert!(output.status.success());
}
}

View file

@ -19,10 +19,10 @@ use tokio::sync::Mutex as TokioMutex;
use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
use super::super::agent::{CodergenBackend, CodergenResult};
use super::super::agent::{CodergenBackend, CodergenResult, CodergenRunRequest, OneShotRequest};
use super::activation_lease::{ActivationLease, ActivationLeaseOptions};
use crate::context::WorkflowContext;
use crate::context::keys::Fidelity;
use crate::context::{Context, WorkflowContext};
use crate::error::Error;
use crate::event::{Emitter, Event, StageScope};
use crate::outcome::billed_model_usage_from_llm;
@ -476,14 +476,13 @@ impl CodergenBackend for AgentApiBackend {
self.shutdown_cached_sessions(emitter);
}
async fn one_shot(
&self,
node: &Node,
prompt: &str,
system_prompt: Option<&str>,
emitter: &Arc<Emitter>,
stage_scope: &StageScope,
) -> Result<CodergenResult, Error> {
async fn one_shot(&self, request: OneShotRequest<'_>) -> Result<CodergenResult, Error> {
let node = request.node;
let prompt = request.prompt;
let system_prompt = request.system_prompt;
let emitter = request.emitter;
let stage_scope = request.stage_scope;
let client = Client::from_source(self.source.as_ref())
.await
.map_err(|e| Error::handler_with_source("Failed to create LLM client", &e))?;
@ -617,17 +616,16 @@ impl CodergenBackend for AgentApiBackend {
})
}
async fn run(
&self,
node: &Node,
prompt: &str,
context: &Context,
thread_id: Option<&str>,
emitter: &Arc<Emitter>,
sandbox: &Arc<dyn Sandbox>,
tool_hooks: Option<Arc<dyn fabro_agent::ToolHookCallback>>,
cancel_token: CancellationToken,
) -> Result<CodergenResult, Error> {
async fn run(&self, request: CodergenRunRequest<'_>) -> Result<CodergenResult, Error> {
let node = request.node;
let prompt = request.prompt;
let context = request.context;
let thread_id = request.thread_id;
let emitter = request.emitter;
let sandbox = request.sandbox;
let tool_hooks = request.tool_hooks;
let cancel_token = request.cancel_token;
let actual_model = node.model().unwrap_or(&self.model).to_string();
let _actual_provider = node
.provider()

View file

@ -0,0 +1,83 @@
use std::collections::HashSet;
use std::sync::Arc;
use fabro_agent::{Sandbox, shell_quote};
const DIFF_MARKER: &str = "__FABRO_CHANGED_FILES_DIFF__";
const UNTRACKED_MARKER: &str = "__FABRO_CHANGED_FILES_UNTRACKED__";
pub async fn detect_changed_files(sandbox: &Arc<dyn Sandbox>) -> Vec<String> {
let mut files: Vec<String> = Vec::new();
let command = format!(
"printf '%s\\n' {diff}; git diff --name-only || true; \
printf '%s\\n' {untracked}; git ls-files --others --exclude-standard || true",
diff = shell_quote(DIFF_MARKER),
untracked = shell_quote(UNTRACKED_MARKER),
);
if let Ok(result) = sandbox
.exec_command(&command, 30_000, None, None, None)
.await
{
if result.is_success() {
files.extend(parse_changed_files(&result.stdout));
}
}
files.sort();
files.dedup();
files
}
pub async fn files_touched_since(
sandbox: &Arc<dyn Sandbox>,
files_before: &[String],
) -> (Vec<String>, Option<String>) {
let files_after = detect_changed_files(sandbox).await;
let files_before: HashSet<&str> = files_before.iter().map(String::as_str).collect();
let files_touched: Vec<String> = files_after
.into_iter()
.filter(|file| !files_before.contains(file.as_str()))
.collect();
let last_file_touched = if files_touched.is_empty() {
None
} else {
let quoted_files: Vec<String> =
files_touched.iter().map(|file| shell_quote(file)).collect();
let cmd = format!("ls -t {} | head -1", quoted_files.join(" "));
sandbox
.exec_command(&cmd, 5_000, None, None, None)
.await
.ok()
.and_then(|result| {
let trimmed = result.stdout.trim().to_string();
(result.is_success() && !trimmed.is_empty()).then_some(trimmed)
})
};
(files_touched, last_file_touched)
}
fn parse_changed_files(stdout: &str) -> impl Iterator<Item = String> + '_ {
stdout.lines().filter_map(|line| {
let trimmed = line.trim();
(!trimmed.is_empty() && trimmed != DIFF_MARKER && trimmed != UNTRACKED_MARKER)
.then(|| trimmed.to_string())
})
}
#[cfg(test)]
mod tests {
use super::parse_changed_files;
#[test]
fn parse_changed_files_ignores_section_markers() {
let files = parse_changed_files(
"__FABRO_CHANGED_FILES_DIFF__\nsrc/main.rs\n\
__FABRO_CHANGED_FILES_UNTRACKED__\nREADME.md\n",
)
.collect::<Vec<_>>();
assert_eq!(files, vec!["src/main.rs", "README.md"]);
}
}

View file

@ -8,11 +8,11 @@ use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use fabro_agent::{Sandbox, StaticEnvProvider, ToolEnvProvider, shell_quote};
use fabro_auth::{CliAgentKind, CredentialResolver, CredentialUsage, ResolvedCredential};
use fabro_auth::CredentialResolver;
use fabro_graphviz::graph::Node;
use fabro_llm::types::TokenCounts;
use fabro_model::Provider;
use fabro_types::{CommandOutputStream, CommandTermination};
use fabro_types::{CommandOutputStream, CommandTermination, LlmBackend};
use fabro_util::time::elapsed_ms;
use tokio_util::sync::CancellationToken;
@ -38,10 +38,12 @@ fn cli_failure_detail(stdout: &str, stderr: &str, command: &str) -> String {
}
}
use super::super::agent::{CodergenBackend, CodergenResult};
use crate::context::Context;
use super::super::agent::{CodergenBackend, CodergenResult, CodergenRunRequest, OneShotRequest};
use super::acp::AgentAcpBackend;
use super::launch_env::{AgentLaunchEnvRequest, resolve_agent_launch_env};
use super::{changed_files, routing};
use crate::error::Error;
use crate::event::{Emitter, Event, RunNoticeCode, RunNoticeLevel, StageScope};
use crate::event::{Emitter, Event, StageScope};
use crate::outcome::billed_model_usage_from_llm;
/// Maps a provider to its corresponding CLI tool metadata.
@ -73,41 +75,20 @@ impl AgentCli {
Self::Gemini => "gemini",
}
}
pub fn npm_package(self) -> &'static str {
match self {
Self::Claude => "@anthropic-ai/claude-code",
Self::Codex => "@openai/codex",
Self::Gemini => "@anthropic-ai/gemini-cli",
}
}
}
/// Ensure the CLI tool for the given provider is installed in the sandbox.
///
/// Checks if the CLI binary exists; if not, installs Node.js (if missing) and
/// the CLI via npm. Emits `CliEnsure*` events for observability.
async fn ensure_cli(
/// Verify the provider CLI exists in the sandbox. Fabro does not install agent
/// CLIs at runtime; sandbox images or setup steps own tool installation.
async fn verify_cli_available(
cli: AgentCli,
provider: Provider,
sandbox: &Arc<dyn Sandbox>,
emitter: &Arc<Emitter>,
cancel_token: &CancellationToken,
) -> Result<(), Error> {
let start = std::time::Instant::now();
let cli_name = cli.name();
let provider_str = <&'static str>::from(provider);
emitter.emit(&Event::CliEnsureStarted {
cli_name: cli_name.to_string(),
provider: provider_str.to_string(),
});
// Check if the CLI is already installed (include ~/.local/bin for npm-installed
// CLIs)
let version_check = sandbox
let availability_check = sandbox
.exec_command(
&format!("PATH=\"$HOME/.local/bin:$PATH\" {cli_name} --version"),
&format!("PATH=\"$HOME/.local/bin:$PATH\" command -v {cli_name}"),
30_000,
None,
None,
@ -115,68 +96,17 @@ async fn ensure_cli(
)
.await
.map_err(|e| {
Error::handler_with_source(format!("Failed to check {cli_name} version"), &e)
Error::handler_with_source(format!("Failed to check {cli_name} availability"), &e)
})?;
if version_check.is_success() {
let duration_ms = elapsed_ms(start);
emitter.emit(&Event::CliEnsureCompleted {
cli_name: cli_name.to_string(),
provider: provider_str.to_string(),
already_installed: true,
node_installed: false,
duration_ms,
});
if availability_check.is_success() {
return Ok(());
}
// Install Node.js (if needed) and the CLI in a single shell so PATH persists
let install_cmd = format!(
"export PATH=\"$HOME/.local/bin:$PATH\" && \
(node --version >/dev/null 2>&1 || \
(mkdir -p ~/.local && curl -fsSL https://nodejs.org/dist/v22.14.0/node-v22.14.0-linux-x64.tar.gz | tar -xz --strip-components=1 -C ~/.local)) && \
npm install -g {}",
cli.npm_package()
);
let install_result = sandbox
.exec_command(
&install_cmd,
180_000,
None,
None,
Some(cancel_token.child_token()),
)
.await
.map_err(|e| Error::handler_with_source(format!("Failed to install {cli_name}"), &e))?;
let node_installed = true;
if !install_result.is_success() {
let duration_ms = elapsed_ms(start);
let exec_output_tail = install_result.default_redacted_output_tail();
let error_msg = format!(
"{cli_name} install exited with code {}",
install_result.display_exit_code()
);
emitter.emit(&Event::CliEnsureFailed {
cli_name: cli_name.to_string(),
provider: provider_str.to_string(),
error: error_msg.clone(),
duration_ms,
exec_output_tail,
});
return Err(Error::handler(error_msg));
}
let duration_ms = elapsed_ms(start);
emitter.emit(&Event::CliEnsureCompleted {
cli_name: cli_name.to_string(),
provider: provider_str.to_string(),
already_installed: false,
node_installed,
duration_ms,
});
Ok(())
Err(Error::handler(format!(
"CLI backend requires '{cli_name}' to be installed in the sandbox PATH. Install it in the \
sandbox image or setup steps before running backend=\"cli\"."
)))
}
/// Models that are only available through CLI tools (not via API).
@ -404,7 +334,6 @@ pub struct AgentCliBackend {
provider: Provider,
tool_env: Option<Arc<dyn ToolEnvProvider>>,
github_token_refresh_managed: bool,
poll_interval: std::time::Duration,
resolver: Option<CredentialResolver>,
}
@ -416,7 +345,6 @@ impl AgentCliBackend {
provider,
tool_env: None,
github_token_refresh_managed: false,
poll_interval: std::time::Duration::from_secs(5),
resolver: Some(resolver),
}
}
@ -428,7 +356,6 @@ impl AgentCliBackend {
provider,
tool_env: None,
github_token_refresh_managed: false,
poll_interval: std::time::Duration::from_secs(5),
resolver: None,
}
}
@ -449,79 +376,20 @@ impl AgentCliBackend {
self.github_token_refresh_managed = github_token_refresh_managed;
self
}
#[must_use]
pub fn with_poll_interval(mut self, interval: std::time::Duration) -> Self {
self.poll_interval = interval;
self
}
/// Detect changed files by comparing git state before and after the CLI
/// run.
async fn detect_changed_files(&self, sandbox: &Arc<dyn Sandbox>) -> Vec<String> {
// Get unstaged changes
let diff_result = sandbox
.exec_command("git diff --name-only", 30_000, None, None, None)
.await;
// Get untracked files
let untracked_result = sandbox
.exec_command(
"git ls-files --others --exclude-standard",
30_000,
None,
None,
None,
)
.await;
let mut files: Vec<String> = Vec::new();
if let Ok(result) = diff_result {
if result.is_success() {
files.extend(
result
.stdout
.lines()
.filter(|l| !l.trim().is_empty())
.map(String::from),
);
}
}
if let Ok(result) = untracked_result {
if result.is_success() {
files.extend(
result
.stdout
.lines()
.filter(|l| !l.trim().is_empty())
.map(String::from),
);
}
}
files.sort();
files.dedup();
files
}
}
#[async_trait]
impl CodergenBackend for AgentCliBackend {
async fn run(
&self,
node: &Node,
prompt: &str,
context: &Context,
_thread_id: Option<&str>,
emitter: &Arc<Emitter>,
sandbox: &Arc<dyn Sandbox>,
_tool_hooks: Option<Arc<dyn fabro_agent::ToolHookCallback>>,
cancel_token: CancellationToken,
) -> Result<CodergenResult, Error> {
async fn run(&self, request: CodergenRunRequest<'_>) -> Result<CodergenResult, Error> {
let node = request.node;
let prompt = request.prompt;
let context = request.context;
let emitter = request.emitter;
let sandbox = request.sandbox;
let cancel_token = request.cancel_token;
// 1. Snapshot git state before the CLI run
let files_before = self.detect_changed_files(sandbox).await;
let files_before = changed_files::detect_changed_files(sandbox).await;
// 2. Generate unique paths for this run
let run_id = uuid::Uuid::new_v4().to_string();
@ -541,9 +409,8 @@ impl CodergenBackend for AgentCliBackend {
.and_then(|s| s.parse::<Provider>().ok())
.unwrap_or(self.provider);
// Ensure the CLI tool is installed in the sandbox
let cli = AgentCli::for_provider(provider);
ensure_cli(cli, provider, sandbox, emitter, &cancel_token).await?;
verify_cli_available(cli, sandbox, &cancel_token).await?;
let command = cli_command_for_provider(provider, model, &prompt_path);
let stage_scope = StageScope::for_handler(context, &node.id);
@ -559,67 +426,18 @@ impl CodergenBackend for AgentCliBackend {
&stage_scope,
);
// Forward provider API key and custom env vars so the CLI tool can
// authenticate. Resolve credentials and run any pre-login command
// before the main CLI invocation.
let cli_agent = match cli {
AgentCli::Claude => CliAgentKind::Claude,
AgentCli::Codex => CliAgentKind::Codex,
AgentCli::Gemini => CliAgentKind::Gemini,
};
let mut launch_env = if let Some(resolver) = &self.resolver {
let resolved = resolver
.resolve(provider, CredentialUsage::CliAgent(cli_agent))
.await
.map_err(|e| Error::handler_with_source("Failed to resolve CLI credential", &e))?;
let ResolvedCredential::Cli(cli_credential) = resolved else {
return Err(Error::handler("Expected CLI credential".to_string()));
};
if let Some(login_cmd) = &cli_credential.login_command {
let login_result = sandbox
.exec_command(
login_cmd,
30_000,
None,
None,
Some(cancel_token.child_token()),
)
.await
.map_err(|e| Error::handler_with_source("codex login failed", &e))?;
if !login_result.is_success() {
tracing::warn!(
exit_code = login_result.display_exit_code(),
"codex login --with-api-key failed: {}",
login_result.stderr
);
}
}
cli_credential.env_vars
} else {
let mut env = HashMap::new();
for name in provider.api_key_env_vars() {
if let Some(val) = process_env_var(name) {
env.insert((*name).to_string(), val);
}
}
env
};
if let Some(provider) = &self.tool_env {
if self.github_token_refresh_managed {
emitter.notice(
RunNoticeLevel::Info,
RunNoticeCode::GithubTokenRefreshLimited,
"CLI agent stages receive GitHub tokens at process launch; stages running \
beyond token expiry may need to be retried.",
);
}
let tool_env = provider.resolve().await.map_err(|err| {
Error::handler_with_anyhow("Failed to resolve CLI agent env", &err)
})?;
for (name, val) in tool_env {
launch_env.insert(name, val);
}
}
let launch_env = resolve_agent_launch_env(AgentLaunchEnvRequest {
provider,
cli,
resolver: self.resolver.as_ref(),
tool_env: self.tool_env.as_ref(),
github_token_refresh_managed: self.github_token_refresh_managed,
stage_label: "CLI",
emitter,
sandbox,
cancel_token: &cancel_token,
})
.await?;
// Write env file so the inner shell that runs the CLI command picks up
// PATH and provider env vars; we still pass `launch_env` to
@ -801,29 +619,8 @@ impl CodergenBackend for AgentCliBackend {
.ok_or_else(|| Error::handler("Failed to parse CLI output".to_string()))?;
// 5. Detect changed files
let files_after = self.detect_changed_files(sandbox).await;
let files_touched: Vec<String> = files_after
.into_iter()
.filter(|f| !files_before.contains(f))
.collect();
// Find the most recently modified file by mtime
let last_file_touched = if files_touched.is_empty() {
None
} else {
let quoted_files: Vec<String> = files_touched.iter().map(|f| shell_quote(f)).collect();
let cmd = format!("ls -t {} | head -1", quoted_files.join(" "));
if let Ok(result) = sandbox.exec_command(&cmd, 5_000, None, None, None).await {
let trimmed = result.stdout.trim().to_string();
if result.is_success() && !trimmed.is_empty() {
Some(trimmed)
} else {
None
}
} else {
None
}
};
let (files_touched, last_file_touched) =
changed_files::files_touched_since(sandbox, &files_before).await;
let stage_usage =
billed_model_usage_from_llm(model, provider, node.speed(), &TokenCounts {
@ -845,105 +642,65 @@ impl CodergenBackend for AgentCliBackend {
clippy::disallowed_methods,
reason = "CLI agent fallback credentials intentionally read provider API-key env vars."
)]
fn process_env_var(name: &str) -> Option<String> {
pub(crate) fn process_env_var(name: &str) -> Option<String> {
std::env::var(name).ok()
}
/// Routes codergen invocations to either the API backend or CLI backend
/// based on node attributes and model type.
/// Routes codergen invocations to API, CLI, or ACP backends based on node
/// attributes and model type.
pub struct BackendRouter {
api_backend: Box<dyn CodergenBackend>,
cli_backend: AgentCliBackend,
api: Box<dyn CodergenBackend>,
cli: AgentCliBackend,
acp: AgentAcpBackend,
}
impl BackendRouter {
#[must_use]
pub fn new(api_backend: Box<dyn CodergenBackend>, cli_backend: AgentCliBackend) -> Self {
pub fn new(
api_backend: Box<dyn CodergenBackend>,
cli_backend: AgentCliBackend,
acp_backend: AgentAcpBackend,
) -> Self {
Self {
api_backend,
cli_backend,
api: api_backend,
cli: cli_backend,
acp: acp_backend,
}
}
#[allow(
clippy::unused_self,
reason = "CLI backend selection lives on the router even though it only inspects the node."
)]
fn should_use_cli(&self, node: &Node) -> bool {
// Explicit backend="cli" attribute on the node
if node.backend() == Some("cli") {
return true;
}
fn select_backend(node: &Node) -> Result<LlmBackend, Error> {
routing::select_run_backend(node)
}
// CLI-only model on the node
if let Some(model) = node.model() {
if is_cli_only_model(model) {
return true;
}
}
fn select_one_shot_backend(node: &Node) -> Result<LlmBackend, Error> {
routing::select_one_shot_backend(node)
}
false
#[cfg(test)]
fn should_use_cli(node: &Node) -> bool {
matches!(Self::select_backend(node), Ok(LlmBackend::Cli))
}
}
#[async_trait]
impl CodergenBackend for BackendRouter {
async fn run(
&self,
node: &Node,
prompt: &str,
context: &Context,
thread_id: Option<&str>,
emitter: &Arc<Emitter>,
sandbox: &Arc<dyn Sandbox>,
tool_hooks: Option<Arc<dyn fabro_agent::ToolHookCallback>>,
cancel_token: CancellationToken,
) -> Result<CodergenResult, Error> {
if self.should_use_cli(node) {
self.cli_backend
.run(
node,
prompt,
context,
thread_id,
emitter,
sandbox,
tool_hooks,
cancel_token,
)
.await
} else {
self.api_backend
.run(
node,
prompt,
context,
thread_id,
emitter,
sandbox,
tool_hooks,
cancel_token,
)
.await
async fn run(&self, request: CodergenRunRequest<'_>) -> Result<CodergenResult, Error> {
match Self::select_backend(request.node)? {
LlmBackend::Api => self.api.run(request).await,
LlmBackend::Cli => self.cli.run(request).await,
LlmBackend::Acp => self.acp.run(request).await,
}
}
async fn one_shot(
&self,
node: &Node,
prompt: &str,
system_prompt: Option<&str>,
emitter: &Arc<Emitter>,
stage_scope: &StageScope,
) -> Result<CodergenResult, Error> {
// CLI backend doesn't support one_shot, always route to API
self.api_backend
.one_shot(node, prompt, system_prompt, emitter, stage_scope)
.await
async fn one_shot(&self, request: OneShotRequest<'_>) -> Result<CodergenResult, Error> {
match Self::select_one_shot_backend(request.node)? {
LlmBackend::Acp => self.acp.one_shot(request).await,
LlmBackend::Api | LlmBackend::Cli => self.api.one_shot(request).await,
}
}
async fn shutdown(&self, emitter: &Arc<Emitter>) {
self.api_backend.shutdown(emitter).await;
self.api.shutdown(emitter).await;
}
}
@ -951,10 +708,12 @@ impl CodergenBackend for BackendRouter {
mod tests {
use std::path::Path;
use fabro_agent::LocalSandbox;
use fabro_agent::sandbox::ExecResult;
use fabro_graphviz::graph::AttrValue;
use super::*;
use crate::context::Context;
// -- AgentCli --
@ -979,18 +738,12 @@ mod tests {
assert_eq!(AgentCli::Gemini.name(), "gemini");
}
#[test]
fn agent_cli_npm_package() {
assert_eq!(AgentCli::Claude.npm_package(), "@anthropic-ai/claude-code");
assert_eq!(AgentCli::Codex.npm_package(), "@openai/codex");
assert_eq!(AgentCli::Gemini.npm_package(), "@anthropic-ai/gemini-cli");
}
// -- ensure_cli --
// -- verify_cli_available --
use std::collections::VecDeque;
use std::sync::Mutex;
use fabro_acp::test_support::fake_acp_agent_script;
use fabro_agent::sandbox::{DirEntry, GrepOptions};
/// Mock sandbox that returns pre-configured ExecResults in FIFO order.
@ -1123,113 +876,47 @@ mod tests {
}
#[tokio::test]
async fn ensure_cli_skips_install_when_present() {
async fn verify_cli_available_succeeds_when_present() {
let commands = Arc::new(Mutex::new(Vec::new()));
let sandbox: Arc<dyn Sandbox> = Arc::new(CliMockSandbox::new(
vec![ok_result()],
Arc::clone(&commands),
));
let emitter = Arc::new(Emitter::default());
let result = ensure_cli(
AgentCli::Claude,
Provider::Anthropic,
&sandbox,
&emitter,
&CancellationToken::new(),
)
.await;
let result =
verify_cli_available(AgentCli::Claude, &sandbox, &CancellationToken::new()).await;
assert!(result.is_ok());
let commands = commands.lock().unwrap();
assert_eq!(commands.len(), 1);
assert!(commands[0].contains("claude --version"));
assert!(commands[0].contains("command -v claude"));
}
#[tokio::test]
async fn ensure_cli_installs_when_missing() {
async fn verify_cli_available_fails_when_missing_without_installing() {
let commands = Arc::new(Mutex::new(Vec::new()));
// version check fails, combined install succeeds
let sandbox: Arc<dyn Sandbox> = Arc::new(CliMockSandbox::new(
vec![
fail_result(127), // claude --version
ok_result(), // combined node + npm install
],
vec![fail_result(127)],
Arc::clone(&commands),
));
let emitter = Arc::new(Emitter::default());
let result = ensure_cli(
AgentCli::Claude,
Provider::Anthropic,
&sandbox,
&emitter,
&CancellationToken::new(),
)
.await;
assert!(result.is_ok());
let result =
verify_cli_available(AgentCli::Claude, &sandbox, &CancellationToken::new()).await;
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("CLI backend requires 'claude' to be installed")
);
let commands = commands.lock().unwrap();
assert_eq!(commands.len(), 2);
assert!(commands[1].contains("npm install -g @anthropic-ai/claude-code"));
}
#[tokio::test]
async fn ensure_cli_fails_on_install_failure() {
let commands = Arc::new(Mutex::new(Vec::new()));
let sandbox: Arc<dyn Sandbox> = Arc::new(CliMockSandbox::new(
vec![
fail_result(127), // claude --version
fail_result_with_output(1, "install stdout detail", "install stderr detail"),
],
Arc::clone(&commands),
));
let emitter = Arc::new(Emitter::default());
let events = Arc::new(Mutex::new(Vec::<fabro_types::RunEvent>::new()));
emitter.on_event({
let events = Arc::clone(&events);
move |event| events.lock().unwrap().push(event.clone())
});
let result = ensure_cli(
AgentCli::Claude,
Provider::Anthropic,
&sandbox,
&emitter,
&CancellationToken::new(),
)
.await;
assert!(result.is_err());
let error = result.unwrap_err().to_string();
assert!(error.contains("install exited with code 1"));
assert!(!error.contains("install stdout detail"));
assert!(!error.contains("install stderr detail"));
let events = events.lock().unwrap();
let failed = events
.iter()
.find(|event| event.event_name() == "cli.ensure.failed")
.expect("cli ensure failed event");
match &failed.body {
fabro_types::EventBody::CliEnsureFailed(props) => {
assert_eq!(props.error, "claude install exited with code 1");
assert_eq!(
props
.exec_output_tail
.as_ref()
.and_then(|tail| tail.stdout.as_deref()),
Some("install stdout detail")
);
assert_eq!(
props
.exec_output_tail
.as_ref()
.and_then(|tail| tail.stderr.as_deref()),
Some("install stderr detail")
);
}
other => panic!("expected cli ensure failed body, got {other:?}"),
}
assert_eq!(commands.len(), 1);
assert!(commands[0].contains("command -v claude"));
assert!(
!commands
.iter()
.any(|command| command.contains("npm install"))
);
}
// -- Cycle 1: cli_command_for_provider --
@ -1388,18 +1075,14 @@ mod tests {
node.attrs
.insert("backend".to_string(), AttrValue::String("cli".to_string()));
let cli_backend = AgentCliBackend::new_from_env("model".into(), Provider::Anthropic);
let router = BackendRouter::new(Box::new(StubBackend), cli_backend);
assert!(router.should_use_cli(&node));
assert!(BackendRouter::should_use_cli(&node));
}
#[test]
fn router_uses_api_by_default() {
let node = Node::new("test");
let cli_backend = AgentCliBackend::new_from_env("model".into(), Provider::Anthropic);
let router = BackendRouter::new(Box::new(StubBackend), cli_backend);
assert!(!router.should_use_cli(&node));
assert!(!BackendRouter::should_use_cli(&node));
}
#[test]
@ -1410,9 +1093,168 @@ mod tests {
AttrValue::String("claude-opus-4-6".to_string()),
);
assert!(!BackendRouter::should_use_cli(&node));
}
#[test]
fn router_uses_api_for_backend_api() {
let mut node = Node::new("test");
node.attrs
.insert("backend".to_string(), AttrValue::String("api".to_string()));
assert_eq!(
BackendRouter::select_backend(&node).unwrap(),
LlmBackend::Api
);
}
#[test]
fn router_uses_cli_for_backend_cli() {
let mut node = Node::new("test");
node.attrs
.insert("backend".to_string(), AttrValue::String("cli".to_string()));
assert_eq!(
BackendRouter::select_backend(&node).unwrap(),
LlmBackend::Cli
);
}
#[test]
fn router_uses_acp_for_backend_acp() {
let mut node = Node::new("test");
node.attrs
.insert("backend".to_string(), AttrValue::String("acp".to_string()));
assert_eq!(
BackendRouter::select_backend(&node).unwrap(),
LlmBackend::Acp
);
}
#[test]
fn router_rejects_unknown_backend() {
let mut node = Node::new("test");
node.attrs.insert(
"backend".to_string(),
AttrValue::String("codex".to_string()),
);
let err = BackendRouter::select_backend(&node).unwrap_err();
assert_eq!(
err.to_string(),
"Validation error: unsupported LLM backend \"codex\"; expected one of: api, cli, acp"
);
}
#[tokio::test]
async fn router_routes_one_shot_to_acp_for_backend_acp() {
let tempdir = tempfile::tempdir().unwrap();
let script_path = tempdir.path().join("fake_acp_agent.py");
tokio::fs::write(&script_path, fake_acp_agent_script())
.await
.unwrap();
let sandbox: Arc<dyn Sandbox> = Arc::new(LocalSandbox::new(tempdir.path().to_path_buf()));
let mut node = Node::new("test");
node.attrs
.insert("backend".to_string(), AttrValue::String("acp".to_string()));
node.attrs.insert(
"acp_command".to_string(),
AttrValue::String(format!(
"python3 {}",
shell_quote(&script_path.to_string_lossy())
)),
);
let context = Context::new();
let router = test_router();
let emitter = Arc::new(Emitter::default());
let stage_scope = StageScope::for_handler(&context, "test");
let result = router
.one_shot(OneShotRequest {
node: &node,
prompt: "prompt",
system_prompt: None,
emitter: &emitter,
stage_scope: &stage_scope,
sandbox: &sandbox,
cancel_token: CancellationToken::new(),
})
.await
.unwrap();
let CodergenResult::Text { text, .. } = result else {
panic!("expected text result");
};
assert_eq!(text, "hello from acp");
}
#[tokio::test]
async fn router_routes_one_shot_to_api_by_default() {
let node = Node::new("test");
let sandbox: Arc<dyn Sandbox> = Arc::new(LocalSandbox::new(
tempfile::tempdir().unwrap().path().to_path_buf(),
));
let context = Context::new();
let router = test_router();
let emitter = Arc::new(Emitter::default());
let stage_scope = StageScope::for_handler(&context, "test");
let result = router
.one_shot(OneShotRequest {
node: &node,
prompt: "prompt",
system_prompt: None,
emitter: &emitter,
stage_scope: &stage_scope,
sandbox: &sandbox,
cancel_token: CancellationToken::new(),
})
.await
.unwrap();
let CodergenResult::Text { text, .. } = result else {
panic!("expected text result");
};
assert_eq!(text, "api one-shot");
}
#[tokio::test]
async fn router_routes_one_shot_to_api_for_legacy_cli_backend() {
let mut node = Node::new("test");
node.attrs
.insert("backend".to_string(), AttrValue::String("cli".to_string()));
let sandbox: Arc<dyn Sandbox> = Arc::new(LocalSandbox::new(
tempfile::tempdir().unwrap().path().to_path_buf(),
));
let context = Context::new();
let router = test_router();
let emitter = Arc::new(Emitter::default());
let stage_scope = StageScope::for_handler(&context, "test");
let result = router
.one_shot(OneShotRequest {
node: &node,
prompt: "prompt",
system_prompt: None,
emitter: &emitter,
stage_scope: &stage_scope,
sandbox: &sandbox,
cancel_token: CancellationToken::new(),
})
.await
.unwrap();
let CodergenResult::Text { text, .. } = result else {
panic!("expected text result");
};
assert_eq!(text, "api one-shot");
}
fn test_router() -> BackendRouter {
let cli_backend = AgentCliBackend::new_from_env("model".into(), Provider::Anthropic);
let router = BackendRouter::new(Box::new(StubBackend), cli_backend);
assert!(!router.should_use_cli(&node));
let acp_backend = AgentAcpBackend::new_from_env("model".into(), Provider::Anthropic);
BackendRouter::new(Box::new(StubBackend), cli_backend, acp_backend)
}
/// Minimal stub backend for testing routing logic.
@ -1420,17 +1262,7 @@ mod tests {
#[async_trait]
impl CodergenBackend for StubBackend {
async fn run(
&self,
_node: &Node,
_prompt: &str,
_context: &Context,
_thread_id: Option<&str>,
_emitter: &Arc<Emitter>,
_sandbox: &Arc<dyn Sandbox>,
_tool_hooks: Option<Arc<dyn fabro_agent::ToolHookCallback>>,
_cancel_token: CancellationToken,
) -> Result<CodergenResult, Error> {
async fn run(&self, _request: CodergenRunRequest<'_>) -> Result<CodergenResult, Error> {
Ok(CodergenResult::Text {
text: "stub".to_string(),
usage: None,
@ -1438,6 +1270,15 @@ mod tests {
last_file_touched: None,
})
}
async fn one_shot(&self, _request: OneShotRequest<'_>) -> Result<CodergenResult, Error> {
Ok(CodergenResult::Text {
text: "api one-shot".to_string(),
usage: None,
files_touched: Vec::new(),
last_file_touched: None,
})
}
}
/// Sandbox stub whose `exec_command_streaming` returns a configurable
@ -1484,8 +1325,8 @@ mod tests {
_cancel_token: Option<CancellationToken>,
) -> fabro_sandbox::Result<ExecResult> {
self.commands.lock().unwrap().push(command.to_string());
// Default: success for git/version/cat/rm/ls.
if command.contains("--version") {
// Default: success for CLI availability checks and lightweight setup.
if command.contains("command -v ") {
return Ok(ok_result());
}
Ok(ExecResult {
@ -1581,16 +1422,16 @@ mod tests {
let events = collect_events(&emitter);
let result = backend
.run(
&node,
"Do something",
&context,
None,
&emitter,
&sandbox,
None,
CancellationToken::new(),
)
.run(CodergenRunRequest {
node: &node,
prompt: "Do something",
context: &context,
thread_id: None,
emitter: &emitter,
sandbox: &sandbox,
tool_hooks: None,
cancel_token: CancellationToken::new(),
})
.await;
let Err(err) = result else {
@ -1634,16 +1475,16 @@ mod tests {
let events = collect_events(&emitter);
let result = backend
.run(
&node,
"Do something slow",
&context,
None,
&emitter,
&sandbox,
None,
CancellationToken::new(),
)
.run(CodergenRunRequest {
node: &node,
prompt: "Do something slow",
context: &context,
thread_id: None,
emitter: &emitter,
sandbox: &sandbox,
tool_hooks: None,
cancel_token: CancellationToken::new(),
})
.await;
let Err(err) = result else {

View file

@ -0,0 +1,107 @@
use std::collections::HashMap;
use std::sync::Arc;
use fabro_agent::{Sandbox, ToolEnvProvider};
use fabro_auth::{CliAgentKind, CredentialResolver, CredentialUsage, ResolvedCredential};
use fabro_model::Provider;
use tokio_util::sync::CancellationToken;
use super::cli::{AgentCli, process_env_var};
use crate::error::Error;
use crate::event::{Emitter, RunNoticeCode, RunNoticeLevel};
pub(crate) struct AgentLaunchEnvRequest<'a> {
pub provider: Provider,
pub cli: AgentCli,
pub resolver: Option<&'a CredentialResolver>,
pub tool_env: Option<&'a Arc<dyn ToolEnvProvider>>,
pub github_token_refresh_managed: bool,
pub stage_label: &'static str,
pub emitter: &'a Arc<Emitter>,
pub sandbox: &'a Arc<dyn Sandbox>,
pub cancel_token: &'a CancellationToken,
}
pub(crate) async fn resolve_agent_launch_env(
request: AgentLaunchEnvRequest<'_>,
) -> Result<HashMap<String, String>, Error> {
let cli_agent = match request.cli {
AgentCli::Claude => CliAgentKind::Claude,
AgentCli::Codex => CliAgentKind::Codex,
AgentCli::Gemini => CliAgentKind::Gemini,
};
let mut launch_env = if let Some(resolver) = request.resolver {
let resolved = resolver
.resolve(request.provider, CredentialUsage::CliAgent(cli_agent))
.await
.map_err(|err| {
Error::handler_with_source(
format!("Failed to resolve {} credential", request.stage_label),
&err,
)
})?;
let ResolvedCredential::Cli(cli_credential) = resolved else {
return Err(Error::handler("Expected CLI credential".to_string()));
};
if let Some(login_cmd) = &cli_credential.login_command {
let login_result = request
.sandbox
.exec_command(
login_cmd,
30_000,
None,
None,
Some(request.cancel_token.child_token()),
)
.await
.map_err(|err| {
Error::handler_with_source(
format!("{} credential login failed", request.stage_label),
&err,
)
})?;
if !login_result.is_success() {
tracing::warn!(
exit_code = login_result.display_exit_code(),
stage = request.stage_label,
"{} credential login failed: {}",
request.stage_label,
login_result.stderr
);
}
}
cli_credential.env_vars
} else {
let mut env = HashMap::new();
for name in request.provider.api_key_env_vars() {
if let Some(value) = process_env_var(name) {
env.insert((*name).to_string(), value);
}
}
env
};
if let Some(provider) = request.tool_env {
if request.github_token_refresh_managed {
request.emitter.notice(
RunNoticeLevel::Info,
RunNoticeCode::GithubTokenRefreshLimited,
format!(
"{} agent stages receive GitHub tokens at process launch; stages running \
beyond token expiry may need to be retried.",
request.stage_label
),
);
}
let tool_env = provider.resolve().await.map_err(|err| {
Error::handler_with_anyhow(
format!("Failed to resolve {} agent env", request.stage_label),
&err,
)
})?;
launch_env.extend(tool_env);
}
Ok(launch_env)
}

View file

@ -1,7 +1,12 @@
pub mod acp;
pub mod activation_lease;
pub mod api;
pub mod changed_files;
pub mod cli;
pub mod launch_env;
pub mod preamble;
pub mod routing;
pub use acp::AgentAcpBackend;
pub use api::AgentApiBackend;
pub use cli::{AgentCliBackend, BackendRouter, parse_cli_response};

View file

@ -0,0 +1,51 @@
use fabro_graphviz::graph::{self, Node};
use fabro_types::LlmBackend;
use super::cli::is_cli_only_model;
use crate::error::Error;
pub(crate) fn select_run_backend(node: &Node) -> Result<LlmBackend, Error> {
match node.llm_backend() {
None => {
if node.model().is_some_and(is_cli_only_model) {
Ok(LlmBackend::Cli)
} else {
Ok(LlmBackend::Api)
}
}
Some(Ok(backend)) => Ok(backend),
Some(Err(_)) => Err(unsupported_backend_error(
node.backend().unwrap_or_default(),
)),
}
}
pub(crate) fn select_one_shot_backend(node: &Node) -> Result<LlmBackend, Error> {
match node.llm_backend() {
Some(Ok(LlmBackend::Acp)) => Ok(LlmBackend::Acp),
Some(Ok(LlmBackend::Api | LlmBackend::Cli)) | None => Ok(LlmBackend::Api),
Some(Err(_)) => Err(unsupported_backend_error(
node.backend().unwrap_or_default(),
)),
}
}
pub(crate) fn node_needs_api_backend(node: &Node) -> bool {
if !graph::is_llm_handler_type(node.handler_type()) {
return false;
}
match node.handler_type() {
Some("prompt" | "one_shot") => {
!matches!(select_one_shot_backend(node), Ok(LlmBackend::Acp))
}
_ => matches!(select_run_backend(node), Ok(LlmBackend::Api)),
}
}
fn unsupported_backend_error(raw: &str) -> Error {
Error::Validation(format!(
"unsupported LLM backend \"{raw}\"; expected one of: {}",
LlmBackend::expected_values()
))
}

View file

@ -6,7 +6,8 @@ use fabro_graphviz::graph::{Graph, Node};
use fabro_model::Provider;
use super::agent::{
CodergenBackend, CodergenResult, expand_variables, extract_status_fields, truncate,
CodergenBackend, CodergenResult, OneShotRequest, expand_variables, extract_status_fields,
truncate,
};
use super::{EngineServices, Handler};
use crate::context::{Context, WorkflowContext, keys};
@ -120,13 +121,15 @@ impl Handler for PromptHandler {
let (response_text, stage_usage, backend_files_touched) =
if let Some(backend) = &self.backend {
let result = backend
.one_shot(
.one_shot(OneShotRequest {
node,
&prompt,
system_prompt.as_deref(),
&services.run.emitter,
&stage_scope,
)
prompt: &prompt,
system_prompt: system_prompt.as_deref(),
emitter: &services.run.emitter,
stage_scope: &stage_scope,
sandbox: &services.run.sandbox,
cancel_token: services.run.cancel_token(),
})
.await;
match result {
Ok(CodergenResult::Full(outcome)) => return Ok(outcome),
@ -207,10 +210,10 @@ mod tests {
use fabro_types::fixtures;
use object_store::memory::InMemory;
use tempfile::TempDir;
use tokio_util::sync::CancellationToken;
use super::*;
use crate::event::Emitter;
use crate::handler::agent::CodergenRunRequest;
fn make_services() -> EngineServices {
EngineServices::test_default()
@ -308,33 +311,17 @@ mod tests {
#[tokio::test]
async fn prompt_handler_dispatches_to_backend_one_shot() {
use fabro_agent::Sandbox;
struct OneShotBackend;
#[async_trait]
impl CodergenBackend for OneShotBackend {
async fn run(
&self,
_node: &Node,
_prompt: &str,
_context: &Context,
_thread_id: Option<&str>,
_emitter: &Arc<Emitter>,
_sandbox: &Arc<dyn Sandbox>,
_tool_hooks: Option<Arc<dyn fabro_agent::ToolHookCallback>>,
_cancel_token: CancellationToken,
) -> Result<CodergenResult, Error> {
async fn run(&self, _request: CodergenRunRequest<'_>) -> Result<CodergenResult, Error> {
panic!("run() should not be called for prompt handler");
}
async fn one_shot(
&self,
_node: &Node,
_prompt: &str,
_system_prompt: Option<&str>,
_emitter: &Arc<Emitter>,
_stage_scope: &StageScope,
_request: OneShotRequest<'_>,
) -> Result<CodergenResult, Error> {
Ok(CodergenResult::Text {
text: "one-shot response".to_string(),
@ -371,33 +358,17 @@ mod tests {
#[tokio::test]
async fn prompt_handler_projects_provider_used_from_prompt_events() {
use fabro_agent::Sandbox;
struct ProviderOneShotBackend;
#[async_trait]
impl CodergenBackend for ProviderOneShotBackend {
async fn run(
&self,
_node: &Node,
_prompt: &str,
_context: &Context,
_thread_id: Option<&str>,
_emitter: &Arc<Emitter>,
_sandbox: &Arc<dyn Sandbox>,
_tool_hooks: Option<Arc<dyn fabro_agent::ToolHookCallback>>,
_cancel_token: CancellationToken,
) -> Result<CodergenResult, Error> {
async fn run(&self, _request: CodergenRunRequest<'_>) -> Result<CodergenResult, Error> {
panic!("run() should not be called for prompt handler");
}
async fn one_shot(
&self,
_node: &Node,
_prompt: &str,
_system_prompt: Option<&str>,
_emitter: &Arc<Emitter>,
_stage_scope: &StageScope,
_request: OneShotRequest<'_>,
) -> Result<CodergenResult, Error> {
Ok(CodergenResult::Text {
text: "one-shot response".to_string(),
@ -437,30 +408,14 @@ mod tests {
#[async_trait]
impl CodergenBackend for OneShotCapturingBackend {
async fn run(
&self,
_node: &Node,
_prompt: &str,
_context: &Context,
_thread_id: Option<&str>,
_emitter: &Arc<Emitter>,
_sandbox: &Arc<dyn fabro_agent::Sandbox>,
_tool_hooks: Option<Arc<dyn fabro_agent::ToolHookCallback>>,
_cancel_token: CancellationToken,
) -> Result<CodergenResult, Error> {
async fn run(&self, _request: CodergenRunRequest<'_>) -> Result<CodergenResult, Error> {
panic!("run() should not be called for prompt handler");
}
async fn one_shot(
&self,
_node: &Node,
prompt: &str,
system_prompt: Option<&str>,
_emitter: &Arc<Emitter>,
_stage_scope: &StageScope,
) -> Result<CodergenResult, Error> {
*self.captured_prompt.lock().unwrap() = Some(prompt.to_string());
*self.captured_system_prompt.lock().unwrap() = Some(system_prompt.map(String::from));
async fn one_shot(&self, request: OneShotRequest<'_>) -> Result<CodergenResult, Error> {
*self.captured_prompt.lock().unwrap() = Some(request.prompt.to_string());
*self.captured_system_prompt.lock().unwrap() =
Some(request.system_prompt.map(String::from));
Ok(CodergenResult::Text {
text: "classified".to_string(),
usage: None,

View file

@ -231,6 +231,9 @@ fn replay_event_for_fork_projection(body: &EventBody) -> bool {
| EventBody::AgentCliStarted(_)
| EventBody::AgentCliCancelled(_)
| EventBody::AgentCliTimedOut(_)
| EventBody::AgentAcpStarted(_)
| EventBody::AgentAcpCancelled(_)
| EventBody::AgentAcpTimedOut(_)
| EventBody::CommandStarted(_)
| EventBody::CommandCompleted(_)
| EventBody::ParallelCompleted(_)
@ -316,6 +319,41 @@ mod tests {
));
}
#[test]
fn fork_replay_preserves_agent_acp_projection_events() {
assert!(replay_event_for_fork_projection(
&EventBody::AgentAcpStarted(fabro_types::run_event::AgentAcpStartedProps {
visit: 1,
mode: "acp".to_string(),
provider: "openai".to_string(),
model: "fake-acp".to_string(),
command: "python fake_agent.py".to_string(),
})
));
assert!(replay_event_for_fork_projection(
&EventBody::AgentAcpCancelled(fabro_types::run_event::AgentAcpCancelledProps {
stdout: "partial".to_string(),
stderr: "cancelled".to_string(),
duration_ms: 7,
})
));
assert!(replay_event_for_fork_projection(
&EventBody::AgentAcpTimedOut(fabro_types::run_event::AgentAcpTimedOutProps {
stdout: "partial".to_string(),
stderr: "timeout".to_string(),
duration_ms: 99,
})
));
assert!(!replay_event_for_fork_projection(
&EventBody::AgentAcpCompleted(fabro_types::run_event::AgentAcpCompletedProps {
stdout: "done".to_string(),
stderr: String::new(),
stop_reason: "end_turn".to_string(),
duration_ms: 42,
})
));
}
#[tokio::test]
async fn fork_persists_historical_node_projection_through_target_checkpoint() {
let store = test_store();

View file

@ -28,7 +28,9 @@ use crate::devcontainer_bridge::{devcontainer_to_snapshot_config, run_devcontain
use crate::error::Error;
use crate::event::{Event, RunNoticeCode, RunNoticeLevel};
use crate::github_token_source::{AppIatMinter, GitHubTokenSource};
use crate::handler::llm::{AgentApiBackend, AgentCliBackend, BackendRouter};
use crate::handler::llm::{
AgentAcpBackend, AgentApiBackend, AgentCliBackend, BackendRouter, routing,
};
use crate::handler::{HandlerRegistry, default_registry};
use crate::run_metadata::{RunMetadataRuntime, build_metadata_writer, metadata_branch_name};
use crate::run_options::{GitCheckpointOptions, RunOptions};
@ -124,7 +126,13 @@ async fn build_registry(
llm_source: Arc<dyn CredentialSource>,
cli_resolver: Option<CredentialResolver>,
) -> Result<(Arc<HandlerRegistry>, bool), Error> {
let build_no_backend = || Arc::new(default_registry(Arc::clone(&interviewer), || None));
let no_backend_interviewer = Arc::clone(&interviewer);
let build_no_backend = move || {
Arc::new(default_registry(
Arc::clone(&no_backend_interviewer),
|| None,
))
};
if spec.dry_run {
return Ok((build_no_backend(), true));
@ -135,6 +143,51 @@ async fn build_registry(
.values()
.any(|n| graph::is_llm_handler_type(n.handler_type()));
if !graph_needs_llm {
return Ok((build_no_backend(), false));
}
let build_llm_registry = || {
let model = spec.model.clone();
let provider = spec.provider;
let fallback_chain = spec.fallback_chain.clone();
let mcp_servers = spec.mcp_servers.clone();
let llm_source_for_api = Arc::clone(&llm_source);
let steering_hub_for_api = Arc::clone(&steering_hub);
let tool_env_provider_for_backend = Arc::clone(&tool_env_provider);
Arc::new(default_registry(interviewer, move || {
let tool_env_provider = Arc::clone(&tool_env_provider_for_backend);
let api = AgentApiBackend::new(
model.clone(),
provider,
fallback_chain.clone(),
Arc::clone(&llm_source_for_api),
Arc::clone(&steering_hub_for_api),
)
.with_tool_env_provider(tool_env_provider.clone())
.with_mcp_servers(mcp_servers.clone());
let cli = cli_resolver
.clone()
.map_or_else(
|| AgentCliBackend::new_from_env(model.clone(), provider),
|resolver| AgentCliBackend::new(model.clone(), provider, resolver),
)
.with_tool_env_provider(tool_env_provider.clone(), github_token_refresh_managed);
let acp = cli_resolver
.clone()
.map_or_else(
|| AgentAcpBackend::new_from_env(model.clone(), provider),
|resolver| AgentAcpBackend::new(model.clone(), provider, resolver),
)
.with_tool_env_provider(tool_env_provider.clone(), github_token_refresh_managed);
Some(Box::new(BackendRouter::new(Box::new(api), cli, acp)))
}))
};
if !graph_needs_api_backend(graph) {
return Ok((build_llm_registry(), false));
}
match llm_source.resolve().await {
Ok(result) if result.credentials.is_empty() => {
if graph_needs_llm {
@ -156,36 +209,7 @@ async fn build_registry(
}
Ok((build_no_backend(), false))
}
Ok(_result) => {
let model = spec.model.clone();
let provider = spec.provider;
let fallback_chain = spec.fallback_chain.clone();
let mcp_servers = spec.mcp_servers.clone();
let llm_source_for_api = Arc::clone(&llm_source);
let steering_hub_for_api = Arc::clone(&steering_hub);
let tool_env_provider_for_backend = Arc::clone(&tool_env_provider);
let registry = Arc::new(default_registry(interviewer, move || {
let tool_env_provider = Arc::clone(&tool_env_provider_for_backend);
let api = AgentApiBackend::new(
model.clone(),
provider,
fallback_chain.clone(),
Arc::clone(&llm_source_for_api),
Arc::clone(&steering_hub_for_api),
)
.with_tool_env_provider(tool_env_provider.clone())
.with_mcp_servers(mcp_servers.clone());
let cli = cli_resolver
.clone()
.map_or_else(
|| AgentCliBackend::new_from_env(model.clone(), provider),
|resolver| AgentCliBackend::new(model.clone(), provider, resolver),
)
.with_tool_env_provider(tool_env_provider, github_token_refresh_managed);
Some(Box::new(BackendRouter::new(Box::new(api), cli)))
}));
Ok((registry, false))
}
Ok(_result) => Ok((build_llm_registry(), false)),
Err(e) => {
if graph_needs_llm {
return Err(Error::Precondition(format!(
@ -197,6 +221,10 @@ async fn build_registry(
}
}
fn graph_needs_api_backend(graph: &graph::Graph) -> bool {
graph.nodes.values().any(routing::node_needs_api_backend)
}
fn build_llm_source(vault: Option<Arc<AsyncRwLock<Vault>>>) -> Arc<dyn CredentialSource> {
match vault {
Some(vault) => Arc::new(VaultCredentialSource::new(vault)),
@ -666,6 +694,7 @@ mod tests {
use std::sync::Arc;
use std::time::Duration;
use fabro_acp::test_support::fake_acp_agent_script;
use fabro_auth::{AuthCredential, AuthDetails};
use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node};
use fabro_interview::AutoApproveInterviewer;
@ -674,9 +703,11 @@ mod tests {
use fabro_types::{EventBody, RunEvent, RunId, WorkflowSettings, fixtures};
use fabro_vault::{SecretType, Vault};
use object_store::memory::InMemory;
use tokio::fs::{create_dir_all, write};
use tokio::sync::RwLock as AsyncRwLock;
use super::*;
use crate::context::{Context, keys};
use crate::event::StoreProgressLogger;
use crate::pipeline::types::InitOptions;
use crate::records::RunSpec;
@ -997,6 +1028,170 @@ mod tests {
assert!(!effective_dry_run);
}
#[tokio::test]
async fn initialize_executes_acp_backend_node_from_registry() {
let temp = tempfile::tempdir().unwrap();
let run_dir = temp.path().join("run");
create_dir_all(&run_dir).await.unwrap();
let script_path = temp.path().join("fake_acp_agent.py");
write(&script_path, fake_acp_agent_script()).await.unwrap();
let source = format!(
r#"digraph test {{
start [shape=Mdiamond];
writer [type="agent", backend="acp", provider="openai", model="fake-acp", prompt="write hello", acp_command="python3 {}"];
exit [shape=Msquare];
start -> writer;
writer -> exit;
}}"#,
script_path.display()
);
let mut graph = Graph::new("test");
let mut start = Node::new("start");
start.attrs.insert(
"shape".to_string(),
AttrValue::String("Mdiamond".to_string()),
);
let mut writer = Node::new("writer");
writer
.attrs
.insert("type".to_string(), AttrValue::String("agent".to_string()));
writer
.attrs
.insert("backend".to_string(), AttrValue::String("acp".to_string()));
writer.attrs.insert(
"provider".to_string(),
AttrValue::String("openai".to_string()),
);
writer.attrs.insert(
"model".to_string(),
AttrValue::String("fake-acp".to_string()),
);
writer.attrs.insert(
"prompt".to_string(),
AttrValue::String("write hello".to_string()),
);
writer.attrs.insert(
"acp_command".to_string(),
AttrValue::String(format!(
"python3 {}",
fabro_sandbox::shell_quote(&script_path.to_string_lossy())
)),
);
let mut exit = Node::new("exit");
exit.attrs.insert(
"shape".to_string(),
AttrValue::String("Msquare".to_string()),
);
graph.nodes.insert("start".to_string(), start);
graph.nodes.insert("writer".to_string(), writer);
graph.nodes.insert("exit".to_string(), exit);
graph.edges.push(Edge::new("start", "writer"));
graph.edges.push(Edge::new("writer", "exit"));
let mut vault = Vault::load(temp.path().join("secrets.json")).unwrap();
vault
.set(
"openai",
&serde_json::to_string(&AuthCredential {
provider: fabro_llm::Provider::OpenAi,
details: AuthDetails::ApiKey {
key: "openai-key".to_string(),
},
})
.unwrap(),
SecretType::Credential,
None,
)
.unwrap();
let vault = Arc::new(AsyncRwLock::new(vault));
let emitter = Arc::new(crate::event::Emitter::new(test_run_id()));
let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
emitter.on_event({
let seen = Arc::clone(&seen);
move |event| seen.lock().unwrap().push(event.event_name().to_string())
});
let store = memory_store();
let run_store = store.create_run(&test_run_id()).await.unwrap();
let initialized = initialize(test_persisted(graph, source, &run_dir), InitOptions {
run_id: test_run_id(),
run_store: run_store.into(),
dry_run: false,
emitter: emitter.clone(),
sandbox: SandboxSpec::Local {
working_directory: temp.path().to_path_buf(),
},
llm: LlmSpec {
model: "fake-acp".to_string(),
provider: fabro_llm::Provider::OpenAi,
fallback_chain: Vec::new(),
mcp_servers: Vec::new(),
dry_run: false,
},
interviewer: Arc::new(AutoApproveInterviewer::engine()),
steering_hub: Arc::new(crate::steering_hub::SteeringHub::new(emitter)),
lifecycle: crate::run_options::LifecycleOptions {
setup_commands: Vec::new(),
setup_command_timeout_ms: 1_000,
devcontainer_phases: Vec::new(),
},
run_options: test_settings(&run_dir),
workflow_path: None,
workflow_bundle: None,
hooks: fabro_hooks::HookSettings { hooks: vec![] },
sandbox_env: SandboxEnvSpec {
devcontainer_env: HashMap::new(),
toml_env: HashMap::new(),
github_permissions: None,
origin_url: None,
},
vault: Some(vault),
devcontainer: None,
git: None,
run_control: None,
registry_override: None,
artifact_sink: None,
checkpoint: None,
seed_context: None,
})
.await
.unwrap();
let node = initialized.graph.nodes.get("writer").unwrap().clone();
let handler = initialized.engine.registry.resolve(&node);
let context = Context::new();
context.set(
keys::INTERNAL_RUN_ID,
serde_json::json!(test_run_id().to_string()),
);
let outcome = handler
.execute(
&node,
&context,
&initialized.graph,
&initialized.run_options.run_dir,
&initialized.engine,
)
.await
.unwrap();
assert_eq!(
outcome.context_updates.get(&keys::response_key("writer")),
Some(&serde_json::json!("hello from acp"))
);
assert!(
seen.lock()
.unwrap()
.contains(&"agent.acp.started".to_string())
);
assert!(
seen.lock()
.unwrap()
.contains(&"agent.acp.completed".to_string())
);
}
#[tokio::test]
async fn initialize_runs_setup_commands() {
let temp = tempfile::tempdir().unwrap();

View file

@ -534,6 +534,7 @@ impl ImportTransform {
| "reasoning_effort"
| "speed"
| "backend"
| "acp_command"
| "fidelity"
| "max_retries"
| "thread_id"
@ -732,7 +733,7 @@ mod tests {
let graph = apply_import(
r#"digraph Deploy {
start [shape=Mdiamond]
validate [import="./validate.fabro", model="haiku", class="fast, shared"]
validate [import="./validate.fabro", model="haiku", backend="acp", acp_command="python fake_agent.py", class="fast, shared"]
exit [shape=Msquare]
start -> validate -> exit
}"#,
@ -772,6 +773,20 @@ mod tests {
.iter()
.any(|class_name| class_name == "validate")
);
assert_eq!(
graph.nodes["validate.test"]
.attrs
.get("backend")
.and_then(AttrValue::as_str),
Some("acp")
);
assert_eq!(
graph.nodes["validate.test"]
.attrs
.get("acp_command")
.and_then(AttrValue::as_str),
Some("python fake_agent.py")
);
}
#[test]

View file

@ -17,6 +17,9 @@
use fabro_sandbox::reconnect::reconnect;
use fabro_types::{RunSandbox, RunSandboxRuntime, SandboxProvider};
const DOCKER_MANAGED_LABEL: &str = "sh.fabro.managed";
const DOCKER_CP_IMAGE: &str = "buildpack-deps:noble";
// ---------------------------------------------------------------------------
// Local sandbox
// ---------------------------------------------------------------------------
@ -143,14 +146,84 @@ fn docker_record(container_id: &str) -> RunSandbox {
}
}
struct DockerCpContainer {
id: String,
cleanup: bool,
}
impl Drop for DockerCpContainer {
fn drop(&mut self) {
if self.cleanup {
let _ = std::process::Command::new("docker")
.args(["rm", "-f", &self.id])
.output();
}
}
}
fn docker_cp_container() -> DockerCpContainer {
if let Ok(id) = std::env::var("FABRO_DOCKER_CP_CONTAINER") {
return DockerCpContainer { id, cleanup: false };
}
ensure_docker_image(DOCKER_CP_IMAGE);
let output = std::process::Command::new("docker")
.args([
"run",
"-d",
"--label",
&format!("{DOCKER_MANAGED_LABEL}=true"),
"--workdir",
"/workspace",
DOCKER_CP_IMAGE,
"sh",
"-c",
"mkdir -p /workspace && sleep 300",
])
.output()
.expect("docker run should execute");
assert!(
output.status.success(),
"docker run failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let id = String::from_utf8(output.stdout)
.expect("docker run stdout should be UTF-8")
.trim()
.to_string();
assert!(!id.is_empty(), "docker run should return a container id");
DockerCpContainer { id, cleanup: true }
}
fn ensure_docker_image(image: &str) {
let inspect = std::process::Command::new("docker")
.args(["image", "inspect", image])
.output()
.expect("docker image inspect should execute");
if inspect.status.success() {
return;
}
let pull = std::process::Command::new("docker")
.args(["pull", image])
.output()
.expect("docker pull should execute");
assert!(
pull.status.success(),
"docker pull {image} failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&pull.stdout),
String::from_utf8_lossy(&pull.stderr)
);
}
#[tokio::test]
#[ignore] // requires Docker daemon
async fn docker_cp_upload_download_round_trip() {
let container_id = std::env::var("FABRO_DOCKER_CP_CONTAINER")
.expect("set FABRO_DOCKER_CP_CONTAINER to an initialized Fabro-managed container ID");
let container = docker_cp_container();
let scratch = tempfile::tempdir().unwrap();
let record = docker_record(&container_id);
let record = docker_record(&container.id);
let sandbox = reconnect(&record, None).await.expect("reconnect docker");
// Upload a text file
@ -176,11 +249,10 @@ async fn docker_cp_upload_download_round_trip() {
#[tokio::test]
#[ignore] // requires Docker daemon
async fn docker_cp_binary_round_trip() {
let container_id = std::env::var("FABRO_DOCKER_CP_CONTAINER")
.expect("set FABRO_DOCKER_CP_CONTAINER to an initialized Fabro-managed container ID");
let container = docker_cp_container();
let scratch = tempfile::tempdir().unwrap();
let record = docker_record(&container_id);
let record = docker_record(&container.id);
let sandbox = reconnect(&record, None).await.expect("reconnect docker");
let binary: Vec<u8> = (0..=255).collect();
@ -204,11 +276,10 @@ async fn docker_cp_binary_round_trip() {
#[tokio::test]
#[ignore] // requires Docker daemon
async fn docker_cp_creates_parent_dirs() {
let container_id = std::env::var("FABRO_DOCKER_CP_CONTAINER")
.expect("set FABRO_DOCKER_CP_CONTAINER to an initialized Fabro-managed container ID");
let container = docker_cp_container();
let scratch = tempfile::tempdir().unwrap();
let record = docker_record(&container_id);
let record = docker_record(&container.id);
let sandbox = reconnect(&record, None).await.expect("reconnect docker");
let content = b"nested docker file\n";

View file

@ -995,7 +995,7 @@ async fn daytona_parallel_git_branching_e2e() {
// CLI Backend on Daytona — real CLI tools via exec_command
// ---------------------------------------------------------------------------
use fabro_workflow::handler::agent::{CodergenBackend, CodergenResult};
use fabro_workflow::handler::agent::{CodergenBackend, CodergenResult, CodergenRunRequest};
use fabro_workflow::handler::llm::AgentCliBackend;
/// Helper: run a real CLI backend test on Daytona.
@ -1072,16 +1072,16 @@ async fn run_daytona_cli_test(provider: Provider, model: &str, install_command:
let emitter = Arc::new(Emitter::default());
let result = backend
.run(
&node,
"What is 2+2? Reply with just the number.",
&context,
None,
&emitter,
&env,
None,
CancellationToken::new(),
)
.run(CodergenRunRequest {
node: &node,
prompt: "What is 2+2? Reply with just the number.",
context: &context,
thread_id: None,
emitter: &emitter,
sandbox: &env,
tool_hooks: None,
cancel_token: CancellationToken::new(),
})
.await;
match result {

View file

@ -23,6 +23,7 @@ use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use fabro_acp::test_support::fake_acp_agent_script;
use fabro_config::RunScratch;
use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node};
use fabro_graphviz::parser::parse;
@ -37,11 +38,14 @@ use fabro_validate::{Severity, validate, validate_or_raise};
use fabro_workflow::context::Context;
use fabro_workflow::error::{Error, FailureSignatureExt};
use fabro_workflow::event::{Emitter, Event};
use fabro_workflow::handler::agent::{AgentHandler, CodergenBackend, CodergenResult};
use fabro_workflow::handler::agent::{
AgentHandler, CodergenBackend, CodergenResult, CodergenRunRequest,
};
use fabro_workflow::handler::command::CommandHandler;
use fabro_workflow::handler::conditional::ConditionalHandler;
use fabro_workflow::handler::exit::ExitHandler;
use fabro_workflow::handler::human::HumanHandler;
use fabro_workflow::handler::llm::AgentAcpBackend;
use fabro_workflow::handler::llm::cli::{AgentCliBackend, BackendRouter, parse_cli_response};
use fabro_workflow::handler::manager_loop::SubWorkflowHandler;
use fabro_workflow::handler::start::StartHandler;
@ -63,6 +67,25 @@ fn local_env() -> Arc<dyn fabro_agent::Sandbox> {
))
}
fn codergen_run_request<'a>(
node: &'a Node,
prompt: &'a str,
context: &'a Context,
emitter: &'a Arc<Emitter>,
sandbox: &'a Arc<dyn fabro_agent::Sandbox>,
) -> CodergenRunRequest<'a> {
CodergenRunRequest {
node,
prompt,
context,
thread_id: None,
emitter,
sandbox,
tool_hooks: None,
cancel_token: CancellationToken::new(),
}
}
fn test_run_id(label: &str) -> RunId {
let mut hasher = DefaultHasher::new();
label.hash(&mut hasher);
@ -1604,22 +1627,12 @@ struct MockCodergenBackend;
#[async_trait::async_trait]
impl CodergenBackend for MockCodergenBackend {
async fn run(
&self,
node: &Node,
prompt: &str,
_context: &Context,
_thread_id: Option<&str>,
_emitter: &Arc<Emitter>,
_sandbox: &Arc<dyn fabro_agent::Sandbox>,
_tool_hooks: Option<Arc<dyn fabro_agent::ToolHookCallback>>,
_cancel_token: tokio_util::sync::CancellationToken,
) -> Result<CodergenResult, Error> {
async fn run(&self, request: CodergenRunRequest<'_>) -> Result<CodergenResult, Error> {
Ok(CodergenResult::Text {
text: format!(
"Response for {}: processed prompt '{}'",
node.id,
&prompt[..prompt.len().min(50)]
request.node.id,
&request.prompt[..request.prompt.len().min(50)]
),
usage: None,
files_touched: Vec::new(),
@ -6281,9 +6294,10 @@ mod real_llm {
use fabro_llm::providers::OpenAiAdapter;
use fabro_llm::types::{Message, Request};
use fabro_types::WorkflowSettings;
use fabro_workflow::context::Context;
use fabro_workflow::error::Error;
use fabro_workflow::handler::agent::{AgentHandler, CodergenBackend, CodergenResult};
use fabro_workflow::handler::agent::{
AgentHandler, CodergenBackend, CodergenResult, CodergenRunRequest, OneShotRequest,
};
use tokio_util::sync::CancellationToken;
struct LlmCodergenBackend {
@ -6294,29 +6308,12 @@ mod real_llm {
#[async_trait]
impl CodergenBackend for LlmCodergenBackend {
async fn run(
&self,
_node: &Node,
prompt: &str,
_context: &Context,
_thread_id: Option<&str>,
_emitter: &Arc<Emitter>,
_sandbox: &Arc<dyn fabro_agent::Sandbox>,
_tool_hooks: Option<Arc<dyn fabro_agent::ToolHookCallback>>,
_cancel_token: tokio_util::sync::CancellationToken,
) -> Result<CodergenResult, Error> {
self.complete(prompt).await
async fn run(&self, request: CodergenRunRequest<'_>) -> Result<CodergenResult, Error> {
self.complete(request.prompt).await
}
async fn one_shot(
&self,
_node: &Node,
prompt: &str,
_system_prompt: Option<&str>,
_emitter: &Arc<Emitter>,
_stage_scope: &fabro_workflow::event::StageScope,
) -> Result<CodergenResult, Error> {
self.complete(prompt).await
async fn one_shot(&self, request: OneShotRequest<'_>) -> Result<CodergenResult, Error> {
self.complete(request.prompt).await
}
}
@ -9656,14 +9653,25 @@ impl fabro_agent::Sandbox for CliTestEnv {
) -> fabro_sandbox::Result<fabro_agent::ExecResult> {
self.commands.lock().unwrap().push(command.to_string());
// git diff calls: first pair returns empty (before), second pair returns
// configured files
if command.starts_with("git diff") || command.starts_with("git ls-files") {
// Changed-file snapshot calls: first returns empty (before), second
// returns configured files (after).
if command.contains("__FABRO_CHANGED_FILES_DIFF__")
|| command.starts_with("git diff")
|| command.starts_with("git ls-files")
{
let call_num = self
.git_diff_call_count
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
// Calls 0,1 = before snapshot (empty), calls 2,3 = after snapshot
let stdout = if call_num >= 2 && command.starts_with("git diff") {
let stdout = if command.contains("__FABRO_CHANGED_FILES_DIFF__") {
if call_num >= 1 {
format!(
"__FABRO_CHANGED_FILES_DIFF__\n{}__FABRO_CHANGED_FILES_UNTRACKED__\n",
self.git_diff_after
)
} else {
"__FABRO_CHANGED_FILES_DIFF__\n__FABRO_CHANGED_FILES_UNTRACKED__\n".to_string()
}
} else if call_num >= 2 && command.starts_with("git diff") {
self.git_diff_after.clone()
} else {
String::new()
@ -9678,11 +9686,10 @@ impl fabro_agent::Sandbox for CliTestEnv {
});
}
// CLI version check during ensure_cli — return success so install path
// is skipped.
if command.contains("--version") {
// CLI availability check.
if command.contains("command -v ") {
return Ok(fabro_agent::ExecResult {
stdout: "1.0.0\n".into(),
stdout: "/usr/local/bin/agent-cli\n".into(),
stderr: String::new(),
exit_code: Some(0),
@ -9789,24 +9796,20 @@ async fn cli_backend_run_writes_prompt_and_calls_exec() {
let claude_output = r#"{"type":"result","result":"I fixed the bug.","usage":{"input_tokens":500,"output_tokens":200}}"#;
let test_env = Arc::new(CliTestEnv::new(claude_output));
let env: Arc<dyn fabro_agent::Sandbox> = test_env.clone();
let backend = AgentCliBackend::new_from_env("claude-opus-4-6".into(), Provider::Anthropic)
.with_poll_interval(Duration::from_millis(10));
let backend = AgentCliBackend::new_from_env("claude-opus-4-6".into(), Provider::Anthropic);
let node = Node::new("fix_code");
let context = Context::new();
let emitter = Arc::new(Emitter::default());
let result = backend
.run(
.run(codergen_run_request(
&node,
"Fix the authentication bug",
&context,
None,
&emitter,
&env,
None,
CancellationToken::new(),
)
))
.await
.expect("CLI backend should succeed");
@ -9863,24 +9866,20 @@ async fn cli_backend_run_detects_changed_files() {
let claude_output = r#"{"type":"result","result":"Created new file.","usage":{"input_tokens":100,"output_tokens":50}}"#;
let env: Arc<dyn fabro_agent::Sandbox> =
Arc::new(CliTestEnv::new(claude_output).with_git_diff_after("src/main.rs\nsrc/lib.rs\n"));
let backend = AgentCliBackend::new_from_env("claude-opus-4-6".into(), Provider::Anthropic)
.with_poll_interval(Duration::from_millis(10));
let backend = AgentCliBackend::new_from_env("claude-opus-4-6".into(), Provider::Anthropic);
let node = Node::new("implement");
let context = Context::new();
let emitter = Arc::new(Emitter::default());
let result = backend
.run(
.run(codergen_run_request(
&node,
"Add a new feature",
&context,
None,
&emitter,
&env,
None,
CancellationToken::new(),
)
))
.await
.expect("CLI backend should succeed");
@ -9897,24 +9896,20 @@ async fn cli_backend_run_with_codex_provider() {
let codex_output = "{\"type\":\"item.completed\",\"item\":{\"id\":\"item_0\",\"type\":\"agent_message\",\"text\":\"Implemented the feature.\"}}\n{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":300,\"output_tokens\":150}}";
let test_env = Arc::new(CliTestEnv::new(codex_output));
let env: Arc<dyn fabro_agent::Sandbox> = test_env.clone();
let backend = AgentCliBackend::new_from_env("gpt-5.3-codex".into(), Provider::OpenAi)
.with_poll_interval(Duration::from_millis(10));
let backend = AgentCliBackend::new_from_env("gpt-5.3-codex".into(), Provider::OpenAi);
let node = Node::new("implement");
let context = Context::new();
let emitter = Arc::new(Emitter::default());
let result = backend
.run(
.run(codergen_run_request(
&node,
"Build the API",
&context,
None,
&emitter,
&env,
None,
CancellationToken::new(),
)
))
.await
.expect("CLI backend should succeed");
@ -9991,10 +9986,10 @@ async fn cli_backend_run_fails_on_nonzero_exit() {
duration_ms: 0,
});
}
// CLI version check during ensure_cli — pretend already installed.
if command.contains("--version") {
// CLI availability check.
if command.contains("command -v ") {
return Ok(fabro_agent::ExecResult {
stdout: "1.0.0\n".into(),
stdout: "/usr/local/bin/agent-cli\n".into(),
stderr: String::new(),
exit_code: Some(0),
@ -10064,8 +10059,7 @@ async fn cli_backend_run_fails_on_nonzero_exit() {
}
let failing_env: Arc<dyn fabro_agent::Sandbox> = Arc::new(FailingCliEnv);
let backend = AgentCliBackend::new_from_env("claude-opus-4-6".into(), Provider::Anthropic)
.with_poll_interval(Duration::from_millis(10));
let backend = AgentCliBackend::new_from_env("claude-opus-4-6".into(), Provider::Anthropic);
let node = Node::new("step");
let context = Context::new();
let emitter = Arc::new(Emitter::default());
@ -10073,16 +10067,13 @@ async fn cli_backend_run_fails_on_nonzero_exit() {
let _ = env; // unused, just for the above struct
let result = backend
.run(
.run(codergen_run_request(
&node,
"do something",
&context,
None,
&emitter,
&failing_env,
None,
CancellationToken::new(),
)
))
.await;
let err = match result {
@ -10103,24 +10094,20 @@ async fn cli_backend_run_fails_on_nonzero_exit() {
#[tokio::test]
async fn cli_backend_run_fails_on_unparseable_output() {
let env: Arc<dyn fabro_agent::Sandbox> = Arc::new(CliTestEnv::new("this is not json at all"));
let backend = AgentCliBackend::new_from_env("claude-opus-4-6".into(), Provider::Anthropic)
.with_poll_interval(Duration::from_millis(10));
let backend = AgentCliBackend::new_from_env("claude-opus-4-6".into(), Provider::Anthropic);
let node = Node::new("step");
let context = Context::new();
let emitter = Arc::new(Emitter::default());
let result = backend
.run(
.run(codergen_run_request(
&node,
"do something",
&context,
None,
&emitter,
&env,
None,
CancellationToken::new(),
)
))
.await;
let err = match result {
@ -10140,8 +10127,7 @@ async fn cli_backend_run_uses_node_model_override() {
r#"{"type":"result","result":"ok","usage":{"input_tokens":10,"output_tokens":5}}"#;
let test_env = Arc::new(CliTestEnv::new(claude_output));
let env: Arc<dyn fabro_agent::Sandbox> = test_env.clone();
let backend = AgentCliBackend::new_from_env("default-model".into(), Provider::Anthropic)
.with_poll_interval(Duration::from_millis(10));
let backend = AgentCliBackend::new_from_env("default-model".into(), Provider::Anthropic);
let mut node = Node::new("step");
node.attrs.insert(
@ -10153,16 +10139,9 @@ async fn cli_backend_run_uses_node_model_override() {
let emitter = Arc::new(Emitter::default());
backend
.run(
&node,
"test",
&context,
None,
&emitter,
&env,
None,
CancellationToken::new(),
)
.run(codergen_run_request(
&node, "test", &context, &emitter, &env,
))
.await
.expect("should succeed");
@ -10186,8 +10165,7 @@ async fn cli_backend_run_uses_node_provider_override() {
let codex_output = "{\"type\":\"item.completed\",\"item\":{\"id\":\"item_0\",\"type\":\"agent_message\",\"text\":\"ok\"}}\n{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}";
let test_env = Arc::new(CliTestEnv::new(codex_output));
let env: Arc<dyn fabro_agent::Sandbox> = test_env.clone();
let backend = AgentCliBackend::new_from_env("default-model".into(), Provider::Anthropic)
.with_poll_interval(Duration::from_millis(10));
let backend = AgentCliBackend::new_from_env("default-model".into(), Provider::Anthropic);
let mut node = Node::new("step");
node.attrs.insert(
@ -10203,16 +10181,9 @@ async fn cli_backend_run_uses_node_provider_override() {
let emitter = Arc::new(Emitter::default());
backend
.run(
&node,
"test",
&context,
None,
&emitter,
&env,
None,
CancellationToken::new(),
)
.run(codergen_run_request(
&node, "test", &context, &emitter, &env,
))
.await
.expect("should succeed");
@ -10229,24 +10200,16 @@ async fn cli_backend_run_returns_text_and_usage() {
let claude_output =
r#"{"type":"result","result":"done","usage":{"input_tokens":10,"output_tokens":5}}"#;
let env: Arc<dyn fabro_agent::Sandbox> = Arc::new(CliTestEnv::new(claude_output));
let backend = AgentCliBackend::new_from_env("claude-opus-4-6".into(), Provider::Anthropic)
.with_poll_interval(Duration::from_millis(10));
let backend = AgentCliBackend::new_from_env("claude-opus-4-6".into(), Provider::Anthropic);
let node = Node::new("step");
let context = Context::new();
let emitter = Arc::new(Emitter::default());
let result = backend
.run(
&node,
"test",
&context,
None,
&emitter,
&env,
None,
CancellationToken::new(),
)
.run(codergen_run_request(
&node, "test", &context, &emitter, &env,
))
.await
.expect("should succeed");
@ -10264,15 +10227,18 @@ async fn cli_backend_run_returns_text_and_usage() {
// -- BackendRouter e2e: delegates to correct backend --
fn test_acp_backend() -> AgentAcpBackend {
AgentAcpBackend::new_from_env("claude-opus-4-6".into(), Provider::Anthropic)
}
#[tokio::test]
async fn backend_router_delegates_to_cli_for_cli_node() {
let claude_output = r#"{"type":"result","result":"CLI response","usage":{"input_tokens":10,"output_tokens":5}}"#;
let env: Arc<dyn fabro_agent::Sandbox> = Arc::new(CliTestEnv::new(claude_output));
let api_backend = Box::new(MockCodergenBackend); // would return "Response for ..."
let cli = AgentCliBackend::new_from_env("claude-opus-4-6".into(), Provider::Anthropic)
.with_poll_interval(Duration::from_millis(10));
let router = BackendRouter::new(api_backend, cli);
let cli = AgentCliBackend::new_from_env("claude-opus-4-6".into(), Provider::Anthropic);
let router = BackendRouter::new(api_backend, cli, test_acp_backend());
let mut node = Node::new("cli_step");
node.attrs
@ -10286,16 +10252,13 @@ async fn backend_router_delegates_to_cli_for_cli_node() {
let emitter = Arc::new(Emitter::default());
let result = router
.run(
.run(codergen_run_request(
&node,
"Fix the bug",
&context,
None,
&emitter,
&env,
None,
CancellationToken::new(),
)
))
.await
.expect("router should succeed");
@ -10315,9 +10278,8 @@ async fn backend_router_delegates_to_api_for_normal_node() {
let env = local_env();
let api_backend = Box::new(MockCodergenBackend);
let cli = AgentCliBackend::new_from_env("claude-opus-4-6".into(), Provider::Anthropic)
.with_poll_interval(Duration::from_millis(10));
let router = BackendRouter::new(api_backend, cli);
let cli = AgentCliBackend::new_from_env("claude-opus-4-6".into(), Provider::Anthropic);
let router = BackendRouter::new(api_backend, cli, test_acp_backend());
let mut node = Node::new("api_step");
node.attrs.insert(
@ -10329,16 +10291,13 @@ async fn backend_router_delegates_to_api_for_normal_node() {
let emitter = Arc::new(Emitter::default());
let result = router
.run(
.run(codergen_run_request(
&node,
"Plan the work",
&context,
None,
&emitter,
&env,
None,
CancellationToken::new(),
)
))
.await
.expect("router should succeed");
@ -10359,9 +10318,8 @@ async fn backend_router_delegates_to_cli_for_backend_attr() {
let env: Arc<dyn fabro_agent::Sandbox> = Arc::new(CliTestEnv::new(codex_output));
let api_backend = Box::new(MockCodergenBackend);
let cli = AgentCliBackend::new_from_env("gpt-5.3-codex".into(), Provider::OpenAi)
.with_poll_interval(Duration::from_millis(10));
let router = BackendRouter::new(api_backend, cli);
let cli = AgentCliBackend::new_from_env("gpt-5.3-codex".into(), Provider::OpenAi);
let router = BackendRouter::new(api_backend, cli, test_acp_backend());
let mut node = Node::new("codex_step");
node.attrs
@ -10375,16 +10333,9 @@ async fn backend_router_delegates_to_cli_for_backend_attr() {
let emitter = Arc::new(Emitter::default());
let result = router
.run(
&node,
"Build it",
&context,
None,
&emitter,
&env,
None,
CancellationToken::new(),
)
.run(codergen_run_request(
&node, "Build it", &context, &emitter, &env,
))
.await
.expect("router should succeed");
@ -10399,6 +10350,64 @@ async fn backend_router_delegates_to_cli_for_backend_attr() {
}
}
#[tokio::test]
async fn backend_router_delegates_to_acp_for_acp_node() {
let tempdir = tempfile::tempdir().unwrap();
let script_path = tempdir.path().join("fake_acp_agent.py");
tokio::fs::write(&script_path, fake_acp_agent_script())
.await
.unwrap();
let env: Arc<dyn fabro_agent::Sandbox> =
Arc::new(fabro_agent::LocalSandbox::new(tempdir.path().to_path_buf()));
let api_backend = Box::new(MockCodergenBackend);
let cli = AgentCliBackend::new_from_env("gpt-5.3-codex".into(), Provider::OpenAi);
let router = BackendRouter::new(
api_backend,
cli,
AgentAcpBackend::new_from_env("fake-acp".into(), Provider::OpenAi),
);
let mut node = Node::new("acp_step");
node.attrs
.insert("backend".to_string(), AttrValue::String("acp".to_string()));
node.attrs.insert(
"provider".to_string(),
AttrValue::String("openai".to_string()),
);
node.attrs.insert(
"model".to_string(),
AttrValue::String("fake-acp".to_string()),
);
node.attrs.insert(
"acp_command".to_string(),
AttrValue::String(format!(
"python3 {}",
fabro_agent::shell_quote(&script_path.to_string_lossy())
)),
);
let context = Context::new();
let emitter = Arc::new(Emitter::default());
let result = router
.run(codergen_run_request(
&node, "Build it", &context, &emitter, &env,
))
.await
.expect("router should succeed");
match result {
CodergenResult::Text { text, .. } => {
assert_eq!(
text, "hello from acp",
"should route to ACP backend for backend=acp"
);
}
CodergenResult::Full(_) => panic!("expected Text result"),
}
}
// -- Full pipeline e2e with BackendRouter --
#[tokio::test]
@ -10453,9 +10462,8 @@ async fn full_pipeline_with_cli_backend_node() {
// Build engine with BackendRouter
let api = MockCodergenBackend;
let cli = AgentCliBackend::new_from_env("claude-opus-4-6".into(), Provider::Anthropic)
.with_poll_interval(Duration::from_millis(10));
let router = BackendRouter::new(Box::new(api), cli);
let cli = AgentCliBackend::new_from_env("claude-opus-4-6".into(), Provider::Anthropic);
let router = BackendRouter::new(Box::new(api), cli, test_acp_backend());
let codergen_handler = AgentHandler::new(Some(Box::new(router)));
let mut registry = HandlerRegistry::new(Box::new(codergen_handler));
@ -10466,9 +10474,8 @@ async fn full_pipeline_with_cli_backend_node() {
Box::new(AgentHandler::new(Some(Box::new({
// Second BackendRouter for the "agent" handler
let api2 = MockCodergenBackend;
let cli2 = AgentCliBackend::new_from_env("claude-opus-4-6".into(), Provider::Anthropic)
.with_poll_interval(Duration::from_millis(10));
BackendRouter::new(Box::new(api2), cli2)
let cli2 = AgentCliBackend::new_from_env("claude-opus-4-6".into(), Provider::Anthropic);
BackendRouter::new(Box::new(api2), cli2, test_acp_backend())
})))),
);
@ -10575,17 +10582,15 @@ async fn stylesheet_backend_property_routes_to_cli() {
// Run the pipeline
let api = MockCodergenBackend;
let cli = AgentCliBackend::new_from_env("claude-opus-4-6".into(), Provider::Anthropic)
.with_poll_interval(Duration::from_millis(10));
let router = BackendRouter::new(Box::new(api), cli);
let cli = AgentCliBackend::new_from_env("claude-opus-4-6".into(), Provider::Anthropic);
let router = BackendRouter::new(Box::new(api), cli, test_acp_backend());
let mut registry = HandlerRegistry::new(Box::new(AgentHandler::new(Some(Box::new(router)))));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
let api2 = MockCodergenBackend;
let cli2 = AgentCliBackend::new_from_env("claude-opus-4-6".into(), Provider::Anthropic)
.with_poll_interval(Duration::from_millis(10));
let router2 = BackendRouter::new(Box::new(api2), cli2);
let cli2 = AgentCliBackend::new_from_env("claude-opus-4-6".into(), Provider::Anthropic);
let router2 = BackendRouter::new(Box::new(api2), cli2, test_acp_backend());
registry.register(
"agent",
Box::new(AgentHandler::new(Some(Box::new(router2)))),