mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
314 lines
12 KiB
Rust
314 lines
12 KiB
Rust
//! 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<Child>,
|
|
}
|
|
|
|
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<String> = 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<Value> = 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);
|
|
}
|