fabro/lib/apps/fabro-server/tests/it/scenario/lifecycle.rs
Bryan Helmkamp c7ad387d3e
feat(agent): add gpt56 profile for GPT-5.6 Sol, Terra, and Luna
Codex drives the GPT-5.6 models with a much narrower tool set than the
other OpenAI models: a shell, `apply_patch`, and `update_plan`. It has no
file-read, file-write, grep, glob, or fetch tool at all -- reading and
searching go through the shell, and every write goes through
`apply_patch`. Offering 5.6 fabro's extra tools advertises affordances its
instructions never mention, so this adds a profile that registers only
what Codex does.

The profile is selected per model via `agent_profile = "gpt56"` on the six
5.6 rows (three each on `openai` and `openrouter`), following the existing
Kimi-over-a-gateway pattern. Every other model on those providers keeps
its provider default, with no code branch and no version sniffing.

- `ToolVocabulary::Codex` renames `shell` to `shell_command`; a strum
  alias keeps `from_any_name` resolving it to `NativeTool::Shell`, so
  permissions, categories, and telemetry still key on the canonical name.
- `shell_command` gains `workdir`, passed to the `cwd` argument
  `execute_shell_command` already accepted, with Codex's "always set
  `workdir`, do not `cd`" guidance.
- `prompts/gpt56.md.j2` is adapted from Codex's 5.6 `base_instructions`,
  which are byte-identical across Sol, Terra, and Luna. A header comment
  records provenance and the departures fabro's harness forces.

This is an alignment-only pass: it matches Codex's tool contract while
keeping direct tool calls. Codex actually drives 5.6 in code mode, with a
single `exec` tool taking JavaScript and every other tool reached through
a `tools` object inside a V8 isolate. That is deliberately out of scope.

Luna's `multi_agent_version: v1` (vs v2 on Sol and Terra) is also out of
scope. It only changes the sub-agent tool set, which fabro registers from
the caller rather than the profile, and fabro's current set matches
neither version exactly.

Two server cancel-timing tests are adjusted. `gpt-5.6-sol` is the
`openai` provider's default model, so runs that name no model now build a
3-tool profile instead of an 8-tool one and reach their first stage
sooner. `full_http_lifecycle_cancel` asserted `status.kind == "blocked"`
at the instant of cancel, which the worker is free to change the moment it
is signaled; it now accepts either live state, matching the tolerance its
own comment already documents for `pending_control`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 10:42:32 -04:00

378 lines
13 KiB
Rust

