//! End-to-end test for the AgentBox marketplace adapter (#3620). //! //! Boots the real `openhuman-core` binary with `OPENHUMAN_AGENTBOX_MODE=1` //! pointed at an in-process OpenAI-compatible wiremock provider (filling the //! `GMI_MAAS_*` env contract that `agentbox::env` consumes at startup), //! submits `POST /run`, then polls `GET /jobs/{job_id}` until terminal. //! //! Mirrors the binary-launch pattern in //! `tests/memory_tree_sync_deep_raw_coverage_e2e.rs` (uses //! `env!("CARGO_BIN_EXE_openhuman-core")`) and the wiremock provider pattern //! in `tests/inference_provider_e2e.rs`. //! //! ## Why `#[ignore]` //! //! The AgentBox `/run` invoker (`agentbox::invoker::CoreAgentInvoker`) drives //! the **live agent runtime** through the web-channel pipeline //! (`channels::providers::web::start_chat`). End-to-end completion against a //! freshly-bootstrapped tempdir workspace requires: //! //! 1. A logged-in user session on disk — `start_chat` and several upstream //! stages (prompt-injection guard, multimodal config, memory) call into //! `Config::load_or_init` and the auth store. A fresh empty workspace //! has no session, so the agent runtime currently returns a `chat_error` //! before the inference provider is ever consulted. //! 2. Most domain services bootstrapped by `bootstrap_core_runtime` to be //! mockable from outside the process (memory store, embeddings, //! heartbeat, etc.). Several of those make outbound calls today that the //! OpenAI-compat mock can't intercept. //! 3. A way to seed `config.toml` so `register_gmi_provider_if_present` //! keeps its writes from racing against the agent runtime's own first //! `Config::load_or_init`. //! //! Un-ignoring this test requires either: //! - a public test-harness hook to seed a fake logged-in session into the //! workspace before `serve` starts (see `e2e-test-support` feature gate //! used by the desktop E2E build for `openhuman.test_reset`), OR //! - a `OPENHUMAN_AGENTBOX_TEST_STUB_INVOKER=1` opt-in in //! `core::jsonrpc::build_core_http_router` that swaps `CoreAgentInvoker` //! for a deterministic stub that echoes the request back without //! touching the agent runtime. //! //! Until then, Task 14's Docker smoke step in the deployment runbook //! (`gitbooks/developing/agentbox-deployment.md`) covers manual end-to-end //! validation against the real GMI MaaS endpoint. //! //! The test below is wired up correctly otherwise: it spawns the binary, //! waits for `/health`, drives `/run` + `/jobs/{id}`, and tears the child //! down via a Drop guard even on assertion failure. #![cfg(not(target_os = "windows"))] use std::io::Read; use std::net::TcpListener; use std::process::{Child, Command, Stdio}; use std::time::{Duration, Instant}; use serde_json::Value; use tempfile::TempDir; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; /// RAII guard that kills the spawned core process on drop, so the test /// cleans up correctly on assertion failure as well as success. struct ChildGuard { child: Option, } impl ChildGuard { fn new(child: Child) -> Self { Self { child: Some(child) } } } impl Drop for ChildGuard { fn drop(&mut self) { if let Some(mut child) = self.child.take() { let _ = child.kill(); // Drain stderr so we surface it on failure. Bounded read so a // chatty child can't hang the test runner. if let Some(err) = child.stderr.take() { let mut buf = Vec::with_capacity(8 * 1024); let _ = err.take(64 * 1024).read_to_end(&mut buf); if !buf.is_empty() { eprintln!( "[agentbox_e2e] child stderr (truncated):\n{}", String::from_utf8_lossy(&buf) ); } } let _ = child.wait(); } } } /// Reserve a TCP port the OS considers free *right now* and immediately drop /// the listener so the spawned core process can bind it. There is a small /// race window between drop and re-bind, but `pick_listen_port_for_host` /// inside the core will fall back to a nearby port on conflict, so this is /// only a best-effort hint. fn reserve_port() -> u16 { let listener = TcpListener::bind("127.0.0.1:0").expect("bind ephemeral"); let port = listener .local_addr() .expect("local_addr on ephemeral bind") .port(); drop(listener); port } fn spawn_core_with_agentbox(port: u16, workspace: &std::path::Path, gmi_base_url: &str) -> Child { Command::new(env!("CARGO_BIN_EXE_openhuman-core")) .arg("serve") .arg("--jsonrpc-only") .env("OPENHUMAN_AGENTBOX_MODE", "1") .env("OPENHUMAN_CORE_PORT", port.to_string()) .env("OPENHUMAN_CORE_HOST", "127.0.0.1") .env("OPENHUMAN_WORKSPACE", workspace) // Token only matters for authed endpoints; /run and /jobs/* bypass auth. .env("OPENHUMAN_CORE_TOKEN", "agentbox-e2e-token") .env("OPENHUMAN_KEYRING_BACKEND", "file") // Wire the GMI MaaS bridge at startup so the agent runtime would // route inference through our wiremock stub. .env("GMI_MAAS_BASE_URL", gmi_base_url) .env("GMI_MAAS_API_KEY", "test-key") .env("GMI_MODELS", "stub-model") // Keep noise down; bump to debug locally when investigating. .env("RUST_LOG", "warn,openhuman_core::openhuman::agentbox=info") .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .expect("spawn openhuman-core") } async fn wait_health(port: u16, deadline: Duration) -> Result<(), String> { let client = reqwest::Client::builder() .timeout(Duration::from_secs(2)) .build() .map_err(|e| e.to_string())?; let url = format!("http://127.0.0.1:{port}/health"); let started = Instant::now(); let mut last_err: Option = None; while started.elapsed() < deadline { match client.get(&url).send().await { Ok(r) if r.status().is_success() => return Ok(()), Ok(r) => last_err = Some(format!("status={}", r.status())), Err(e) => last_err = Some(e.to_string()), } tokio::time::sleep(Duration::from_millis(200)).await; } Err(format!( "core /health did not become ready in {:?}: last_err={:?}", deadline, last_err )) } fn openai_chat_response(content: &str) -> Value { serde_json::json!({ "id": "chatcmpl-agentbox-e2e", "object": "chat.completion", "created": 1_700_000_000_u64, "model": "stub-model", "choices": [{ "index": 0, "message": { "role": "assistant", "content": content }, "finish_reason": "stop" }], "usage": { "prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2 } }) } async fn start_openai_compat_mock() -> MockServer { let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/v1/chat/completions")) .respond_with(ResponseTemplate::new(200).set_body_json(openai_chat_response("stub-reply"))) .mount(&server) .await; Mock::given(method("GET")) .and(path("/v1/models")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "data": [{"id": "stub-model", "object": "model"}], "object": "list" }))) .mount(&server) .await; server } /// End-to-end: spawn `openhuman-core serve` with `OPENHUMAN_AGENTBOX_MODE=1` /// pointed at an in-process OpenAI-compat mock, drive `POST /run` then poll /// `GET /jobs/{id}` until terminal, and assert a non-empty completion. /// /// **Currently `#[ignore]`d** — see the module-level docstring. The Docker /// smoke step in `gitbooks/developing/agentbox-deployment.md` covers manual /// end-to-end validation against the real GMI MaaS endpoint until the /// runtime exposes a test stub for the invoker (see TODO below). /// /// TODO(#3620): un-ignore once one of these lands: /// 1. An `e2e-test-support`-gated env var /// `OPENHUMAN_AGENTBOX_TEST_STUB_INVOKER=1` that swaps /// `agentbox::invoker::CoreAgentInvoker` for a deterministic echo stub /// in `core::jsonrpc::build_core_http_router`, OR /// 2. A reusable test fixture that seeds a logged-in session + /// pre-rendered `config.toml` into `OPENHUMAN_WORKSPACE` so the real /// agent runtime can complete a turn against the wiremock provider /// from a cold-start binary. #[ignore = "TODO(#3620): needs a test-stub invoker or a seeded-session fixture; see module docs"] #[tokio::test] async fn agentbox_run_then_poll_completes() { let workspace: TempDir = tempfile::tempdir().expect("workspace tempdir"); // 1. Stand up the OpenAI-compat mock first so GMI_MAAS_BASE_URL is real // by the time the core boots. let mock = start_openai_compat_mock().await; let gmi_base_url = mock.uri(); // 2. Pick a port (unusual range; the binary will fall back if it // collides between reservation and re-bind). let port = { let p = reserve_port(); if p < 17788 { 17788 } else { p } }; // 3. Spawn the binary under a Drop guard so the child is reaped on // every code path (panic, assertion failure, early return). let guard = ChildGuard::new(spawn_core_with_agentbox( port, workspace.path(), &gmi_base_url, )); // 4. Wait for /health to become ready (~10s bounded). wait_health(port, Duration::from_secs(10)) .await .expect("core /health should become ready within 10s"); // 5. Submit a run. let client = reqwest::Client::builder() .timeout(Duration::from_secs(10)) .build() .expect("build reqwest client"); let run_url = format!("http://127.0.0.1:{port}/run"); let resp = client .post(&run_url) .json(&serde_json::json!({ "payload": { "message": "hello" } })) .send() .await .expect("POST /run"); assert_eq!(resp.status().as_u16(), 202, "POST /run should return 202"); let body: Value = resp.json().await.expect("parse /run JSON"); let job_id = body .get("job_id") .and_then(Value::as_str) .expect("/run response should include job_id") .to_string(); assert!(!job_id.is_empty(), "job_id should be non-empty"); // 6. Poll the job until terminal (~25s bounded, 250ms cadence). let jobs_url = format!("http://127.0.0.1:{port}/jobs/{job_id}"); let deadline = Instant::now() + Duration::from_secs(25); let mut last_view: Option = None; while Instant::now() < deadline { let r = client.get(&jobs_url).send().await.expect("GET /jobs/{id}"); assert_eq!( r.status().as_u16(), 200, "GET /jobs/{{id}} should return 200" ); let view: Value = r.json().await.expect("parse /jobs JSON"); let status = view .get("status") .and_then(Value::as_str) .unwrap_or("") .to_string(); if status == "completed" || status == "failed" { last_view = Some(view); break; } last_view = Some(view); tokio::time::sleep(Duration::from_millis(250)).await; } let view = last_view.expect("at least one /jobs poll should have returned a body"); let status = view .get("status") .and_then(Value::as_str) .unwrap_or("") .to_string(); assert_eq!( status, "completed", "job should reach completed; last view: {view}" ); let message = view .pointer("/result/message") .and_then(Value::as_str) .unwrap_or(""); assert!( !message.is_empty(), "completed job should have a non-empty result.message; got view: {view}" ); // 7. Drop the guard explicitly so the child is reaped before the test // returns. (Lexical drop would do the same; this is just explicit // documentation of intent.) drop(guard); }