From 6599f7cdc0852e03f4c5592962eedd0840cd86c4 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Fri, 11 Sep 2026 16:32:52 -0600 Subject: [PATCH] Cover the pebble agent loop with workflow-level tests Each behaviour the pebble backend owes a run now has one test that drives it through the workflow engine against a scripted OpenAI-compatible model: - an agent stage under every harness profile (openai, anthropic, claude-5, gemini, kimi) writing a file with that profile's own tool spelling, with the event sequence, files touched, response, usage, and cost checked; the codex vocabulary applies a patch through the OpenAI twin's custom tool call, which `TwinToolCall::custom` now scripts - steering delivered mid-stage, an interrupt with a steer, run cancellation, and the executor-enforced stage timeout - a question answered through the interviewer, a subagent whose events carry its parent's session id, and an MCP tool served by a stdio server - failover to a second provider after a tool ran, continuing the recorded conversation without running the tool again - a failing event sink ending the stage with the sink's error - Ask Fabro resuming a stored record across turns, with the cursor moved past the run's event log when the record's own cursor fell behind - Docker and Daytona smokes running an agent stage through the provider sandboxes Co-Authored-By: Claude Fable 5.1 --- .../src/server/handler/sessions.rs | 285 ++++ .../fabro-workflow/tests/it/main.rs | 1 + .../fabro-workflow/tests/it/pebble_agent.rs | 1337 +++++++++++++++++ lib/foundation/fabro-test/src/lib.rs | 19 + 4 files changed, 1642 insertions(+) create mode 100644 lib/components/fabro-workflow/tests/it/pebble_agent.rs diff --git a/lib/apps/fabro-server/src/server/handler/sessions.rs b/lib/apps/fabro-server/src/server/handler/sessions.rs index 74b99de38..c511b9ca7 100644 --- a/lib/apps/fabro-server/src/server/handler/sessions.rs +++ b/lib/apps/fabro-server/src/server/handler/sessions.rs @@ -1960,3 +1960,288 @@ enabled = true assert!(input.ends_with("User question:\nWhy did it fail?")); } } + +/// Ask Fabro across turns and processes: a second turn resumes the stored +/// pebble record, and a record whose cursor fell behind the run's event log +/// (a crash between the two writes) is moved past the log before it answers. +#[cfg(test)] +mod resume_tests { + use std::sync::Arc; + + use axum::body::{Body, to_bytes}; + use axum::http::{Request, StatusCode}; + use fabro_config::daemon::ServerDaemon; + use fabro_config::{RunEnvironmentLayer, RunLayer, Storage}; + use fabro_static::EnvVars; + use fabro_test::{TwinScenario, TwinScenarios, twin_openai}; + use fabro_types::{RunId, SessionId}; + use tower::ServiceExt; + + use crate::server::{AppState, spawn_scheduler}; + use crate::test_support::{ + TestAppStateBuilder, build_test_router, default_test_server_settings, + llm_overlay_with_provider_base_url, + }; + + const MODEL: &str = "gpt-5.4-mini"; + const DOT: &str = r#"digraph Test { + graph [goal="Test"] + start [shape=Mdiamond] + exit [shape=Msquare] + start -> exit +}"#; + + fn api(path: &str) -> String { + format!("/api/v1{path}") + } + + async fn json_response( + app: &axum::Router, + request: Request, + expected: StatusCode, + ) -> serde_json::Value { + let response = app.clone().oneshot(request).await.unwrap(); + let status = response.status(); + let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + assert_eq!( + status, + expected, + "unexpected status, body {}", + String::from_utf8_lossy(&bytes) + ); + if bytes.is_empty() { + serde_json::Value::Null + } else { + serde_json::from_slice(&bytes).expect("response should be JSON") + } + } + + fn post_json(path: &str, body: &serde_json::Value) -> Request { + Request::builder() + .method("POST") + .uri(api(path)) + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap() + } + + /// A server whose `openai` provider is the twin under `namespace`, whose + /// runs execute in place, and whose own address Ask Fabro can resolve. + fn twin_backed_state(base_url: String, namespace: &str) -> Arc { + let api_key = namespace.to_string(); + let state = TestAppStateBuilder::new() + .runtime_settings(default_test_server_settings(), RunLayer { + environment: Some(RunEnvironmentLayer { + id: Some("local".to_string()), + ..RunEnvironmentLayer::default() + }), + ..RunLayer::default() + }) + .max_concurrent_runs(2) + // A registry factory runs the dry run in this process, so no + // worker executable is needed. + .registry_factory(|interviewer| { + fabro_workflow::handler::default_registry(interviewer, || None) + }) + .llm_overlay(llm_overlay_with_provider_base_url("openai", base_url)) + .vault_entries([(EnvVars::OPENAI_API_KEY, namespace.to_string())]) + .env_lookup(move |name| (name == EnvVars::OPENAI_API_KEY).then(|| api_key.clone())) + .build(); + let runtime_directory = Storage::new(state.server_storage_dir()).runtime_directory(); + ServerDaemon::new( + std::process::id(), + fabro_config::bind::Bind::Tcp("127.0.0.1:32277".parse().unwrap()), + runtime_directory.log_path(), + ) + .write(&runtime_directory) + .expect("test server record should be written"); + state + } + + /// A completed local dry run, so the session has a sandbox to reconnect. + async fn completed_run(app: &axum::Router) -> RunId { + let manifest = serde_json::json!({ + "version": 1, + "cwd": std::env::temp_dir().display().to_string(), + "args": { "dry_run": true }, + "target": { "path": "workflow.fabro" }, + "workflows": { "workflow.fabro": { "source": DOT, "files": {} } }, + }); + let created = json_response(app, post_json("/runs", &manifest), StatusCode::CREATED).await; + let run_id = created["id"].as_str().unwrap().to_string(); + let start = Request::builder() + .method("POST") + .uri(api(&format!("/runs/{run_id}/start"))) + .body(Body::empty()) + .unwrap(); + json_response(app, start, StatusCode::OK).await; + for _ in 0..500 { + let get = Request::builder() + .method("GET") + .uri(api(&format!("/runs/{run_id}"))) + .body(Body::empty()) + .unwrap(); + let run = json_response(app, get, StatusCode::OK).await; + match run["lifecycle"]["status"]["kind"].as_str() { + Some("succeeded") => return run_id.parse().unwrap(), + Some("failed") => panic!("the dry run failed: {run}"), + _ => tokio::time::sleep(std::time::Duration::from_millis(10)).await, + } + } + panic!("run {run_id} did not complete"); + } + + /// Submits one turn and returns the streamed session events. + async fn turn( + app: &axum::Router, + session_id: SessionId, + input: &str, + ) -> Vec { + let response = app + .clone() + .oneshot(post_json( + &format!("/sessions/{session_id}/turns"), + &serde_json::json!({ "input": input }), + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let body = String::from_utf8(bytes.to_vec()).unwrap(); + let events: Vec = body + .lines() + .filter_map(|line| line.strip_prefix("data: ")) + .map(|data| serde_json::from_str(data).unwrap()) + .collect(); + assert!( + events + .iter() + .any(|event| event["event"] == "run.session.turn.succeeded"), + "the turn should succeed: {events:#?}" + ); + events + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_resumed_session_continues_its_conversation_past_the_event_log() { + let twin = twin_openai().await; + let namespace = format!("{}::{}", module_path!(), line!()); + TwinScenarios::new(namespace.clone()) + .scenario( + TwinScenario::responses(MODEL) + .input_contains("First question") + .text("First answer"), + ) + .scenario( + TwinScenario::responses(MODEL) + .input_contains("Second question") + .text("Second answer"), + ) + .load(twin) + .await; + let state = twin_backed_state(twin.base_url.clone(), &namespace); + spawn_scheduler(Arc::clone(&state)); + let app = build_test_router(Arc::clone(&state)); + let run_id = completed_run(&app).await; + + let created = json_response( + &app, + post_json( + &format!("/runs/{run_id}/sessions"), + &serde_json::json!({ "title": "Ask Fabro", "model": MODEL }), + ), + StatusCode::CREATED, + ) + .await; + let session_id: SessionId = created["id"].as_str().unwrap().parse().unwrap(); + + turn(&app, session_id, "First question").await; + let after_first = state + .stores + .session_records + .get(session_id) + .await + .unwrap() + .expect("the first turn persists the record"); + assert!( + after_first.record.last_event_seq > 0, + "the record carries the committed event cursor" + ); + + // The crash: the run's events were written, the record's cursor was + // not. Drop the agent so the next turn resumes from the stale record + // the way a new process would. + let mut stale = after_first.record.clone(); + stale.last_event_seq = 0; + state + .stores + .session_records + .put(session_id, run_id, &stale, chrono::Utc::now()) + .await + .unwrap(); + state + .session_runtimes() + .load_or_create_runtime(session_id) + .clear_agent() + .await; + let log_head_before_resume = state + .store_ref() + .open_run_reader(&run_id) + .await + .unwrap() + .last_event_seq() + .await + .unwrap() + .expect("the run has events"); + + turn(&app, session_id, "Second question").await; + + let after_second = state + .stores + .session_records + .get(session_id) + .await + .unwrap() + .expect("the second turn persists the record"); + assert!( + after_second.record.last_event_seq > u64::from(log_head_before_resume), + "the resumed session numbers past the log head {log_head_before_resume}, got {}", + after_second.record.last_event_seq + ); + assert!( + after_second.record.last_event_seq > after_first.record.last_event_seq, + "the cursor only moves forward" + ); + assert_eq!( + after_second.record.messages.len(), + 2 * after_first.record.messages.len(), + "the record holds both turns" + ); + + // The run's title generator also calls the model; the turns are the + // streamed requests. The twin logs the user side of the input, so the + // resumed turn shows as carrying the first question ahead of the + // second. + let logs = twin.request_logs(&namespace).await; + let turns: Vec<&str> = logs["requests"] + .as_array() + .unwrap() + .iter() + .filter(|request| request["stream"] == true) + .map(|request| request["input_text"].as_str().unwrap_or_default()) + .collect(); + assert_eq!(turns.len(), 2, "one model call per turn: {logs}"); + assert!( + !turns[0].contains("Second question"), + "the first turn knows nothing of the second, got {}", + turns[0] + ); + let first_at = turns[1] + .find("User question: First question") + .expect("the resumed turn replays the first question"); + let second_at = turns[1] + .find("User question: Second question") + .expect("the resumed turn ends with the second question"); + assert!(first_at < second_at, "got {}", turns[1]); + } +} diff --git a/lib/components/fabro-workflow/tests/it/main.rs b/lib/components/fabro-workflow/tests/it/main.rs index 9a34818fb..b357ddcda 100644 --- a/lib/components/fabro-workflow/tests/it/main.rs +++ b/lib/components/fabro-workflow/tests/it/main.rs @@ -3,3 +3,4 @@ mod cp_integration; mod daytona_integration; mod git_integration; mod integration; +mod pebble_agent; diff --git a/lib/components/fabro-workflow/tests/it/pebble_agent.rs b/lib/components/fabro-workflow/tests/it/pebble_agent.rs new file mode 100644 index 000000000..f688306c5 --- /dev/null +++ b/lib/components/fabro-workflow/tests/it/pebble_agent.rs @@ -0,0 +1,1337 @@ +//! Agent stages on pebble's `CodingAgent`, driven end to end through the +//! workflow engine against a scripted OpenAI-compatible model. +//! +//! Each test covers one behaviour the pebble backend owes the run: the tool +//! vocabulary of every harness profile, steering, interrupts, cancellation, +//! the stage timeout, questions, subagents, MCP tools, model failover, and a +//! failing event sink. + +#![allow( + clippy::absolute_paths, + clippy::items_after_statements, + clippy::large_futures, + clippy::too_many_lines, + clippy::unwrap_used, + reason = "These integration tests value explicit scenarios over pedantic style lints." +)] + +use std::collections::BTreeMap; +use std::path::Path; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use fabro_auth::test_support as auth_test_support; +use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node}; +use fabro_sandbox::RunSandbox; +use fabro_types::settings::{McpServerSettings, McpTransport, ModelRef}; +use fabro_types::{ + EventBody, Principal, RunEvent, RunId, StageId, SystemActorKind, WorkflowSettings, +}; +use fabro_workflow::context::Context; +use fabro_workflow::error::Error; +use fabro_workflow::event::{Emitter, RunEventLogger, RunEventSink}; +use fabro_workflow::handler::HandlerRegistry; +use fabro_workflow::handler::agent::{AgentHandler, CodergenBackend, CodergenRunRequest}; +use fabro_workflow::handler::exit::ExitHandler; +use fabro_workflow::handler::llm::PebbleBackend; +use fabro_workflow::handler::start::StartHandler; +use fabro_workflow::model_fallback::{self, ModelFallbackPolicy}; +use fabro_workflow::outcome::{Outcome, StageOutcome}; +use fabro_workflow::run_options::RunOptions; +use fabro_workflow::steering_hub::SteeringHub; +use fabro_workflow::test_support::WorkflowRunner; +use httpmock::Method::POST; +use httpmock::MockServer; +use lithos_llm::catalog::ProviderId; +use pebble_coding_agent::events::CodingEvent; +use tokio_util::sync::CancellationToken; + +const MODEL: &str = "mock-model"; +const PROVIDER: &str = "mock"; +const CHAT_PATH: &str = "/v1/chat/completions"; +const TOOL_RESULT_MARKER: &str = r#""role":"tool""#; +const INPUT_TOKENS_PER_CALL: i64 = 11; +const OUTPUT_TOKENS_PER_CALL: i64 = 7; + +// --- Scripted model --------------------------------------------------------- + +fn chat_chunk(delta: &serde_json::Value, finish_reason: Option<&str>) -> String { + let chunk = serde_json::json!({ + "id": "chatcmpl-test", + "object": "chat.completion.chunk", + "model": MODEL, + "choices": [{ + "index": 0, + "delta": delta, + "finish_reason": finish_reason, + }] + }); + format!("data: {chunk}\n\n") +} + +fn usage_chunk() -> String { + let chunk = serde_json::json!({ + "id": "chatcmpl-test", + "object": "chat.completion.chunk", + "model": MODEL, + "choices": [], + "usage": { + "prompt_tokens": INPUT_TOKENS_PER_CALL, + "completion_tokens": OUTPUT_TOKENS_PER_CALL, + "total_tokens": INPUT_TOKENS_PER_CALL + OUTPUT_TOKENS_PER_CALL, + } + }); + format!("data: {chunk}\n\n") +} + +/// A streamed assistant answer of `text`. +fn sse_text(text: &str) -> String { + let mut body = chat_chunk(&serde_json::json!({ "role": "assistant" }), None); + body.push_str(&chat_chunk(&serde_json::json!({ "content": text }), None)); + body.push_str(&chat_chunk(&serde_json::json!({}), Some("stop"))); + body.push_str(&usage_chunk()); + body.push_str("data: [DONE]\n\n"); + body +} + +/// A streamed assistant turn calling `tool` with `arguments`. +fn sse_tool_call(tool_call_id: &str, tool: &str, arguments: &serde_json::Value) -> String { + let mut body = chat_chunk(&serde_json::json!({ "role": "assistant" }), None); + body.push_str(&chat_chunk( + &serde_json::json!({ + "tool_calls": [{ + "index": 0, + "id": tool_call_id, + "type": "function", + "function": { + "name": tool, + "arguments": arguments.to_string(), + } + }] + }), + None, + )); + body.push_str(&chat_chunk(&serde_json::json!({}), Some("tool_calls"))); + body.push_str(&usage_chunk()); + body.push_str("data: [DONE]\n\n"); + body +} + +fn sse_headers(then: httpmock::Then, body: String) -> httpmock::Then { + then.status(200) + .header("content-type", "text/event-stream") + .body(body) +} + +/// One OpenAI-compatible provider on `server`, reached at `base_path`, whose +/// models run under `profile`. Priced so a call's cost is checkable: one +/// microdollar per input token, two per output token. +fn provider_toml(name: &str, model: &str, base_url: &str, profile: &str) -> String { + format!( + r#" +[providers.{name}] +display_name = "{name}" +adapter = "openai-compatible" +codec = "openai-chat" +base_url = {base_url} +auth = {{ type = "bearer" }} +default_model = "{model}" + +[providers.{name}.metadata.agent] +profile = "{profile}" + +[providers.{name}.models.{model}] +display_name = "{model}" +api_model = "{model}" +limits = {{ context_tokens = 100000, max_output_tokens = 1024 }} +capabilities = {{ text = true, tools = true }} +pricing = {{ input_usd_micros_per_million = 1000000, output_usd_micros_per_million = 2000000 }} +"#, + base_url = toml::Value::String(base_url.to_string()), + ) +} + +fn mock_catalog(server: &MockServer, profile: &str) -> Arc { + Arc::new(fabro_llm::test_support::test_catalog_with_overlay( + &provider_toml(PROVIDER, MODEL, &server.url("/v1"), profile), + )) +} + +fn mock_credentials() -> Arc { + auth_test_support::env_credential_source(|name| { + name.ends_with("_API_KEY").then(|| "sk-test".to_string()) + }) +} + +fn mock_backend(server: &MockServer, profile: &str, hub: Arc) -> PebbleBackend { + PebbleBackend::new_with_catalog( + MODEL.to_string(), + ProviderId::new(PROVIDER), + ModelFallbackPolicy::default(), + mock_credentials(), + hub, + mock_catalog(server, profile), + ) +} + +// --- Workflow harness ------------------------------------------------------- + +/// `start -> work -> exit`, where `work` is an agent stage prompted with +/// `prompt`. +fn agent_graph(name: &str, prompt: &str) -> Graph { + let mut graph = Graph::new(name); + 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("prompt".to_string(), AttrValue::String(prompt.to_string())); + graph.nodes.insert("work".to_string(), work); + graph.edges.push(Edge::new("start", "work")); + graph.edges.push(Edge::new("work", "exit")); + graph +} + +fn run_options(run_dir: &Path, cancel_token: CancellationToken) -> RunOptions { + RunOptions { + settings: WorkflowSettings::default(), + run_dir: run_dir.to_path_buf(), + cancel_token, + run_id: RunId::new(), + 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, + } +} + +async fn local_sandbox(dir: &Path) -> Arc { + Arc::new( + fabro_sandbox::local_sandbox(dir.to_path_buf()) + .await + .expect("local sandbox should be created"), + ) +} + +fn agent_registry(backend: PebbleBackend) -> HandlerRegistry { + let mut registry = HandlerRegistry::new(Box::new(AgentHandler::new(Some(Box::new(backend))))); + registry.register("start", Box::new(StartHandler)); + registry.register("exit", Box::new(ExitHandler)); + registry +} + +/// Every run event the run emitted, in order. +type Events = Arc>>; + +fn observe(emitter: &Emitter) -> Events { + let events: Events = Arc::new(Mutex::new(Vec::new())); + let sink = Arc::clone(&events); + emitter.on_event(move |event| sink.lock().unwrap().push(event.clone())); + events +} + +fn names(events: &Events) -> Vec { + events + .lock() + .unwrap() + .iter() + .map(|event| event.event_name().to_string()) + .collect() +} + +fn position(events: &Events, name: &str) -> Option { + names(events).iter().position(|actual| actual == name) +} + +fn count(events: &Events, name: &str) -> usize { + names(events) + .iter() + .filter(|actual| *actual == name) + .count() +} + +/// Whether the event at `index` was emitted for the `work` stage. +fn work_stage_event(events: &Events, index: usize) -> bool { + events.lock().unwrap()[index].node_id.as_deref() == Some("work") +} + +fn coding_events(events: &Events) -> Vec<(RunEvent, CodingEvent)> { + events + .lock() + .unwrap() + .iter() + .filter_map(|event| match &event.body { + EventBody::Agent(props) => Some((event.clone(), props.event.event.clone())), + _ => None, + }) + .collect() +} + +/// The everything-in-one-place fixture: a scripted model, a temp working +/// directory, an observed emitter, and a steering hub. +struct Stage { + server: MockServer, + dir: tempfile::TempDir, + emitter: Arc, + events: Events, + hub: Arc, +} + +impl Stage { + async fn new() -> Self { + let server = MockServer::start_async().await; + let dir = tempfile::tempdir().unwrap(); + let emitter = Arc::new(Emitter::default()); + let events = observe(&emitter); + let hub = Arc::new(SteeringHub::new(Arc::clone(&emitter))); + Self { + server, + dir, + emitter, + events, + hub, + } + } + + fn backend(&self, profile: &str) -> PebbleBackend { + mock_backend(&self.server, profile, Arc::clone(&self.hub)) + } + + fn file(&self, name: &str) -> String { + self.dir.path().join(name).display().to_string() + } + + async fn run( + &self, + backend: PebbleBackend, + graph: &Graph, + cancel_token: CancellationToken, + ) -> Result<(Outcome, fabro_types::RunProjection), Error> { + let sandbox = local_sandbox(self.dir.path()).await; + let runner = + WorkflowRunner::new(agent_registry(backend), Arc::clone(&self.emitter), sandbox); + let options = run_options(self.dir.path(), cancel_token); + runner.run_with_state(graph, &options).await + } + + /// Runs `graph` and returns the `work` stage's response. + async fn run_ok(&self, backend: PebbleBackend, graph: &Graph) -> fabro_types::RunProjection { + let (outcome, state) = self + .run(backend, graph, CancellationToken::new()) + .await + .expect("workflow execution should complete"); + assert_eq!(outcome.status, StageOutcome::Succeeded, "{outcome:?}"); + state + } + + /// Fires `action` once, when the stage's first model call starts. + fn on_first_llm_call(&self, action: impl Fn() + Send + Sync + 'static) { + let fired = AtomicBool::new(false); + self.emitter.on_event(move |event| { + if event.event_name() == "agent.llm.started" && !fired.swap(true, Ordering::SeqCst) { + action(); + } + }); + } +} + +fn work_stage(state: &fabro_types::RunProjection) -> &fabro_types::StageProjection { + state + .stage(&StageId::new("work", 1)) + .expect("the work stage should be projected") +} + +// --- Profiles --------------------------------------------------------------- + +/// One agent stage under `profile`: the model writes a file with the profile's +/// own spelling of the write tool and answers "Done". Checks the event +/// sequence, the files the stage touched, the response, usage, and cost. +async fn write_file_under_profile(profile: &str, tool: &str, path_key: &str) { + let stage = Stage::new().await; + let path = stage.file("hello.txt"); + let arguments = serde_json::json!({ path_key: path, "content": "hello from the model" }); + stage + .server + .mock_async(|when, then| { + when.method(POST) + .path(CHAT_PATH) + .body_excludes(TOOL_RESULT_MARKER); + sse_headers(then, sse_tool_call("call-1", tool, &arguments)); + }) + .await; + stage + .server + .mock_async(|when, then| { + when.method(POST) + .path(CHAT_PATH) + .body_includes(TOOL_RESULT_MARKER); + sse_headers(then, sse_text("Done")); + }) + .await; + + let backend = stage.backend(profile); + let graph = agent_graph("Profile", "Create hello.txt"); + let state = stage.run_ok(backend, &graph).await; + + assert_eq!( + tokio::fs::read_to_string(&path).await.unwrap(), + "hello from the model", + "{profile}: the write tool should reach the sandbox" + ); + let work = work_stage(&state); + assert_eq!(work.response.as_deref(), Some("Done"), "{profile}"); + assert_eq!( + work.usage.input_tokens, + 2 * INPUT_TOKENS_PER_CALL, + "{profile}: two model calls of input" + ); + assert_eq!( + work.usage.output_tokens, + 2 * OUTPUT_TOKENS_PER_CALL, + "{profile}" + ); + assert_eq!( + work.usage.total_usd_micros, + Some(2 * (INPUT_TOKENS_PER_CALL + 2 * OUTPUT_TOKENS_PER_CALL)), + "{profile}: cost from the catalog's pricing" + ); + let checkpoint = state.current_checkpoint().expect("a checkpoint"); + let outcome = checkpoint + .node_outcomes + .get("work") + .expect("the work outcome"); + assert_eq!(outcome.files_touched, vec![path.clone()], "{profile}"); + + // The assistant message that carries the tool call comes before the + // tool runs; the answer comes after; the stage closes after the session. + let sequence = [ + "agent.session.started", + "agent.message", + "agent.tool.started", + "agent.tool.completed", + "agent.llm.started", + "agent.message", + "agent.session.ended", + "stage.completed", + ]; + let mut cursor = 0; + let all_names = names(&stage.events); + for name in sequence { + let found = all_names + .iter() + .enumerate() + .skip(cursor) + .find(|(index, actual)| { + *actual == name + && (name != "stage.completed" || work_stage_event(&stage.events, *index)) + }) + .map(|(index, _)| index); + let Some(index) = found else { + panic!("{profile}: {name} should follow position {cursor}, got {all_names:?}"); + }; + cursor = index + 1; + } + assert_eq!(count(&stage.events, "agent.message"), 2, "{profile}"); + let tool_started = coding_events(&stage.events) + .into_iter() + .find_map(|(_, event)| match event { + CodingEvent::ToolCallStarted { tool_name, .. } => Some(tool_name), + _ => None, + }) + .expect("the tool call should be reported"); + assert_eq!( + tool_started, tool, + "{profile}: the tool keeps the profile's name" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn openai_profile_writes_a_file() { + write_file_under_profile("openai", "write_file", "file_path").await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn anthropic_profile_writes_a_file() { + write_file_under_profile("anthropic", "write_file", "file_path").await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn claude_5_profile_writes_a_file() { + write_file_under_profile("claude-5", "Write", "file_path").await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn gemini_profile_writes_a_file() { + write_file_under_profile("gemini", "write_file", "file_path").await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn kimi_profile_writes_a_file() { + write_file_under_profile("kimi", "Write", "path").await; +} + +/// The codex vocabulary edits through `apply_patch`, a custom tool the chat +/// codec cannot carry, so this one runs on the OpenAI twin's responses API. +#[fabro_macros::e2e_test(twin)] +async fn codex_vocabulary_applies_a_patch() { + use fabro_test::{TwinScenario, TwinScenarios, TwinToolCall}; + + let twin = fabro_test::twin_openai().await; + let namespace = format!("{}::{}", module_path!(), line!()); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("codex.txt").display().to_string(); + TwinScenarios::new(namespace.clone()) + .scenario( + TwinScenario::responses("gpt-5.6-sol") + .tool_call(TwinToolCall::custom( + "apply_patch", + format!("*** Begin Patch\n*** Add File: {path}\n+hello codex\n*** End Patch"), + )) + .text("Done"), + ) + .load(twin) + .await; + + let base_url = twin.base_url.clone(); + let catalog = fabro_llm::build_catalog(&fabro_config::LlmLayer::default(), &move |name| { + (name == fabro_static::EnvVars::OPENAI_BASE_URL).then(|| base_url.clone()) + }) + .expect("twin catalog should build"); + let api_key = namespace.clone(); + let source = auth_test_support::env_credential_source(move |name| { + (name == fabro_static::EnvVars::OPENAI_API_KEY).then(|| api_key.clone()) + }); + let emitter = Arc::new(Emitter::default()); + let events = observe(&emitter); + let backend = PebbleBackend::new_with_catalog( + "gpt-5.6-sol".to_string(), + lithos_llm::catalog::builtin::openai(), + ModelFallbackPolicy::default(), + source, + Arc::new(SteeringHub::new(Arc::clone(&emitter))), + Arc::new(catalog), + ); + + let sandbox = local_sandbox(dir.path()).await; + let runner = WorkflowRunner::new(agent_registry(backend), emitter, sandbox); + let graph = agent_graph("Codex", "Create codex.txt"); + let (outcome, state) = runner + .run_with_state(&graph, &run_options(dir.path(), CancellationToken::new())) + .await + .expect("workflow execution should complete"); + assert_eq!(outcome.status, StageOutcome::Succeeded, "{outcome:?}"); + + let written = tokio::fs::read_to_string(&path) + .await + .unwrap_or_else(|error| { + panic!( + "codex.txt should be written ({error}); events {:?}; tool calls {:?}", + names(&events), + coding_events(&events) + .into_iter() + .filter(|(_, event)| matches!( + event, + CodingEvent::ToolCallStarted { .. } | CodingEvent::ToolCallCompleted { .. } + )) + .map(|(_, event)| event) + .collect::>(), + ) + }); + assert_eq!(written.trim_end(), "hello codex"); + let checkpoint = state.current_checkpoint().expect("a checkpoint"); + assert_eq!( + checkpoint.node_outcomes["work"].files_touched, + vec![path], + "apply_patch adds count as touched files" + ); + assert!(position(&events, "agent.tool.completed").is_some()); +} + +// --- Steering, interrupts, cancellation, timeout ----------------------------- + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_steer_delivered_mid_stage_reaches_the_model() { + let stage = Stage::new().await; + stage + .server + .mock_async(|when, then| { + when.method(POST) + .path(CHAT_PATH) + .body_excludes("mention the steer"); + sse_headers(then, sse_text("First answer")).delay(Duration::from_millis(300)); + }) + .await; + let steered = stage + .server + .mock_async(|when, then| { + when.method(POST) + .path(CHAT_PATH) + .body_includes("mention the steer"); + sse_headers(then, sse_text("Steered answer")); + }) + .await; + + let hub = Arc::clone(&stage.hub); + stage.on_first_llm_call(move || { + hub.deliver_steer("Please also mention the steer".to_string(), None); + }); + + let backend = stage.backend("openai"); + let graph = agent_graph("Steer", "Say hello"); + let state = stage.run_ok(backend, &graph).await; + + assert_eq!(steered.calls_async().await, 1, "{:?}", names(&stage.events)); + assert_eq!( + work_stage(&state).response.as_deref(), + Some("Steered answer") + ); + assert_eq!(count(&stage.events, "run.steer"), 1); + assert_eq!( + count(&stage.events, "agent.steering.injected"), + 1, + "the steer is recorded as steering, got {:?}", + names(&stage.events) + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn an_interrupt_with_a_steer_abandons_the_round() { + let stage = Stage::new().await; + stage + .server + .mock_async(|when, then| { + when.method(POST).path(CHAT_PATH).body_excludes("STOPPED"); + sse_headers(then, sse_text("Original answer")).delay(Duration::from_millis(800)); + }) + .await; + let steered = stage + .server + .mock_async(|when, then| { + when.method(POST).path(CHAT_PATH).body_includes("STOPPED"); + sse_headers(then, sse_text("Stopped as asked")); + }) + .await; + + let hub = Arc::clone(&stage.hub); + stage.on_first_llm_call(move || { + hub.interrupt_then_steer("Stop and reply STOPPED", None); + }); + + let backend = stage.backend("openai"); + let graph = agent_graph("Interrupt", "Write an essay"); + let state = stage.run_ok(backend, &graph).await; + + assert_eq!(steered.calls_async().await, 1, "{:?}", names(&stage.events)); + assert_eq!( + work_stage(&state).response.as_deref(), + Some("Stopped as asked") + ); + assert_eq!(count(&stage.events, "run.interrupt"), 1); + assert_eq!(count(&stage.events, "agent.interrupt.injected"), 1); + assert_eq!( + count(&stage.events, "agent.round.interrupted"), + 1, + "pebble announces the abandoned round once, got {:?}", + names(&stage.events) + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn cancelling_the_run_ends_the_stage_as_cancelled() { + let stage = Stage::new().await; + stage + .server + .mock_async(|when, then| { + when.method(POST).path(CHAT_PATH); + sse_headers(then, sse_text("Too late")).delay(Duration::from_secs(2)); + }) + .await; + + let cancel_token = CancellationToken::new(); + let trigger = cancel_token.clone(); + stage.on_first_llm_call(move || trigger.cancel()); + + let backend = stage.backend("openai"); + let graph = agent_graph("Cancel", "Take your time"); + let started = std::time::Instant::now(); + let result = stage.run(backend, &graph, cancel_token).await; + + let error = result.expect_err("a cancelled run fails"); + assert!(matches!(error, Error::Cancelled), "got {error:#}"); + assert!( + started.elapsed() < Duration::from_secs(2), + "cancellation should not wait for the model" + ); + let work_completed = names(&stage.events) + .iter() + .enumerate() + .any(|(index, name)| name == "stage.completed" && work_stage_event(&stage.events, index)); + assert!(!work_completed, "got {:?}", names(&stage.events)); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn the_stage_timeout_fails_a_slow_agent() { + let stage = Stage::new().await; + stage + .server + .mock_async(|when, then| { + when.method(POST).path(CHAT_PATH); + sse_headers(then, sse_text("Too late")).delay(Duration::from_secs(2)); + }) + .await; + + let mut graph = agent_graph("Timeout", "Take your time"); + let work = graph.nodes.get_mut("work").unwrap(); + work.attrs.insert( + "timeout".to_string(), + AttrValue::Duration(Duration::from_millis(300)), + ); + work.attrs + .insert("max_retries".to_string(), AttrValue::Integer(0)); + graph.edges.retain(|edge| edge.from != "work"); + let mut fail_edge = Edge::new("work", "exit"); + fail_edge.attrs.insert( + "condition".to_string(), + AttrValue::String("outcome=failed".to_string()), + ); + graph.edges.push(fail_edge); + + let backend = stage.backend("openai"); + let (_, state) = stage + .run(backend, &graph, CancellationToken::new()) + .await + .expect("the fail edge carries the run to exit"); + + let completion = work_stage(&state) + .completion + .as_ref() + .expect("the work stage completes"); + assert_eq!(completion.outcome, StageOutcome::Failed { + retry_requested: false, + }); + let failed = stage + .events + .lock() + .unwrap() + .iter() + .find(|event| { + event.event_name() == "stage.failed" && event.node_id.as_deref() == Some("work") + }) + .cloned() + .expect("the stage failure is emitted"); + assert_eq!( + failed.actor, + Some(Principal::System { + system_kind: SystemActorKind::Timeout, + }) + ); +} + +// --- Questions, subagents, MCP +// -------------------------------------------------- + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_question_is_answered_through_the_interviewer() { + let stage = Stage::new().await; + let question = serde_json::json!({ + "questions": [{ + "id": "ship", + "header": "Ship", + "question": "Ship it?", + "options": [ + { "label": "Yes", "description": "Ship now" }, + { "label": "No", "description": "Hold" } + ] + }] + }); + stage + .server + .mock_async(|when, then| { + when.method(POST) + .path(CHAT_PATH) + .body_excludes(TOOL_RESULT_MARKER); + sse_headers( + then, + sse_tool_call("call-1", "request_user_input", &question), + ); + }) + .await; + let answered = stage + .server + .mock_async(|when, then| { + when.method(POST) + .path(CHAT_PATH) + .body_includes(TOOL_RESULT_MARKER) + .body_includes("Yes"); + sse_headers(then, sse_text("Shipping")); + }) + .await; + + let backend = stage.backend("openai"); + let graph = agent_graph("Question", "Decide whether to ship"); + let state = stage.run_ok(backend, &graph).await; + + assert_eq!( + answered.calls_async().await, + 1, + "{:?}", + names(&stage.events) + ); + assert_eq!(work_stage(&state).response.as_deref(), Some("Shipping")); + assert_eq!(count(&stage.events, "interview.started"), 1); + let completed = stage + .events + .lock() + .unwrap() + .iter() + .find_map(|event| match &event.body { + EventBody::InterviewCompleted(props) => Some(props.clone()), + _ => None, + }) + .expect("the interview completes"); + assert!(completed.question.contains("Ship it?"), "got {completed:?}"); + assert!(completed.answer.contains("Yes"), "got {completed:?}"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_subagent_runs_under_its_parent_session() { + let stage = Stage::new().await; + stage + .server + .mock_async(|when, then| { + when.method(POST) + .path(CHAT_PATH) + .body_includes("Delegate the review") + .body_excludes(TOOL_RESULT_MARKER); + sse_headers( + then, + sse_tool_call( + "call-1", + "spawn_agent", + &serde_json::json!({ "task": "Inspect the module" }), + ), + ); + }) + .await; + let child = stage + .server + .mock_async(|when, then| { + when.method(POST) + .path(CHAT_PATH) + .body_includes("Inspect the module") + .body_excludes("Delegate the review"); + sse_headers(then, sse_text("Child done: 42")); + }) + .await; + // Spawning answers at once with the child's id; the parent then waits for + // every child, and the wait result carries the child's answer. + stage + .server + .mock_async(|when, then| { + when.method(POST) + .path(CHAT_PATH) + .body_includes("Delegate the review") + .body_includes(TOOL_RESULT_MARKER) + .body_excludes("Child done: 42"); + sse_headers( + then, + sse_tool_call("call-2", "wait", &serde_json::json!({})), + ); + }) + .await; + stage + .server + .mock_async(|when, then| { + when.method(POST) + .path(CHAT_PATH) + .body_includes("Delegate the review") + .body_includes("Child done: 42"); + sse_headers(then, sse_text("Parent done")); + }) + .await; + + let backend = stage.backend("openai"); + let graph = agent_graph("Subagent", "Delegate the review"); + let state = stage.run_ok(backend, &graph).await; + + assert_eq!(child.calls_async().await, 1, "{:?}", names(&stage.events)); + assert_eq!(work_stage(&state).response.as_deref(), Some("Parent done")); + assert_eq!(count(&stage.events, "agent.sub.spawned"), 1); + + let agent_events = coding_events(&stage.events); + let root_session = agent_events + .iter() + .find_map(|(event, coding)| { + matches!(coding, CodingEvent::SessionStarted { .. }) + .then(|| event.session_id.clone()) + .flatten() + }) + .expect("the root session starts"); + let child_events: Vec<&RunEvent> = agent_events + .iter() + .map(|(event, _)| event) + .filter(|event| event.parent_session_id.is_some()) + .collect(); + assert!( + !child_events.is_empty(), + "child events carry a parent session id, got {:?}", + names(&stage.events) + ); + for event in child_events { + assert_eq!( + event.parent_session_id.as_deref(), + Some(root_session.as_str()) + ); + assert_ne!(event.session_id.as_deref(), Some(root_session.as_str())); + } + let root_events = agent_events + .iter() + .filter(|(event, _)| event.session_id.as_deref() == Some(root_session.as_str())); + assert!( + root_events.clone().count() > 0 + && root_events + .into_iter() + .all(|(event, _)| event.parent_session_id.is_none()), + "root events carry no parent session id" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn an_mcp_tool_is_available_to_the_stage() { + let stage = Stage::new().await; + stage + .server + .mock_async(|when, then| { + when.method(POST) + .path(CHAT_PATH) + .body_excludes(TOOL_RESULT_MARKER); + sse_headers( + then, + sse_tool_call( + "call-1", + "mcp__echo__echo", + &serde_json::json!({ "message": "hello mcp" }), + ), + ); + }) + .await; + let echoed = stage + .server + .mock_async(|when, then| { + when.method(POST) + .path(CHAT_PATH) + .body_includes(TOOL_RESULT_MARKER) + .body_includes("hello mcp"); + sse_headers(then, sse_text("Echoed")); + }) + .await; + + let server_script = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../fabro-mcp/tests/test_mcp_server.py") + .canonicalize() + .expect("the MCP test server ships with fabro-mcp"); + let backend = stage + .backend("openai") + .with_mcp_servers(vec![McpServerSettings { + name: "echo".to_string(), + transport: McpTransport::Stdio { + command: vec!["python3".to_string(), server_script.display().to_string()], + env: std::collections::HashMap::new(), + }, + ..McpServerSettings::default() + }]); + let graph = agent_graph("Mcp", "Echo hello mcp"); + let state = stage.run_ok(backend, &graph).await; + + assert_eq!(echoed.calls_async().await, 1, "{:?}", names(&stage.events)); + assert_eq!(work_stage(&state).response.as_deref(), Some("Echoed")); + let ready = stage + .events + .lock() + .unwrap() + .iter() + .find_map(|event| match &event.body { + EventBody::AgentMcpReady(props) => Some(props.clone()), + _ => None, + }) + .expect("the MCP server reports ready"); + assert_eq!(ready.server_name, "echo"); + assert_eq!(ready.tool_count, 1); + let completed = coding_events(&stage.events) + .into_iter() + .find_map(|(_, event)| match event { + CodingEvent::ToolCallCompleted { + tool_name, output, .. + } => Some((tool_name, output)), + _ => None, + }) + .expect("the MCP tool call completes"); + assert_eq!(completed.0, "mcp__echo__echo"); + assert!( + completed.1.to_string().contains("hello mcp"), + "got {}", + completed.1 + ); +} + +// --- Failover --------------------------------------------------------------- + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn failover_continues_the_conversation_without_rerunning_tools() { + let stage = Stage::new().await; + let path = stage.file("failover.txt"); + let arguments = serde_json::json!({ "file_path": path, "content": "written once" }); + let primary_tool_call = stage + .server + .mock_async(|when, then| { + when.method(POST) + .path("/primary/v1/chat/completions") + .body_excludes(TOOL_RESULT_MARKER); + sse_headers(then, sse_tool_call("call-1", "write_file", &arguments)); + }) + .await; + let primary_failure = stage + .server + .mock_async(|when, then| { + when.method(POST).path("/primary/v1/chat/completions"); + then.status(401) + .header("content-type", "application/json") + .json_body(serde_json::json!({ + "error": { "message": "primary key revoked", "type": "invalid_request_error" } + })); + }) + .await; + let backup = stage + .server + .mock_async(|when, then| { + when.method(POST) + .path("/backup/v1/chat/completions") + .body_includes(TOOL_RESULT_MARKER) + .body_includes("write_file"); + sse_headers(then, sse_text("Recovered on backup")); + }) + .await; + + let overlay = format!( + "{}\n{}", + provider_toml( + "primary", + "primary-model", + &stage.server.url("/primary/v1"), + "openai" + ), + provider_toml( + "backup", + "backup-model", + &stage.server.url("/backup/v1"), + "openai" + ), + ); + let catalog = Arc::new(fabro_llm::test_support::test_catalog_with_overlay(&overlay)); + let primary = ProviderId::new("primary"); + let fallbacks = model_fallback::resolve_model_fallbacks( + &catalog, + &[primary.clone(), ProviderId::new("backup")], + &BTreeMap::from([("primary-model".to_string(), vec![ + "backup/backup-model".parse::().unwrap(), + ])]), + ) + .expect("the fallback chain resolves"); + assert!(fallbacks.notices.is_empty(), "{:?}", fallbacks.notices); + let backend = PebbleBackend::new_with_catalog( + "primary-model".to_string(), + primary, + fallbacks.policy, + mock_credentials(), + Arc::clone(&stage.hub), + catalog, + ); + + let graph = agent_graph("Failover", "Create failover.txt"); + let state = stage.run_ok(backend, &graph).await; + + assert_eq!( + tokio::fs::read_to_string(&path).await.unwrap(), + "written once" + ); + assert_eq!(primary_tool_call.calls_async().await, 1); + assert_eq!( + primary_failure.calls_async().await, + 1, + "an auth failure is not retried on the same route" + ); + assert_eq!( + backup.calls_async().await, + 1, + "the backup sees the tool result, got {:?}", + names(&stage.events) + ); + assert_eq!( + work_stage(&state).response.as_deref(), + Some("Recovered on backup") + ); + let failover = stage + .events + .lock() + .unwrap() + .iter() + .find_map(|event| match &event.body { + EventBody::Failover(props) => Some(props.clone()), + _ => None, + }) + .expect("the failover is emitted"); + assert_eq!(failover.from_provider, "primary"); + assert_eq!(failover.to_provider, "backup"); + assert_eq!(failover.to_model, "backup-model"); + assert!( + failover.error.contains("primary key revoked"), + "got {}", + failover.error + ); + let tool_completions = coding_events(&stage.events) + .into_iter() + .filter(|(_, event)| matches!(event, CodingEvent::ToolCallCompleted { .. })) + .count(); + assert_eq!(tool_completions, 1, "the tool ran once across both routes"); + assert_eq!( + work_stage(&state) + .provider_used + .as_ref() + .and_then(|used| used.provider.clone()), + Some("backup".to_string()) + ); +} + +// --- Durability +// --------------------------------------------------------------- + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_failing_event_sink_ends_the_stage() { + let server = MockServer::start_async().await; + server + .mock_async(|when, then| { + when.method(POST).path(CHAT_PATH); + sse_headers(then, sse_text("Never persisted")); + }) + .await; + let dir = tempfile::tempdir().unwrap(); + let emitter = Arc::new(Emitter::default()); + RunEventLogger::new(RunEventSink::callback(|_event| async { + Err(anyhow::anyhow!("disk full")) + })) + .register(&emitter); + let backend = mock_backend( + &server, + "openai", + Arc::new(SteeringHub::new(Arc::clone(&emitter))), + ); + let sandbox = local_sandbox(dir.path()).await; + let node = agent_graph("Sink", "Say hello") + .nodes + .remove("work") + .unwrap(); + let context = Context::new(); + + let result = backend + .run(CodergenRunRequest { + node: &node, + prompt: "Say hello", + context: &context, + thread_id: None, + emitter: &emitter, + sandbox: &sandbox, + tool_middleware: None, + cancel_token: CancellationToken::new(), + human_input: None, + }) + .await; + + let error = result + .err() + .expect("a stage whose events cannot persist fails"); + let rendered = format!("{:#}", anyhow::Error::new(error)); + assert!( + rendered.contains("disk full"), + "the sink failure is the cause, got {rendered}" + ); +} + +// --- Provider smokes +// ---------------------------------------------------------- + +/// One agent stage whose tools run in `sandbox`: the model writes a file +/// there and reads it back through the shell, so both the filesystem and the +/// exec facets are exercised through pebble's `Environment`. +async fn agent_stage_smoke(sandbox: Arc, label: &str) { + let server = MockServer::start_async().await; + let path = format!("{}/smoke.txt", sandbox.working_directory()); + let arguments = serde_json::json!({ "file_path": path, "content": "hello from the model" }); + server + .mock_async(|when, then| { + when.method(POST) + .path(CHAT_PATH) + .body_excludes(TOOL_RESULT_MARKER); + sse_headers(then, sse_tool_call("call-1", "write_file", &arguments)); + }) + .await; + server + .mock_async(|when, then| { + when.method(POST) + .path(CHAT_PATH) + .body_includes(TOOL_RESULT_MARKER) + .body_excludes("hello from the model\\n"); + sse_headers( + then, + sse_tool_call( + "call-2", + "shell", + &serde_json::json!({ "command": format!("cat {path}") }), + ), + ); + }) + .await; + let finished = server + .mock_async(|when, then| { + when.method(POST) + .path(CHAT_PATH) + .body_includes("hello from the model\\n"); + sse_headers(then, sse_text("Done")); + }) + .await; + + let emitter = Arc::new(Emitter::default()); + let events = observe(&emitter); + let backend = mock_backend( + &server, + "openai", + Arc::new(SteeringHub::new(Arc::clone(&emitter))), + ); + let run_dir = tempfile::tempdir().unwrap(); + let runner = WorkflowRunner::new(agent_registry(backend), emitter, Arc::clone(&sandbox)); + let graph = agent_graph("Smoke", "Create and read smoke.txt"); + let (outcome, state) = runner + .run_with_state( + &graph, + &run_options(run_dir.path(), CancellationToken::new()), + ) + .await + .expect("workflow execution should complete"); + assert_eq!( + outcome.status, + StageOutcome::Succeeded, + "{label}: {outcome:?}" + ); + + assert_eq!( + finished.calls_async().await, + 1, + "{label}: {:?}", + names(&events) + ); + assert_eq!( + sandbox.read_file_text(&path).await.unwrap(), + "hello from the model", + "{label}: the file lives in the sandbox" + ); + assert_eq!( + work_stage(&state).response.as_deref(), + Some("Done"), + "{label}" + ); + let checkpoint = state.current_checkpoint().expect("a checkpoint"); + assert_eq!( + checkpoint.node_outcomes["work"].files_touched, + vec![path], + "{label}" + ); + assert_eq!(count(&events, "agent.tool.completed"), 2, "{label}"); +} + +/// Requires Docker with the default sandbox image available locally. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires a Docker daemon"] +async fn docker_sandbox_runs_an_agent_stage() { + let sandbox: Arc = Arc::new( + fabro_sandbox::provider_sandbox( + fabro_sandbox::SandboxProviderKind::DOCKER, + &fabro_sandbox::ProviderAccess::default(), + fabro_sandbox::SandboxOptions { + skip_clone: true, + ..Default::default() + }, + None, + None, + None, + None, + None, + None, + ) + .await + .expect("Docker not available"), + ); + sandbox.initialize().await.expect("Docker init failed"); + + agent_stage_smoke(Arc::clone(&sandbox), "docker").await; + + sandbox.cleanup().await.expect("Docker cleanup failed"); +} + +#[fabro_macros::e2e_test(live("DAYTONA_API_KEY"))] +#[expect( + clippy::disallowed_methods, + reason = "The live Daytona smoke reads its credentials from the process environment." +)] +async fn daytona_sandbox_runs_an_agent_stage() { + use fabro_static::EnvVars; + + let access = fabro_sandbox::ProviderAccess { + daytona: Some(fabro_sandbox::DaytonaCredentials { + api_key: std::env::var(EnvVars::DAYTONA_API_KEY) + .expect("DAYTONA_API_KEY must be set"), + api_url: std::env::var(EnvVars::DAYTONA_API_URL) + .or_else(|_| std::env::var(EnvVars::DAYTONA_SERVER_URL)) + .ok(), + organization_id: std::env::var(EnvVars::DAYTONA_ORGANIZATION_ID).ok(), + target: None, + http_client: None, + }), + ..fabro_sandbox::ProviderAccess::default() + }; + let sandbox: Arc = Arc::new( + fabro_sandbox::provider_sandbox( + fabro_sandbox::SandboxProviderKind::DAYTONA, + &access, + fabro_sandbox::SandboxOptions { + skip_clone: true, + ..Default::default() + }, + None, + None, + None, + None, + None, + None, + ) + .await + .expect("Failed to create Daytona client"), + ); + sandbox.initialize().await.expect("Daytona init failed"); + + agent_stage_smoke(Arc::clone(&sandbox), "daytona").await; + + sandbox.cleanup().await.expect("Daytona cleanup failed"); +} diff --git a/lib/foundation/fabro-test/src/lib.rs b/lib/foundation/fabro-test/src/lib.rs index 62dd1a89c..74bf304a2 100644 --- a/lib/foundation/fabro-test/src/lib.rs +++ b/lib/foundation/fabro-test/src/lib.rs @@ -2340,6 +2340,7 @@ pub struct TwinToolCall { name: String, arguments: Value, raw_arguments: Option, + custom: bool, } impl TwinToolCall { @@ -2349,6 +2350,7 @@ impl TwinToolCall { name: name.into(), arguments, raw_arguments: None, + custom: false, } } @@ -2362,6 +2364,7 @@ impl TwinToolCall { name: name.into(), arguments, raw_arguments: Some(raw_arguments.into()), + custom: false, } } @@ -2417,6 +2420,19 @@ impl TwinToolCall { Self::new_raw_arguments("apply_patch", Value::Null, patch.into()) } + /// A free-form `custom_tool_call` on the Responses API, carrying `input` + /// as text rather than JSON arguments. This is how the codex harness's + /// `apply_patch` reaches the model. + #[must_use] + pub fn custom(name: impl Into, input: impl Into) -> Self { + Self { + name: name.into(), + arguments: Value::String(input.into()), + raw_arguments: None, + custom: true, + } + } + fn into_json(self) -> Value { let mut value = json!({ "name": self.name, @@ -2425,6 +2441,9 @@ impl TwinToolCall { if let Some(raw_arguments) = self.raw_arguments { value["raw_arguments"] = Value::String(raw_arguments); } + if self.custom { + value["kind"] = Value::String("custom".to_string()); + } value } }