use std::sync::Arc;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use fabro_interview::Interviewer;
use fabro_server::server::spawn_scheduler;
use fabro_server::test_support::test_app_state_with_runtime_settings_and_registry_factory;
use fabro_workflow::handler::HandlerRegistry;
use fabro_workflow::handler::agent::AgentHandler;
use fabro_workflow::handler::exit::ExitHandler;
use fabro_workflow::handler::human::HumanHandler;
use fabro_workflow::handler::start::StartHandler;
use tokio::time::sleep;
use tower::ServiceExt;
use crate::helpers::{
POLL_ATTEMPTS, POLL_INTERVAL, api, minimal_manifest_json, response_json, response_status,
run_json, test_settings, wait_for_run_status,
};
fn gate_registry(interviewer: Arc<dyn Interviewer>) -> HandlerRegistry {
let mut registry = HandlerRegistry::new(Box::new(AgentHandler::new(None)));
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
registry.register("agent", Box::new(AgentHandler::new(None)));
registry.register("human", Box::new(HumanHandler::new(interviewer)));
registry
}
async fn wait_for_question_id(app: &axum::Router, run_id: &str) -> String {
for _ in 0..POLL_ATTEMPTS {
let req = Request::builder()
.method("GET")
.uri(api(&format!("/runs/{run_id}/questions")))
.body(Body::empty())
.expect("questions request should build");
let response = app.clone().oneshot(req).await.unwrap();
let body = response_json(
response,
StatusCode::OK,
format!("GET /api/v1/runs/{run_id}/questions"),
)
.await;
let arr = body["data"]
.as_array()
.expect("questions response should include a data array");
if let Some(question_id) = arr
.first()
.and_then(|item| item["id"].as_str())
.map(ToOwned::to_owned)
{
return question_id;
}
sleep(POLL_INTERVAL).await;
}
panic!("question should have appeared");
}
async fn wait_for_question(app: &axum::Router, run_id: &str) -> serde_json::Value {
for _ in 0..POLL_ATTEMPTS {
let req = Request::builder()
.method("GET")
.uri(api(&format!("/runs/{run_id}/questions")))
.body(Body::empty())
.expect("questions request should build");
let response = app.clone().oneshot(req).await.unwrap();
let body = response_json(
response,
StatusCode::OK,
format!("GET /api/v1/runs/{run_id}/questions"),
)
.await;
let arr = body["data"]
.as_array()
.expect("questions response should include a data array");
if let Some(question) = arr.first() {
return question.clone();
}
sleep(POLL_INTERVAL).await;
}
panic!("question should have appeared");
}
async fn wait_for_run_state(
app: &axum::Router,
run_id: &str,
expected_status: &str,
expected_reason: &str,
) -> serde_json::Value {
for _ in 0..POLL_ATTEMPTS {
let body = run_json(app, run_id).await;
if body["lifecycle"]["status"]["kind"].as_str() == Some(expected_status)
&& body["lifecycle"]["status"]["reason"].as_str() == Some(expected_reason)
{
return body;
}
sleep(POLL_INTERVAL).await;
}
panic!("run {run_id} did not reach status={expected_status} reason={expected_reason}");
}
const GATE_DOT: &str = r#"digraph GateTest {
graph [goal="Test gate"]
start [shape=Mdiamond]
exit [shape=Msquare]
work [shape=box, prompt="Do work"]
gate [shape=hexagon, type="human", label="Approve?"]
done [shape=box, prompt="Finish"]
revise [shape=box, prompt="Revise"]
start -> work -> gate
gate -> done [label="[A] Approve"]
gate -> revise [label="[R] Revise"]
done -> exit
revise -> gate
}"#;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn full_http_lifecycle_approve_and_complete() {
let settings = test_settings();
let state = test_app_state_with_runtime_settings_and_registry_factory(
settings.server_settings,
settings.manifest_run_defaults,
gate_registry,
);
spawn_scheduler(Arc::clone(&state));
let app = fabro_server::test_support::build_test_router(Arc::clone(&state));
// 1. Create run
let req = Request::builder()
.method("POST")
.uri(api("/runs"))
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&minimal_manifest_json(GATE_DOT)).unwrap(),
))
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
let body = response_json(response, StatusCode::CREATED, "POST /api/v1/runs").await;
let run_id = body["id"].as_str().unwrap().to_string();
// 1b. Start the run
let req = Request::builder()
.method("POST")
.uri(api(&format!("/runs/{run_id}/start")))
.body(Body::empty())
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
response_status(
response,
StatusCode::OK,
format!("POST /api/v1/runs/{run_id}/start"),
)
.await;
// 2. Poll for question to appear (run goes start -> work -> gate, then blocks)
let question = wait_for_question(&app, &run_id).await;
let question_id = question["id"].as_str().unwrap().to_string();
assert_eq!(question["stage"], "gate");
assert!(question["timeout_seconds"].is_null());
assert!(question["context_display"].is_null() || question["context_display"].is_string());
// 3. Submit answer selecting first option (Approve)
let req = Request::builder()
.method("POST")
.uri(api(&format!(
"/runs/{run_id}/questions/{question_id}/answer"
)))
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&serde_json::json!({
"kind": "selected",
"option_key": "A",
}))
.unwrap(),
))
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
response_status(
response,
StatusCode::NO_CONTENT,
format!("POST /api/v1/runs/{run_id}/questions/{question_id}/answer"),
)
.await;
// 4. Poll until the run reaches a terminal success or failure state.
let final_status = wait_for_run_status(&app, &run_id, &["succeeded", "failed"]).await;
assert_eq!(final_status, "succeeded");
// 5. Verify no pending questions
let req = Request::builder()
.method("GET")
.uri(api(&format!("/runs/{run_id}/questions")))
.body(Body::empty())
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
let body = response_json(
response,
StatusCode::OK,
format!("GET /api/v1/runs/{run_id}/questions"),
)
.await;
assert!(
body["data"].as_array().unwrap().is_empty(),
"no pending questions after completion"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn full_http_lifecycle_cancel() {
let settings = test_settings();
let state = test_app_state_with_runtime_settings_and_registry_factory(
settings.server_settings,
settings.manifest_run_defaults,
gate_registry,
);
spawn_scheduler(Arc::clone(&state));
let app = fabro_server::test_support::build_test_router(Arc::clone(&state));
// Create and start a run that will block at the human gate
let req = Request::builder()
.method("POST")
.uri(api("/runs"))
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&minimal_manifest_json(GATE_DOT)).unwrap(),
))
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
let body = response_json(response, StatusCode::CREATED, "POST /api/v1/runs").await;
let run_id = body["id"].as_str().unwrap().to_string();
let req = Request::builder()
.method("POST")
.uri(api(&format!("/runs/{run_id}/start")))
.body(Body::empty())
.unwrap();
response_status(
app.clone().oneshot(req).await.unwrap(),
StatusCode::OK,
format!("POST /api/v1/runs/{run_id}/start"),
)
.await;
// Wait until the worker has reached the human gate so cancel exercises the
// live-running path rather than racing the in-memory queue transition.
let _question_id = wait_for_question_id(&app, &run_id).await;
// Cancel it
let req = Request::builder()
.method("POST")
.uri(api(&format!("/runs/{run_id}/cancel")))
.body(Body::empty())
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
let body = response_json(
response,
StatusCode::ACCEPTED,
format!("POST /api/v1/runs/{run_id}/cancel"),
)
.await;
// Both fields below are read from the same post-signal projection, so both
// race the worker the same way. `status.kind` is "blocked" while the worker
// still sits at the gate and "running" once it has been notified and
// resumed to process the cancel. What matters here is that cancel reached a
// live run rather than racing the in-memory queue transition, so this
// asserts "not queued" via the two live states. Durable convergence is
// asserted below.
let status_kind = &body["lifecycle"]["status"]["kind"];
assert!(
status_kind == "blocked" || status_kind == "running",
"expected status.kind to be \"blocked\" or \"running\", got {status_kind}"
);
// `pending_control` is computed from the store projection after the cancel
// event is appended AND the worker is signaled. The worker is sitting at a
// human gate; once notified it can emit a clearing event before this
// handler re-reads the projection, so the response can legitimately
// observe either the still-pending "cancel" or a null where the worker
// already consumed it. Durable convergence is asserted below.
let pending_control = &body["lifecycle"]["pending_control"];
assert!(
pending_control == "cancel" || pending_control.is_null(),
"expected pending_control to be \"cancel\" or null, got {pending_control}"
);
// Verify the durable store view converges to cancelled failure.
let body = wait_for_run_state(&app, &run_id, "failed", "cancelled").await;
assert_eq!(body["lifecycle"]["status"]["reason"], "cancelled");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn cancel_at_human_gate_persists_cancelled_terminal_event() {
let settings = test_settings();
let state = test_app_state_with_runtime_settings_and_registry_factory(
settings.server_settings,
settings.manifest_run_defaults,
gate_registry,
);
spawn_scheduler(Arc::clone(&state));
let app = fabro_server::test_support::build_test_router(Arc::clone(&state));
let req = Request::builder()
.method("POST")
.uri(api("/runs"))
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&minimal_manifest_json(GATE_DOT)).unwrap(),
))
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
let body = response_json(response, StatusCode::CREATED, "POST /api/v1/runs").await;
let run_id = body["id"].as_str().unwrap().to_string();
let req = Request::builder()
.method("POST")
.uri(api(&format!("/runs/{run_id}/start")))
.body(Body::empty())
.unwrap();
response_status(
app.clone().oneshot(req).await.unwrap(),
StatusCode::OK,
format!("POST /api/v1/runs/{run_id}/start"),
)
.await;
let _question_id = wait_for_question_id(&app, &run_id).await;
let req = Request::builder()
.method("POST")
.uri(api(&format!("/runs/{run_id}/cancel")))
.body(Body::empty())
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
response_status(
response,
StatusCode::ACCEPTED,
format!("POST /api/v1/runs/{run_id}/cancel"),
)
.await;
let status = wait_for_run_status(&app, &run_id, &["failed"]).await;
assert_eq!(status, "failed");
let req = Request::builder()
.method("GET")
.uri(api(&format!("/runs/{run_id}/events")))
.body(Body::empty())
.unwrap();
let response = app.oneshot(req).await.unwrap();
let body = response_json(
response,
StatusCode::OK,
format!("GET /api/v1/runs/{run_id}/events"),
)
.await;
let failed_reasons = body["data"]
.as_array()
.unwrap()
.iter()
.filter(|&event| event["event"] == "run.failed")
.map(|event| {
(
event["properties"]["failure"]["reason"]
.as_str()
.map(ToOwned::to_owned),
event["properties"]["failure"]["detail"]["message"]
.as_str()
.map(ToOwned::to_owned),
)
})
.collect::<Vec<_>>();
assert_eq!(failed_reasons, vec![(
Some("cancelled".to_string()),
Some("Pipeline cancelled".to_string())
)]);
}