diff --git a/Cargo.lock b/Cargo.lock index dfd05fcf9..110120b0e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1584,20 +1584,17 @@ version = "0.237.0-nightly.0" dependencies = [ "agent-client-protocol", "agent-client-protocol-tokio", - "bytes", - "fabro-model", "fabro-sandbox", "fabro-types", "fabro-util", "futures", - "serde", "serde_json", + "shlex", "tempfile", "thiserror 2.0.18", "tokio", "tokio-util", "tracing", - "uuid", ] [[package]] @@ -1678,7 +1675,6 @@ dependencies = [ "httpmock", "serde", "serde_json", - "shlex", "tempfile", "thiserror 2.0.18", "tokio", @@ -2508,6 +2504,7 @@ dependencies = [ name = "fabro-validate" version = "0.237.0-nightly.0" dependencies = [ + "fabro-acp", "fabro-graphviz", "fabro-model", "fabro-types", diff --git a/apps/fabro-web/app/routes/run-stages.test.ts b/apps/fabro-web/app/routes/run-stages.test.ts index 11e5db24a..57f447e90 100644 --- a/apps/fabro-web/app/routes/run-stages.test.ts +++ b/apps/fabro-web/app/routes/run-stages.test.ts @@ -368,13 +368,12 @@ describe("eventsToActivity", () => { properties: { model: "claude-opus-4-5" }, }), envelope(2, { - event: "agent.cli.started", + event: "agent.session.activated", stage_id: "agent@1", node_id: "agent", properties: { provider: "anthropic", model: "claude-sonnet-4-6", - command: "claude", }, }), ]; diff --git a/apps/fabro-web/app/routes/run-stages.tsx b/apps/fabro-web/app/routes/run-stages.tsx index e41fc5b09..33d753091 100644 --- a/apps/fabro-web/app/routes/run-stages.tsx +++ b/apps/fabro-web/app/routes/run-stages.tsx @@ -490,7 +490,6 @@ export function buildThreadDnaItems( const STAGE_MODEL_EVENT_NAMES = new Set([ "stage.prompt", "agent.session.activated", - "agent.cli.started", ]); export function extractStageModel( diff --git a/docs/public/agents/subagents.mdx b/docs/public/agents/subagents.mdx index 0427b9fa8..48e821625 100644 --- a/docs/public/agents/subagents.mdx +++ b/docs/public/agents/subagents.mdx @@ -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. -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. +Sub-agents are only available with the [API backend](/core-concepts/agents#api-backend-default) (the default). Agents using the [ACP backend](/core-concepts/agents#acp-backend) cannot spawn Fabro sub-agents. ## Tools diff --git a/docs/public/agents/tools.mdx b/docs/public/agents/tools.mdx index 5185cfc18..111d8adbc 100644 --- a/docs/public/agents/tools.mdx +++ b/docs/public/agents/tools.mdx @@ -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. -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. +The tools described on this page apply to the **API backend** (the default). When using the [ACP backend](/core-concepts/agents#acp-backend), the external agent process provides its own tools — Fabro's built-in tools are not used. ## Core tools diff --git a/docs/public/core-concepts/agents.mdx b/docs/public/core-concepts/agents.mdx index 8a4331d4f..e2387e6af 100644 --- a/docs/public/core-concepts/agents.mdx +++ b/docs/public/core-concepts/agents.mdx @@ -18,7 +18,7 @@ This loop continues until the model stops calling tools, indicating it considers ## Backends -Every agent and prompt node uses a **backend** that determines how Fabro interacts with the LLM. There are three options: +Every agent node uses a **backend** that determines how Fabro interacts with the LLM or external agent process. Prompt nodes always use the API backend. Agent nodes support two backend values: `api` and `acp`. ### API backend (default) @@ -29,66 +29,48 @@ Fabro manages the agent loop directly — it calls the LLM provider's API, execu - Provider failover - All [built-in tools](/agents/tools) and [MCP](/agents/mcp) integrations -### CLI backend - -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: - -| Provider | CLI tool | -|---|---| -| Anthropic | `claude` | -| 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 -implement [label="Implement", backend="cli"] -``` - -``` -// Stylesheet -* { 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. +Fabro can 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, cancellation, and file-change tracking behavior as other agent stages. -Set ACP on a node with `backend="acp"` and an explicit `acp_command`: +ACP stages do not use Fabro model/provider credentials. The ACP process owns its auth, tools, and model behavior. Configure the process with exactly one of: + +- `acp.command`: a shell command string +- `acp.config`: a JSON stdio ACP config ```dot -implement [label="Implement", backend="acp", acp_command="python3 tools/fake_acp_agent.py"] +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. +```dot +implement [ + label="Implement" + backend="acp" + acp.config="{\"type\":\"stdio\",\"name\":\"agent\",\"command\":\"python3\",\"args\":[\"tools/fake_acp_agent.py\"]}" +] +``` -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. +Fabro does not install ACP agents, Node.js, npm, or `npx` at runtime. Commands 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. + +The legacy `acp_command` attribute is rejected; use `acp.command` for shell commands or `acp.config` for JSON stdio configs. 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 | 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 - -- **CLI-specific tools** — leverage tool implementations built into a specific CLI (e.g. Claude Code's computer use, Codex's sandboxed execution) -- **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 +| Capability | API backend | ACP backend | +|---|---|---| +| Tools | Fabro built-in tools + MCP | ACP agent's own tool set | +| Session caching | Supported (`fidelity` + `thread_id`) | Agent-dependent | +| Sub-agents | Supported | Not supported through Fabro tools | +| Provider failover | Supported | Not supported | +| Model/provider credentials | Resolved by Fabro | Not used by Fabro | +| File tracking | Tool call events | `git diff` before/after | ### 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 +- **Command-owned auth** — let the ACP command manage its own provider login, model selection, and credentials +- **Custom agents** — use `acp.command` or `acp.config` for a checked-in or preinstalled ACP adapter ## Tools diff --git a/docs/public/reference/dot-language.mdx b/docs/public/reference/dot-language.mdx index 3ba7a7259..1c1656057 100644 --- a/docs/public/reference/dot-language.mdx +++ b/docs/public/reference/dot-language.mdx @@ -206,8 +206,9 @@ 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), `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. | +| `backend` | String | Agent execution backend: `api` (default) or `acp`. `api` runs Fabro's tool loop through provider APIs; `acp` runs an Agent Client Protocol stdio agent inside the active sandbox. Prompt nodes are API-only. See [Agents — Backends](/core-concepts/agents#backends). | +| `acp.command` | String | Shell command for nodes with `backend="acp"`. Mutually exclusive with `acp.config`. The value is always parsed as a command string, not JSON. | +| `acp.config` | String | JSON stdio ACP config for nodes with `backend="acp"`. Mutually exclusive with `acp.command`. | ### Command nodes diff --git a/docs/public/workflows/imports.mdx b/docs/public/workflows/imports.mdx index a3ee29906..c5eb6b0f2 100644 --- a/docs/public/workflows/imports.mdx +++ b/docs/public/workflows/imports.mdx @@ -73,7 +73,8 @@ A small set of attributes on the placeholder node propagate as **defaults** to e | `reasoning_effort` | | `speed` | | `backend` | -| `acp_command` | +| `acp.command` | +| `acp.config` | | `fidelity` | | `max_retries` | | `thread_id` | diff --git a/lib/crates/fabro-acp/Cargo.toml b/lib/crates/fabro-acp/Cargo.toml index c373c7944..3089b5c48 100644 --- a/lib/crates/fabro-acp/Cargo.toml +++ b/lib/crates/fabro-acp/Cargo.toml @@ -7,6 +7,16 @@ license.workspace = true description = "Agent Client Protocol backend support for Fabro" [features] +default = ["runtime"] +runtime = [ + "dep:fabro-sandbox", + "dep:fabro-types", + "dep:fabro-util", + "dep:futures", + "dep:tokio", + "dep:tokio-util", + "dep:tracing", +] test-support = [] [lib] @@ -18,19 +28,16 @@ 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 +fabro-sandbox = { path = "../fabro-sandbox", optional = true } +fabro-types = { path = "../fabro-types", optional = true } +fabro-util = { path = "../fabro-util", optional = true } serde_json.workspace = true +shlex = "1" thiserror.workspace = true -tokio.workspace = true -tokio-util = { workspace = true, features = ["compat", "io"] } -futures.workspace = true -uuid.workspace = true -tracing.workspace = true +tokio = { workspace = true, optional = true } +tokio-util = { workspace = true, features = ["compat", "io"], optional = true } +futures = { workspace = true, optional = true } +tracing = { workspace = true, optional = true } [dev-dependencies] fabro-sandbox = { path = "../fabro-sandbox", features = ["test-support"] } diff --git a/lib/crates/fabro-acp/src/command.rs b/lib/crates/fabro-acp/src/command.rs index c27ecd35f..1ffae80cc 100644 --- a/lib/crates/fabro-acp/src/command.rs +++ b/lib/crates/fabro-acp/src/command.rs @@ -1,19 +1,102 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; -use std::str::FromStr; -use agent_client_protocol::schema::McpServer; +use agent_client_protocol::schema::{McpServer, McpServerStdio}; use agent_client_protocol_tokio::AcpAgent; #[derive(Debug, Clone, PartialEq, Eq)] -pub struct AcpCommand { - display: String, +pub struct AcpProcessSpec { + name: Option, program: PathBuf, args: Vec, env: HashMap, } -impl AcpCommand { +impl AcpProcessSpec { + pub fn from_attrs( + legacy_command: Option<&str>, + command: Option<&str>, + config: Option<&str>, + ) -> Result { + if legacy_command.is_some() { + return Err(AcpCommandError::LegacyCommandAttribute); + } + + match (command, config) { + (Some(command), None) => Self::from_command_attr(command), + (None, Some(config)) => Self::from_config_attr(config), + (None, None) | (Some(_), Some(_)) => Err(AcpCommandError::MissingOverride), + } + } + + pub fn from_command_attr(raw: &str) -> Result { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err(AcpCommandError::EmptyOverride); + } + + let parts = shlex::split(trimmed).ok_or(AcpCommandError::InvalidCommandString)?; + let agent = + AcpAgent::from_args(parts).map_err(|_| AcpCommandError::InvalidCommandString)?; + let mut spec = Self::from_server(agent.into_server())?; + spec.name = None; + Ok(spec) + } + + pub fn from_config_attr(raw: &str) -> Result { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err(AcpCommandError::EmptyOverride); + } + + let server = parse_config_server(trimmed)?; + Self::from_server(server) + } + + fn from_server(server: McpServer) -> Result { + match server { + McpServer::Stdio(stdio) => Self::from_stdio_config(stdio), + _ => Err(AcpCommandError::UnsupportedTransport), + } + } + + fn from_stdio_config(stdio: McpServerStdio) -> Result { + if stdio.command.as_os_str().is_empty() { + return Err(AcpCommandError::InvalidConfigShape("missing command")); + } + + let env = stdio + .env + .into_iter() + .map(|env| (env.name, env.value)) + .collect(); + Ok(Self::from_stdio_parts( + Some(stdio.name), + stdio.command, + stdio.args, + env, + )) + } + + fn from_stdio_parts( + name: Option, + program: PathBuf, + args: Vec, + env: HashMap, + ) -> Self { + Self { + name, + program, + args, + env, + } + } + + #[must_use] + pub fn name(&self) -> Option<&str> { + self.name.as_deref() + } + #[must_use] pub fn program(&self) -> &Path { &self.program @@ -29,101 +112,70 @@ impl AcpCommand { &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 { +impl std::fmt::Display for AcpProcessSpec { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&self.display) + f.write_str(&self.to_shell_command()) } } #[derive(Debug, thiserror::Error)] pub enum AcpCommandError { - #[error("acp_command must not be empty")] + #[error("acp_command is no longer supported; use acp.command or acp.config")] + LegacyCommandAttribute, + #[error("ACP process attribute must not be empty")] EmptyOverride, - #[error( - "acp_command is required for backend=\"acp\" because Fabro does not install ACP agents" - )] + #[error("backend=\"acp\" requires exactly one of acp.command or acp.config")] MissingOverride, #[error("only stdio ACP commands are supported")] UnsupportedTransport, - #[error("failed to parse acp_command")] - Parse(#[source] agent_client_protocol::Error), -} - -impl From for AcpCommandError { - fn from(error: agent_client_protocol::Error) -> Self { - Self::Parse(error) - } -} - -pub fn resolve_acp_command(override_command: Option<&str>) -> Result { - 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 { - 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(), - }) + #[error("failed to parse acp.command as a shell command")] + InvalidCommandString, + #[error("failed to parse acp.config as JSON")] + InvalidConfigJson(#[source] serde_json::Error), + #[error("invalid acp.config shape: {0}")] + InvalidConfigShape(&'static str), } 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)) + .map(|part| shell_quote(&part)) .collect::>() .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::(trimmed) else { - return Ok(()); - }; +fn parse_config_server(raw: &str) -> Result { + let mut value: serde_json::Value = + serde_json::from_str(raw).map_err(AcpCommandError::InvalidConfigJson)?; match value.get("type").and_then(serde_json::Value::as_str) { - Some("stdio") | None => Ok(()), - Some(_) => Err(AcpCommandError::UnsupportedTransport), + Some("stdio") | None => {} + Some(_) => return Err(AcpCommandError::UnsupportedTransport), } + + if let Some(object) = value.as_object_mut() { + object + .entry("args".to_string()) + .or_insert_with(|| serde_json::Value::Array(Vec::new())); + object + .entry("env".to_string()) + .or_insert_with(|| serde_json::Value::Array(Vec::new())); + } + + serde_json::from_value(value).map_err(AcpCommandError::InvalidConfigJson) +} + +fn shell_quote(s: &str) -> String { + shlex::try_quote(s).map_or_else( + |_| format!("'{}'", s.replace('\'', "'\\''")), + |quoted| quoted.to_string(), + ) } #[cfg(test)] @@ -133,41 +185,53 @@ mod tests { 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(); + fn command_attr_parses_shell_command() { + let command = AcpProcessSpec::from_command_attr("python fake_agent.py").unwrap(); assert_eq!(command.to_string(), "python fake_agent.py"); + assert_eq!(command.name(), None); 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")); + fn command_attr_parses_leading_env_assignments() { + let command = AcpProcessSpec::from_command_attr( + "RUST_LOG=debug TOKEN='secret value' python fake_agent.py", + ) + .unwrap(); + + assert_eq!(command.to_string(), "python fake_agent.py"); + assert_eq!(command.program(), Path::new("python")); + assert_eq!( + command.env().get("RUST_LOG").map(String::as_str), + Some("debug") + ); + assert_eq!( + command.env().get("TOKEN").map(String::as_str), + Some("secret value") + ); } #[test] - fn json_stdio_acp_command_is_supported() { + fn blank_acp_process_attr_is_rejected() { + let err = AcpProcessSpec::from_command_attr(" ").unwrap_err(); + assert!(err.to_string().contains("must not be empty")); + } + + #[test] + fn json_stdio_acp_config_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(); + let command = AcpProcessSpec::from_config_attr(raw).unwrap(); + assert_eq!(command.name(), Some("fake")); 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() { + fn json_stdio_acp_config_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(); + let command = AcpProcessSpec::from_config_attr(raw).unwrap(); assert_eq!( command.env().get("OPENAI_API_KEY").map(String::as_str), @@ -179,12 +243,40 @@ mod tests { } #[test] - fn non_stdio_acp_command_is_rejected() { + fn non_stdio_acp_config_is_rejected() { let raw = r#"{"type":"http","name":"remote","url":"https://example.test/acp"}"#; - let err = resolve_acp_command(Some(raw)).unwrap_err(); + let err = AcpProcessSpec::from_config_attr(raw).unwrap_err(); assert!( err.to_string() .contains("only stdio ACP commands are supported") ); } + + #[test] + fn command_attr_is_always_shell_command_even_when_json_shaped() { + let command = AcpProcessSpec::from_command_attr(r#"{"type":"stdio"}"#).unwrap(); + + assert_ne!(command.program(), Path::new("stdio")); + assert!(command.args().is_empty()); + } + + #[test] + fn config_attr_requires_json_stdio_config() { + let command = AcpProcessSpec::from_config_attr( + r#"{"type":"stdio","name":"fake","command":"python3","args":["agent.py"]}"#, + ) + .unwrap(); + + assert_eq!(command.name(), Some("fake")); + assert_eq!(command.program(), Path::new("python3")); + assert_eq!(command.args(), &["agent.py".to_string()]); + + assert!(AcpProcessSpec::from_config_attr("python3 agent.py").is_err()); + assert!( + AcpProcessSpec::from_config_attr( + r#"{"type":"http","name":"remote","url":"https://example.test/acp"}"# + ) + .is_err() + ); + } } diff --git a/lib/crates/fabro-acp/src/lib.rs b/lib/crates/fabro-acp/src/lib.rs index 74ba22cca..af07ab9a6 100644 --- a/lib/crates/fabro-acp/src/lib.rs +++ b/lib/crates/fabro-acp/src/lib.rs @@ -1,12 +1,18 @@ pub mod command; + +#[cfg(feature = "runtime")] pub mod error; +#[cfg(feature = "runtime")] pub mod session; #[cfg(any(test, feature = "test-support"))] pub mod test_support; +#[cfg(feature = "runtime")] mod transport; -pub use command::{AcpCommand, AcpCommandError, resolve_acp_command}; +pub use command::{AcpCommandError, AcpProcessSpec}; +#[cfg(feature = "runtime")] pub use error::{AcpError, AcpProcessExit}; +#[cfg(feature = "runtime")] pub use session::{AcpRunRequest, AcpRunResult, render_stop_reason, run_acp_turn}; diff --git a/lib/crates/fabro-acp/src/session.rs b/lib/crates/fabro-acp/src/session.rs index e0dd343b5..b52a45f28 100644 --- a/lib/crates/fabro-acp/src/session.rs +++ b/lib/crates/fabro-acp/src/session.rs @@ -14,12 +14,12 @@ use fabro_util::time::elapsed_ms; use tokio::time::{sleep, timeout}; use tokio_util::sync::CancellationToken; -use crate::command::AcpCommand; +use crate::command::AcpProcessSpec; use crate::error::AcpError; use crate::transport::{SandboxAcpTransport, TransportState}; pub struct AcpRunRequest { - pub command: AcpCommand, + pub command: AcpProcessSpec, pub prompt: String, pub cwd: String, pub timeout_ms: Option, diff --git a/lib/crates/fabro-acp/src/test_support.rs b/lib/crates/fabro-acp/src/test_support.rs index fc697731b..7ec358bc7 100644 --- a/lib/crates/fabro-acp/src/test_support.rs +++ b/lib/crates/fabro-acp/src/test_support.rs @@ -35,6 +35,19 @@ 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())) +if os.environ.get("ACP_ENV_RECORD"): + keys = [ + key.strip() + for key in os.environ.get( + "ACP_ENV_RECORD_KEYS", + "ANTHROPIC_API_KEY,OPENAI_API_KEY,GEMINI_API_KEY", + ).split(",") + if key.strip() + ] + snapshot = {key: os.environ[key] for key in keys if key in os.environ} + with open(os.environ["ACP_ENV_RECORD"], "w", encoding="utf-8") as record: + record.write(json.dumps(snapshot, sort_keys=True)) + 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: diff --git a/lib/crates/fabro-acp/src/transport.rs b/lib/crates/fabro-acp/src/transport.rs index 1154f38b0..f599c5a27 100644 --- a/lib/crates/fabro-acp/src/transport.rs +++ b/lib/crates/fabro-acp/src/transport.rs @@ -20,7 +20,7 @@ use tokio::sync::Mutex as TokioMutex; use tokio::time::timeout; use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; -use crate::command::AcpCommand; +use crate::command::AcpProcessSpec; use crate::error::AcpProcessExit; const CLEAN_EXIT_PROTOCOL_GRACE: Duration = Duration::from_millis(500); @@ -89,7 +89,7 @@ impl TransportState { } pub(crate) struct SandboxAcpTransport { - command: AcpCommand, + command: AcpProcessSpec, cwd: String, env: HashMap, sandbox: Arc, @@ -98,7 +98,7 @@ pub(crate) struct SandboxAcpTransport { impl SandboxAcpTransport { pub(crate) fn new( - command: AcpCommand, + command: AcpProcessSpec, cwd: String, env: HashMap, sandbox: Arc, diff --git a/lib/crates/fabro-acp/tests/session.rs b/lib/crates/fabro-acp/tests/session.rs index e537e027b..4e987a746 100644 --- a/lib/crates/fabro-acp/tests/session.rs +++ b/lib/crates/fabro-acp/tests/session.rs @@ -4,7 +4,7 @@ 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_acp::{AcpError, AcpProcessSpec, AcpRunRequest, AcpRunResult, run_acp_turn}; use fabro_sandbox::test_support::{MockSandbox, MockStdioProcess}; use fabro_sandbox::{LocalSandbox, Sandbox, shell_quote}; use fabro_util::error::collect_chain; @@ -31,7 +31,7 @@ use test_support::fake_acp_agent_script; 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 command = AcpProcessSpec::from_command_attr("fake-acp-agent").expect("parse ACP command"); let mut sandbox = MockSandbox::linux(); sandbox.stdio_process_error = Some(SANDBOX_FAILURE.to_string()); let sandbox: Arc = Arc::new(sandbox); @@ -67,7 +67,7 @@ async fn clean_stdio_exit_after_final_response_completes_turn() { let sandbox = MockSandbox::linux(); sandbox.set_stdio_process(mock_acp_stdio_process("end_turn")); let sandbox: Arc = Arc::new(sandbox); - let command = resolve_acp_command(Some("mock-acp-agent")).expect("resolve ACP command"); + let command = AcpProcessSpec::from_command_attr("mock-acp-agent").expect("parse ACP command"); let result = run_acp_turn(AcpRunRequest { command, @@ -96,7 +96,7 @@ async fn session_lifecycle_initializes_sends_prompt_and_aggregates_text() { .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 command = AcpProcessSpec::from_command_attr(&raw_command).expect("parse ACP command"); let sandbox: Arc = Arc::new(LocalSandbox::new(tempdir.path().to_path_buf())); let result = run_acp_turn(AcpRunRequest { @@ -461,7 +461,7 @@ async fn run_fake_agent_with_activity( .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 command = AcpProcessSpec::from_command_attr(&raw_command).expect("parse ACP command"); let sandbox: Arc = Arc::new(LocalSandbox::new(tempdir.to_path_buf())); run_acp_turn(AcpRunRequest { diff --git a/lib/crates/fabro-auth/Cargo.toml b/lib/crates/fabro-auth/Cargo.toml index cfa60907c..992aa66a2 100644 --- a/lib/crates/fabro-auth/Cargo.toml +++ b/lib/crates/fabro-auth/Cargo.toml @@ -23,7 +23,6 @@ fabro-types = { path = "../fabro-types" } fabro-vault = { path = "../fabro-vault" } serde.workspace = true serde_json.workspace = true -shlex = "1" thiserror.workspace = true tokio.workspace = true diff --git a/lib/crates/fabro-auth/src/lib.rs b/lib/crates/fabro-auth/src/lib.rs index b04257035..c46283c4e 100644 --- a/lib/crates/fabro-auth/src/lib.rs +++ b/lib/crates/fabro-auth/src/lib.rs @@ -16,8 +16,8 @@ pub use credential_source::{CredentialSource, ResolvedCredentials}; pub use env_source::EnvCredentialSource; pub use refresh::refresh_oauth_credential; pub use resolve::{ - ApiCredential, CliAgentKind, CliCredential, CredentialResolver, CredentialUsage, EnvLookup, - ResolveError, ResolvedCredential, auth_issue_message, build_api_key_header, + ApiCredential, CredentialResolver, CredentialUsage, EnvLookup, ResolveError, + ResolvedCredential, auth_issue_message, build_api_key_header, configured_providers_from_process_env, }; pub use strategy::{ diff --git a/lib/crates/fabro-auth/src/resolve.rs b/lib/crates/fabro-auth/src/resolve.rs index 3e08045f2..6daa97d28 100644 --- a/lib/crates/fabro-auth/src/resolve.rs +++ b/lib/crates/fabro-auth/src/resolve.rs @@ -5,7 +5,6 @@ use fabro_model::catalog::CatalogProvider; use fabro_model::{ApiKeyHeaderPolicy, Catalog, CredentialRef, HeaderValueRef, ProviderId}; use fabro_static::EnvVars; use fabro_vault::{SecretType, Vault}; -use shlex::try_quote; use tokio::sync::RwLock as AsyncRwLock; use tokio::task::spawn_blocking; @@ -17,17 +16,9 @@ use crate::vault_ext::{VaultLookupError, vault_get_oauth, vault_get_token, vault pub type EnvLookup = Arc Option + Send + Sync>; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum CliAgentKind { - Claude, - Codex, - Gemini, -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CredentialUsage { ApiRequest, - CliAgent(CliAgentKind), } #[derive(Debug, Clone, PartialEq, Eq)] @@ -125,16 +116,9 @@ fn auth_header_for_catalog_provider( Ok(build_api_key_header(auth.header.clone(), key)) } -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct CliCredential { - pub env_vars: HashMap, - pub login_command: Option, -} - #[derive(Debug, Clone, PartialEq, Eq)] pub enum ResolvedCredential { Api(ApiCredential), - Cli(CliCredential), } #[derive(Debug, thiserror::Error)] @@ -212,14 +196,14 @@ impl CredentialResolver { pub async fn resolve( &self, provider: impl Into, - usage: CredentialUsage, + _usage: CredentialUsage, catalog: &Catalog, ) -> Result { let provider_id = provider.into(); let Some(catalog_provider) = catalog.provider(&provider_id) else { return Err(ResolveError::NotConfigured(provider_id)); }; - if usage == CredentialUsage::ApiRequest && catalog_provider.auth.is_none() { + if catalog_provider.auth.is_none() { let vault = self.vault.read().await; return self .api_credential_from_provider_auth(&vault, catalog_provider, catalog) @@ -274,14 +258,8 @@ impl CredentialResolver { }; let vault = self.vault.read().await; - match usage { - CredentialUsage::ApiRequest => self - .to_api_credential(&vault, &provider_id, &secret, catalog) - .map(ResolvedCredential::Api), - CredentialUsage::CliAgent(kind) => Ok(ResolvedCredential::Cli( - Self::to_cli_credential(&provider_id, &secret, kind, catalog), - )), - } + self.to_api_credential(&vault, &provider_id, &secret, catalog) + .map(ResolvedCredential::Api) } #[must_use] @@ -468,49 +446,6 @@ impl CredentialResolver { project_id: None, }) } - - fn to_cli_credential( - provider_id: &ProviderId, - secret: &ResolvedSecret, - kind: CliAgentKind, - catalog: &Catalog, - ) -> CliCredential { - let mut env_vars = HashMap::new(); - let is_openai = provider_id == &ProviderId::openai(); - let login_command = match (is_openai, secret, kind) { - (true, ResolvedSecret::ApiKey(key), CliAgentKind::Codex) => { - env_vars.insert(EnvVars::OPENAI_API_KEY.to_string(), key.clone()); - Some(codex_login_command(key)) - } - (true, ResolvedSecret::OAuth { credential, .. }, CliAgentKind::Codex) => { - env_vars.insert( - EnvVars::OPENAI_API_KEY.to_string(), - credential.tokens.access_token.clone(), - ); - if let Some(account_id) = &credential.account_id { - env_vars.insert(EnvVars::CHATGPT_ACCOUNT_ID.to_string(), account_id.clone()); - } - Some(codex_login_command(&credential.tokens.access_token)) - } - (_, ResolvedSecret::ApiKey(key), _) => { - if let Some(name) = primary_api_key_env_var(provider_id, catalog) { - env_vars.insert(name.to_string(), key.clone()); - } - None - } - (_, ResolvedSecret::OAuth { credential, .. }, _) => { - if let Some(name) = primary_api_key_env_var(provider_id, catalog) { - env_vars.insert(name.to_string(), credential.tokens.access_token.clone()); - } - None - } - }; - - CliCredential { - env_vars, - login_command, - } - } } fn vault_lookup_error(provider: &ProviderId, name: &str, err: VaultLookupError) -> ResolveError { @@ -545,32 +480,8 @@ pub async fn configured_providers_from_process_env( } } } -fn primary_api_key_env_var<'a>(provider: &ProviderId, catalog: &'a Catalog) -> Option<&'a str> { - catalog - .provider(provider)? - .auth - .as_ref()? - .credentials - .iter() - .find_map(|credential_ref| match credential_ref { - CredentialRef::Env(name) => Some(name.as_str()), - CredentialRef::Vault(_) => None, - }) -} - -fn codex_login_command(api_key: &str) -> String { - let quoted = - try_quote(api_key).map_or_else(|_| api_key.to_string(), std::borrow::Cow::into_owned); - format!( - "export PATH=\"$HOME/.local/bin:$PATH\" && printf '%s\\n' {quoted} | codex login --with-api-key" - ) -} - #[cfg(test)] mod tests { - #[cfg(unix)] - use std::os::unix::fs::PermissionsExt; - use chrono::{Duration, Utc}; use fabro_model::catalog::LlmCatalogSettings; use httpmock::Method::POST; @@ -628,9 +539,7 @@ mod tests { .await .unwrap(); - let ResolvedCredential::Api(api) = resolved else { - panic!("expected api credential"); - }; + let ResolvedCredential::Api(api) = resolved; assert_eq!( api.auth_header, Some(ApiKeyHeader::Bearer("env-key".to_string())) @@ -658,9 +567,7 @@ mod tests { .await .unwrap(); - let ResolvedCredential::Api(api) = resolved else { - panic!("expected api credential"); - }; + let ResolvedCredential::Api(api) = resolved; assert_eq!( api.auth_header, Some(ApiKeyHeader::Bearer("expired-access".to_string())) @@ -702,17 +609,15 @@ mod tests { let resolver = test_resolver(vault, Arc::new(|_| None)); let catalog = default_catalog(); - let ResolvedCredential::Api(api) = resolver + let resolved = resolver .resolve( ProviderId::anthropic(), CredentialUsage::ApiRequest, &catalog, ) .await - .unwrap() - else { - panic!("expected api credential"); - }; + .unwrap(); + let ResolvedCredential::Api(api) = resolved; assert_eq!( api.auth_header, @@ -764,9 +669,7 @@ reasoning = false .await .unwrap(); - let ResolvedCredential::Api(api) = resolved else { - panic!("expected api credential"); - }; + let ResolvedCredential::Api(api) = resolved; assert_eq!( api.auth_header, Some(ApiKeyHeader::Bearer("compat-key".to_string())) @@ -777,141 +680,6 @@ reasoning = false ); } - #[tokio::test] - async fn openai_codex_cli_credential_includes_login_command_and_account_id() { - let dir = tempfile::tempdir().unwrap(); - let mut vault = Vault::load(dir.path().join("secrets.json")).unwrap(); - vault_set_oauth( - &mut vault, - crate::OPENAI_CODEX_VAULT_SECRET_NAME, - &oauth_credential( - "https://auth.openai.com/oauth/token".to_string(), - Utc::now() + Duration::hours(1), - ), - ) - .unwrap(); - let resolver = test_resolver(vault, Arc::new(|_| None)); - let catalog = default_catalog(); - - let ResolvedCredential::Cli(cli) = resolver - .resolve( - ProviderId::openai(), - CredentialUsage::CliAgent(CliAgentKind::Codex), - &catalog, - ) - .await - .unwrap() - else { - panic!("expected cli credential"); - }; - - assert_eq!( - cli.env_vars.get("OPENAI_API_KEY").map(String::as_str), - Some("expired-access") - ); - assert_eq!( - cli.env_vars.get("CHATGPT_ACCOUNT_ID").map(String::as_str), - Some("acct_123") - ); - assert!( - cli.login_command - .as_deref() - .is_some_and(|command| command.contains("codex login --with-api-key")) - ); - } - - #[tokio::test] - async fn openai_api_key_cli_fallback_has_no_account_id() { - let dir = tempfile::tempdir().unwrap(); - let mut vault = Vault::load(dir.path().join("secrets.json")).unwrap(); - vault_set_token(&mut vault, "OPENAI_API_KEY", "openai-key").unwrap(); - let resolver = test_resolver(vault, Arc::new(|_| None)); - let catalog = default_catalog(); - - let ResolvedCredential::Cli(cli) = resolver - .resolve( - ProviderId::openai(), - CredentialUsage::CliAgent(CliAgentKind::Codex), - &catalog, - ) - .await - .unwrap() - else { - panic!("expected cli credential"); - }; - - assert_eq!( - cli.env_vars.get("OPENAI_API_KEY").map(String::as_str), - Some("openai-key") - ); - assert!(!cli.env_vars.contains_key("CHATGPT_ACCOUNT_ID")); - assert!(cli.login_command.is_some()); - } - - #[cfg(unix)] - #[tokio::test] - #[expect( - clippy::disallowed_methods, - reason = "integration-style test: writes and reads a fake codex script via sync std::fs to \ - verify the login_command string passes stdin correctly" - )] - async fn openai_api_key_cli_login_command_executes_codex_from_local_bin() { - let dir = tempfile::tempdir().unwrap(); - let local_bin = dir.path().join(".local/bin"); - std::fs::create_dir_all(&local_bin).unwrap(); - - let codex_path = local_bin.join("codex"); - std::fs::write( - &codex_path, - "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$HOME/codex-args.txt\"\ncat > \"$HOME/codex-stdin.txt\"\n", - ) - .unwrap(); - let mut permissions = std::fs::metadata(&codex_path).unwrap().permissions(); - permissions.set_mode(0o755); - std::fs::set_permissions(&codex_path, permissions).unwrap(); - - let mut vault = Vault::load(dir.path().join("secrets.json")).unwrap(); - vault_set_token(&mut vault, "OPENAI_API_KEY", "openai-key").unwrap(); - let resolver = test_resolver(vault, Arc::new(|_| None)); - let catalog = default_catalog(); - - let ResolvedCredential::Cli(cli) = resolver - .resolve( - ProviderId::openai(), - CredentialUsage::CliAgent(CliAgentKind::Codex), - &catalog, - ) - .await - .unwrap() - else { - panic!("expected cli credential"); - }; - - #[allow( - clippy::disallowed_methods, - reason = "This test shells through /bin/sh to verify the configured login command." - )] - let status = std::process::Command::new("/bin/sh") - .arg("-lc") - .arg(cli.login_command.unwrap()) - .env("HOME", dir.path()) - .env("PATH", "/usr/bin:/bin") - .status() - .unwrap(); - - assert!(status.success()); - assert_eq!( - std::fs::read_to_string(dir.path().join("codex-args.txt")).unwrap(), - "login\n--with-api-key\n" - ); - assert_eq!( - std::fs::read_to_string(dir.path().join("codex-stdin.txt")) - .unwrap() - .trim_end(), - "openai-key" - ); - } - #[tokio::test] async fn with_env_lookup_overrides_vault_settings() { let dir = tempfile::tempdir().unwrap(); @@ -935,13 +703,11 @@ reasoning = false ); let catalog = default_catalog(); - let ResolvedCredential::Api(api) = resolver + let resolved = resolver .resolve(ProviderId::openai(), CredentialUsage::ApiRequest, &catalog) .await - .unwrap() - else { - panic!("expected api credential"); - }; + .unwrap(); + let ResolvedCredential::Api(api) = resolved; assert_eq!(api.org_id.as_deref(), Some("env-org")); } @@ -1002,9 +768,7 @@ reasoning = false .await .unwrap(); - let ResolvedCredential::Api(api) = resolved else { - panic!("expected api credential"); - }; + let ResolvedCredential::Api(api) = resolved; assert_eq!(api.provider, ProviderId::new("acme")); assert_eq!( api.auth_header, @@ -1068,22 +832,17 @@ reasoning = false let resolver = CredentialResolver::with_env_lookup(Arc::clone(&vault), Arc::new(|_| None)); let catalog = default_catalog(); - let ResolvedCredential::Cli(cli) = resolver - .resolve( - ProviderId::openai(), - CredentialUsage::CliAgent(CliAgentKind::Codex), - &catalog, - ) + let resolved = resolver + .resolve(ProviderId::openai(), CredentialUsage::ApiRequest, &catalog) .await - .unwrap() - else { - panic!("expected cli credential"); - }; + .unwrap(); + let ResolvedCredential::Api(api) = resolved; assert_eq!( - cli.env_vars.get("OPENAI_API_KEY").map(String::as_str), - Some("new-access") + api.auth_header, + Some(ApiKeyHeader::Bearer("new-access".to_string())) ); + assert!(api.codex_mode); let stored = { let vault = vault.read().await; @@ -1116,11 +875,7 @@ reasoning = false let catalog = default_catalog(); let err = resolver - .resolve( - ProviderId::openai(), - CredentialUsage::CliAgent(CliAgentKind::Codex), - &catalog, - ) + .resolve(ProviderId::openai(), CredentialUsage::ApiRequest, &catalog) .await .unwrap_err(); diff --git a/lib/crates/fabro-auth/src/vault_source.rs b/lib/crates/fabro-auth/src/vault_source.rs index 16329ed0a..26068b5ab 100644 --- a/lib/crates/fabro-auth/src/vault_source.rs +++ b/lib/crates/fabro-auth/src/vault_source.rs @@ -52,7 +52,6 @@ impl CredentialSource for VaultCredentialSource { .await { Ok(ResolvedCredential::Api(credential)) => credentials.push(credential), - Ok(ResolvedCredential::Cli(_)) => {} Err(ResolveError::NotConfigured(_)) if provider.auth.is_some() => {} Err(err) => auth_issues.push((provider.id.clone(), err)), } diff --git a/lib/crates/fabro-cli/tests/it/workflow/acp.rs b/lib/crates/fabro-cli/tests/it/workflow/acp.rs index 706a0729b..3f34c935d 100644 --- a/lib/crates/fabro-cli/tests/it/workflow/acp.rs +++ b/lib/crates/fabro-cli/tests/it/workflow/acp.rs @@ -19,9 +19,8 @@ fn acp_backend_workflow() { "[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 acp_config = fake_acp_config_attr(&fake_agent); let workflow = context.temp_dir.join("acp_backend.fabro"); context.write_temp( "acp_backend.fabro", @@ -29,7 +28,7 @@ fn acp_backend_workflow() { 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}] + work [type="agent", backend="acp", prompt="write hello.txt", acp.config={acp_config}] exit [shape=Msquare] start -> work work -> exit @@ -78,34 +77,37 @@ fn acp_backend_workflow() { assert!( stages.values().any(|stage| { stage["provider_used"]["mode"] == "acp" - && stage["provider_used"]["provider"] == "openai" + && stage["provider_used"]["config_name"] == "fake" + && stage["provider_used"].get("provider").is_none() }), - "run projection should include ACP provider metadata: {stages:?}" + "run projection should include ACP process metadata without provider: {stages:?}" ); } #[test] -fn acp_prompt_workflow_uses_acp_backend() { +fn acp_backend_does_not_inject_registered_provider_credentials() { let mut context = test_context!(); context.write_home( ".fabro/settings.toml", "[server.auth]\nmethods = [\"dev-token\"]\n", ); context.isolated_server(); - seed_openai_vault(&context.storage_dir); + seed_anthropic_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"); + let env_record = context.temp_dir.join("acp-env.json"); + let acp_config = fake_acp_config_attr_recording_env(&fake_agent, &env_record); + let workflow = context.temp_dir.join("acp_provider_env.fabro"); context.write_temp( - "acp_prompt_backend.fabro", + "acp_provider_env.fabro", format!( r#"digraph ACP {{ - graph [goal="Exercise ACP prompt backend"] + graph [goal="Exercise ACP backend with stored provider credentials"] start [shape=Mdiamond] - prompt [type="prompt", backend="acp", provider="openai", model="fake-acp", project_memory=false, prompt="write hello.txt", acp_command={acp_command}] + work [type="agent", backend="acp", prompt="write hello.txt", acp.config={acp_config}] exit [shape=Msquare] - start -> prompt - prompt -> exit + start -> work + work -> exit }}"# ), ); @@ -113,6 +115,9 @@ fn acp_prompt_workflow_uses_acp_backend() { context .run_cmd() + .env_remove("ANTHROPIC_API_KEY") + .env_remove("OPENAI_API_KEY") + .env_remove("GEMINI_API_KEY") .args(["--auto-approve", "--sandbox", "local"]) .arg(&workflow) .assert() @@ -122,45 +127,12 @@ fn acp_prompt_workflow_uses_acp_backend() { 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_API_KEY", "test-openai-key", SecretType::Token, None) - .expect("OpenAI credential should store in test vault"); + let recorded_env: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(&env_record) + .expect("fake ACP agent should record its environment"), + ) + .expect("fake ACP environment record should be valid JSON"); + assert_eq!(recorded_env, serde_json::json!({})); } fn write_fake_acp_agent(context: &fabro_test::TestContext) -> std::path::PathBuf { @@ -168,16 +140,52 @@ fn write_fake_acp_agent(context: &fabro_test::TestContext) -> std::path::PathBuf context.temp_dir.join("fake_acp_agent.py") } -fn fake_acp_command_attr(script_path: &std::path::Path) -> String { - let command = serde_json::json!({ +fn fake_acp_config_attr(script_path: &std::path::Path) -> String { + fake_acp_config_attr_with_env(script_path, Vec::new()) +} + +fn fake_acp_config_attr_recording_env( + script_path: &std::path::Path, + env_record: &std::path::Path, +) -> String { + fake_acp_config_attr_with_env(script_path, vec![ + serde_json::json!({"name": "ACP_ENV_RECORD", "value": env_record.to_string_lossy()}), + serde_json::json!({ + "name": "ACP_ENV_RECORD_KEYS", + "value": "ANTHROPIC_API_KEY,OPENAI_API_KEY,GEMINI_API_KEY", + }), + ]) +} + +fn fake_acp_config_attr_with_env( + script_path: &std::path::Path, + extra_env: Vec, +) -> String { + let mut env = vec![serde_json::json!({"name": "ACP_MODE", "value": "write_file"})]; + env.extend(extra_env); + + let config = serde_json::json!({ "type": "stdio", "name": "fake", "command": "python3", "args": [script_path.to_string_lossy()], - "env": [{"name": "ACP_MODE", "value": "write_file"}], + "env": env, }) .to_string(); - format!("{command:?}") + format!("{config:?}") +} + +fn seed_anthropic_vault(storage_dir: &std::path::Path) { + let mut vault = + Vault::load(Storage::new(storage_dir).secrets_path()).expect("test vault should load"); + vault + .set( + "ANTHROPIC_API_KEY", + "vault-anthropic-key", + SecretType::Token, + None, + ) + .expect("Anthropic credential should store in test vault"); } fn init_git_repo(dir: &std::path::Path) { diff --git a/lib/crates/fabro-cli/tests/it/workflow/mod.rs b/lib/crates/fabro-cli/tests/it/workflow/mod.rs index 023d4f825..21cf17c21 100644 --- a/lib/crates/fabro-cli/tests/it/workflow/mod.rs +++ b/lib/crates/fabro-cli/tests/it/workflow/mod.rs @@ -12,7 +12,6 @@ mod dry_run_examples; mod full_stack; mod hooks; mod human_gate; -mod real_cli; use std::path::{Path, PathBuf}; use std::time::Duration; diff --git a/lib/crates/fabro-cli/tests/it/workflow/real_cli.rs b/lib/crates/fabro-cli/tests/it/workflow/real_cli.rs deleted file mode 100644 index ad0b51fcb..000000000 --- a/lib/crates/fabro-cli/tests/it/workflow/real_cli.rs +++ /dev/null @@ -1,71 +0,0 @@ -use std::sync::Arc; - -use fabro_graphviz::graph::{AttrValue, Node}; -use fabro_model::ProviderId; -use fabro_workflow::context::Context; -use fabro_workflow::event::Emitter; -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. -async fn run_real_cli_test(provider: ProviderId, model: &str) { - let workspace = tempfile::tempdir().expect("real CLI test workspace should create"); - let env: Arc = Arc::new(fabro_agent::LocalSandbox::new( - workspace.path().to_path_buf(), - )); - let backend = AgentCliBackend::new_from_env(model.to_string(), provider.clone()); - - let mut node = Node::new("real_cli_test"); - node.attrs.insert( - "prompt".to_string(), - AttrValue::String("What is 2+2? Reply with just the number.".to_string()), - ); - - let context = Context::new(); - let emitter = Arc::new(Emitter::default()); - let result = backend - .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")); - - match result { - CodergenResult::Text { text, usage, .. } => { - assert!( - text.contains('4'), - "{provider}/{model}: expected response to contain '4', got: {text}" - ); - let usage = usage.unwrap_or_else(|| panic!("{provider}/{model}: should have usage")); - let tokens = usage.tokens(); - assert!( - tokens.input_tokens > 0, - "{provider}/{model}: input_tokens should be > 0, got {}", - tokens.input_tokens - ); - } - CodergenResult::Full(_) => panic!("expected Text result from {provider}/{model}"), - } -} - -#[fabro_macros::e2e_test(live("ANTHROPIC_API_KEY"))] -async fn real_cli_claude() { - run_real_cli_test(ProviderId::anthropic(), "haiku").await; -} - -#[fabro_macros::e2e_test(live("OPENAI_API_KEY"))] -async fn real_cli_codex() { - run_real_cli_test(ProviderId::openai(), "").await; -} - -#[fabro_macros::e2e_test(live("GEMINI_API_KEY"))] -async fn real_cli_gemini() { - run_real_cli_test(ProviderId::gemini(), "gemini-2.5-flash").await; -} diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index 2be58dfb5..f2d165d62 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -2483,18 +2483,17 @@ fn update_live_run_from_event(state: &AppState, run_id: RunId, event: &RunEvent) } } } - // Track non-steerable agent stages. CLI/ACP started/completed are + // Track non-steerable agent stages. 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(_) => { + EventBody::AgentAcpStarted(_) => { if let Some(stage_id) = event.stage_id.as_ref() { managed_run .active_non_steerable_agent_stages .insert(stage_id.clone()); } } - EventBody::AgentCliCompleted(_) - | EventBody::AgentAcpCompleted(_) + EventBody::AgentAcpCompleted(_) | EventBody::AgentAcpCancelled(_) | EventBody::AgentAcpTimedOut(_) => { if let Some(stage_id) = &event.stage_id { @@ -2504,7 +2503,7 @@ fn update_live_run_from_event(state: &AppState, run_id: RunId, event: &RunEvent) } } // Stage lifecycle backstop: cover both completion and failure - // paths so a failing CLI stage doesn't strand its entry. + // paths so a failing ACP stage doesn't strand its entry. EventBody::StageCompleted(_) | EventBody::StageFailed(_) => { if let Some(stage_id) = &event.stage_id { managed_run.active_api_stages.remove(stage_id); diff --git a/lib/crates/fabro-server/src/server/tests.rs b/lib/crates/fabro-server/src/server/tests.rs index 60353d4b5..e34e12800 100644 --- a/lib/crates/fabro-server/src/server/tests.rs +++ b/lib/crates/fabro-server/src/server/tests.rs @@ -8551,12 +8551,10 @@ async fn steer_with_active_acp_stage_returns_non_steerable_conflict() { ); 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(), + node_id: "agent".to_string(), + visit: 1, + command: "python fake_agent.py".to_string(), + config_name: None, }); update_live_run_from_event(&state, run_id, &started); @@ -8640,12 +8638,10 @@ async fn active_acp_stage_marker_clears_on_terminal_paths() { 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(), + node_id: "agent".to_string(), + visit: 1, + command: "python fake_agent.py".to_string(), + config_name: None, }); update_live_run_from_event(&state, run_id, &started); let terminal = acp_event_for_stage(&run_id, &terminal_event); diff --git a/lib/crates/fabro-store/src/run_state.rs b/lib/crates/fabro-store/src/run_state.rs index c09c0c3bf..38e083641 100644 --- a/lib/crates/fabro-store/src/run_state.rs +++ b/lib/crates/fabro-store/src/run_state.rs @@ -3,9 +3,8 @@ use std::str::FromStr; use chrono::{DateTime, Utc}; use fabro_types::run_event::{ - AgentAcpStartedProps, AgentCliStartedProps, AgentSessionActivatedProps, - CheckpointCompletedProps, RunCompletedProps, RunFailedProps, StageCompletedProps, - StagePromptProps, + AgentAcpStartedProps, AgentSessionActivatedProps, CheckpointCompletedProps, RunCompletedProps, + RunFailedProps, StageCompletedProps, StagePromptProps, }; use fabro_types::settings::run::RunSandboxSettings; use fabro_types::{ @@ -376,13 +375,6 @@ impl RunProjectionReducer for RunProjection { }; stage.provider_used = Some(provider_used_from_agent_session_activated(props)); } - EventBody::AgentCliStarted(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_cli_started(props)); - } EventBody::AgentAcpStarted(props) => { let Some(stage) = stage_at_stored_or_visit(self, stored, props.visit, event.seq) else { @@ -412,42 +404,6 @@ impl RunProjectionReducer for RunProjection { stage.termination = Some(props.termination); stage.script_timing = Some(script_timing); } - EventBody::AgentCliCompleted(props) => { - let Some(stage) = stage_at_current_visit(self, stored, event.seq) else { - return Ok(()); - }; - apply_agent_terminal( - "agent.cli", - stage, - props, - merge_agent_cli_output(&props.stdout, &props.stderr), - CommandTermination::Exited, - )?; - } - EventBody::AgentCliCancelled(props) => { - let Some(stage) = stage_at_current_visit(self, stored, event.seq) else { - return Ok(()); - }; - apply_agent_terminal( - "agent.cli", - stage, - props, - merge_agent_cli_output(&props.stdout, &props.stderr), - CommandTermination::Cancelled, - )?; - } - EventBody::AgentCliTimedOut(props) => { - let Some(stage) = stage_at_current_visit(self, stored, event.seq) else { - return Ok(()); - }; - 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(()); @@ -456,7 +412,7 @@ impl RunProjectionReducer for RunProjection { "agent.acp", stage, props, - merge_agent_cli_output(&props.stdout, &props.stderr), + merge_agent_process_output(&props.stdout, &props.stderr), CommandTermination::Exited, )?; } @@ -468,7 +424,7 @@ impl RunProjectionReducer for RunProjection { "agent.acp", stage, props, - merge_agent_cli_output(&props.stdout, &props.stderr), + merge_agent_process_output(&props.stdout, &props.stderr), CommandTermination::Cancelled, )?; } @@ -480,7 +436,7 @@ impl RunProjectionReducer for RunProjection { "agent.acp", stage, props, - merge_agent_cli_output(&props.stdout, &props.stderr), + merge_agent_process_output(&props.stdout, &props.stderr), CommandTermination::TimedOut, )?; } @@ -893,25 +849,13 @@ fn provider_used_from_agent_session_activated(props: &AgentSessionActivatedProps Value::Object(provider_used) } -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(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())); + provider_used.insert("mode".to_string(), Value::String("acp".to_string())); + provider_used.insert("command".to_string(), Value::String(props.command.clone())); + if let Some(config_name) = props.config_name.clone() { + provider_used.insert("config_name".to_string(), Value::String(config_name)); + } Value::Object(provider_used) } @@ -931,7 +875,7 @@ fn apply_agent_terminal( Ok(()) } -fn merge_agent_cli_output(stdout: &str, stderr: &str) -> String { +fn merge_agent_process_output(stdout: &str, stderr: &str) -> String { match (stdout.is_empty(), stderr.is_empty()) { (true, true) => String::new(), (false, true) => stdout.to_string(), @@ -948,8 +892,7 @@ mod tests { use fabro_types::run_event::run::RunFailedProps; use fabro_types::run_event::{ AgentAcpCancelledProps, AgentAcpCompletedProps, AgentAcpStartedProps, - AgentAcpTimedOutProps, AgentCliCancelledProps, AgentCliCompletedProps, - AgentCliTimedOutProps, AgentMessageProps, AgentSessionActivatedProps, + AgentAcpTimedOutProps, AgentMessageProps, AgentSessionActivatedProps, AgentSessionEndedProps, AgentSessionStartedProps, CheckpointCompletedProps, InterviewCompletedProps, InterviewOption, InterviewStartedProps, RunControlEffectProps, StageCompletedProps, StageFailedProps, StagePromptProps, StageRetryingProps, @@ -1367,11 +1310,9 @@ mod tests { .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(), + visit: 1, + command: "python fake_agent.py".to_string(), + config_name: Some("fake".to_string()), }), stage_id.clone(), )) @@ -1382,9 +1323,8 @@ mod tests { stage.provider_used.as_ref().unwrap(), &json!({ "mode": "acp", - "provider": "openai", - "model": "fake-acp", - "command": "python fake_agent.py" + "command": "python fake_agent.py", + "config_name": "fake" }) ); } @@ -1460,88 +1400,6 @@ mod tests { assert_eq!(stage.termination, Some(CommandTermination::TimedOut)); } - #[test] - fn agent_cli_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::AgentCliCompleted(AgentCliCompletedProps { - stdout: "done".to_string(), - stderr: "warn".to_string(), - exit_code: 0, - 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()["duration_ms"], - serde_json::json!(42) - ); - } - - #[test] - fn agent_cli_cancelled_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::AgentCliCancelled(AgentCliCancelledProps { - stdout: "partial".to_string(), - stderr: "cancelled".to_string(), - duration_ms: 7, - }), - stage_id.clone(), - )) - .unwrap(); - - let stage = state.stage(&stage_id).unwrap(); - assert_eq!(stage.output.as_deref(), Some("partial\ncancelled")); - assert_eq!(stage.termination, Some(CommandTermination::Cancelled)); - assert_eq!( - stage.script_timing.as_ref().unwrap()["duration_ms"], - serde_json::json!(7) - ); - } - - #[test] - fn agent_cli_timed_out_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::AgentCliTimedOut(AgentCliTimedOutProps { - stdout: "partial".to_string(), - stderr: "timeout".to_string(), - duration_ms: 600, - }), - stage_id.clone(), - )) - .unwrap(); - - let stage = state.stage(&stage_id).unwrap(); - assert_eq!(stage.output.as_deref(), Some("partial\ntimeout")); - assert_eq!(stage.termination, Some(CommandTermination::TimedOut)); - assert_eq!( - stage.script_timing.as_ref().unwrap()["duration_ms"], - serde_json::json!(600) - ); - } - #[test] fn stage_completed_event_captures_duration_and_usage_per_visit() { let mut state = initialized_projection(); diff --git a/lib/crates/fabro-types/src/graph.rs b/lib/crates/fabro-types/src/graph.rs index 48ef9a2d7..27344f8c9 100644 --- a/lib/crates/fabro-types/src/graph.rs +++ b/lib/crates/fabro-types/src/graph.rs @@ -3,7 +3,7 @@ use std::time::Duration; use serde::{Deserialize, Serialize}; -use crate::LlmBackend; +use crate::AgentBackend; /// Typed attribute values for nodes, edges, and graph-level attributes. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -258,15 +258,25 @@ impl Node { } #[must_use] - pub fn llm_backend(&self) -> Option> { + pub fn agent_backend(&self) -> Option> { self.backend().map(str::parse) } #[must_use] - pub fn acp_command(&self) -> Option<&str> { + pub fn legacy_acp_command_attr(&self) -> Option<&str> { self.str_attr("acp_command") } + #[must_use] + pub fn acp_command_attr(&self) -> Option<&str> { + self.str_attr("acp.command") + } + + #[must_use] + pub fn acp_config_attr(&self) -> Option<&str> { + self.str_attr("acp.config") + } + #[must_use] pub fn selection(&self) -> &str { self.str_attr("selection").unwrap_or("deterministic") diff --git a/lib/crates/fabro-types/src/lib.rs b/lib/crates/fabro-types/src/lib.rs index e4fd7b55d..6a0138ffa 100644 --- a/lib/crates/fabro-types/src/lib.rs +++ b/lib/crates/fabro-types/src/lib.rs @@ -61,7 +61,7 @@ pub use graph::{ shape_to_handler_type, }; pub use interview::{InterviewQuestionRecord, QuestionType}; -pub use llm_backend::LlmBackend; +pub use llm_backend::AgentBackend; pub use manifest_path::{ManifestPath, ManifestPathParseError}; pub use outcome::{ FailureCategory, FailureDetail, NodeResult, Outcome, OutcomeMeta, StageOutcome, StageState, diff --git a/lib/crates/fabro-types/src/llm_backend.rs b/lib/crates/fabro-types/src/llm_backend.rs index 5a91fce2e..adacb0db1 100644 --- a/lib/crates/fabro-types/src/llm_backend.rs +++ b/lib/crates/fabro-types/src/llm_backend.rs @@ -18,15 +18,27 @@ use strum::{Display, EnumString, IntoStaticStr, VariantArray, VariantNames}; )] #[serde(rename_all = "snake_case")] #[strum(serialize_all = "snake_case")] -pub enum LlmBackend { +pub enum AgentBackend { Api, - Cli, Acp, } -impl LlmBackend { +impl AgentBackend { #[must_use] pub fn expected_values() -> String { ::VARIANTS.join(", ") } } + +#[cfg(test)] +mod tests { + use super::AgentBackend; + + #[test] + fn agent_backend_accepts_only_api_and_acp() { + assert_eq!("api".parse::().unwrap(), AgentBackend::Api); + assert_eq!("acp".parse::().unwrap(), AgentBackend::Acp); + assert!("cli".parse::().is_err()); + assert_eq!(AgentBackend::expected_values(), "api, acp"); + } +} diff --git a/lib/crates/fabro-types/src/run_event/misc.rs b/lib/crates/fabro-types/src/run_event/misc.rs index 6345cd298..b2280c243 100644 --- a/lib/crates/fabro-types/src/run_event/misc.rs +++ b/lib/crates/fabro-types/src/run_event/misc.rs @@ -218,44 +218,12 @@ pub struct CommandCompletedProps { pub live_streaming: bool, } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct AgentCliStartedProps { - pub visit: u32, - pub mode: String, - pub provider: String, - pub model: String, - pub command: String, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct AgentCliCompletedProps { - pub stdout: String, - pub stderr: String, - pub exit_code: i32, - pub duration_ms: u64, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct AgentCliCancelledProps { - pub stdout: String, - pub stderr: String, - pub duration_ms: u64, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct AgentCliTimedOutProps { - pub stdout: String, - pub stderr: String, - 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, + pub visit: u32, + pub command: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config_name: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] diff --git a/lib/crates/fabro-types/src/run_event/mod.rs b/lib/crates/fabro-types/src/run_event/mod.rs index 8ee972961..9be0c8669 100644 --- a/lib/crates/fabro-types/src/run_event/mod.rs +++ b/lib/crates/fabro-types/src/run_event/mod.rs @@ -286,14 +286,6 @@ pub enum EventBody { CommandStarted(CommandStartedProps), #[serde(rename = "command.completed")] CommandCompleted(CommandCompletedProps), - #[serde(rename = "agent.cli.started")] - AgentCliStarted(AgentCliStartedProps), - #[serde(rename = "agent.cli.completed")] - AgentCliCompleted(AgentCliCompletedProps), - #[serde(rename = "agent.cli.cancelled")] - AgentCliCancelled(AgentCliCancelledProps), - #[serde(rename = "agent.cli.timed_out")] - AgentCliTimedOut(AgentCliTimedOutProps), #[serde(rename = "agent.acp.started")] AgentAcpStarted(AgentAcpStartedProps), #[serde(rename = "agent.acp.completed")] @@ -498,10 +490,6 @@ impl EventBody { Self::CliEnsureFailed(_) => "cli.ensure.failed", Self::CommandStarted(_) => "command.started", Self::CommandCompleted(_) => "command.completed", - Self::AgentCliStarted(_) => "agent.cli.started", - 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", @@ -653,10 +641,6 @@ fn is_known_event_name(event: &str) -> bool { | "cli.ensure.failed" | "command.started" | "command.completed" - | "agent.cli.started" - | "agent.cli.completed" - | "agent.cli.cancelled" - | "agent.cli.timed_out" | "agent.acp.started" | "agent.acp.completed" | "agent.acp.cancelled" diff --git a/lib/crates/fabro-validate/Cargo.toml b/lib/crates/fabro-validate/Cargo.toml index d2a1252f0..a6c3ef3f9 100644 --- a/lib/crates/fabro-validate/Cargo.toml +++ b/lib/crates/fabro-validate/Cargo.toml @@ -13,6 +13,7 @@ doctest = false workspace = true [dependencies] +fabro-acp = { path = "../fabro-acp", default-features = false } fabro-graphviz = { path = "../fabro-graphviz" } fabro-model = { path = "../fabro-model" } fabro-types = { path = "../fabro-types" } diff --git a/lib/crates/fabro-validate/src/rules/backend_valid.rs b/lib/crates/fabro-validate/src/rules/backend_valid.rs index 19014be56..b59500254 100644 --- a/lib/crates/fabro-validate/src/rules/backend_valid.rs +++ b/lib/crates/fabro-validate/src/rules/backend_valid.rs @@ -1,5 +1,6 @@ +use fabro_acp::{AcpCommandError, AcpProcessSpec}; use fabro_graphviz::graph::{Graph, Node}; -use fabro_types::LlmBackend; +use fabro_types::AgentBackend; use crate::{Diagnostic, LintRule, Severity}; @@ -18,38 +19,16 @@ impl LintRule for Rule { let mut diagnostics = Vec::new(); for node in graph.nodes.values() { if let Some(backend) = node.backend() { - match node.llm_backend() { + match node.agent_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}")), - - ..Diagnostic::default() - }); + diagnostics.push(unsupported_backend_diagnostic( + self.name(), + node, + backend, + )); } - 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(), - ), - - ..Diagnostic::default() - }); + Some(Ok(AgentBackend::Acp)) => { + diagnostics.extend(validate_acp_node(self.name(), node)); } Some(Ok(_)) | None => {} } @@ -59,11 +38,150 @@ impl LintRule for Rule { } } -fn acp_command_missing(node: &Node) -> bool { - match node.acp_command() { - Some(command) => command.trim().is_empty(), - None => true, +fn unsupported_backend_diagnostic(rule: &str, node: &Node, backend: &str) -> Diagnostic { + if backend == "cli" { + return Diagnostic { + rule: rule.to_string(), + severity: Severity::Error, + message: "backend=\"cli\" is no longer supported; external agents must be launched \ + through backend=\"acp\" with acp.command or acp.config" + .to_string(), + node_id: Some(node.id.clone()), + edge: None, + fix: Some( + "Use backend=\"api\" for Fabro-owned provider execution, or backend=\"acp\" with \ + acp.command/acp.config for a user-supplied ACP process" + .to_string(), + ), + + ..Diagnostic::default() + }; } + + let expected = AgentBackend::expected_values(); + Diagnostic { + rule: rule.to_string(), + severity: Severity::Error, + message: format!("unsupported agent backend \"{backend}\"; expected one of: {expected}"), + node_id: Some(node.id.clone()), + edge: None, + fix: Some(format!("Use one of: {expected}")), + + ..Diagnostic::default() + } +} + +fn validate_acp_node(rule: &str, node: &Node) -> Vec { + let mut diagnostics = Vec::new(); + + if node.handler_type() != Some("agent") { + diagnostics.push(Diagnostic { + rule: rule.to_string(), + severity: Severity::Error, + message: "backend=\"acp\" is only valid on agent nodes; prompt nodes are API-only" + .to_string(), + node_id: Some(node.id.clone()), + edge: None, + fix: Some("Use backend=\"api\" on prompt nodes".to_string()), + + ..Diagnostic::default() + }); + } + + if let Err(error) = AcpProcessSpec::from_attrs( + node.legacy_acp_command_attr(), + node.acp_command_attr(), + node.acp_config_attr(), + ) { + diagnostics.push(acp_process_diagnostic(rule, node, &error)); + } + + let api_only_attrs = api_only_attrs_present(node); + if !api_only_attrs.is_empty() { + diagnostics.push(Diagnostic { + rule: rule.to_string(), + severity: Severity::Error, + message: format!( + "backend=\"acp\" does not support API-only attributes: {}", + api_only_attrs.join(", ") + ), + node_id: Some(node.id.clone()), + edge: None, + fix: Some("Remove API model/provider/control attributes from ACP nodes".to_string()), + + ..Diagnostic::default() + }); + } + + diagnostics +} + +fn acp_process_diagnostic(rule: &str, node: &Node, error: &AcpCommandError) -> Diagnostic { + match error { + AcpCommandError::LegacyCommandAttribute => Diagnostic { + rule: rule.to_string(), + severity: Severity::Error, + message: "acp_command is no longer supported; use acp.command for shell commands or \ + acp.config for JSON stdio ACP configs" + .to_string(), + node_id: Some(node.id.clone()), + edge: None, + fix: Some("Rename acp_command to acp.command".to_string()), + + ..Diagnostic::default() + }, + AcpCommandError::EmptyOverride + | AcpCommandError::MissingOverride + | AcpCommandError::InvalidCommandString => Diagnostic { + rule: rule.to_string(), + severity: Severity::Error, + message: render_acp_process_error(error), + node_id: Some(node.id.clone()), + edge: None, + fix: Some( + "Set acp.command to a shell command, or acp.config to a JSON stdio ACP config" + .to_string(), + ), + + ..Diagnostic::default() + }, + AcpCommandError::InvalidConfigJson(_) + | AcpCommandError::InvalidConfigShape(_) + | AcpCommandError::UnsupportedTransport => Diagnostic { + rule: rule.to_string(), + severity: Severity::Error, + message: format!( + "acp.config must be a JSON stdio ACP config: {}", + render_acp_process_error(error) + ), + node_id: Some(node.id.clone()), + edge: None, + fix: Some( + "Provide a JSON config with type=\"stdio\", command, and optional args".to_string(), + ), + + ..Diagnostic::default() + }, + } +} + +fn render_acp_process_error(error: &AcpCommandError) -> String { + error.to_string() +} + +fn api_only_attrs_present(node: &Node) -> Vec<&'static str> { + const API_ONLY_ATTRS: &[&str] = &[ + "model", + "provider", + "reasoning_effort", + "max_tokens", + "speed", + ]; + API_ONLY_ATTRS + .iter() + .copied() + .filter(|attr| node.attrs.contains_key(*attr)) + .collect() } #[cfg(test)] @@ -75,8 +193,8 @@ mod tests { use crate::{LintRule, Severity}; #[test] - fn backend_valid_accepts_absent_api_and_cli() { - for backend in [None, Some("api"), Some("cli")] { + fn backend_valid_accepts_absent_and_api() { + for backend in [None, Some("api")] { let mut graph = minimal_graph(); let mut node = Node::new("work"); if let Some(backend) = backend { @@ -107,12 +225,12 @@ mod tests { assert!( diagnostics[0] .message - .contains("unsupported LLM backend \"codex\"; expected one of: api, cli, acp") + .contains("unsupported agent backend \"codex\"; expected one of: api, acp") ); } #[test] - fn backend_valid_requires_acp_command_for_acp_backend() { + fn backend_valid_requires_acp_process_attr_for_acp_backend() { let mut graph = minimal_graph(); let mut node = Node::new("work"); node.attrs @@ -122,23 +240,208 @@ mod tests { 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" - )); + assert!( + diagnostics[0] + .message + .contains("requires exactly one of acp.command or acp.config") + ); } #[test] - fn backend_valid_accepts_acp_backend_with_acp_command() { + fn backend_valid_accepts_acp_backend_with_acp_command_attr() { 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(), + "acp.command".to_string(), AttrValue::String("agent-acp".to_string()), ); graph.nodes.insert("work".to_string(), node); assert!(Rule.apply(&graph).is_empty()); } + + #[test] + fn backend_valid_rejects_cli_backend_with_migration_guidance() { + let mut graph = minimal_graph(); + let mut node = Node::new("work"); + node.attrs + .insert("backend".to_string(), AttrValue::String("cli".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=\"cli\" is no longer supported") + ); + assert!( + diagnostics[0] + .fix + .as_deref() + .unwrap() + .contains("backend=\"acp\"") + ); + } + + #[test] + fn backend_valid_requires_exactly_one_acp_process_attr() { + let mut missing = minimal_graph(); + let mut missing_node = Node::new("missing"); + missing_node + .attrs + .insert("backend".to_string(), AttrValue::String("acp".to_string())); + missing.nodes.insert("missing".to_string(), missing_node); + + let diagnostics = Rule.apply(&missing); + assert_eq!(diagnostics.len(), 1); + assert!( + diagnostics[0] + .message + .contains("requires exactly one of acp.command or acp.config") + ); + + let mut both = minimal_graph(); + let mut both_node = Node::new("both"); + both_node + .attrs + .insert("backend".to_string(), AttrValue::String("acp".to_string())); + both_node.attrs.insert( + "acp.command".to_string(), + AttrValue::String("python3 agent.py".to_string()), + ); + both_node.attrs.insert( + "acp.config".to_string(), + AttrValue::String( + r#"{"type":"stdio","name":"agent","command":"python3","args":["agent.py"]}"# + .to_string(), + ), + ); + both.nodes.insert("both".to_string(), both_node); + + let diagnostics = Rule.apply(&both); + assert_eq!(diagnostics.len(), 1); + assert!( + diagnostics[0] + .message + .contains("requires exactly one of acp.command or acp.config") + ); + } + + #[test] + fn backend_valid_rejects_legacy_acp_command_attr() { + 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("python3 agent.py".to_string()), + ); + graph.nodes.insert("work".to_string(), node); + + let diagnostics = Rule.apply(&graph); + assert_eq!(diagnostics.len(), 1); + assert!( + diagnostics[0] + .message + .contains("acp_command is no longer supported") + ); + } + + #[test] + fn backend_valid_rejects_acp_on_prompt_nodes_and_api_only_attrs() { + let mut graph = minimal_graph(); + let mut node = Node::new("prompt"); + node.attrs + .insert("type".to_string(), AttrValue::String("prompt".to_string())); + node.attrs + .insert("backend".to_string(), AttrValue::String("acp".to_string())); + node.attrs.insert( + "acp.command".to_string(), + AttrValue::String("python3 agent.py".to_string()), + ); + node.attrs.insert( + "model".to_string(), + AttrValue::String("gpt-5.4".to_string()), + ); + node.attrs.insert( + "reasoning_effort".to_string(), + AttrValue::String("high".to_string()), + ); + graph.nodes.insert("prompt".to_string(), node); + + let diagnostics = Rule.apply(&graph); + assert_eq!(diagnostics.len(), 2); + assert!(diagnostics.iter().any(|diagnostic| { + diagnostic + .message + .contains("backend=\"acp\" is only valid on agent nodes") + })); + assert!(diagnostics.iter().any(|diagnostic| { + diagnostic + .message + .contains("backend=\"acp\" does not support API-only attributes") + })); + } + + #[test] + fn backend_valid_rejects_invalid_acp_config_but_accepts_json_shaped_command() { + let mut command_graph = minimal_graph(); + let mut command_node = Node::new("command"); + command_node + .attrs + .insert("backend".to_string(), AttrValue::String("acp".to_string())); + command_node.attrs.insert( + "acp.command".to_string(), + AttrValue::String(r#"{"type":"stdio"}"#.to_string()), + ); + command_graph + .nodes + .insert("command".to_string(), command_node); + assert!(Rule.apply(&command_graph).is_empty()); + + let mut config_graph = minimal_graph(); + let mut config_node = Node::new("config"); + config_node + .attrs + .insert("backend".to_string(), AttrValue::String("acp".to_string())); + config_node.attrs.insert( + "acp.config".to_string(), + AttrValue::String("python3 agent.py".to_string()), + ); + config_graph.nodes.insert("config".to_string(), config_node); + + let diagnostics = Rule.apply(&config_graph); + assert_eq!(diagnostics.len(), 1); + assert!( + diagnostics[0] + .message + .contains("acp.config must be a JSON stdio ACP config") + ); + } + + #[test] + fn backend_valid_rejects_invalid_acp_command() { + let mut graph = minimal_graph(); + let mut node = Node::new("command"); + node.attrs + .insert("backend".to_string(), AttrValue::String("acp".to_string())); + node.attrs.insert( + "acp.command".to_string(), + AttrValue::String("python 'unterminated".to_string()), + ); + graph.nodes.insert("command".to_string(), node); + + let diagnostics = Rule.apply(&graph); + assert_eq!(diagnostics.len(), 1); + assert!( + diagnostics[0] + .message + .contains("failed to parse acp.command as a shell command") + ); + } } diff --git a/lib/crates/fabro-workflow/src/event/convert.rs b/lib/crates/fabro-workflow/src/event/convert.rs index 0ce439079..249320ba8 100644 --- a/lib/crates/fabro-workflow/src/event/convert.rs +++ b/lib/crates/fabro-workflow/src/event/convert.rs @@ -1028,32 +1028,6 @@ fn event_body_from_event(event: &Event) -> EventBody { output_bytes: *output_bytes, live_streaming: *live_streaming, }), - Event::AgentCliStarted { - visit, - mode, - provider, - model, - command, - .. - } => EventBody::AgentCliStarted(fabro_types::AgentCliStartedProps { - visit: *visit, - mode: mode.clone(), - provider: provider.clone(), - model: model.clone(), - command: command.clone(), - }), - Event::AgentCliCompleted { - stdout, - stderr, - exit_code, - duration_ms, - .. - } => EventBody::AgentCliCompleted(fabro_types::AgentCliCompletedProps { - stdout: stdout.clone(), - stderr: stderr.clone(), - exit_code: *exit_code, - duration_ms: *duration_ms, - }), Event::AgentSessionStarted { provider, model, .. } => EventBody::AgentSessionStarted(fabro_types::AgentSessionStartedProps { @@ -1096,39 +1070,15 @@ fn event_body_from_event(event: &Event) -> EventBody { count: *count, }) } - Event::AgentCliCancelled { - stdout, - stderr, - duration_ms, - .. - } => EventBody::AgentCliCancelled(fabro_types::AgentCliCancelledProps { - stdout: stdout.clone(), - stderr: stderr.clone(), - duration_ms: *duration_ms, - }), - Event::AgentCliTimedOut { - stdout, - stderr, - duration_ms, - .. - } => EventBody::AgentCliTimedOut(fabro_types::AgentCliTimedOutProps { - stdout: stdout.clone(), - stderr: stderr.clone(), - duration_ms: *duration_ms, - }), Event::AgentAcpStarted { visit, - mode, - provider, - model, command, + config_name, .. } => EventBody::AgentAcpStarted(fabro_types::AgentAcpStartedProps { - visit: *visit, - mode: mode.clone(), - provider: provider.clone(), - model: model.clone(), - command: command.clone(), + visit: *visit, + command: command.clone(), + config_name: config_name.clone(), }), Event::AgentAcpCompleted { stdout, @@ -2077,48 +2027,6 @@ mod tests { assert_eq!(message.billing.total_usd_micros, None); } - #[test] - fn agent_cli_cancelled_maps_to_event_body_with_node_id() { - let stored = to_run_event(&fixtures::RUN_1, &Event::AgentCliCancelled { - node_id: "code".to_string(), - stdout: "out".to_string(), - stderr: "err".to_string(), - duration_ms: 42, - }); - - assert_eq!(stored.event_name(), "agent.cli.cancelled"); - assert_eq!(stored.node_id.as_deref(), Some("code")); - match &stored.body { - EventBody::AgentCliCancelled(props) => { - assert_eq!(props.stdout, "out"); - assert_eq!(props.stderr, "err"); - assert_eq!(props.duration_ms, 42); - } - other => panic!("expected AgentCliCancelled, got {other:?}"), - } - } - - #[test] - fn agent_cli_timed_out_maps_to_event_body_with_node_id() { - let stored = to_run_event(&fixtures::RUN_1, &Event::AgentCliTimedOut { - node_id: "code".to_string(), - stdout: "out".to_string(), - stderr: "err".to_string(), - duration_ms: 99, - }); - - assert_eq!(stored.event_name(), "agent.cli.timed_out"); - assert_eq!(stored.node_id.as_deref(), Some("code")); - match &stored.body { - EventBody::AgentCliTimedOut(props) => { - assert_eq!(props.stdout, "out"); - assert_eq!(props.stderr, "err"); - assert_eq!(props.duration_ms, 99); - } - other => panic!("expected AgentCliTimedOut, got {other:?}"), - } - } - #[test] fn agent_acp_events_map_to_event_bodies_with_stage_scope() { let scope = StageScope { @@ -2131,12 +2039,10 @@ mod tests { 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(), + node_id: "code".to_string(), + visit: 2, + command: "python fake_agent.py".to_string(), + config_name: Some("fake".to_string()), }, Utc::now(), Some(&scope), @@ -2149,10 +2055,8 @@ mod tests { 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"); + assert_eq!(props.config_name.as_deref(), Some("fake")); } other => panic!("expected AgentAcpStarted, got {other:?}"), } diff --git a/lib/crates/fabro-workflow/src/event/events.rs b/lib/crates/fabro-workflow/src/event/events.rs index fc26008e1..ca6f97f2f 100644 --- a/lib/crates/fabro-workflow/src/event/events.rs +++ b/lib/crates/fabro-workflow/src/event/events.rs @@ -536,14 +536,6 @@ pub enum Event { output_bytes: u64, live_streaming: bool, }, - AgentCliStarted { - node_id: String, - visit: u32, - mode: String, - provider: String, - model: String, - command: String, - }, /// A top-level agent session object started its lifecycle. AgentSessionStarted { session_id: String, @@ -606,32 +598,12 @@ pub enum Event { #[serde(default, skip_serializing_if = "Option::is_none")] visit: Option, }, - AgentCliCompleted { - node_id: String, - stdout: String, - stderr: String, - exit_code: i32, - duration_ms: u64, - }, - AgentCliCancelled { - node_id: String, - stdout: String, - stderr: String, - duration_ms: u64, - }, - AgentCliTimedOut { - node_id: String, - stdout: String, - stderr: String, - duration_ms: u64, - }, AgentAcpStarted { - node_id: String, - visit: u32, - mode: String, - provider: String, - model: String, - command: String, + node_id: String, + visit: u32, + command: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + config_name: Option, }, AgentAcpCompleted { node_id: String, @@ -1356,22 +1328,6 @@ impl Event { "Command completed" ); } - Self::AgentCliStarted { - node_id, - provider, - model, - .. - } => { - debug!(node_id, provider, model, "Agent CLI started"); - } - Self::AgentCliCompleted { - node_id, - exit_code, - duration_ms, - .. - } => { - debug!(node_id, exit_code, duration_ms, "Agent CLI completed"); - } Self::AgentSessionStarted { session_id, provider, @@ -1412,27 +1368,13 @@ impl Event { Self::AgentSteerDropped { reason, count, .. } => { warn!(?reason, count, "Steer dropped"); } - Self::AgentCliCancelled { - node_id, - duration_ms, - .. - } => { - debug!(node_id, duration_ms, "Agent CLI cancelled"); - } - Self::AgentCliTimedOut { - node_id, - duration_ms, - .. - } => { - debug!(node_id, duration_ms, "Agent CLI timed out"); - } Self::AgentAcpStarted { node_id, - provider, - model, + command, + config_name, .. } => { - debug!(node_id, provider, model, "Agent ACP started"); + debug!(node_id, command, ?config_name, "Agent ACP started"); } Self::AgentAcpCompleted { node_id, diff --git a/lib/crates/fabro-workflow/src/event/names.rs b/lib/crates/fabro-workflow/src/event/names.rs index c5abfbc7c..02154926c 100644 --- a/lib/crates/fabro-workflow/src/event/names.rs +++ b/lib/crates/fabro-workflow/src/event/names.rs @@ -125,8 +125,6 @@ pub fn event_name(event: &Event) -> &'static str { Event::Failover { .. } => "agent.failover", Event::CommandStarted { .. } => "command.started", Event::CommandCompleted { .. } => "command.completed", - Event::AgentCliStarted { .. } => "agent.cli.started", - Event::AgentCliCompleted { .. } => "agent.cli.completed", Event::AgentSessionStarted { .. } => "agent.session.started", Event::AgentSessionActivated { .. } => "agent.session.activated", Event::AgentSessionDeactivated { .. } => "agent.session.deactivated", @@ -134,8 +132,6 @@ pub fn event_name(event: &Event) -> &'static str { Event::AgentInterruptInjected { .. } => "agent.interrupt.injected", Event::AgentSteerBuffered { .. } => "agent.steer.buffered", 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", diff --git a/lib/crates/fabro-workflow/src/event/stored_fields.rs b/lib/crates/fabro-workflow/src/event/stored_fields.rs index c640f0d39..7f31cc4a8 100644 --- a/lib/crates/fabro-workflow/src/event/stored_fields.rs +++ b/lib/crates/fabro-workflow/src/event/stored_fields.rs @@ -121,10 +121,6 @@ fn stored_event_fields_for_variant(event: &Event) -> StoredEventFields { | Event::PromptCompleted { node_id, .. } | Event::CommandStarted { node_id, .. } | Event::CommandCompleted { node_id, .. } - | Event::AgentCliStarted { node_id, .. } - | Event::AgentCliCompleted { node_id, .. } - | Event::AgentCliCancelled { node_id, .. } - | Event::AgentCliTimedOut { node_id, .. } | Event::AgentAcpCompleted { node_id, .. } | Event::AgentAcpCancelled { node_id, .. } | Event::AgentAcpTimedOut { node_id, .. } => node_stored_fields(Some(node_id.clone())), diff --git a/lib/crates/fabro-workflow/src/handler/llm/acp.rs b/lib/crates/fabro-workflow/src/handler/llm/acp.rs index 08bfd011f..02a21687c 100644 --- a/lib/crates/fabro-workflow/src/handler/llm/acp.rs +++ b/lib/crates/fabro-workflow/src/handler/llm/acp.rs @@ -4,62 +4,28 @@ 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_acp::{AcpCommandError, AcpError, AcpProcessSpec, AcpRunRequest, render_stop_reason}; use fabro_agent::{Sandbox, StaticEnvProvider, ToolEnvProvider}; -use fabro_auth::CredentialResolver; use fabro_graphviz::graph::Node; -use fabro_model::{Catalog, ProviderId}; use fabro_util::time::elapsed_ms; use tokio_util::sync::CancellationToken; use super::super::agent::{CodergenBackend, CodergenResult, CodergenRunRequest, OneShotRequest}; -use super::cli::AgentCli; -use super::launch_env::{AgentLaunchEnvRequest, resolve_agent_launch_env}; -use super::{changed_files, routing}; +use super::changed_files; use crate::error::Error; -use crate::event::{Emitter, Event, StageScope}; +use crate::event::{Emitter, Event, RunNoticeCode, RunNoticeLevel, StageScope}; pub struct AgentAcpBackend { - model: String, - provider_id: ProviderId, - tool_env: Option>, + tool_env: Option>, github_token_refresh_managed: bool, - resolver: Option, - catalog: Arc, } impl AgentAcpBackend { #[must_use] - pub fn new( - model: String, - provider_id: impl Into, - resolver: CredentialResolver, - ) -> Self { - let provider_id = provider_id.into(); - let catalog = default_catalog(); + pub fn new() -> Self { Self { - model, - provider_id, - tool_env: None, + tool_env: None, github_token_refresh_managed: false, - resolver: Some(resolver), - catalog, - } - } - - #[must_use] - pub fn new_from_env(model: String, provider_id: impl Into) -> Self { - let provider_id = provider_id.into(); - let catalog = default_catalog(); - Self { - model, - provider_id, - tool_env: None, - github_token_refresh_managed: false, - resolver: None, - catalog, } } @@ -80,12 +46,6 @@ impl AgentAcpBackend { self } - #[must_use] - pub fn with_catalog(mut self, catalog: Arc) -> Self { - self.catalog = catalog; - self - } - async fn run_turn( &self, node: &Node, @@ -95,53 +55,29 @@ impl AgentAcpBackend { sandbox: &Arc, cancel_token: CancellationToken, ) -> Result { - let files_before = changed_files::detect_changed_files(sandbox).await; - let model = node.model().unwrap_or(&self.model); - let provider = routing::resolve_node_provider_context( - self.catalog.as_ref(), - &self.provider_id, - &self.model, - node, - )?; - let provider_id = provider.provider_id; - let profile_kind = provider.profile_kind; - let command = - resolve_acp_command(node.acp_command()).map_err(acp_command_error_to_workflow)?; - - let launch_env = resolve_agent_launch_env(AgentLaunchEnvRequest { - provider_id: provider_id.clone(), - cli: AgentCli::for_profile_kind(profile_kind), - catalog: self.catalog.as_ref(), - 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 process_spec = resolve_acp_process_spec(node)?; + let config_name = process_spec.name().map(str::to_string); + let launch_env = self.resolve_launch_env(emitter).await?; let on_activity = { let emitter = Arc::clone(emitter); Arc::new(move || emitter.touch()) as Arc }; - let command_display = command.to_string(); + let command_display = process_spec.to_string(); emitter.emit_scoped( &Event::AgentAcpStarted { - node_id: node.id.clone(), - visit: stage_scope.visit, - mode: "acp".to_string(), - provider: provider_id.to_string(), - model: model.to_string(), - command: command_display, + node_id: node.id.clone(), + visit: stage_scope.visit, + command: command_display, + config_name, }, stage_scope, ); + let files_before = changed_files::detect_changed_files(sandbox).await; let launch_start = std::time::Instant::now(); let result = match fabro_acp::run_acp_turn(AcpRunRequest { - command, + command: process_spec, prompt, cwd: sandbox.working_directory().to_string(), timeout_ms: node.timeout().map(crate::millis_u64), @@ -224,10 +160,33 @@ impl AgentAcpBackend { last_file_touched, }) } + + async fn resolve_launch_env( + &self, + emitter: &Arc, + ) -> Result, Error> { + let Some(provider) = &self.tool_env else { + return Ok(HashMap::new()); + }; + if self.github_token_refresh_managed { + emitter.notice( + RunNoticeLevel::Info, + RunNoticeCode::GithubTokenRefreshLimited, + "ACP agent stages receive workflow env at process launch; stages running beyond \ + token expiry may need to be retried.", + ); + } + provider + .resolve() + .await + .map_err(|err| Error::handler_with_anyhow("Failed to resolve ACP agent env", err)) + } } -fn default_catalog() -> Arc { - Arc::new(Catalog::from_builtin().expect("default catalog should build")) +impl Default for AgentAcpBackend { + fn default() -> Self { + Self::new() + } } #[async_trait] @@ -245,38 +204,46 @@ impl CodergenBackend for AgentAcpBackend { .await } - async fn one_shot(&self, request: OneShotRequest<'_>) -> Result { - 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 + async fn one_shot(&self, _request: OneShotRequest<'_>) -> Result { + Err(Error::Validation( + "backend=\"acp\" is only valid on agent nodes; prompt nodes are API-only".to_string(), + )) } } -fn acp_command_error_to_workflow(error: AcpCommandError) -> Error { +fn acp_process_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::LegacyCommandAttribute => { + Error::handler("acp_command is no longer supported; use acp.command or acp.config") + } + AcpCommandError::EmptyOverride => Error::handler("ACP process attribute must not be empty"), + AcpCommandError::MissingOverride => { + Error::handler("backend=\"acp\" requires exactly one of acp.command or acp.config") + } AcpCommandError::UnsupportedTransport => { Error::handler("only stdio ACP commands are supported") } - AcpCommandError::Parse(source) => { - Error::handler_with_source("Failed to resolve ACP command", source) + AcpCommandError::InvalidCommandString => { + Error::handler("Failed to parse acp.command as a shell command") + } + AcpCommandError::InvalidConfigJson(source) => { + Error::handler_with_source("Failed to parse acp.config as JSON", source) + } + AcpCommandError::InvalidConfigShape(message) => { + Error::handler(format!("Invalid acp.config shape: {message}")) } } } +fn resolve_acp_process_spec(node: &Node) -> Result { + AcpProcessSpec::from_attrs( + node.legacy_acp_command_attr(), + node.acp_command_attr(), + node.acp_config_attr(), + ) + .map_err(acp_process_error_to_workflow) +} + fn acp_error_to_workflow(error: AcpError) -> Error { match error { AcpError::Cancelled => Error::Cancelled, @@ -307,17 +274,14 @@ mod tests { use fabro_acp::{AcpError, AcpProcessExit}; use fabro_agent::{LocalSandbox, Sandbox, shell_quote}; use fabro_graphviz::graph::{AttrValue, Node}; - use fabro_model::ProviderId; use fabro_sandbox::test_support::MockSandbox; use fabro_types::{CommandTermination, EventBody, ExecOutputTail}; use tokio_util::sync::CancellationToken; use super::{AgentAcpBackend, acp_error_to_workflow}; use crate::context::Context; - use crate::event::{Emitter, StageScope}; - use crate::handler::agent::{ - CodergenBackend, CodergenResult, CodergenRunRequest, OneShotRequest, - }; + use crate::event::Emitter; + use crate::handler::agent::{CodergenBackend, CodergenResult, CodergenRunRequest}; #[tokio::test] async fn acp_backend_run_sends_prompt_and_returns_text() { @@ -329,28 +293,20 @@ mod tests { .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(), + "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(), ProviderId::openai()).with_env( - HashMap::from([("ACP_MODE".to_string(), "write_file".to_string())]), - ); + let backend = AgentAcpBackend::new().with_env(HashMap::from([( + "ACP_MODE".to_string(), + "write_file".to_string(), + )])); let sandbox: Arc = Arc::new(LocalSandbox::new(tempdir.path().to_path_buf())); let emitter = Arc::new(Emitter::default()); let context = Context::new(); @@ -381,63 +337,93 @@ mod tests { } #[tokio::test] - async fn acp_backend_one_shot_combines_system_prompt_and_uses_passed_sandbox() { + async fn acp_backend_accepts_acp_command_attribute_without_model_or_provider() { let tempdir = tempfile::tempdir().unwrap(); + init_git(tempdir.path()); 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()), - ); + let mut node = Node::new("work"); node.attrs .insert("backend".to_string(), AttrValue::String("acp".to_string())); node.attrs.insert( - "acp_command".to_string(), + "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(), ProviderId::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 backend = AgentAcpBackend::new().with_env(HashMap::from([( + "ACP_MODE".to_string(), + "write_file".to_string(), + )])); let sandbox: Arc = 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(), + .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(); - 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" + let CodergenResult::Text { text, .. } = result else { + panic!("expected text result"); + }; + assert_eq!(text, "hello from acp"); + } + + #[tokio::test] + async fn acp_backend_does_not_forward_provider_credentials() { + let mut sandbox = MockSandbox::linux(); + sandbox.stdio_process_error = Some("stop before ACP handshake".to_string()); + let sandbox = Arc::new(sandbox); + let sandbox_dyn: Arc = sandbox.clone(); + + 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("fake-acp-agent".to_string()), ); + + let backend = AgentAcpBackend::new(); + 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; + assert!(result.is_err()); + + let captured = sandbox + .captured_env_vars + .lock() + .expect("captured env lock poisoned") + .clone() + .unwrap_or_default(); + assert!(!captured.contains_key("OPENAI_API_KEY")); + assert!(!captured.contains_key("ANTHROPIC_API_KEY")); + assert!(!captured.contains_key("GEMINI_API_KEY")); } #[tokio::test] @@ -450,21 +436,17 @@ mod tests { let mut node = Node::new("work"); node.attrs.insert( - "provider".to_string(), - AttrValue::String("openai".to_string()), - ); - node.attrs.insert( - "acp_command".to_string(), + "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(), ProviderId::openai()).with_env( - HashMap::from([("ACP_STOP_REASON".to_string(), "cancelled".to_string())]), - ); + let backend = AgentAcpBackend::new().with_env(HashMap::from([( + "ACP_STOP_REASON".to_string(), + "cancelled".to_string(), + )])); let sandbox: Arc = Arc::new(LocalSandbox::new(tempdir.path().to_path_buf())); let emitter = Arc::new(Emitter::default()); let context = Context::new(); @@ -506,16 +488,12 @@ mod tests { }) .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)); + .insert("acp.config".to_string(), AttrValue::String(raw_command)); - let backend = AgentAcpBackend::new_from_env("fake-acp".to_string(), ProviderId::openai()); + let backend = AgentAcpBackend::new(); let sandbox: Arc = Arc::new(LocalSandbox::new(tempdir.path().to_path_buf())); let emitter = Arc::new(Emitter::default()); let events = Arc::new(Mutex::new(Vec::new())); @@ -554,20 +532,16 @@ mod tests { } #[tokio::test] - async fn acp_backend_requires_explicit_acp_command() { + async fn acp_backend_requires_explicit_process_attr() { let sandbox = MockSandbox::linux(); let sandbox = Arc::new(sandbox); let sandbox_dyn: Arc = 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(), ProviderId::openai()); + let backend = AgentAcpBackend::new(); let emitter = Arc::new(Emitter::default()); let context = Context::new(); let result = backend @@ -583,11 +557,11 @@ mod tests { }) .await; let Err(err) = result else { - panic!("ACP without acp_command should fail"); + panic!("ACP without process attr should fail"); }; assert!( err.to_string() - .contains("acp_command is required for backend=\"acp\"") + .contains("requires exactly one of acp.command or acp.config") ); assert!( sandbox @@ -595,7 +569,7 @@ mod tests { .lock() .expect("captured env lock poisoned") .is_none(), - "ACP process should not launch when acp_command is missing" + "ACP process should not launch when process attr is missing" ); } @@ -609,21 +583,17 @@ mod tests { let sandbox_dyn: Arc = 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(), + "acp.command".to_string(), AttrValue::String("fake-acp-agent".to_string()), ); - let backend = - AgentAcpBackend::new_from_env("fake-acp".to_string(), ProviderId::openai()).with_env( - HashMap::from([("OPENAI_API_KEY".to_string(), "test-key".to_string())]), - ); + let backend = AgentAcpBackend::new().with_env(HashMap::from([( + "WORKFLOW_ENV".to_string(), + "test-value".to_string(), + )])); let emitter = Arc::new(Emitter::default()); let context = Context::new(); let result = backend diff --git a/lib/crates/fabro-workflow/src/handler/llm/cli.rs b/lib/crates/fabro-workflow/src/handler/llm/cli.rs deleted file mode 100644 index 5b070b18b..000000000 --- a/lib/crates/fabro-workflow/src/handler/llm/cli.rs +++ /dev/null @@ -1,1568 +0,0 @@ -//! CLI agent stages resolve workflow tool env once when launching the external -//! CLI process. Long-running CLI stages do not observe later GitHub -//! installation token refreshes until a future credential-helper integration -//! moves token lookup inside the child process. - -use std::collections::HashMap; -use std::sync::{Arc, Mutex}; - -use async_trait::async_trait; -use fabro_agent::{Sandbox, StaticEnvProvider, ToolEnvProvider, shell_quote}; -use fabro_auth::CredentialResolver; -use fabro_graphviz::graph::Node; -use fabro_llm::types::TokenCounts; -use fabro_model::{AgentProfileKind, Catalog, ModelRef, ProviderId}; -use fabro_types::settings::run::RunModelControls; -use fabro_types::{CommandOutputStream, CommandTermination, LlmBackend}; -use fabro_util::time::elapsed_ms; -use tokio_util::sync::CancellationToken; - -/// Returns up to the last `n` characters of `s`, preserving char boundaries. -fn tail_chars(s: &str, n: usize) -> String { - let total = s.chars().count(); - if total <= n { - return s.to_string(); - } - s.chars().skip(total - n).collect() -} - -/// Build a "\nstdout: " detail string for CLI failure -/// messages, falling back to the original command when both streams are empty. -fn cli_failure_detail(stdout: &str, stderr: &str, command: &str) -> String { - let stderr_tail = tail_chars(stderr, 500); - let stdout_tail = tail_chars(stdout, 500); - match (stderr_tail.is_empty(), stdout_tail.is_empty()) { - (false, false) => format!("{stderr_tail}\nstdout: {stdout_tail}"), - (false, true) => stderr_tail, - (true, false) => format!("stdout: {stdout_tail}"), - (true, true) => format!("command: {command}"), - } -} - -use super::super::agent::{CodergenBackend, CodergenResult, CodergenRunRequest, OneShotRequest}; -use super::acp::AgentAcpBackend; -use super::api::effective_request_controls; -use super::launch_env::{AgentLaunchEnvRequest, resolve_agent_launch_env}; -use super::{changed_files, routing}; -use crate::error::Error; -use crate::event::{Emitter, Event, StageScope}; -use crate::outcome::billed_model_usage_from_llm; - -/// Maps a provider to its corresponding CLI tool metadata. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum AgentCli { - Claude, - Codex, - Gemini, -} - -impl AgentCli { - pub fn for_profile_kind(profile_kind: AgentProfileKind) -> Self { - match profile_kind { - AgentProfileKind::Anthropic => Self::Claude, - AgentProfileKind::OpenAi => Self::Codex, - AgentProfileKind::Gemini => Self::Gemini, - } - } - - pub fn name(self) -> &'static str { - match self { - Self::Claude => "claude", - Self::Codex => "codex", - Self::Gemini => "gemini", - } - } -} - -/// 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, - sandbox: &Arc, - cancel_token: &CancellationToken, -) -> Result<(), Error> { - let cli_name = cli.name(); - - let availability_check = sandbox - .exec_command( - &format!("PATH=\"$HOME/.local/bin:$PATH\" command -v {cli_name}"), - 30_000, - None, - None, - Some(cancel_token.child_token()), - ) - .await - .map_err(|e| { - Error::handler_with_source(format!("Failed to check {cli_name} availability"), e) - })?; - - if availability_check.is_success() { - return 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). -const CLI_ONLY_MODELS: &[&str] = &[]; - -/// Returns true if the given model is only available through a CLI tool. -#[must_use] -pub fn is_cli_only_model(model: &str) -> bool { - CLI_ONLY_MODELS.contains(&model) -} - -/// Build the CLI command string for a given agent profile. -/// -/// The `prompt_file` is the path to a file containing the prompt text, which -/// is piped into the command's stdin via `cat`. -#[must_use] -pub fn cli_command_for_profile_kind( - profile_kind: AgentProfileKind, - model: &str, - prompt_file: &str, -) -> String { - let prompt_file = shell_quote(prompt_file); - let cli = AgentCli::for_profile_kind(profile_kind); - let model_flag = if model.is_empty() { - String::new() - } else { - let model = shell_quote(model); - match cli { - AgentCli::Codex | AgentCli::Gemini => format!(" -m {model}"), - AgentCli::Claude => format!(" --model {model}"), - } - }; - // Use `cat | command` instead of `command < file` because the background - // launch wrapper (`setsid sh -c '...' { - format!("cat {prompt_file} | codex exec --json --full-auto{model_flag}") - } - // --yolo: auto-approve all tool calls - AgentCli::Gemini => format!("cat {prompt_file} | gemini -o json --yolo{model_flag}"), - // --dangerously-skip-permissions: bypass all permission checks (required for - // non-interactive use). CLAUDECODE= unset to allow running inside a Claude Code - // session. - AgentCli::Claude => format!( - "cat {prompt_file} | CLAUDECODE= claude -p --verbose --output-format stream-json --dangerously-skip-permissions{model_flag}" - ), - } -} - -/// Parsed response from a CLI tool invocation. -#[derive(Debug)] -pub struct CliResponse { - pub text: String, - pub input_tokens: i64, - pub output_tokens: i64, -} - -/// Parse NDJSON output from Claude CLI (`--output-format stream-json`). -/// -/// Looks for the last `{"type":"result",...}` line, extracts `result` text and -/// `usage`. -fn parse_claude_ndjson(output: &str) -> Option { - let mut last_result: Option = None; - - for line in output.lines() { - let line = line.trim(); - if line.is_empty() { - continue; - } - if let Ok(value) = serde_json::from_str::(line) { - if value.get("type").and_then(|t| t.as_str()) == Some("result") { - last_result = Some(value); - } - } - } - - let result = last_result?; - let text = result - .get("result") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - let input_tokens = result - .pointer("/usage/input_tokens") - .and_then(serde_json::Value::as_i64) - .unwrap_or(0); - let output_tokens = result - .pointer("/usage/output_tokens") - .and_then(serde_json::Value::as_i64) - .unwrap_or(0); - - Some(CliResponse { - text, - input_tokens, - output_tokens, - }) -} - -/// Parse NDJSON output from Codex CLI (`codex exec --json`). -/// -/// Codex emits NDJSON lines. Text comes from `item.completed` events where -/// `item.type == "agent_message"`. TokenCounts comes from the `turn.completed` -/// event. -fn parse_codex_ndjson(output: &str) -> Option { - let mut last_message_text = String::new(); - let mut input_tokens: i64 = 0; - let mut output_tokens: i64 = 0; - let mut found_anything = false; - - for line in output.lines() { - let line = line.trim(); - if line.is_empty() { - continue; - } - let value: serde_json::Value = match serde_json::from_str(line) { - Ok(v) => v, - Err(_) => continue, - }; - - let event_type = value.get("type").and_then(|t| t.as_str()).unwrap_or(""); - - match event_type { - "item.completed" => { - let item_type = value - .pointer("/item/type") - .and_then(|t| t.as_str()) - .unwrap_or(""); - if item_type == "agent_message" { - if let Some(text) = value.pointer("/item/text").and_then(|t| t.as_str()) { - last_message_text = text.to_string(); - found_anything = true; - } - } - } - "turn.completed" => { - input_tokens = value - .pointer("/usage/input_tokens") - .and_then(serde_json::Value::as_i64) - .unwrap_or(0); - output_tokens = value - .pointer("/usage/output_tokens") - .and_then(serde_json::Value::as_i64) - .unwrap_or(0); - found_anything = true; - } - _ => {} - } - } - - if !found_anything { - return None; - } - - Some(CliResponse { - text: last_message_text, - input_tokens, - output_tokens, - }) -} - -/// Parse JSON output from Gemini CLI (`-o json`). -/// -/// Gemini outputs a single JSON object with `response` for text and -/// `stats.models..tokens` for usage. -fn parse_gemini_json(output: &str) -> Option { - let value: serde_json::Value = serde_json::from_str(output.trim()).ok()?; - let text = value - .get("response") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - - // Extract tokens from the first model in stats.models - let (input_tokens, output_tokens) = value - .pointer("/stats/models") - .and_then(|m| m.as_object()) - .and_then(|models| models.values().next()) - .map_or((0, 0), |model_stats| { - let input = model_stats - .pointer("/tokens/input") - .and_then(serde_json::Value::as_i64) - .unwrap_or(0); - let output = model_stats - .pointer("/tokens/candidates") - .and_then(serde_json::Value::as_i64) - .unwrap_or(0); - (input, output) - }); - - Some(CliResponse { - text, - input_tokens, - output_tokens, - }) -} - -/// Parse CLI output, choosing the right parser based on profile behavior. -pub fn parse_cli_response(profile_kind: AgentProfileKind, output: &str) -> Option { - match AgentCli::for_profile_kind(profile_kind) { - AgentCli::Codex => parse_codex_ndjson(output), - AgentCli::Gemini => parse_gemini_json(output), - AgentCli::Claude => parse_claude_ndjson(output), - } -} - -/// CLI backend that invokes external CLI tools (claude, codex, gemini) via -/// `exec_command()`. -pub struct AgentCliBackend { - model: String, - provider_id: ProviderId, - tool_env: Option>, - github_token_refresh_managed: bool, - resolver: Option, - run_model_controls: RunModelControls, - catalog: Arc, -} - -impl AgentCliBackend { - #[must_use] - pub fn new( - model: String, - provider_id: impl Into, - resolver: CredentialResolver, - ) -> Self { - let provider_id = provider_id.into(); - let catalog = default_catalog(); - Self { - model, - provider_id, - tool_env: None, - github_token_refresh_managed: false, - resolver: Some(resolver), - run_model_controls: RunModelControls::default(), - catalog, - } - } - - #[must_use] - pub fn new_from_env(model: String, provider_id: impl Into) -> Self { - let provider_id = provider_id.into(); - let catalog = default_catalog(); - Self { - model, - provider_id, - tool_env: None, - github_token_refresh_managed: false, - resolver: None, - run_model_controls: RunModelControls::default(), - catalog, - } - } - - #[must_use] - pub fn with_env(mut self, env: HashMap) -> Self { - self.tool_env = Some(Arc::new(StaticEnvProvider(env))); - self - } - - #[must_use] - pub fn with_tool_env_provider( - mut self, - provider: Arc, - github_token_refresh_managed: bool, - ) -> Self { - self.tool_env = Some(provider); - self.github_token_refresh_managed = github_token_refresh_managed; - self - } - - #[must_use] - pub fn with_run_model_controls(mut self, controls: RunModelControls) -> Self { - self.run_model_controls = controls; - self - } - - #[must_use] - pub fn with_catalog(mut self, catalog: Arc) -> Self { - self.catalog = catalog; - self - } -} - -fn default_catalog() -> Arc { - Arc::new(Catalog::from_builtin().expect("default catalog should build")) -} - -#[async_trait] -impl CodergenBackend for AgentCliBackend { - async fn run(&self, request: CodergenRunRequest<'_>) -> Result { - 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 = changed_files::detect_changed_files(sandbox).await; - - // 2. Generate unique paths for this run - let run_id = uuid::Uuid::new_v4().to_string(); - let tmp_prefix = format!("/tmp/fabro_cli_{run_id}"); - let prompt_path = format!("{tmp_prefix}_prompt.txt"); - let env_path = format!("{tmp_prefix}_env.sh"); - - sandbox - .write_file(&prompt_path, prompt) - .await - .map_err(|e| Error::handler_with_source("Failed to write prompt file", e))?; - - // 3. Build CLI command - let model = node.model().unwrap_or(&self.model); - let provider = routing::resolve_node_provider_context( - self.catalog.as_ref(), - &self.provider_id, - &self.model, - node, - )?; - let provider_id = provider.provider_id; - let profile_kind = provider.profile_kind; - let controls = effective_request_controls(&self.run_model_controls, node)?; - - let cli = AgentCli::for_profile_kind(profile_kind); - verify_cli_available(cli, sandbox, &cancel_token).await?; - - let command = cli_command_for_profile_kind(profile_kind, model, &prompt_path); - let stage_scope = StageScope::for_handler(context, &node.id); - emitter.emit_scoped( - &Event::AgentCliStarted { - node_id: node.id.clone(), - visit: stage_scope.visit, - mode: "cli".to_string(), - provider: provider_id.to_string(), - model: model.to_string(), - command: command.clone(), - }, - &stage_scope, - ); - - let launch_env = resolve_agent_launch_env(AgentLaunchEnvRequest { - provider_id: provider_id.clone(), - cli, - catalog: self.catalog.as_ref(), - 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 - // `exec_command_streaming` for parity. - let mut env_lines: Vec = vec!["export PATH=\"$HOME/.local/bin:$PATH\"".to_string()]; - env_lines.extend( - launch_env - .iter() - .map(|(k, v)| format!("export {k}={}", shell_quote(v))), - ); - sandbox - .write_file(&env_path, &env_lines.join("\n")) - .await - .map_err(|e| Error::handler_with_source("Failed to write env file", e))?; - - // Disable auto-stop so the sandbox stays alive during long CLI runs. - if let Err(e) = sandbox.set_autostop_interval(0).await { - tracing::warn!("Failed to disable sandbox auto-stop: {e}"); - } - - // Stream the CLI command directly: the previous detached `setsid &` - // launcher could not be cancelled mid-flight. By running through - // `exec_command_streaming` the run-level cancel token (and node - // timeout, when set) terminate the CLI and its descendants. - let outer_command = format!(". {} && {command}", shell_quote(&env_path)); - // Use a synchronous Mutex: each callback invocation only does a short - // `extend_from_slice` with no awaits while the lock is held, so an - // async Mutex would just add per-chunk scheduling overhead. - let stdout_buffer: Arc>> = Arc::new(Mutex::new(Vec::new())); - let stderr_buffer: Arc>> = Arc::new(Mutex::new(Vec::new())); - let stdout_buf_cb = Arc::clone(&stdout_buffer); - let stderr_buf_cb = Arc::clone(&stderr_buffer); - let emitter_for_callback = Arc::clone(emitter); - let output_callback: fabro_agent::CommandOutputCallback = Arc::new(move |stream, bytes| { - let stdout_buf = Arc::clone(&stdout_buf_cb); - let stderr_buf = Arc::clone(&stderr_buf_cb); - let emitter = Arc::clone(&emitter_for_callback); - Box::pin(async move { - // Touch the stall watchdog whenever the CLI emits output - // so long-running invocations don't trip stall timeout. - emitter.touch(); - let buf = match stream { - CommandOutputStream::Stdout => stdout_buf, - CommandOutputStream::Stderr => stderr_buf, - }; - buf.lock() - .expect("CLI output buffer mutex poisoned") - .extend_from_slice(&bytes); - Ok(()) - }) - }); - let launch_env_ref = if launch_env.is_empty() { - None - } else { - Some(&launch_env) - }; - let timeout_ms = node.timeout().map(crate::millis_u64); - let invocation_token = cancel_token.child_token(); - let launch_start = std::time::Instant::now(); - let streaming_result = sandbox - .exec_command_streaming( - &outer_command, - timeout_ms, - None, - launch_env_ref, - Some(invocation_token.clone()), - output_callback, - ) - .await; - - let cleanup_temp_files = || { - let sandbox = Arc::clone(sandbox); - let cleanup_cmd = format!("rm -f {}_*", shell_quote(&tmp_prefix)); - async move { - let _ = sandbox - .exec_command(&cleanup_cmd, 30_000, None, None, None) - .await; - } - }; - - let streaming = match streaming_result { - Ok(streaming) => streaming, - Err(err) => { - cleanup_temp_files().await; - return Err(Error::handler_with_source("Failed to run CLI command", err)); - } - }; - let result = streaming.result; - // Prefer the buffered streaming output (live chunks); fall back to the - // result struct for sandboxes that bundle output at the end. - let buffered_stdout = { - let buf = stdout_buffer - .lock() - .expect("CLI stdout buffer mutex poisoned"); - String::from_utf8_lossy(&buf).into_owned() - }; - let buffered_stderr = { - let buf = stderr_buffer - .lock() - .expect("CLI stderr buffer mutex poisoned"); - String::from_utf8_lossy(&buf).into_owned() - }; - let stdout = if buffered_stdout.is_empty() { - result.stdout.clone() - } else { - buffered_stdout - }; - let stderr = if buffered_stderr.is_empty() { - result.stderr.clone() - } else { - buffered_stderr - }; - let duration_ms = elapsed_ms(launch_start); - - match result.termination { - CommandTermination::Cancelled => { - emitter.emit_scoped( - &Event::AgentCliCancelled { - node_id: node.id.clone(), - stdout: stdout.clone(), - stderr: stderr.clone(), - duration_ms, - }, - &stage_scope, - ); - cleanup_temp_files().await; - return Err(Error::Cancelled); - } - CommandTermination::TimedOut => { - emitter.emit_scoped( - &Event::AgentCliTimedOut { - node_id: node.id.clone(), - stdout: stdout.clone(), - stderr: stderr.clone(), - duration_ms, - }, - &stage_scope, - ); - cleanup_temp_files().await; - let detail = cli_failure_detail(&stdout, &stderr, &command); - return Err(Error::handler(format!( - "CLI command timed out after {duration_ms} ms: {detail}" - ))); - } - CommandTermination::Exited => { - emitter.emit_scoped( - &Event::AgentCliCompleted { - node_id: node.id.clone(), - stdout: stdout.clone(), - stderr: stderr.clone(), - exit_code: result.exit_code.unwrap_or(-1), - duration_ms, - }, - &stage_scope, - ); - } - } - - // Cleanup temp files (Exited path). - cleanup_temp_files().await; - - let exited_success = - result.termination == CommandTermination::Exited && result.exit_code == Some(0); - if !exited_success { - let detail = cli_failure_detail(&stdout, &stderr, &command); - return Err(Error::handler(format!( - "CLI command exited with code {}: {detail}", - result - .exit_code - .map_or_else(|| "".to_string(), |c| c.to_string()), - ))); - } - - // 4. Parse the CLI output - let parsed = parse_cli_response(profile_kind, &stdout) - .ok_or_else(|| Error::handler("Failed to parse CLI output".to_string()))?; - - // 5. Detect changed files - let (files_touched, last_file_touched) = - changed_files::files_touched_since(sandbox, &files_before).await; - - let stage_usage = billed_model_usage_from_llm( - self.catalog.as_ref(), - &ModelRef { - provider: provider_id, - model_id: model.to_string(), - speed: controls.speed, - }, - &TokenCounts { - input_tokens: parsed.input_tokens, - output_tokens: parsed.output_tokens, - ..TokenCounts::default() - }, - )?; - - Ok(CodergenResult::Text { - text: parsed.text, - usage: Some(stage_usage), - files_touched, - last_file_touched, - }) - } -} - -#[expect( - clippy::disallowed_methods, - reason = "CLI agent fallback credentials intentionally read provider API-key env vars." -)] -pub(crate) fn process_env_var(name: &str) -> Option { - std::env::var(name).ok() -} - -/// Routes codergen invocations to API, CLI, or ACP backends based on node -/// attributes and model type. -pub struct BackendRouter { - api: Box, - cli: AgentCliBackend, - acp: AgentAcpBackend, -} - -impl BackendRouter { - #[must_use] - pub fn new( - api_backend: Box, - cli_backend: AgentCliBackend, - acp_backend: AgentAcpBackend, - ) -> Self { - Self { - api: api_backend, - cli: cli_backend, - acp: acp_backend, - } - } - - fn select_backend(node: &Node) -> Result { - routing::select_run_backend(node) - } - - fn select_one_shot_backend(node: &Node) -> Result { - routing::select_one_shot_backend(node) - } - - #[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, request: CodergenRunRequest<'_>) -> Result { - 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, request: OneShotRequest<'_>) -> Result { - 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) { - self.api.shutdown(emitter).await; - } -} - -#[cfg(test)] -mod tests { - use std::path::Path; - - use fabro_agent::LocalSandbox; - use fabro_agent::sandbox::ExecResult; - use fabro_graphviz::graph::AttrValue; - use fabro_model::ProviderId; - - use super::*; - use crate::context::Context; - - // -- AgentCli -- - - #[test] - fn agent_cli_for_provider() { - assert_eq!( - AgentCli::for_profile_kind(AgentProfileKind::Anthropic), - AgentCli::Claude - ); - assert_eq!( - AgentCli::for_profile_kind(AgentProfileKind::OpenAi), - AgentCli::Codex - ); - assert_eq!( - AgentCli::for_profile_kind(AgentProfileKind::Gemini), - AgentCli::Gemini - ); - assert_eq!( - AgentCli::for_profile_kind(AgentProfileKind::OpenAi), - AgentCli::Codex - ); - assert_eq!( - AgentCli::for_profile_kind(AgentProfileKind::OpenAi), - AgentCli::Codex - ); - assert_eq!( - AgentCli::for_profile_kind(AgentProfileKind::OpenAi), - AgentCli::Codex - ); - assert_eq!( - AgentCli::for_profile_kind(AgentProfileKind::OpenAi), - AgentCli::Codex - ); - } - - #[test] - fn agent_cli_name() { - assert_eq!(AgentCli::Claude.name(), "claude"); - assert_eq!(AgentCli::Codex.name(), "codex"); - assert_eq!(AgentCli::Gemini.name(), "gemini"); - } - - // -- 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. - struct CliMockSandbox { - results: Mutex>, - commands: Arc>>, - } - - impl CliMockSandbox { - fn new(results: Vec, commands: Arc>>) -> Self { - Self { - results: Mutex::new(results.into()), - commands, - } - } - } - - #[async_trait] - impl Sandbox for CliMockSandbox { - async fn read_file( - &self, - _path: &str, - _offset: Option, - _limit: Option, - ) -> fabro_sandbox::Result { - Ok(String::new()) - } - async fn write_file(&self, _path: &str, _content: &str) -> fabro_sandbox::Result<()> { - Ok(()) - } - async fn delete_file(&self, _path: &str) -> fabro_sandbox::Result<()> { - Ok(()) - } - async fn file_exists(&self, _path: &str) -> fabro_sandbox::Result { - Ok(false) - } - async fn list_directory( - &self, - _path: &str, - _depth: Option, - ) -> fabro_sandbox::Result> { - Ok(vec![]) - } - async fn exec_command( - &self, - command: &str, - _timeout_ms: u64, - _working_dir: Option<&str>, - _env_vars: Option<&std::collections::HashMap>, - _cancel_token: Option, - ) -> fabro_sandbox::Result { - self.commands.lock().unwrap().push(command.to_string()); - self.results - .lock() - .unwrap() - .pop_front() - .ok_or_else(|| fabro_sandbox::Error::message("no more mock results")) - } - async fn grep( - &self, - _pattern: &str, - _path: &str, - _options: &GrepOptions, - ) -> fabro_sandbox::Result> { - Ok(vec![]) - } - async fn glob( - &self, - _pattern: &str, - _path: Option<&str>, - ) -> fabro_sandbox::Result> { - Ok(vec![]) - } - async fn download_file_to_local( - &self, - _remote: &str, - _local: &Path, - ) -> fabro_sandbox::Result<()> { - Ok(()) - } - async fn upload_file_from_local( - &self, - _local: &Path, - _remote: &str, - ) -> fabro_sandbox::Result<()> { - Ok(()) - } - async fn initialize(&self) -> fabro_sandbox::Result<()> { - Ok(()) - } - async fn cleanup(&self) -> fabro_sandbox::Result<()> { - Ok(()) - } - fn working_directory(&self) -> &str { - "/workspace" - } - fn platform(&self) -> &str { - "linux" - } - fn os_version(&self) -> String { - "Ubuntu 22.04".to_string() - } - async fn set_autostop_interval(&self, _minutes: i32) -> fabro_sandbox::Result<()> { - Ok(()) - } - } - - fn ok_result() -> ExecResult { - ExecResult { - exit_code: Some(0), - termination: CommandTermination::Exited, - stdout: String::new(), - stderr: String::new(), - duration_ms: 10, - } - } - - fn fail_result(code: i32) -> ExecResult { - fail_result_with_output(code, "", "error") - } - - fn fail_result_with_output(code: i32, stdout: &str, stderr: &str) -> ExecResult { - ExecResult { - exit_code: Some(code), - termination: CommandTermination::Exited, - stdout: stdout.to_string(), - stderr: stderr.to_string(), - duration_ms: 10, - } - } - - #[tokio::test] - async fn verify_cli_available_succeeds_when_present() { - let commands = Arc::new(Mutex::new(Vec::new())); - let sandbox: Arc = Arc::new(CliMockSandbox::new( - vec![ok_result()], - Arc::clone(&commands), - )); - 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("command -v claude")); - } - - #[tokio::test] - async fn verify_cli_available_fails_when_missing_without_installing() { - let commands = Arc::new(Mutex::new(Vec::new())); - let sandbox: Arc = Arc::new(CliMockSandbox::new( - vec![fail_result(127)], - Arc::clone(&commands), - )); - - 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(), 1); - assert!(commands[0].contains("command -v claude")); - assert!( - !commands - .iter() - .any(|command| command.contains("npm install")) - ); - } - - // -- Cycle 1: cli_command_for_profile_kind -- - - #[test] - fn cli_command_for_codex() { - let cmd = cli_command_for_profile_kind( - AgentProfileKind::OpenAi, - "gpt-5.3-codex", - "/tmp/prompt.txt", - ); - assert!(cmd.starts_with("cat /tmp/prompt.txt | codex exec --json --full-auto")); - assert!(cmd.contains("-m gpt-5.3-codex")); - } - - #[test] - fn cli_command_for_claude() { - let cmd = cli_command_for_profile_kind( - AgentProfileKind::Anthropic, - "claude-opus-4-6", - "/tmp/prompt.txt", - ); - assert!(cmd.starts_with("cat /tmp/prompt.txt |")); - assert!(cmd.contains("claude -p")); - assert!(cmd.contains("--dangerously-skip-permissions")); - assert!(cmd.contains("--output-format stream-json")); - assert!(cmd.contains("--model claude-opus-4-6")); - } - - #[test] - fn cli_command_for_gemini() { - let cmd = cli_command_for_profile_kind( - AgentProfileKind::Gemini, - "gemini-3.1-pro", - "/tmp/prompt.txt", - ); - assert!(cmd.starts_with("cat /tmp/prompt.txt | gemini -o json --yolo")); - assert!(cmd.contains("-m gemini-3.1-pro")); - } - - #[test] - fn cli_command_omits_model_when_empty() { - let cmd = cli_command_for_profile_kind(AgentProfileKind::OpenAi, "", "/tmp/prompt.txt"); - assert!(cmd.contains("codex exec --json --full-auto")); - assert!(!cmd.contains("-m ")); - let cmd = cli_command_for_profile_kind(AgentProfileKind::Anthropic, "", "/tmp/prompt.txt"); - assert!(cmd.contains("--dangerously-skip-permissions")); - assert!(!cmd.contains("--model ")); - let cmd = cli_command_for_profile_kind(AgentProfileKind::Gemini, "", "/tmp/prompt.txt"); - assert!(cmd.contains("--yolo")); - assert!(!cmd.contains("-m ")); - } - - // -- Cycle 2: is_cli_only_model -- - - #[test] - fn no_models_are_currently_cli_only() { - assert!(!is_cli_only_model("gpt-5.3-codex")); - assert!(!is_cli_only_model("claude-opus-4-6")); - assert!(!is_cli_only_model("gemini-3.1-pro-preview")); - } - - // -- Cycle 3: parse_cli_response — Claude/Gemini NDJSON -- - - #[test] - fn parse_claude_ndjson_extracts_text_and_usage() { - let output = r#"{"type":"system","message":"Claude CLI v1.0"} -{"type":"assistant","message":{"content":"thinking..."}} -{"type":"result","result":"Here is the implementation.","usage":{"input_tokens":100,"output_tokens":50}}"#; - let response = parse_cli_response(AgentProfileKind::Anthropic, output).unwrap(); - assert_eq!(response.text, "Here is the implementation."); - assert_eq!(response.input_tokens, 100); - assert_eq!(response.output_tokens, 50); - } - - #[test] - fn parse_claude_ndjson_uses_last_result() { - let output = r#"{"type":"result","result":"first","usage":{"input_tokens":10,"output_tokens":5}} -{"type":"result","result":"second","usage":{"input_tokens":20,"output_tokens":10}}"#; - let response = parse_cli_response(AgentProfileKind::Anthropic, output).unwrap(); - assert_eq!(response.text, "second"); - assert_eq!(response.input_tokens, 20); - } - - #[test] - fn parse_claude_ndjson_returns_none_for_no_result() { - let output = r#"{"type":"system","message":"hello"} -{"type":"assistant","message":{"content":"no result line"}}"#; - assert!(parse_cli_response(AgentProfileKind::Anthropic, output).is_none()); - } - - #[test] - fn parse_gemini_json_extracts_text_and_usage() { - let output = r#"{"session_id":"abc","response":"Gemini says hello","stats":{"models":{"gemini-2.5-flash":{"tokens":{"input":200,"candidates":80,"total":280}}}}}"#; - let response = parse_cli_response(AgentProfileKind::Gemini, output).unwrap(); - assert_eq!(response.text, "Gemini says hello"); - assert_eq!(response.input_tokens, 200); - assert_eq!(response.output_tokens, 80); - } - - #[test] - fn parse_gemini_json_handles_missing_stats() { - let output = r#"{"response":"hello"}"#; - let response = parse_cli_response(AgentProfileKind::Gemini, output).unwrap(); - assert_eq!(response.text, "hello"); - assert_eq!(response.input_tokens, 0); - assert_eq!(response.output_tokens, 0); - } - - #[test] - fn parse_gemini_json_returns_none_for_invalid_json() { - assert!(parse_cli_response(AgentProfileKind::Gemini, "not json").is_none()); - } - - // -- Cycle 4: parse_cli_response — Codex NDJSON -- - - #[test] - fn parse_codex_ndjson_extracts_text_and_usage() { - let output = r#"{"type":"thread.started","thread_id":"abc"} -{"type":"turn.started"} -{"type":"item.completed","item":{"id":"item_0","type":"reasoning","text":"thinking..."}} -{"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"Fixed the bug."}} -{"type":"turn.completed","usage":{"input_tokens":300,"output_tokens":150}}"#; - let response = parse_cli_response(AgentProfileKind::OpenAi, output).unwrap(); - assert_eq!(response.text, "Fixed the bug."); - assert_eq!(response.input_tokens, 300); - assert_eq!(response.output_tokens, 150); - } - - #[test] - fn parse_codex_ndjson_handles_no_message() { - let output = r#"{"type":"turn.completed","usage":{"input_tokens":10,"output_tokens":5}}"#; - let response = parse_cli_response(AgentProfileKind::OpenAi, output).unwrap(); - assert_eq!(response.text, ""); - assert_eq!(response.input_tokens, 10); - } - - #[test] - fn parse_codex_ndjson_returns_none_for_no_events() { - assert!(parse_cli_response(AgentProfileKind::OpenAi, "not json at all").is_none()); - } - - // -- Cycle 5: Node::backend() accessor (tested here since the accessor is - // simple) -- - - #[test] - fn node_backend_returns_none_by_default() { - let node = Node::new("test"); - assert_eq!(node.backend(), None); - } - - #[test] - fn node_backend_returns_cli_when_set() { - let mut node = Node::new("test"); - node.attrs - .insert("backend".to_string(), AttrValue::String("cli".to_string())); - assert_eq!(node.backend(), Some("cli")); - } - - // -- Cycle 6: backend in stylesheet (tested in stylesheet.rs) -- - - // -- Cycle 7: BackendRouter routing logic -- - - #[test] - fn router_uses_cli_for_backend_attr() { - let mut node = Node::new("test"); - node.attrs - .insert("backend".to_string(), AttrValue::String("cli".to_string())); - - assert!(BackendRouter::should_use_cli(&node)); - } - - #[test] - fn router_uses_api_by_default() { - let node = Node::new("test"); - - assert!(!BackendRouter::should_use_cli(&node)); - } - - #[test] - fn router_uses_api_for_non_cli_model() { - let mut node = Node::new("test"); - node.attrs.insert( - "model".to_string(), - 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 = 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 = 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 = 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(), ProviderId::anthropic()); - let acp_backend = AgentAcpBackend::new_from_env("model".into(), ProviderId::anthropic()); - BackendRouter::new(Box::new(StubBackend), cli_backend, acp_backend) - } - - /// Minimal stub backend for testing routing logic. - struct StubBackend; - - #[async_trait] - impl CodergenBackend for StubBackend { - async fn run(&self, _request: CodergenRunRequest<'_>) -> Result { - Ok(CodergenResult::Text { - text: "stub".to_string(), - usage: None, - files_touched: Vec::new(), - last_file_touched: None, - }) - } - - async fn one_shot(&self, _request: OneShotRequest<'_>) -> Result { - 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 - /// `CommandTermination` so we can exercise the cancel/timeout paths in - /// `AgentCliBackend::run` without spawning real processes. - struct StreamingCliMock { - commands: Arc>>, - termination: CommandTermination, - exit_code: Option, - } - - #[async_trait] - impl Sandbox for StreamingCliMock { - async fn read_file( - &self, - _path: &str, - _offset: Option, - _limit: Option, - ) -> fabro_sandbox::Result { - Ok(String::new()) - } - async fn write_file(&self, _path: &str, _content: &str) -> fabro_sandbox::Result<()> { - Ok(()) - } - async fn delete_file(&self, _path: &str) -> fabro_sandbox::Result<()> { - Ok(()) - } - async fn file_exists(&self, _path: &str) -> fabro_sandbox::Result { - Ok(false) - } - async fn list_directory( - &self, - _path: &str, - _depth: Option, - ) -> fabro_sandbox::Result> { - Ok(vec![]) - } - async fn exec_command( - &self, - command: &str, - _timeout_ms: u64, - _working_dir: Option<&str>, - _env_vars: Option<&std::collections::HashMap>, - _cancel_token: Option, - ) -> fabro_sandbox::Result { - self.commands.lock().unwrap().push(command.to_string()); - // Default: success for CLI availability checks and lightweight setup. - if command.contains("command -v ") { - return Ok(ok_result()); - } - Ok(ExecResult { - stdout: String::new(), - stderr: String::new(), - exit_code: Some(0), - termination: CommandTermination::Exited, - duration_ms: 1, - }) - } - async fn exec_command_streaming( - &self, - command: &str, - _timeout_ms: Option, - _working_dir: Option<&str>, - _env_vars: Option<&std::collections::HashMap>, - _cancel_token: Option, - _output_callback: fabro_agent::CommandOutputCallback, - ) -> fabro_sandbox::Result { - self.commands.lock().unwrap().push(command.to_string()); - Ok(fabro_sandbox::ExecStreamingResult { - result: ExecResult { - stdout: String::new(), - stderr: String::new(), - exit_code: self.exit_code, - termination: self.termination, - duration_ms: 5, - }, - streams_separated: true, - live_streaming: true, - }) - } - async fn grep( - &self, - _pattern: &str, - _path: &str, - _options: &fabro_agent::sandbox::GrepOptions, - ) -> fabro_sandbox::Result> { - Ok(vec![]) - } - async fn glob( - &self, - _pattern: &str, - _path: Option<&str>, - ) -> fabro_sandbox::Result> { - Ok(vec![]) - } - async fn download_file_to_local(&self, _: &str, _: &Path) -> fabro_sandbox::Result<()> { - Ok(()) - } - async fn upload_file_from_local(&self, _: &Path, _: &str) -> fabro_sandbox::Result<()> { - Ok(()) - } - async fn initialize(&self) -> fabro_sandbox::Result<()> { - Ok(()) - } - async fn cleanup(&self) -> fabro_sandbox::Result<()> { - Ok(()) - } - fn working_directory(&self) -> &str { - "/workspace" - } - fn platform(&self) -> &str { - "linux" - } - fn os_version(&self) -> String { - "Ubuntu 22.04".into() - } - async fn set_autostop_interval(&self, _minutes: i32) -> fabro_sandbox::Result<()> { - Ok(()) - } - } - - fn collect_events(emitter: &Arc) -> Arc>> { - let events = Arc::new(Mutex::new(Vec::new())); - let events_clone = Arc::clone(&events); - emitter.on_event(move |event| events_clone.lock().unwrap().push(event.clone())); - events - } - - #[tokio::test] - async fn agent_cli_backend_run_emits_cancelled_event_and_returns_cancelled() { - let commands = Arc::new(Mutex::new(Vec::new())); - let sandbox: Arc = Arc::new(StreamingCliMock { - commands: Arc::clone(&commands), - termination: CommandTermination::Cancelled, - exit_code: None, - }); - let backend = - AgentCliBackend::new_from_env("claude-opus-4-6".into(), ProviderId::anthropic()); - let node = Node::new("step"); - let context = Context::new(); - let emitter = Arc::new(Emitter::default()); - let events = collect_events(&emitter); - - let result = backend - .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 { - panic!("cancelled streaming should bubble Error::Cancelled"); - }; - assert!(matches!(err, Error::Cancelled)); - - let events = events.lock().unwrap(); - let names: Vec = events - .iter() - .map(|e| e.body.event_name().to_string()) - .collect(); - assert!( - names.iter().any(|n| n == "agent.cli.cancelled"), - "expected agent.cli.cancelled, got events: {names:?}" - ); - assert!( - !names.iter().any(|n| n == "agent.cli.completed"), - "should not emit agent.cli.completed on cancellation" - ); - // Cleanup `rm -f` ran. - let cmds = commands.lock().unwrap(); - assert!( - cmds.iter().any(|c| c.starts_with("rm -f /tmp/fabro_cli_")), - "expected temp cleanup, got commands: {cmds:?}" - ); - } - - #[tokio::test] - async fn agent_cli_backend_run_emits_timed_out_event_and_returns_handler_error() { - let commands = Arc::new(Mutex::new(Vec::new())); - let sandbox: Arc = Arc::new(StreamingCliMock { - commands: Arc::clone(&commands), - termination: CommandTermination::TimedOut, - exit_code: None, - }); - let backend = - AgentCliBackend::new_from_env("claude-opus-4-6".into(), ProviderId::anthropic()); - let node = Node::new("step"); - let context = Context::new(); - let emitter = Arc::new(Emitter::default()); - let events = collect_events(&emitter); - - let result = backend - .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 { - panic!("timeout streaming should produce a handler error"); - }; - assert!( - matches!(err, Error::Handler { .. }), - "expected handler error on timeout, got {err:?}" - ); - - let events = events.lock().unwrap(); - let names: Vec = events - .iter() - .map(|e| e.body.event_name().to_string()) - .collect(); - assert!( - names.iter().any(|n| n == "agent.cli.timed_out"), - "expected agent.cli.timed_out, got events: {names:?}" - ); - assert!( - !names.iter().any(|n| n == "agent.cli.completed"), - "should not emit agent.cli.completed on timeout" - ); - } -} diff --git a/lib/crates/fabro-workflow/src/handler/llm/launch_env.rs b/lib/crates/fabro-workflow/src/handler/llm/launch_env.rs deleted file mode 100644 index 955bcc61f..000000000 --- a/lib/crates/fabro-workflow/src/handler/llm/launch_env.rs +++ /dev/null @@ -1,121 +0,0 @@ -use std::collections::HashMap; -use std::sync::Arc; - -use fabro_agent::{Sandbox, ToolEnvProvider}; -use fabro_auth::{CliAgentKind, CredentialResolver, CredentialUsage, ResolvedCredential}; -use fabro_model::{Catalog, CredentialRef, ProviderId}; -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_id: ProviderId, - pub cli: AgentCli, - pub catalog: &'a Catalog, - pub resolver: Option<&'a CredentialResolver>, - pub tool_env: Option<&'a Arc>, - pub github_token_refresh_managed: bool, - pub stage_label: &'static str, - pub emitter: &'a Arc, - pub sandbox: &'a Arc, - pub cancel_token: &'a CancellationToken, -} - -pub(crate) async fn resolve_agent_launch_env( - request: AgentLaunchEnvRequest<'_>, -) -> Result, 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_id.clone(), - CredentialUsage::CliAgent(cli_agent), - request.catalog, - ) - .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(); - if let Some(auth) = request - .catalog - .provider(&request.provider_id) - .and_then(|provider| provider.auth.as_ref()) - { - for credential_ref in &auth.credentials { - let CredentialRef::Env(name) = credential_ref else { - continue; - }; - if let Some(value) = process_env_var(name) { - env.insert(name.clone(), 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) -} diff --git a/lib/crates/fabro-workflow/src/handler/llm/mod.rs b/lib/crates/fabro-workflow/src/handler/llm/mod.rs index 6a19ef8b5..d028171a3 100644 --- a/lib/crates/fabro-workflow/src/handler/llm/mod.rs +++ b/lib/crates/fabro-workflow/src/handler/llm/mod.rs @@ -2,11 +2,10 @@ 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 router; pub mod routing; pub use acp::AgentAcpBackend; pub use api::AgentApiBackend; -pub use cli::{AgentCliBackend, BackendRouter, parse_cli_response}; +pub use router::BackendRouter; diff --git a/lib/crates/fabro-workflow/src/handler/llm/router.rs b/lib/crates/fabro-workflow/src/handler/llm/router.rs new file mode 100644 index 000000000..7b9378af1 --- /dev/null +++ b/lib/crates/fabro-workflow/src/handler/llm/router.rs @@ -0,0 +1,148 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use fabro_graphviz::graph::Node; +use fabro_types::AgentBackend; + +use super::super::agent::{CodergenBackend, CodergenResult, CodergenRunRequest, OneShotRequest}; +use super::acp::AgentAcpBackend; +use super::routing; +use crate::error::Error; +use crate::event::Emitter; + +/// Routes codergen invocations to API or ACP backends based on node attributes. +pub struct BackendRouter { + api: Box, + acp: AgentAcpBackend, +} + +impl BackendRouter { + #[must_use] + pub fn new(api_backend: Box, acp_backend: AgentAcpBackend) -> Self { + Self { + api: api_backend, + acp: acp_backend, + } + } + + fn select_backend(node: &Node) -> Result { + routing::select_run_backend(node) + } + + fn select_one_shot_backend(node: &Node) -> Result { + routing::select_one_shot_backend(node) + } +} + +#[async_trait] +impl CodergenBackend for BackendRouter { + async fn run(&self, request: CodergenRunRequest<'_>) -> Result { + match Self::select_backend(request.node)? { + AgentBackend::Api => self.api.run(request).await, + AgentBackend::Acp => self.acp.run(request).await, + } + } + + async fn one_shot(&self, request: OneShotRequest<'_>) -> Result { + match Self::select_one_shot_backend(request.node)? { + AgentBackend::Api => self.api.one_shot(request).await, + AgentBackend::Acp => { + unreachable!("ACP one-shot is rejected by select_one_shot_backend") + } + } + } + + async fn shutdown(&self, emitter: &Arc) { + self.api.shutdown(emitter).await; + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use async_trait::async_trait; + use fabro_agent::{LocalSandbox, Sandbox}; + use fabro_graphviz::graph::{AttrValue, Node}; + use tokio_util::sync::CancellationToken; + + use super::*; + use crate::context::Context; + use crate::event::{Emitter, StageScope}; + + #[test] + fn router_uses_api_by_default() { + let node = Node::new("test"); + + assert_eq!( + BackendRouter::select_backend(&node).unwrap(), + AgentBackend::Api + ); + } + + #[test] + fn router_rejects_cli_backend() { + let mut node = Node::new("test"); + node.attrs + .insert("backend".to_string(), AttrValue::String("cli".to_string())); + + let err = BackendRouter::select_backend(&node).unwrap_err(); + assert_eq!( + err.to_string(), + "Validation error: unsupported agent backend \"cli\"; expected one of: api, acp" + ); + } + + #[tokio::test] + async fn router_routes_one_shot_to_api_by_default() { + let node = Node::new("test"); + let sandbox: Arc = Arc::new(LocalSandbox::new( + tempfile::tempdir().unwrap().path().to_path_buf(), + )); + let context = Context::new(); + let router = BackendRouter::new(Box::new(StubBackend), AgentAcpBackend::new()); + 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"); + } + + struct StubBackend; + + #[async_trait] + impl CodergenBackend for StubBackend { + async fn run(&self, _request: CodergenRunRequest<'_>) -> Result { + Ok(CodergenResult::Text { + text: "api run".to_string(), + usage: None, + files_touched: Vec::new(), + last_file_touched: None, + }) + } + + async fn one_shot(&self, _request: OneShotRequest<'_>) -> Result { + Ok(CodergenResult::Text { + text: "api one-shot".to_string(), + usage: None, + files_touched: Vec::new(), + last_file_touched: None, + }) + } + } +} diff --git a/lib/crates/fabro-workflow/src/handler/llm/routing.rs b/lib/crates/fabro-workflow/src/handler/llm/routing.rs index 14577d5d0..952c31939 100644 --- a/lib/crates/fabro-workflow/src/handler/llm/routing.rs +++ b/lib/crates/fabro-workflow/src/handler/llm/routing.rs @@ -1,19 +1,12 @@ use fabro_graphviz::graph::{self, Node}; use fabro_model::{AgentProfileKind, Catalog, ProviderId}; -use fabro_types::LlmBackend; +use fabro_types::AgentBackend; -use super::cli::is_cli_only_model; use crate::error::Error; -pub(crate) fn select_run_backend(node: &Node) -> Result { - match node.llm_backend() { - None => { - if node.model().is_some_and(is_cli_only_model) { - Ok(LlmBackend::Cli) - } else { - Ok(LlmBackend::Api) - } - } +pub(crate) fn select_run_backend(node: &Node) -> Result { + match node.agent_backend() { + None => Ok(AgentBackend::Api), Some(Ok(backend)) => Ok(backend), Some(Err(_)) => Err(unsupported_backend_error( node.backend().unwrap_or_default(), @@ -21,10 +14,12 @@ pub(crate) fn select_run_backend(node: &Node) -> Result { } } -pub(crate) fn select_one_shot_backend(node: &Node) -> Result { - match node.llm_backend() { - Some(Ok(LlmBackend::Acp)) => Ok(LlmBackend::Acp), - Some(Ok(LlmBackend::Api | LlmBackend::Cli)) | None => Ok(LlmBackend::Api), +pub(crate) fn select_one_shot_backend(node: &Node) -> Result { + match node.agent_backend() { + Some(Ok(AgentBackend::Acp)) => Err(Error::Validation( + "backend=\"acp\" is only valid on agent nodes; prompt nodes are API-only".to_string(), + )), + Some(Ok(AgentBackend::Api)) | None => Ok(AgentBackend::Api), Some(Err(_)) => Err(unsupported_backend_error( node.backend().unwrap_or_default(), )), @@ -37,8 +32,8 @@ pub(crate) fn node_needs_api_backend(node: &Node) -> bool { } match node.handler_type() { - Some("prompt") => !matches!(select_one_shot_backend(node), Ok(LlmBackend::Acp)), - _ => matches!(select_run_backend(node), Ok(LlmBackend::Api)), + Some("prompt") => true, + _ => matches!(select_run_backend(node), Ok(AgentBackend::Api)), } } @@ -93,7 +88,7 @@ pub(crate) fn resolve_node_provider_context( fn unsupported_backend_error(raw: &str) -> Error { Error::Validation(format!( - "unsupported LLM backend \"{raw}\"; expected one of: {}", - LlmBackend::expected_values() + "unsupported agent backend \"{raw}\"; expected one of: {}", + AgentBackend::expected_values() )) } diff --git a/lib/crates/fabro-workflow/src/operations/fork.rs b/lib/crates/fabro-workflow/src/operations/fork.rs index a42bcf5bb..6379274ed 100644 --- a/lib/crates/fabro-workflow/src/operations/fork.rs +++ b/lib/crates/fabro-workflow/src/operations/fork.rs @@ -229,9 +229,6 @@ fn replay_event_for_fork_projection(body: &EventBody) -> bool { | EventBody::InterviewTimeout(_) | EventBody::InterviewInterrupted(_) | EventBody::AgentSessionActivated(_) - | EventBody::AgentCliStarted(_) - | EventBody::AgentCliCancelled(_) - | EventBody::AgentCliTimedOut(_) | EventBody::AgentAcpStarted(_) | EventBody::AgentAcpCancelled(_) | EventBody::AgentAcpTimedOut(_) @@ -324,11 +321,9 @@ mod tests { 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(), + visit: 1, + command: "python fake_agent.py".to_string(), + config_name: Some("fake".to_string()), }) )); assert!(replay_event_for_fork_projection( diff --git a/lib/crates/fabro-workflow/src/pipeline/initialize.rs b/lib/crates/fabro-workflow/src/pipeline/initialize.rs index f1560d5dc..3097a57ea 100644 --- a/lib/crates/fabro-workflow/src/pipeline/initialize.rs +++ b/lib/crates/fabro-workflow/src/pipeline/initialize.rs @@ -5,8 +5,7 @@ use std::time::Instant; use fabro_agent::Sandbox; use fabro_auth::{ - CredentialResolver, CredentialSource, EnvCredentialSource, VaultCredentialSource, - auth_issue_message, + CredentialSource, EnvCredentialSource, VaultCredentialSource, auth_issue_message, }; use fabro_graphviz::graph; use fabro_hooks::{HookContext, HookDecision, HookEvent, HookRunner}; @@ -29,9 +28,7 @@ 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::{ - AgentAcpBackend, AgentApiBackend, AgentCliBackend, BackendRouter, routing, -}; +use crate::handler::llm::{AgentAcpBackend, AgentApiBackend, 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}; @@ -126,7 +123,6 @@ async fn build_registry( graph: &graph::Graph, llm_source: Arc, catalog: Arc, - cli_resolver: Option, ) -> Result<(Arc, bool), Error> { let no_backend_interviewer = Arc::clone(&interviewer); let build_no_backend = move || { @@ -172,24 +168,9 @@ async fn build_registry( .with_run_model_controls(model_controls.clone()) .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_id.clone()), - |resolver| AgentCliBackend::new(model.clone(), provider_id.clone(), resolver), - ) - .with_catalog(Arc::clone(&catalog_for_api)) - .with_run_model_controls(model_controls.clone()) + let acp = AgentAcpBackend::new() .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_id.clone()), - |resolver| AgentAcpBackend::new(model.clone(), provider_id.clone(), resolver), - ) - .with_catalog(Arc::clone(&catalog_for_api)) - .with_tool_env_provider(tool_env_provider.clone(), github_token_refresh_managed); - Some(Box::new(BackendRouter::new(Box::new(api), cli, acp))) + Some(Box::new(BackendRouter::new(Box::new(api), acp))) })) }; @@ -350,7 +331,6 @@ pub async fn initialize( let llm_source = build_llm_source(options.vault.clone()); let catalog = Arc::clone(&options.catalog); - let cli_resolver = options.vault.clone().map(CredentialResolver::new); let sandbox_git = Arc::new(SandboxGitRuntime::new()); let metadata_runtime = Arc::new(RunMetadataRuntime::new()); @@ -518,7 +498,6 @@ pub async fn initialize( &graph, Arc::clone(&llm_source), Arc::clone(&catalog), - cli_resolver, ) .await? }; @@ -1046,7 +1025,6 @@ mod tests { &graph, Arc::new(VaultCredentialSource::new(Arc::clone(&vault))), test_catalog(), - Some(CredentialResolver::new(vault)), ) .await .unwrap(); @@ -1065,7 +1043,7 @@ mod tests { 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 {}"]; + writer [type="agent", backend="acp", prompt="write hello", acp.command="python3 {}"]; exit [shape=Msquare]; start -> writer; writer -> exit; @@ -1085,20 +1063,12 @@ mod tests { 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(), + "acp.command".to_string(), AttrValue::String(format!( "python3 {}", fabro_sandbox::shell_quote(&script_path.to_string_lossy()) diff --git a/lib/crates/fabro-workflow/src/transforms/import.rs b/lib/crates/fabro-workflow/src/transforms/import.rs index 32a48fc9f..e5e3cccb1 100644 --- a/lib/crates/fabro-workflow/src/transforms/import.rs +++ b/lib/crates/fabro-workflow/src/transforms/import.rs @@ -598,7 +598,8 @@ impl ImportTransform { | "reasoning_effort" | "speed" | "backend" - | "acp_command" + | "acp.command" + | "acp.config" | "fidelity" | "max_retries" | "thread_id" @@ -1005,7 +1006,7 @@ mod tests { let graph = apply_import( r#"digraph Deploy { start [shape=Mdiamond] - validate [import="./validate.fabro", model="haiku", backend="acp", acp_command="python fake_agent.py", 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 }"#, @@ -1055,7 +1056,7 @@ mod tests { assert_eq!( graph.nodes["validate.test"] .attrs - .get("acp_command") + .get("acp.command") .and_then(AttrValue::as_str), Some("python fake_agent.py") ); diff --git a/lib/crates/fabro-workflow/src/transforms/stylesheet.rs b/lib/crates/fabro-workflow/src/transforms/stylesheet.rs index 2ecebe37d..b6e61f6d7 100644 --- a/lib/crates/fabro-workflow/src/transforms/stylesheet.rs +++ b/lib/crates/fabro-workflow/src/transforms/stylesheet.rs @@ -216,20 +216,20 @@ mod tests { #[test] fn apply_backend_property_via_stylesheet() { - let ss = parse_stylesheet("* { backend: cli; }").unwrap(); + let ss = parse_stylesheet("* { backend: acp; }").unwrap(); let mut graph = Graph::new("test"); graph.nodes.insert("a".into(), Node::new("a")); apply_stylesheet(&ss, &mut graph); assert_eq!( graph.nodes["a"].attrs.get("backend"), - Some(&AttrValue::String("cli".into())) + Some(&AttrValue::String("acp".into())) ); } #[test] fn backend_property_not_overridden_by_stylesheet() { - let ss = parse_stylesheet("* { backend: cli; }").unwrap(); + let ss = parse_stylesheet("* { backend: acp; }").unwrap(); let mut graph = Graph::new("test"); let mut node = Node::new("a"); node.attrs diff --git a/lib/crates/fabro-workflow/tests/it/daytona_integration.rs b/lib/crates/fabro-workflow/tests/it/daytona_integration.rs index e2c1e0ad6..8d6e20763 100644 --- a/lib/crates/fabro-workflow/tests/it/daytona_integration.rs +++ b/lib/crates/fabro-workflow/tests/it/daytona_integration.rs @@ -24,7 +24,6 @@ use std::sync::Arc; use fabro_agent::Sandbox; use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node}; -use fabro_model::ProviderId; use fabro_sandbox::daytona::{DaytonaConfig, DaytonaSandbox, DaytonaSnapshotConfig}; use fabro_static::EnvVars; use fabro_store::{ArtifactKey, ArtifactStore, Database}; @@ -991,151 +990,6 @@ async fn daytona_parallel_git_branching_e2e() { env.cleanup().await.expect("Daytona cleanup should succeed"); } -// --------------------------------------------------------------------------- -// CLI Backend on Daytona — real CLI tools via exec_command -// --------------------------------------------------------------------------- - -use fabro_workflow::handler::agent::{CodergenBackend, CodergenResult, CodergenRunRequest}; -use fabro_workflow::handler::llm::AgentCliBackend; - -/// Helper: run a real CLI backend test on Daytona. -/// -/// Installs the CLI tool in the sandbox, then runs the AgentCliBackend against -/// it. -async fn run_daytona_cli_test(provider: ProviderId, model: &str, install_command: &str) { - let creds = load_github_app_credentials(); - let config = DaytonaConfig { - snapshot: Some(DaytonaSnapshotConfig { - name: "daytona-medium".into(), - cpu: None, - memory: None, - disk: None, - dockerfile: None, - }), - ..DaytonaConfig::default() - }; - let env = DaytonaSandbox::new(config, Some(creds), None, None, None, None) - .await - .expect("Failed to create Daytona client — is DAYTONA_API_KEY set?"); - env.initialize() - .await - .expect("Daytona sandbox should initialize"); - let env: Arc = Arc::new(env); - - // Install prerequisites (bash, curl, Node 20 via nodesource) if not available - let prereq_check = env - .exec_command( - "bash --version && curl --version && node --version && npm --version", - 10_000, - None, - None, - None, - ) - .await; - if prereq_check.as_ref().map_or(true, |r| !r.is_success()) { - let prereq = env - .exec_command( - "apt-get update -qq && apt-get install -y -qq bash curl ca-certificates gnupg >/dev/null 2>&1 \ - && curl -fsSL https://deb.nodesource.com/setup_20.x | bash - >/dev/null 2>&1 \ - && apt-get install -y -qq nodejs >/dev/null 2>&1", - 180_000, - None, - None, - None, - ) - .await - .expect("prerequisite install should not error"); - assert_eq!( - prereq.exit_code, - Some(0), - "prerequisite install failed: {}", - prereq.stderr - ); - } - - // Install the CLI tool inside the Daytona sandbox - let install_result = env - .exec_command(install_command, 120_000, None, None, None) - .await - .expect("install command should not error"); - assert_eq!( - install_result.exit_code, - Some(0), - "install command failed (exit {:?}): {}", - install_result.exit_code, - install_result.stdout - ); - - let backend = AgentCliBackend::new_from_env(model.to_string(), provider.clone()); - let node = Node::new("daytona_cli_test"); - let context = Context::new(); - let emitter = Arc::new(Emitter::default()); - - let result = backend - .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 { - Ok(CodergenResult::Text { text, usage, .. }) => { - assert!( - text.contains('4'), - "{provider}/{model} on Daytona: expected '4', got: {text}" - ); - if let Some(u) = usage { - assert!( - u.tokens().input_tokens > 0, - "{provider}/{model}: input_tokens should be > 0" - ); - } - } - Ok(CodergenResult::Full(_)) => panic!("expected Text result"), - Err(e) => panic!("{provider}/{model} on Daytona failed: {e}"), - } - - env.cleanup() - .await - .expect("Daytona sandbox cleanup should succeed"); -} - -#[fabro_macros::e2e_test(live("DAYTONA_API_KEY"), live("GITHUB_APP_PRIVATE_KEY"))] -async fn daytona_cli_claude() { - run_daytona_cli_test( - ProviderId::anthropic(), - "haiku", - "curl -fsSL https://claude.ai/install.sh | bash", - ) - .await; -} - -#[fabro_macros::e2e_test(live("DAYTONA_API_KEY"), live("GITHUB_APP_PRIVATE_KEY"))] -async fn daytona_cli_codex() { - run_daytona_cli_test( - ProviderId::openai(), - "o4-mini", - "npm install -g @openai/codex", - ) - .await; -} - -#[fabro_macros::e2e_test(live("DAYTONA_API_KEY"), live("GITHUB_APP_PRIVATE_KEY"))] -async fn daytona_cli_gemini() { - run_daytona_cli_test( - ProviderId::gemini(), - "gemini-2.5-flash", - "npm install -g @google/gemini-cli", - ) - .await; -} - // --------------------------------------------------------------------------- // Daytona shadow commit E2E with sandbox-native metadata // --------------------------------------------------------------------------- diff --git a/lib/crates/fabro-workflow/tests/it/integration.rs b/lib/crates/fabro-workflow/tests/it/integration.rs index 36fff6272..6f0af825f 100644 --- a/lib/crates/fabro-workflow/tests/it/integration.rs +++ b/lib/crates/fabro-workflow/tests/it/integration.rs @@ -23,7 +23,6 @@ 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; @@ -31,10 +30,10 @@ use fabro_interview::{ Answer, AnswerValue, AutoApproveInterviewer, CallbackInterviewer, Interviewer, QueueInterviewer, RecordingInterviewer, }; +use fabro_model::Catalog; use fabro_model::catalog::{LlmCatalogSettings, ProviderCatalogSettings}; -use fabro_model::{AgentProfileKind, Catalog, ProviderId}; use fabro_store::{ArtifactKey, ArtifactStore, Database}; -use fabro_types::{CommandTermination, RunEvent, RunId, StageId, WorkflowSettings, parse_blob_ref}; +use fabro_types::{RunEvent, RunId, StageId, WorkflowSettings, parse_blob_ref}; use fabro_validate::{Severity, validate, validate_or_raise}; use fabro_workflow::context::Context; use fabro_workflow::error::{Error, FailureSignatureExt}; @@ -46,8 +45,6 @@ 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; use fabro_workflow::handler::wait::WaitHandler; @@ -86,25 +83,6 @@ fn local_env() -> Arc { )) } -fn codergen_run_request<'a>( - node: &'a Node, - prompt: &'a str, - context: &'a Context, - emitter: &'a Arc, - sandbox: &'a Arc, -) -> 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); @@ -9577,1114 +9555,6 @@ async fn node_dir_uses_visit_count_on_revisit() { ); } -// --------------------------------------------------------------------------- -// CLI Backend end-to-end tests -// --------------------------------------------------------------------------- - -/// A mock sandbox for CLI backend e2e tests. -/// Records all exec_command and write_file calls, and returns configurable -/// responses based on command content. -struct CliTestEnv { - /// All commands passed to exec_command, in order. - commands: std::sync::Mutex>, - /// All (path, content) pairs from write_file. - written_files: std::sync::Mutex>, - /// The stdout to return when the CLI command (not git) is executed. - cli_stdout: String, - /// Files returned by "git diff --name-only" AFTER the CLI runs. - /// First call returns empty (before), second returns these (after). - git_diff_call_count: std::sync::atomic::AtomicU32, - git_diff_after: String, -} - -impl CliTestEnv { - fn new(cli_stdout: &str) -> Self { - Self { - commands: std::sync::Mutex::new(Vec::new()), - written_files: std::sync::Mutex::new(Vec::new()), - cli_stdout: cli_stdout.to_string(), - git_diff_call_count: std::sync::atomic::AtomicU32::new(0), - git_diff_after: String::new(), - } - } - - fn with_git_diff_after(mut self, files: &str) -> Self { - self.git_diff_after = files.to_string(); - self - } - - fn recorded_commands(&self) -> Vec { - self.commands.lock().unwrap().clone() - } - - fn recorded_written_files(&self) -> Vec<(String, String)> { - self.written_files.lock().unwrap().clone() - } -} - -#[async_trait::async_trait] -impl fabro_agent::Sandbox for CliTestEnv { - async fn read_file( - &self, - _path: &str, - _offset: Option, - _limit: Option, - ) -> fabro_sandbox::Result { - Ok(String::new()) - } - - async fn write_file(&self, path: &str, content: &str) -> fabro_sandbox::Result<()> { - self.written_files - .lock() - .unwrap() - .push((path.to_string(), content.to_string())); - Ok(()) - } - - async fn delete_file(&self, _path: &str) -> fabro_sandbox::Result<()> { - Ok(()) - } - - async fn file_exists(&self, _path: &str) -> fabro_sandbox::Result { - Ok(false) - } - - async fn list_directory( - &self, - _path: &str, - _depth: Option, - ) -> fabro_sandbox::Result> { - Ok(vec![]) - } - - async fn exec_command( - &self, - command: &str, - _timeout_ms: u64, - _working_dir: Option<&str>, - _env_vars: Option<&std::collections::HashMap>, - _cancel_token: Option, - ) -> fabro_sandbox::Result { - self.commands.lock().unwrap().push(command.to_string()); - - // 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); - 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() - }; - return Ok(fabro_agent::ExecResult { - stdout, - stderr: String::new(), - exit_code: Some(0), - - termination: CommandTermination::Exited, - duration_ms: 5, - }); - } - - // CLI availability check. - if command.contains("command -v ") { - return Ok(fabro_agent::ExecResult { - stdout: "/usr/local/bin/agent-cli\n".into(), - stderr: String::new(), - exit_code: Some(0), - - termination: CommandTermination::Exited, - duration_ms: 1, - }); - } - - // Cleanup temp files - if command.starts_with("rm -f") { - return Ok(fabro_agent::ExecResult { - stdout: String::new(), - stderr: String::new(), - exit_code: Some(0), - - termination: CommandTermination::Exited, - duration_ms: 1, - }); - } - - // ls -t for last_file_touched - if command.starts_with("ls -t ") { - return Ok(fabro_agent::ExecResult { - stdout: String::new(), - stderr: String::new(), - exit_code: Some(0), - - termination: CommandTermination::Exited, - duration_ms: 1, - }); - } - - // Fallback: this is the streaming CLI invocation. The default trait - // implementation of `exec_command_streaming` delegates to this path - // and replays output through the streaming callback. - Ok(fabro_agent::ExecResult { - stdout: self.cli_stdout.clone(), - stderr: String::new(), - exit_code: Some(0), - - termination: CommandTermination::Exited, - duration_ms: 100, - }) - } - - async fn grep( - &self, - _pattern: &str, - _path: &str, - _options: &fabro_agent::GrepOptions, - ) -> fabro_sandbox::Result> { - Ok(vec![]) - } - - async fn glob( - &self, - _pattern: &str, - _path: Option<&str>, - ) -> fabro_sandbox::Result> { - Ok(vec![]) - } - - async fn initialize(&self) -> fabro_sandbox::Result<()> { - Ok(()) - } - - async fn cleanup(&self) -> fabro_sandbox::Result<()> { - Ok(()) - } - - async fn download_file_to_local( - &self, - _: &str, - _: &std::path::Path, - ) -> fabro_sandbox::Result<()> { - Err("not implemented".into()) - } - - async fn upload_file_from_local( - &self, - _: &std::path::Path, - _: &str, - ) -> fabro_sandbox::Result<()> { - Err("not implemented".into()) - } - - fn working_directory(&self) -> &str { - "/tmp/test" - } - - fn platform(&self) -> &str { - "darwin" - } - - fn os_version(&self) -> String { - "Darwin 24.0.0".into() - } -} - -// -- Cycle 8: AgentCliBackend::run() e2e via mock Sandbox -- - -#[tokio::test] -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 = test_env.clone(); - let backend = AgentCliBackend::new_from_env("claude-opus-4-6".into(), ProviderId::anthropic()); - - let node = Node::new("fix_code"); - let context = Context::new(); - let emitter = Arc::new(Emitter::default()); - - let result = backend - .run(codergen_run_request( - &node, - "Fix the authentication bug", - &context, - &emitter, - &env, - )) - .await - .expect("CLI backend should succeed"); - - // Verify prompt was written - let written = test_env.recorded_written_files(); - let prompt_file = written - .iter() - .find(|(path, _)| path.contains("_prompt.txt")) - .expect("should write a prompt file"); - assert!( - prompt_file.0.starts_with("/tmp/fabro_cli_") && prompt_file.0.ends_with("_prompt.txt"), - "prompt path should use UUID prefix: {}", - prompt_file.0 - ); - assert_eq!(prompt_file.1, "Fix the authentication bug"); - - // Verify the CLI command was streamed (env file is sourced, then `cat - // | claude -p ...` runs as the inner shell command). - let commands = test_env.recorded_commands(); - let cli_cmd = commands - .iter() - .find(|c| c.contains("claude") && c.contains("_prompt.txt")) - .expect("should run claude CLI command"); - assert!(cli_cmd.contains("-p"), "should use pipe mode"); - assert!( - cli_cmd.contains("claude-opus-4-6"), - "should use correct model" - ); - assert!( - cli_cmd.contains(". /tmp/fabro_cli_") && cli_cmd.contains("_env.sh"), - "should source the env file before invoking the CLI: {cli_cmd}" - ); - - // Verify parsed response - match result { - CodergenResult::Text { - text, - usage, - files_touched, - .. - } => { - assert_eq!(text, "I fixed the bug."); - let usage = usage.expect("should have usage"); - assert_eq!(usage.tokens().input_tokens, 500); - assert_eq!(usage.tokens().output_tokens, 200); - assert!(files_touched.is_empty(), "no files changed before/after"); - } - CodergenResult::Full(_) => panic!("expected Text result, got Full"), - } -} - -#[tokio::test] -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 = - 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(), ProviderId::anthropic()); - - let node = Node::new("implement"); - let context = Context::new(); - let emitter = Arc::new(Emitter::default()); - - let result = backend - .run(codergen_run_request( - &node, - "Add a new feature", - &context, - &emitter, - &env, - )) - .await - .expect("CLI backend should succeed"); - - match result { - CodergenResult::Text { files_touched, .. } => { - assert_eq!(files_touched, vec!["src/lib.rs", "src/main.rs"]); - } - CodergenResult::Full(_) => panic!("expected Text result"), - } -} - -#[tokio::test] -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 = test_env.clone(); - let backend = AgentCliBackend::new_from_env("gpt-5.3-codex".into(), ProviderId::openai()); - - let node = Node::new("implement"); - let context = Context::new(); - let emitter = Arc::new(Emitter::default()); - - let result = backend - .run(codergen_run_request( - &node, - "Build the API", - &context, - &emitter, - &env, - )) - .await - .expect("CLI backend should succeed"); - - // Verify codex command was streamed. - let commands = test_env.recorded_commands(); - let cli_cmd = commands - .iter() - .find(|c| c.contains("codex") && c.contains("_prompt.txt")) - .expect("should run codex CLI command"); - assert!(cli_cmd.contains("exec --json"), "should use exec mode"); - assert!( - cli_cmd.contains("gpt-5.3-codex"), - "should use correct model" - ); - - match result { - CodergenResult::Text { text, usage, .. } => { - assert_eq!(text, "Implemented the feature."); - let usage = usage.expect("should have usage"); - assert_eq!(usage.tokens().input_tokens, 300); - assert_eq!(usage.tokens().output_tokens, 150); - } - CodergenResult::Full(_) => panic!("expected Text result"), - } -} - -#[tokio::test] -async fn cli_backend_run_fails_on_nonzero_exit() { - let env = Arc::new(CliTestEnv::new("")); - - // Override exec_command to return non-zero for the CLI call - struct FailingCliEnv; - #[async_trait::async_trait] - impl fabro_agent::Sandbox for FailingCliEnv { - async fn read_file( - &self, - _: &str, - _: Option, - _: Option, - ) -> fabro_sandbox::Result { - Ok(String::new()) - } - async fn write_file(&self, _: &str, _: &str) -> fabro_sandbox::Result<()> { - Ok(()) - } - async fn delete_file(&self, _: &str) -> fabro_sandbox::Result<()> { - Ok(()) - } - async fn file_exists(&self, _: &str) -> fabro_sandbox::Result { - Ok(false) - } - async fn list_directory( - &self, - _: &str, - _: Option, - ) -> fabro_sandbox::Result> { - Ok(vec![]) - } - async fn exec_command( - &self, - command: &str, - _: u64, - _: Option<&str>, - _: Option<&std::collections::HashMap>, - _: Option, - ) -> fabro_sandbox::Result { - if command.starts_with("git") { - return Ok(fabro_agent::ExecResult { - stdout: String::new(), - stderr: String::new(), - exit_code: Some(0), - - termination: CommandTermination::Exited, - duration_ms: 0, - }); - } - // CLI availability check. - if command.contains("command -v ") { - return Ok(fabro_agent::ExecResult { - stdout: "/usr/local/bin/agent-cli\n".into(), - stderr: String::new(), - exit_code: Some(0), - - termination: CommandTermination::Exited, - duration_ms: 0, - }); - } - // The streaming CLI invocation: return non-zero exit with stderr. - if command.contains("claude") || command.contains("codex") { - return Ok(fabro_agent::ExecResult { - stdout: String::new(), - stderr: "command not found: claude".into(), - exit_code: Some(127), - termination: CommandTermination::Exited, - duration_ms: 0, - }); - } - Ok(fabro_agent::ExecResult { - stdout: String::new(), - stderr: String::new(), - exit_code: Some(0), - - termination: CommandTermination::Exited, - duration_ms: 0, - }) - } - async fn grep( - &self, - _: &str, - _: &str, - _: &fabro_agent::GrepOptions, - ) -> fabro_sandbox::Result> { - Ok(vec![]) - } - async fn glob(&self, _: &str, _: Option<&str>) -> fabro_sandbox::Result> { - Ok(vec![]) - } - async fn initialize(&self) -> fabro_sandbox::Result<()> { - Ok(()) - } - async fn cleanup(&self) -> fabro_sandbox::Result<()> { - Ok(()) - } - async fn download_file_to_local( - &self, - _: &str, - _: &std::path::Path, - ) -> fabro_sandbox::Result<()> { - Err("not implemented".into()) - } - async fn upload_file_from_local( - &self, - _: &std::path::Path, - _: &str, - ) -> fabro_sandbox::Result<()> { - Err("not implemented".into()) - } - fn working_directory(&self) -> &str { - "/tmp" - } - fn platform(&self) -> &str { - "darwin" - } - fn os_version(&self) -> String { - "Darwin 24.0.0".into() - } - } - - let failing_env: Arc = Arc::new(FailingCliEnv); - let backend = AgentCliBackend::new_from_env("claude-opus-4-6".into(), ProviderId::anthropic()); - let node = Node::new("step"); - let context = Context::new(); - let emitter = Arc::new(Emitter::default()); - - let _ = env; // unused, just for the above struct - - let result = backend - .run(codergen_run_request( - &node, - "do something", - &context, - &emitter, - &failing_env, - )) - .await; - - let err = match result { - Err(e) => e, - Ok(_) => panic!("should fail on non-zero exit"), - }; - - assert!( - err.to_string().contains("exited with code 127"), - "error: {err}" - ); - assert!( - err.to_string().contains("command not found"), - "error: {err}" - ); -} - -#[tokio::test] -async fn cli_backend_run_fails_on_unparseable_output() { - let env: Arc = Arc::new(CliTestEnv::new("this is not json at all")); - let backend = AgentCliBackend::new_from_env("claude-opus-4-6".into(), ProviderId::anthropic()); - - let node = Node::new("step"); - let context = Context::new(); - let emitter = Arc::new(Emitter::default()); - - let result = backend - .run(codergen_run_request( - &node, - "do something", - &context, - &emitter, - &env, - )) - .await; - - let err = match result { - Err(e) => e, - Ok(_) => panic!("should fail on unparseable output"), - }; - - assert!( - err.to_string().contains("Failed to parse CLI output"), - "error: {err}" - ); -} - -#[tokio::test] -async fn cli_backend_run_uses_node_model_override() { - let claude_output = - r#"{"type":"result","result":"ok","usage":{"input_tokens":10,"output_tokens":5}}"#; - let test_env = Arc::new(CliTestEnv::new(claude_output)); - let env: Arc = test_env.clone(); - let backend = AgentCliBackend::new_from_env("default-model".into(), ProviderId::anthropic()); - - let mut node = Node::new("step"); - node.attrs.insert( - "model".to_string(), - AttrValue::String("claude-sonnet-4-5".to_string()), - ); - - let context = Context::new(); - let emitter = Arc::new(Emitter::default()); - - backend - .run(codergen_run_request( - &node, "test", &context, &emitter, &env, - )) - .await - .expect("should succeed"); - - let commands = test_env.recorded_commands(); - let cli_cmd = commands - .iter() - .find(|c| c.contains("claude") && c.contains("_prompt.txt")) - .unwrap(); - assert!( - cli_cmd.contains("claude-sonnet-4-5"), - "should use node's model override, not default: {cli_cmd}" - ); - assert!( - !cli_cmd.contains("default-model"), - "should NOT use default model: {cli_cmd}" - ); -} - -#[tokio::test] -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 = test_env.clone(); - let backend = AgentCliBackend::new_from_env("default-model".into(), ProviderId::anthropic()); - - let mut node = Node::new("step"); - node.attrs.insert( - "provider".to_string(), - AttrValue::String("openai".to_string()), - ); - node.attrs.insert( - "model".to_string(), - AttrValue::String("gpt-5.3-codex".to_string()), - ); - - let context = Context::new(); - let emitter = Arc::new(Emitter::default()); - - backend - .run(codergen_run_request( - &node, "test", &context, &emitter, &env, - )) - .await - .expect("should succeed"); - - let commands = test_env.recorded_commands(); - let cli_cmd = commands - .iter() - .find(|c| c.contains("codex") && c.contains("_prompt.txt")) - .expect("should launch codex based on provider override"); - assert!(cli_cmd.contains("gpt-5.3-codex")); -} - -#[tokio::test] -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 = Arc::new(CliTestEnv::new(claude_output)); - let backend = AgentCliBackend::new_from_env("claude-opus-4-6".into(), ProviderId::anthropic()); - - let node = Node::new("step"); - let context = Context::new(); - let emitter = Arc::new(Emitter::default()); - - let result = backend - .run(codergen_run_request( - &node, "test", &context, &emitter, &env, - )) - .await - .expect("should succeed"); - - match result { - CodergenResult::Text { text, usage, .. } => { - assert_eq!(text, "done"); - let usage = usage.expect("CLI backend should report usage"); - assert_eq!(usage.tokens().input_tokens, 10); - assert_eq!(usage.tokens().output_tokens, 5); - assert_eq!(usage.model_id(), "claude-opus-4-6"); - } - CodergenResult::Full(_) => panic!("expected Text result"), - } -} - -// -- BackendRouter e2e: delegates to correct backend -- - -fn test_acp_backend() -> AgentAcpBackend { - AgentAcpBackend::new_from_env("claude-opus-4-6".into(), ProviderId::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 = 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(), ProviderId::anthropic()); - let router = BackendRouter::new(api_backend, cli, test_acp_backend()); - - let mut node = Node::new("cli_step"); - node.attrs - .insert("backend".to_string(), AttrValue::String("cli".to_string())); - node.attrs.insert( - "prompt".to_string(), - AttrValue::String("Fix the bug".to_string()), - ); - - let context = Context::new(); - let emitter = Arc::new(Emitter::default()); - - let result = router - .run(codergen_run_request( - &node, - "Fix the bug", - &context, - &emitter, - &env, - )) - .await - .expect("router should succeed"); - - match result { - CodergenResult::Text { text, .. } => { - assert_eq!( - text, "CLI response", - "should use CLI backend response, not mock API" - ); - } - CodergenResult::Full(_) => panic!("expected Text result"), - } -} - -#[tokio::test] -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(), ProviderId::anthropic()); - let router = BackendRouter::new(api_backend, cli, test_acp_backend()); - - let mut node = Node::new("api_step"); - node.attrs.insert( - "prompt".to_string(), - AttrValue::String("Plan the work".to_string()), - ); - - let context = Context::new(); - let emitter = Arc::new(Emitter::default()); - - let result = router - .run(codergen_run_request( - &node, - "Plan the work", - &context, - &emitter, - &env, - )) - .await - .expect("router should succeed"); - - match result { - CodergenResult::Text { text, .. } => { - assert!( - text.starts_with("Response for api_step"), - "should use API mock response: {text}" - ); - } - CodergenResult::Full(_) => panic!("expected Text result"), - } -} - -#[tokio::test] -async fn backend_router_delegates_to_cli_for_backend_attr() { - let codex_output = "{\"type\":\"item.completed\",\"item\":{\"id\":\"item_0\",\"type\":\"agent_message\",\"text\":\"Codex did it\"}}\n{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}"; - let env: Arc = Arc::new(CliTestEnv::new(codex_output)); - - let api_backend = Box::new(MockCodergenBackend); - let cli = AgentCliBackend::new_from_env("gpt-5.3-codex".into(), ProviderId::openai()); - let router = BackendRouter::new(api_backend, cli, test_acp_backend()); - - let mut node = Node::new("codex_step"); - node.attrs - .insert("backend".to_string(), AttrValue::String("cli".to_string())); - node.attrs.insert( - "provider".to_string(), - AttrValue::String("openai".to_string()), - ); - - 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, "Codex did it", - "should route to CLI backend for backend=cli" - ); - } - CodergenResult::Full(_) => panic!("expected Text result"), - } -} - -#[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 = - 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(), ProviderId::openai()); - let router = BackendRouter::new( - api_backend, - cli, - AgentAcpBackend::new_from_env("fake-acp".into(), ProviderId::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] -async fn full_pipeline_with_cli_backend_node() { - // Pipeline: start -> api_work -> cli_work -> exit - // api_work uses MockCodergenBackend (API), cli_work has backend="cli" - let claude_output = r#"{"type":"result","result":"CLI completed the task.","usage":{"input_tokens":100,"output_tokens":50}}"#; - let env: Arc = Arc::new(CliTestEnv::new(claude_output)); - - let mut graph = Graph::new("CliPipelineTest"); - - let mut start = Node::new("start"); - start.attrs.insert( - "shape".to_string(), - AttrValue::String("Mdiamond".to_string()), - ); - graph.nodes.insert("start".to_string(), start); - - let mut exit = Node::new("exit"); - exit.attrs.insert( - "shape".to_string(), - AttrValue::String("Msquare".to_string()), - ); - graph.nodes.insert("exit".to_string(), exit); - - let mut api_work = Node::new("api_work"); - api_work - .attrs - .insert("shape".to_string(), AttrValue::String("box".to_string())); - api_work.attrs.insert( - "prompt".to_string(), - AttrValue::String("Plan the work".to_string()), - ); - graph.nodes.insert("api_work".to_string(), api_work); - - let mut cli_work = Node::new("cli_work"); - cli_work - .attrs - .insert("shape".to_string(), AttrValue::String("box".to_string())); - cli_work.attrs.insert( - "prompt".to_string(), - AttrValue::String("Implement via CLI".to_string()), - ); - cli_work - .attrs - .insert("backend".to_string(), AttrValue::String("cli".to_string())); - graph.nodes.insert("cli_work".to_string(), cli_work); - - graph.edges.push(Edge::new("start", "api_work")); - graph.edges.push(Edge::new("api_work", "cli_work")); - graph.edges.push(Edge::new("cli_work", "exit")); - - // Build engine with BackendRouter - let api = MockCodergenBackend; - let cli = AgentCliBackend::new_from_env("claude-opus-4-6".into(), ProviderId::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)); - registry.register("start", Box::new(StartHandler)); - registry.register("exit", Box::new(ExitHandler)); - registry.register( - "agent", - 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(), ProviderId::anthropic()); - BackendRouter::new(Box::new(api2), cli2, test_acp_backend()) - })))), - ); - - let dir = tempfile::tempdir().unwrap(); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), env); - let run_options = RunOptions { - settings: WorkflowSettings::default(), - run_dir: dir.path().to_path_buf(), - cancel_token: CancellationToken::new(), - run_id: test_run_id("test-run"), - labels: std::collections::HashMap::new(), - workflow_slug: None, - github_app: None, - base_branch: None, - display_base_sha: None, - pre_run_git: None, - fork_source_ref: None, - git: None, - }; - let (outcome, state) = engine - .run_with_state(&graph, &run_options) - .await - .expect("pipeline should succeed"); - assert_eq!(outcome.status, StageOutcome::Succeeded); - - let api_response = state - .stage(&fabro_types::StageId::new("api_work", 1)) - .and_then(|node| node.response.as_deref()) - .unwrap(); - assert!( - api_response.starts_with("Response for api_work"), - "API node should use mock: {api_response}" - ); - - let cli_response = state - .stage(&fabro_types::StageId::new("cli_work", 1)) - .and_then(|node| node.response.as_deref()) - .unwrap(); - assert_eq!( - cli_response, "CLI completed the task.", - "CLI node should use CLI backend: {cli_response}" - ); - - let provider_json = state - .stage(&fabro_types::StageId::new("cli_work", 1)) - .unwrap() - .provider_used - .as_ref() - .unwrap() - .clone(); - assert_eq!(provider_json["mode"], "cli"); -} - -// -- Stylesheet applies backend property to nodes in a full pipeline -- - -#[tokio::test] -async fn stylesheet_backend_property_routes_to_cli() { - let claude_output = r#"{"type":"result","result":"Styled CLI response.","usage":{"input_tokens":10,"output_tokens":5}}"#; - let env: Arc = Arc::new(CliTestEnv::new(claude_output)); - - let mut graph = Graph::new("StylesheetTest"); - graph.attrs.insert( - "model_stylesheet".to_string(), - AttrValue::String(".cli-node { backend: cli; }".to_string()), - ); - - let mut start = Node::new("start"); - start.attrs.insert( - "shape".to_string(), - AttrValue::String("Mdiamond".to_string()), - ); - graph.nodes.insert("start".to_string(), start); - - let mut exit = Node::new("exit"); - exit.attrs.insert( - "shape".to_string(), - AttrValue::String("Msquare".to_string()), - ); - graph.nodes.insert("exit".to_string(), exit); - - let mut work = Node::new("work"); - work.attrs - .insert("shape".to_string(), AttrValue::String("box".to_string())); - work.attrs.insert( - "prompt".to_string(), - AttrValue::String("Do work".to_string()), - ); - work.classes.push("cli-node".to_string()); - graph.nodes.insert("work".to_string(), work); - - graph.edges.push(Edge::new("start", "work")); - graph.edges.push(Edge::new("work", "exit")); - - // Apply stylesheet - let ss = parse_stylesheet(graph.model_stylesheet()).unwrap(); - apply_stylesheet(&ss, &mut graph); - - // Verify the stylesheet applied the backend property - assert_eq!( - graph.nodes["work"].backend(), - Some("cli"), - "stylesheet should set backend=cli on .cli-node" - ); - - // Run the pipeline - let api = MockCodergenBackend; - let cli = AgentCliBackend::new_from_env("claude-opus-4-6".into(), ProviderId::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(), ProviderId::anthropic()); - let router2 = BackendRouter::new(Box::new(api2), cli2, test_acp_backend()); - registry.register( - "agent", - Box::new(AgentHandler::new(Some(Box::new(router2)))), - ); - - let dir = tempfile::tempdir().unwrap(); - let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), env); - let run_options = RunOptions { - settings: WorkflowSettings::default(), - run_dir: dir.path().to_path_buf(), - cancel_token: CancellationToken::new(), - run_id: test_run_id("test-run"), - labels: std::collections::HashMap::new(), - workflow_slug: None, - github_app: None, - base_branch: None, - display_base_sha: None, - pre_run_git: None, - fork_source_ref: None, - git: None, - }; - let (outcome, state) = engine - .run_with_state(&graph, &run_options) - .await - .expect("pipeline should succeed"); - assert_eq!(outcome.status, StageOutcome::Succeeded); - - let response = state - .stage(&fabro_types::StageId::new("work", 1)) - .and_then(|node| node.response.as_deref()) - .unwrap(); - assert_eq!( - response, "Styled CLI response.", - "stylesheet-driven node should use CLI backend" - ); -} - -/// Verify parse_cli_response works against real Claude CLI output captured from -/// stream-json. -#[test] -fn parse_real_claude_stream_json() { - // Real output captured from: claude -p --output-format stream-json --model - // haiku "What is 2+2?" - let output = r#"{"type":"system","subtype":"init","cwd":"/tmp","session_id":"abc"} -{"type":"assistant","message":{"content":[{"type":"text","text":"4"}]}} -{"type":"result","subtype":"success","is_error":false,"duration_ms":2000,"num_turns":1,"result":"4","usage":{"input_tokens":9,"output_tokens":5}}"#; - let response = parse_cli_response(AgentProfileKind::Anthropic, output).unwrap(); - assert_eq!(response.text, "4"); - assert_eq!(response.input_tokens, 9); - assert_eq!(response.output_tokens, 5); -} - -/// Verify parse_cli_response works against real Codex CLI output. -#[test] -fn parse_real_codex_ndjson() { - // Real output captured from: echo "What is 2+2?" | codex exec --json - let output = r#"{"type":"thread.started","thread_id":"019ca1ec-1e86-79b2-b2b2-b1d963f1aea2"} -{"type":"turn.started"} -{"type":"item.completed","item":{"id":"item_0","type":"reasoning","text":"**Confirming simple numeric reply**"}} -{"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"4"}} -{"type":"turn.completed","usage":{"input_tokens":7999,"cached_input_tokens":7040,"output_tokens":33}}"#; - let response = parse_cli_response(AgentProfileKind::OpenAi, output).unwrap(); - assert_eq!(response.text, "4"); - assert_eq!(response.input_tokens, 7999); - assert_eq!(response.output_tokens, 33); -} - -/// Verify parse_cli_response works against real Gemini CLI output. -#[test] -fn parse_real_gemini_json() { - // Real output captured from: gemini "What is 2+2?" -m gemini-2.5-flash - // --sandbox -o json - let output = r#"{"session_id":"abc","response":"4","stats":{"models":{"gemini-2.5-flash":{"api":{"totalRequests":1,"totalErrors":0,"totalLatencyMs":618},"tokens":{"input":123,"prompt":8911,"candidates":1,"total":8912,"cached":8788,"thoughts":0,"tool":0}}},"tools":{"totalCalls":0},"files":{"totalLinesAdded":0,"totalLinesRemoved":0}}}"#; - let response = parse_cli_response(AgentProfileKind::Gemini, output).unwrap(); - assert_eq!(response.text, "4"); - assert_eq!(response.input_tokens, 123); - assert_eq!(response.output_tokens, 1); -} - // --------------------------------------------------------------------------- // Git checkpoint e2e (Local) // ---------------------------------------------------------------------------