mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
3115 lines
127 KiB
Rust
3115 lines
127 KiB
Rust
//! E2E tests for agent-harness behaviors (issue #3471): subagent delegation,
|
|
//! clarification flows, approval gate, multi-turn state, error paths, streaming.
|
|
//!
|
|
//! Runs the real core JSON-RPC stack against an in-test scripted upstream that
|
|
//! replays queued OpenAI-style chat completions and captures every request.
|
|
//! Mirrors the infrastructure of `tests/json_rpc_e2e.rs`.
|
|
//!
|
|
//! Every test holds the process-global `env_lock()` guard across its `.await`
|
|
//! points on purpose: `HOME` / backend-URL / approval-TTL env vars are global,
|
|
//! so tests must run one at a time. That makes `clippy::await_holding_lock`
|
|
//! a false positive here — the lock IS the serialization mechanism.
|
|
#![allow(clippy::await_holding_lock)]
|
|
|
|
use std::net::SocketAddr;
|
|
use std::path::Path;
|
|
use std::sync::{Mutex, OnceLock};
|
|
use std::time::Duration;
|
|
|
|
/// Process-stable tempdir for the approval gate's audit store.
|
|
/// Prevents `ApprovalGate` from writing `./approval/approval.db` into the repo root.
|
|
static GATE_WORKSPACE: OnceLock<tempfile::TempDir> = OnceLock::new();
|
|
|
|
use axum::http::{header::AUTHORIZATION, HeaderMap, StatusCode, Uri};
|
|
use axum::routing::{get, post};
|
|
use axum::{Json, Router};
|
|
use futures_util::StreamExt;
|
|
use serde_json::{json, Value};
|
|
use tempfile::tempdir;
|
|
|
|
use openhuman_core::core::auth::{init_rpc_token, CORE_TOKEN_ENV_VAR};
|
|
use openhuman_core::core::jsonrpc::build_core_http_router;
|
|
use openhuman_core::openhuman::agent::harness::AgentDefinitionRegistry;
|
|
|
|
const TEST_RPC_TOKEN: &str = "json-rpc-e2e-local-token";
|
|
|
|
// ─── Env serialization (same rationale as json_rpc_e2e.rs:61-79) ───────────
|
|
|
|
static AGENT_HARNESS_E2E_ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
|
static AGENT_HARNESS_KEYRING_INIT: OnceLock<()> = OnceLock::new();
|
|
|
|
/// Initialise the global `AgentDefinitionRegistry` with built-ins exactly once
|
|
/// across all tests in this binary. Guard with OnceLock so repeated `boot_stack`
|
|
/// calls are safe even though the underlying registry uses its own OnceLock
|
|
/// (`init_global_builtins` silently ignores a second set).
|
|
static AGENT_DEF_REGISTRY_INIT: OnceLock<()> = OnceLock::new();
|
|
|
|
fn init_agent_def_registry() {
|
|
AGENT_DEF_REGISTRY_INIT.get_or_init(|| {
|
|
AgentDefinitionRegistry::init_global_builtins()
|
|
.expect("AgentDefinitionRegistry::init_global_builtins must not fail");
|
|
});
|
|
}
|
|
|
|
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
|
|
AGENT_HARNESS_KEYRING_INIT.get_or_init(|| unsafe {
|
|
std::env::set_var("OPENHUMAN_KEYRING_BACKEND", "file");
|
|
});
|
|
let mutex = AGENT_HARNESS_E2E_ENV_LOCK.get_or_init(|| Mutex::new(()));
|
|
match mutex.lock() {
|
|
Ok(guard) => guard,
|
|
Err(poisoned) => poisoned.into_inner(),
|
|
}
|
|
}
|
|
|
|
struct EnvVarGuard {
|
|
key: &'static str,
|
|
old: Option<String>,
|
|
}
|
|
|
|
impl EnvVarGuard {
|
|
fn set_to_path(key: &'static str, path: &Path) -> Self {
|
|
let old = std::env::var(key).ok();
|
|
std::env::set_var(key, path.as_os_str());
|
|
Self { key, old }
|
|
}
|
|
|
|
fn set(key: &'static str, value: &str) -> Self {
|
|
let old = std::env::var(key).ok();
|
|
std::env::set_var(key, value);
|
|
Self { key, old }
|
|
}
|
|
|
|
fn unset(key: &'static str) -> Self {
|
|
let old = std::env::var(key).ok();
|
|
std::env::remove_var(key);
|
|
Self { key, old }
|
|
}
|
|
}
|
|
|
|
impl Drop for EnvVarGuard {
|
|
fn drop(&mut self) {
|
|
match &self.old {
|
|
Some(v) => std::env::set_var(self.key, v),
|
|
None => std::env::remove_var(self.key),
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── Scripted upstream ──────────────────────────────────────────────────────
|
|
//
|
|
// Queue entries are JSON objects:
|
|
// { "content": "...", "toolCalls": [{"id","name","arguments"}] } → 200 completion
|
|
// { "status": 500, "error": "..." } → error injection
|
|
|
|
static SCRIPTED_COMPLETIONS: OnceLock<Mutex<std::collections::VecDeque<Value>>> = OnceLock::new();
|
|
static CAPTURED_COMPLETION_REQUESTS: OnceLock<Mutex<Vec<Value>>> = OnceLock::new();
|
|
|
|
fn with_scripted<T>(f: impl FnOnce(&mut std::collections::VecDeque<Value>) -> T) -> T {
|
|
let m = SCRIPTED_COMPLETIONS.get_or_init(|| Mutex::new(Default::default()));
|
|
let mut guard = match m.lock() {
|
|
Ok(g) => g,
|
|
Err(p) => p.into_inner(),
|
|
};
|
|
f(&mut guard)
|
|
}
|
|
|
|
fn with_captured<T>(f: impl FnOnce(&mut Vec<Value>) -> T) -> T {
|
|
let m = CAPTURED_COMPLETION_REQUESTS.get_or_init(|| Mutex::new(Vec::new()));
|
|
let mut guard = match m.lock() {
|
|
Ok(g) => g,
|
|
Err(p) => p.into_inner(),
|
|
};
|
|
f(&mut guard)
|
|
}
|
|
|
|
/// Reset scripted queue + captures. Call at the start of every test, under the env lock.
|
|
fn reset_script(responses: Vec<Value>) {
|
|
with_scripted(|q| {
|
|
q.clear();
|
|
q.extend(responses);
|
|
});
|
|
with_captured(|c| c.clear());
|
|
}
|
|
|
|
fn text_completion(content: &str) -> Value {
|
|
json!({ "content": content })
|
|
}
|
|
|
|
fn tool_call_completion(name: &str, arguments: Value) -> Value {
|
|
json!({ "content": "", "toolCalls": [{
|
|
"id": format!("call_{name}"),
|
|
"name": name,
|
|
"arguments": arguments.to_string(),
|
|
}]})
|
|
}
|
|
|
|
fn error_completion(status: u16, message: &str) -> Value {
|
|
json!({ "status": status, "error": message })
|
|
}
|
|
|
|
async fn scripted_chat_completions(
|
|
uri: Uri,
|
|
_headers: HeaderMap,
|
|
Json(body): Json<Value>,
|
|
) -> axum::response::Response {
|
|
use axum::response::IntoResponse;
|
|
|
|
let streaming = body.get("stream").and_then(Value::as_bool).unwrap_or(false);
|
|
with_captured(|reqs| {
|
|
reqs.push(json!({
|
|
"path": uri.path(),
|
|
"model": body.get("model").and_then(Value::as_str),
|
|
"stream": streaming,
|
|
"body": body.clone(),
|
|
}))
|
|
});
|
|
|
|
let next = with_scripted(|q| q.pop_front());
|
|
let Some(entry) = next else {
|
|
let message = json!({ "role": "assistant", "content": "default scripted completion" });
|
|
return completion_response(streaming, message);
|
|
};
|
|
|
|
if let Some(status) = entry.get("status").and_then(Value::as_u64) {
|
|
let message = entry
|
|
.get("error")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("scripted upstream error");
|
|
// Non-2xx short-circuits before any SSE parsing on both the old and crate
|
|
// clients, so an error entry is a plain JSON body regardless of `stream`.
|
|
return (
|
|
StatusCode::from_u16(status as u16).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
|
|
Json(json!({ "error": { "message": message, "type": "server_error" } })),
|
|
)
|
|
.into_response();
|
|
}
|
|
|
|
let content = entry.get("content").and_then(Value::as_str).unwrap_or("");
|
|
let mut message = json!({ "role": "assistant", "content": content });
|
|
if let Some(tool_calls) = entry.get("toolCalls").and_then(Value::as_array) {
|
|
let calls: Vec<Value> = tool_calls
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, tc)| {
|
|
let args = tc.get("arguments").and_then(Value::as_str).unwrap_or("{}");
|
|
json!({
|
|
"id": tc.get("id").and_then(Value::as_str)
|
|
.map(str::to_string)
|
|
.unwrap_or_else(|| format!("call_scripted_{i}")),
|
|
"type": "function",
|
|
"function": {
|
|
"name": tc.get("name").and_then(Value::as_str).unwrap_or(""),
|
|
"arguments": args,
|
|
}
|
|
})
|
|
})
|
|
.collect();
|
|
message["tool_calls"] = json!(calls);
|
|
}
|
|
completion_response(streaming, message)
|
|
}
|
|
|
|
/// Serve a scripted completion as either a non-streaming Chat Completions JSON body
|
|
/// or a Server-Sent-Events stream (`stream: true`). The SSE shape matches what the
|
|
/// crate `OpenAiModel::stream` parser expects — `data:` lines carrying
|
|
/// `choices[].delta.{content,tool_calls}`, a terminal `finish_reason` chunk, and
|
|
/// `data: [DONE]` — so both the legacy host client and the crate-native path parse it.
|
|
fn completion_response(streaming: bool, message: Value) -> axum::response::Response {
|
|
use axum::response::IntoResponse;
|
|
|
|
if !streaming {
|
|
return Json(json!({ "choices": [{ "message": message }] })).into_response();
|
|
}
|
|
|
|
let mut delta = json!({ "role": "assistant" });
|
|
if let Some(content) = message.get("content").and_then(Value::as_str) {
|
|
if !content.is_empty() {
|
|
delta["content"] = json!(content);
|
|
}
|
|
}
|
|
if let Some(tool_calls) = message.get("tool_calls").and_then(Value::as_array) {
|
|
// Streaming tool-call fragments carry a positional `index`.
|
|
let indexed: Vec<Value> = tool_calls
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, tc)| {
|
|
let mut c = tc.clone();
|
|
c["index"] = json!(i);
|
|
c
|
|
})
|
|
.collect();
|
|
delta["tool_calls"] = json!(indexed);
|
|
}
|
|
|
|
let mut body = String::new();
|
|
body.push_str(&format!(
|
|
"data: {}\n\n",
|
|
json!({ "choices": [{ "index": 0, "delta": delta }] })
|
|
));
|
|
body.push_str(&format!(
|
|
"data: {}\n\n",
|
|
json!({ "choices": [{ "index": 0, "delta": {}, "finish_reason": "stop" }] })
|
|
));
|
|
body.push_str("data: [DONE]\n\n");
|
|
|
|
(
|
|
[(axum::http::header::CONTENT_TYPE, "text/event-stream")],
|
|
body,
|
|
)
|
|
.into_response()
|
|
}
|
|
|
|
async fn current_user(_headers: HeaderMap) -> Json<Value> {
|
|
Json(json!({ "success": true, "data": { "_id": "e2e-user-1", "username": "e2e" } }))
|
|
}
|
|
|
|
fn scripted_upstream_router() -> Router {
|
|
Router::new()
|
|
.route("/settings", get(current_user))
|
|
.route("/auth/me", get(current_user))
|
|
.route(
|
|
"/openai/v1/chat/completions",
|
|
post(scripted_chat_completions),
|
|
)
|
|
.route("/v1/chat/completions", post(scripted_chat_completions))
|
|
.route("/chat/completions", post(scripted_chat_completions))
|
|
}
|
|
|
|
// ─── Server + RPC helpers (json_rpc_e2e.rs:614-657, 830-938) ───────────────
|
|
|
|
async fn serve_on_ephemeral(
|
|
app: Router,
|
|
) -> (
|
|
SocketAddr,
|
|
tokio::task::JoinHandle<Result<(), std::io::Error>>,
|
|
) {
|
|
// Mirrors json_rpc_e2e.rs::ensure_test_rpc_auth — init the shared RPC bearer once.
|
|
static AUTH_INIT: OnceLock<()> = OnceLock::new();
|
|
AUTH_INIT.get_or_init(|| {
|
|
// SAFETY: runs exactly once via OnceLock before concurrent env reads occur.
|
|
unsafe { std::env::set_var(CORE_TOKEN_ENV_VAR, TEST_RPC_TOKEN) };
|
|
let token_dir = std::env::temp_dir().join("openhuman-agent-harness-e2e-auth");
|
|
init_rpc_token(&token_dir).expect("init rpc auth token for agent_harness_e2e");
|
|
});
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
|
.await
|
|
.expect("bind");
|
|
let addr = listener.local_addr().expect("addr");
|
|
let handle = tokio::spawn(async move { axum::serve(listener, app).await });
|
|
(addr, handle)
|
|
}
|
|
|
|
async fn post_json_rpc(rpc_base: &str, id: i64, method: &str, params: Value) -> Value {
|
|
let client = reqwest::Client::builder()
|
|
.timeout(Duration::from_secs(120))
|
|
.build()
|
|
.expect("client");
|
|
let body = json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params });
|
|
let url = format!("{}/rpc", rpc_base.trim_end_matches('/'));
|
|
let resp = client
|
|
.post(&url)
|
|
.header(AUTHORIZATION, format!("Bearer {TEST_RPC_TOKEN}"))
|
|
.json(&body)
|
|
.send()
|
|
.await
|
|
.unwrap_or_else(|e| panic!("POST {url}: {e}"));
|
|
assert!(
|
|
resp.status().is_success(),
|
|
"HTTP {} for {}",
|
|
resp.status(),
|
|
method
|
|
);
|
|
resp.json::<Value>()
|
|
.await
|
|
.unwrap_or_else(|e| panic!("json for {method}: {e}"))
|
|
}
|
|
|
|
fn assert_no_jsonrpc_error<'a>(v: &'a Value, context: &str) -> &'a Value {
|
|
if let Some(err) = v.get("error") {
|
|
panic!("{context}: JSON-RPC error: {err}");
|
|
}
|
|
v.get("result")
|
|
.unwrap_or_else(|| panic!("{context}: missing result: {v}"))
|
|
}
|
|
|
|
fn write_min_config(openhuman_dir: &Path, api_origin: &str, super_context_enabled: bool) {
|
|
let cfg = format!(
|
|
r#"api_url = "{api_origin}"
|
|
default_model = "e2e-mock-model"
|
|
default_temperature = 0.7
|
|
chat_onboarding_completed = true
|
|
|
|
[secrets]
|
|
encrypt = false
|
|
|
|
[context]
|
|
# Most harness tests script the mock-LLM call sequence exactly; the default-on
|
|
# first-turn "super context" pass (#4085) would spawn a context_scout and consume
|
|
# a scripted response, desyncing the orchestrator turns. Tests opt in only when
|
|
# they explicitly cover super context.
|
|
super_context_enabled = {super_context_enabled}
|
|
"#
|
|
);
|
|
fn write_config_file(config_dir: &Path, cfg: &str) {
|
|
std::fs::create_dir_all(config_dir).expect("mkdir openhuman");
|
|
std::fs::write(config_dir.join("config.toml"), cfg).expect("write config");
|
|
}
|
|
write_config_file(openhuman_dir, &cfg);
|
|
if openhuman_dir
|
|
.file_name()
|
|
.is_some_and(|name| name == std::ffi::OsStr::new(".openhuman"))
|
|
{
|
|
write_config_file(&openhuman_dir.join("users").join("local"), &cfg);
|
|
}
|
|
let _: openhuman_core::openhuman::config::Config =
|
|
toml::from_str(&cfg).expect("config toml must match Config schema");
|
|
}
|
|
|
|
// ─── SSE collector ──────────────────────────────────────────────────────────
|
|
//
|
|
// One long-lived /events connection per test; events fan into an mpsc channel
|
|
// so a test can wait for `approval_request` and later `chat_done` without
|
|
// reconnect gaps losing events in between.
|
|
|
|
fn spawn_sse_collector(events_url: String) -> tokio::sync::mpsc::UnboundedReceiver<Value> {
|
|
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
|
tokio::spawn(async move {
|
|
let client = reqwest::Client::builder()
|
|
.timeout(Duration::from_secs(300))
|
|
.build()
|
|
.expect("client");
|
|
let resp = client
|
|
.get(&events_url)
|
|
.header(AUTHORIZATION, format!("Bearer {TEST_RPC_TOKEN}"))
|
|
.send()
|
|
.await
|
|
.unwrap_or_else(|e| panic!("GET {events_url}: {e}"));
|
|
assert!(resp.status().is_success(), "SSE HTTP {}", resp.status());
|
|
let mut stream = resp.bytes_stream();
|
|
// Accumulate raw bytes: a multi-byte UTF-8 char may be split across
|
|
// chunk boundaries, so only complete "\n\n"-delimited frames are decoded.
|
|
let mut buffer: Vec<u8> = Vec::new();
|
|
while let Some(item) = stream.next().await {
|
|
let Ok(chunk) = item else { return };
|
|
buffer.extend_from_slice(&chunk);
|
|
while let Some(idx) = buffer.windows(2).position(|w| w == b"\n\n") {
|
|
let frame_bytes: Vec<u8> = buffer.drain(..idx + 2).take(idx).collect();
|
|
let block = std::str::from_utf8(&frame_bytes).unwrap_or_else(|e| {
|
|
panic!("invalid UTF-8 in complete SSE frame: {e}; bytes: {frame_bytes:?}")
|
|
});
|
|
let data: Vec<&str> = block
|
|
.lines()
|
|
.filter_map(|l| l.strip_prefix("data:"))
|
|
.map(str::trim_start)
|
|
.collect();
|
|
if data.is_empty() {
|
|
continue;
|
|
}
|
|
if let Ok(value) = serde_json::from_str::<Value>(&data.join("\n")) {
|
|
if tx.send(value).is_err() {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
rx
|
|
}
|
|
|
|
async fn wait_for_event(
|
|
rx: &mut tokio::sync::mpsc::UnboundedReceiver<Value>,
|
|
event_name: &str,
|
|
timeout: Duration,
|
|
) -> Value {
|
|
let deadline = tokio::time::Instant::now() + timeout;
|
|
let mut seen: Vec<String> = Vec::new();
|
|
loop {
|
|
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
|
|
match tokio::time::timeout(remaining, rx.recv()).await {
|
|
Ok(Some(v)) => {
|
|
seen.push(
|
|
v.get("event")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("<no event>")
|
|
.to_string(),
|
|
);
|
|
if v.get("event").and_then(Value::as_str) == Some(event_name) {
|
|
return v;
|
|
}
|
|
}
|
|
Ok(None) => {
|
|
panic!("SSE channel closed waiting for {event_name}; seen events so far: {seen:?}")
|
|
}
|
|
Err(_) => panic!(
|
|
"timed out waiting for SSE event {event_name}; all received events: {seen:?}"
|
|
),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Wait for chat_done or chat_error; returns the terminal event.
|
|
async fn wait_for_terminal(
|
|
rx: &mut tokio::sync::mpsc::UnboundedReceiver<Value>,
|
|
timeout: Duration,
|
|
) -> Value {
|
|
let deadline = tokio::time::Instant::now() + timeout;
|
|
loop {
|
|
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
|
|
match tokio::time::timeout(remaining, rx.recv()).await {
|
|
Ok(Some(v)) => match v.get("event").and_then(Value::as_str) {
|
|
Some("chat_done") | Some("chat_error") => return v,
|
|
_ => {}
|
|
},
|
|
Ok(None) => panic!("SSE channel closed waiting for terminal event"),
|
|
Err(_) => panic!("timed out waiting for terminal web-chat event"),
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── Per-test stack bootstrap ───────────────────────────────────────────────
|
|
|
|
struct Stack {
|
|
rpc_base: String,
|
|
_home_guard: EnvVarGuard,
|
|
_workspace_guard: EnvVarGuard,
|
|
_backend_guard: EnvVarGuard,
|
|
_vite_guard: EnvVarGuard,
|
|
_tmp: tempfile::TempDir,
|
|
mock_join: tokio::task::JoinHandle<Result<(), std::io::Error>>,
|
|
rpc_join: tokio::task::JoinHandle<Result<(), std::io::Error>>,
|
|
}
|
|
|
|
impl Stack {
|
|
fn shutdown(&self) {
|
|
self.mock_join.abort();
|
|
self.rpc_join.abort();
|
|
}
|
|
}
|
|
|
|
// Abort server tasks even when a test assertion panics before shutdown().
|
|
impl Drop for Stack {
|
|
fn drop(&mut self) {
|
|
self.mock_join.abort();
|
|
self.rpc_join.abort();
|
|
}
|
|
}
|
|
|
|
async fn boot_stack() -> Stack {
|
|
boot_stack_with_super_context(false).await
|
|
}
|
|
|
|
async fn boot_stack_with_super_context(super_context_enabled: bool) -> Stack {
|
|
// Ensure the global AgentDefinitionRegistry is populated with built-in
|
|
// archetypes (orchestrator, researcher, task_manager_agent, etc.) before
|
|
// the RPC stack starts. Without this the session builder cannot synthesise
|
|
// delegation tools and every `research`/`spawn_subagent` call becomes
|
|
// "Unknown tool: …", making delegation tests vacuous.
|
|
init_agent_def_registry();
|
|
|
|
let tmp = tempdir().expect("tempdir");
|
|
let home = tmp.path().to_path_buf();
|
|
let openhuman_home = home.join(".openhuman");
|
|
|
|
let home_guard = EnvVarGuard::set_to_path("HOME", &home);
|
|
let workspace_guard = EnvVarGuard::unset("OPENHUMAN_WORKSPACE");
|
|
let backend_guard = EnvVarGuard::unset("BACKEND_URL");
|
|
let vite_guard = EnvVarGuard::unset("VITE_BACKEND_URL");
|
|
|
|
let (mock_addr, mock_join) = serve_on_ephemeral(scripted_upstream_router()).await;
|
|
let mock_origin = format!("http://{mock_addr}");
|
|
write_min_config(&openhuman_home, &mock_origin, super_context_enabled);
|
|
// Pre-write user-scoped config so it's found after auth_store_session activates "e2e-user".
|
|
write_min_config(
|
|
&openhuman_home.join("users").join("e2e-user"),
|
|
&mock_origin,
|
|
super_context_enabled,
|
|
);
|
|
|
|
let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await;
|
|
let rpc_base = format!("http://{rpc_addr}");
|
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
|
|
|
// Establish an authenticated session so the OpenHuman backend provider
|
|
// can read the stored JWT from the keyring (same pattern as json_rpc_e2e.rs:1768-1779).
|
|
let store = post_json_rpc(
|
|
&rpc_base,
|
|
1,
|
|
"openhuman.auth_store_session",
|
|
json!({ "token": "e2e-test-jwt", "user_id": "e2e-user" }),
|
|
)
|
|
.await;
|
|
assert_no_jsonrpc_error(&store, "auth_store_session in boot_stack");
|
|
|
|
Stack {
|
|
rpc_base,
|
|
_home_guard: home_guard,
|
|
_workspace_guard: workspace_guard,
|
|
_backend_guard: backend_guard,
|
|
_vite_guard: vite_guard,
|
|
_tmp: tmp,
|
|
mock_join,
|
|
rpc_join,
|
|
}
|
|
}
|
|
|
|
async fn send_web_chat(rpc_base: &str, id: i64, client_id: &str, thread_id: &str, message: &str) {
|
|
let resp = post_json_rpc(
|
|
rpc_base,
|
|
id,
|
|
"openhuman.channel_web_chat",
|
|
json!({
|
|
"client_id": client_id,
|
|
"thread_id": thread_id,
|
|
"message": message,
|
|
"model_override": "e2e-mock-model",
|
|
}),
|
|
)
|
|
.await;
|
|
let result = assert_no_jsonrpc_error(&resp, "channel_web_chat");
|
|
assert_eq!(
|
|
result.get("result").and_then(|v| v.get("accepted")),
|
|
Some(&json!(true)),
|
|
"web chat not accepted: {result}"
|
|
);
|
|
}
|
|
|
|
// ─── Tests ───────────────────────────────────────────────────────────────────
|
|
|
|
/// Smoke: a single scripted text response flows through the full RPC stack.
|
|
#[test]
|
|
fn scripted_stack_smoke() {
|
|
run_on_agent_stack("scripted_stack_smoke", scripted_stack_smoke_inner);
|
|
}
|
|
|
|
async fn scripted_stack_smoke_inner() {
|
|
let _lock = env_lock();
|
|
reset_script(vec![text_completion("CANARY_SMOKE_3471")]);
|
|
let stack = boot_stack().await;
|
|
|
|
let mut events =
|
|
spawn_sse_collector(format!("{}/events?client_id=harness-smoke", stack.rpc_base));
|
|
send_web_chat(
|
|
&stack.rpc_base,
|
|
100,
|
|
"harness-smoke",
|
|
"thread-smoke",
|
|
"hello",
|
|
)
|
|
.await;
|
|
|
|
let done = wait_for_terminal(&mut events, Duration::from_secs(60)).await;
|
|
assert_eq!(
|
|
done.get("event").and_then(Value::as_str),
|
|
Some("chat_done"),
|
|
"expected chat_done, got: {done}"
|
|
);
|
|
// chat_done shape (verified against json_rpc_e2e.rs:1833-1841):
|
|
// { "event": "chat_done", "thread_id": "...", "full_response": "..." }
|
|
let full_response = done
|
|
.get("full_response")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or_else(|| panic!("chat_done missing 'full_response' field; actual event: {done}"));
|
|
assert!(
|
|
full_response.contains("CANARY_SMOKE_3471"),
|
|
"full_response missing canary: {done}"
|
|
);
|
|
|
|
stack.shutdown();
|
|
}
|
|
|
|
// ─── Large-stack thread wrapper (mirrors json_rpc_e2e.rs:81-101) ────────────
|
|
|
|
fn run_on_agent_stack<F, Fut>(name: &str, future_factory: F)
|
|
where
|
|
F: FnOnce() -> Fut + Send + 'static,
|
|
Fut: std::future::Future<Output = ()> + 'static,
|
|
{
|
|
std::thread::Builder::new()
|
|
.name(name.to_string())
|
|
.stack_size(openhuman_core::core::runtime::AGENT_WORKER_STACK_BYTES)
|
|
.spawn(move || {
|
|
let rt = tokio::runtime::Builder::new_multi_thread()
|
|
.worker_threads(2)
|
|
.thread_stack_size(openhuman_core::core::runtime::AGENT_WORKER_STACK_BYTES)
|
|
.enable_all()
|
|
.build()
|
|
.expect("build agent harness e2e runtime");
|
|
rt.block_on(future_factory());
|
|
})
|
|
.expect("spawn agent harness e2e thread")
|
|
.join()
|
|
.expect("agent harness e2e thread should not panic");
|
|
}
|
|
|
|
// ─── Task 2: Multi-turn state persistence ────────────────────────────────────
|
|
|
|
/// Turn 2's upstream request must include turn 1's user message and assistant
|
|
/// reply — proves transcript/history persistence across turns on one thread.
|
|
#[test]
|
|
fn multi_turn_state_persistence() {
|
|
run_on_agent_stack(
|
|
"multi_turn_state_persistence",
|
|
multi_turn_state_persistence_inner,
|
|
);
|
|
}
|
|
|
|
async fn multi_turn_state_persistence_inner() {
|
|
let _lock = env_lock();
|
|
reset_script(vec![
|
|
text_completion("The project is called FOO_CANARY."),
|
|
text_completion("Yes, FOO_CANARY is the one."),
|
|
]);
|
|
let stack = boot_stack().await;
|
|
|
|
let mut events = spawn_sse_collector(format!(
|
|
"{}/events?client_id=harness-multiturn",
|
|
stack.rpc_base
|
|
));
|
|
|
|
send_web_chat(
|
|
&stack.rpc_base,
|
|
200,
|
|
"harness-multiturn",
|
|
"thread-mt",
|
|
"what is the project name?",
|
|
)
|
|
.await;
|
|
let first = wait_for_terminal(&mut events, Duration::from_secs(60)).await;
|
|
assert_eq!(
|
|
first.get("event").and_then(Value::as_str),
|
|
Some("chat_done"),
|
|
"turn-1 expected chat_done, got: {first}"
|
|
);
|
|
// chat_done shape: { "event": "chat_done", "thread_id": "...", "full_response": "..." }
|
|
let first_response = first
|
|
.get("full_response")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or_else(|| panic!("turn-1 chat_done missing 'full_response': {first}"));
|
|
assert!(
|
|
first_response.contains("FOO_CANARY"),
|
|
"turn-1 full_response must contain FOO_CANARY: {first}"
|
|
);
|
|
|
|
send_web_chat(
|
|
&stack.rpc_base,
|
|
201,
|
|
"harness-multiturn",
|
|
"thread-mt",
|
|
"are you sure?",
|
|
)
|
|
.await;
|
|
let second = wait_for_terminal(&mut events, Duration::from_secs(60)).await;
|
|
assert_eq!(
|
|
second.get("event").and_then(Value::as_str),
|
|
Some("chat_done"),
|
|
"turn-2 expected chat_done, got: {second}"
|
|
);
|
|
|
|
// Last captured upstream request must carry turn-1 context in body.messages.
|
|
let requests = with_captured(|c| c.clone());
|
|
assert!(
|
|
requests.len() >= 2,
|
|
"expected ≥2 upstream calls, got {}; captured: {}",
|
|
requests.len(),
|
|
serde_json::to_string_pretty(&requests).unwrap_or_default()
|
|
);
|
|
let last_messages = requests
|
|
.last()
|
|
.unwrap()
|
|
.pointer("/body/messages")
|
|
.and_then(Value::as_array)
|
|
.cloned()
|
|
.unwrap_or_else(|| {
|
|
panic!(
|
|
"last upstream request missing /body/messages; request: {}",
|
|
serde_json::to_string_pretty(requests.last().unwrap()).unwrap_or_default()
|
|
)
|
|
});
|
|
let serialized = serde_json::to_string(&last_messages).unwrap();
|
|
assert!(
|
|
serialized.contains("what is the project name?"),
|
|
"turn-2 request missing turn-1 user message; messages: {serialized}"
|
|
);
|
|
assert!(
|
|
serialized.contains("FOO_CANARY"),
|
|
"turn-2 request missing turn-1 assistant reply (FOO_CANARY); messages: {serialized}"
|
|
);
|
|
|
|
stack.shutdown();
|
|
}
|
|
|
|
// ─── Task 3: Subagent delegation happy path ───────────────────────────────────
|
|
//
|
|
// Tool surface (src/openhuman/tools/orchestrator_tools.rs,
|
|
// src/openhuman/agent_registry/agents/researcher/agent.toml):
|
|
// - researcher has `delegate_name = "research"`, so the orchestrator LLM sees a
|
|
// tool named "research" synthesised by collect_orchestrator_tools.
|
|
// - The tool takes { "prompt": string, ... } per ArchetypeDelegationTool schema.
|
|
// - The orchestrator TOML lists "researcher" in its subagents.allowlist.
|
|
// - AgentDefinitionRegistry must be initialised (done in boot_stack) for the
|
|
// delegation tool to be synthesised; without it the call becomes "Unknown tool: research".
|
|
//
|
|
// Actual LLM request ordering (with registry init):
|
|
// request[0] = orchestrator → model returns { tool_calls: [research(...)] }
|
|
// request[1] = researcher subagent inner loop → model returns canary text
|
|
// request[2] = orchestrator synthesis → model returns final text with canary
|
|
|
|
/// Orchestrator delegates to researcher via the `research` tool (delegate_name
|
|
/// on the researcher agent definition); the researcher subagent runs its own
|
|
/// inner LLM call; the final orchestrator synthesis reply contains the researcher
|
|
/// canary. Three upstream requests prove the full delegation path ran.
|
|
#[test]
|
|
fn subagent_delegation_happy_path() {
|
|
run_on_agent_stack(
|
|
"subagent_delegation_happy_path",
|
|
subagent_delegation_happy_path_inner,
|
|
);
|
|
}
|
|
|
|
async fn subagent_delegation_happy_path_inner() {
|
|
let _lock = env_lock();
|
|
reset_script(vec![
|
|
// request[0]: Orchestrator calls the `research` tool (researcher's delegate_name).
|
|
tool_call_completion("research", json!({ "prompt": "Find the marker phrase" })),
|
|
// request[1]: Researcher subagent inner LLM call returns its canary.
|
|
text_completion("RESEARCHER_CANARY_42 is the marker."),
|
|
// request[2]: Orchestrator receives the researcher result and synthesizes.
|
|
text_completion("Done. The result is: RESEARCHER_CANARY_42"),
|
|
]);
|
|
let stack = boot_stack().await;
|
|
|
|
let mut events = spawn_sse_collector(format!(
|
|
"{}/events?client_id=harness-subagent",
|
|
stack.rpc_base
|
|
));
|
|
send_web_chat(
|
|
&stack.rpc_base,
|
|
300,
|
|
"harness-subagent",
|
|
"thread-sub",
|
|
"research the marker",
|
|
)
|
|
.await;
|
|
|
|
let done = wait_for_terminal(&mut events, Duration::from_secs(120)).await;
|
|
assert_eq!(
|
|
done.get("event").and_then(Value::as_str),
|
|
Some("chat_done"),
|
|
"expected chat_done for subagent delegation: {done}"
|
|
);
|
|
// chat_done carries the final synthesis in full_response.
|
|
let full_response = done
|
|
.get("full_response")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or_else(|| panic!("chat_done missing 'full_response': {done}"));
|
|
assert!(
|
|
full_response.contains("RESEARCHER_CANARY_42"),
|
|
"final response missing researcher canary; full_response: {full_response}\nevent: {done}"
|
|
);
|
|
|
|
// Delegation evidenced by ≥3 captured upstream requests:
|
|
// request[0] = orchestrator turn: research tool call returned
|
|
// request[1] = researcher subagent inner LLM call: canary text returned
|
|
// request[2] = orchestrator synthesis: canary forwarded in final reply
|
|
//
|
|
// NOTE: a completed turn's snapshot is now RETAINED (lifecycle `Completed`)
|
|
// so "View processing" can replay a finished turn; the snapshot is overwritten
|
|
// by the next turn, not deleted on completion. We verify delegation via the
|
|
// captured upstream requests rather than turn state, which keeps this test
|
|
// independent of the snapshot's retention/lifecycle details.
|
|
let requests = with_captured(|c| c.clone());
|
|
assert!(
|
|
requests.len() >= 3,
|
|
"expected ≥3 upstream requests (orchestrator + researcher + orchestrator synthesis), \
|
|
got {};\nall requests: {}",
|
|
requests.len(),
|
|
serde_json::to_string_pretty(&requests).unwrap_or_default()
|
|
);
|
|
|
|
// No "Unknown tool:" anywhere — proves the delegation tool was synthesised
|
|
// and executed (registry init worked).
|
|
let all_serialized = serde_json::to_string(&requests).unwrap_or_default();
|
|
assert!(
|
|
!all_serialized.contains("Unknown tool:"),
|
|
"found 'Unknown tool:' in captured requests — delegation tool was not synthesised; \
|
|
requests: {}",
|
|
serde_json::to_string_pretty(&requests).unwrap_or_default()
|
|
);
|
|
|
|
// request[1] (researcher subagent) must have different system/message content
|
|
// from request[0] (orchestrator) — proves a genuinely different agent context
|
|
// ran, not the same orchestrator re-called.
|
|
let req0_sys = requests
|
|
.first()
|
|
.and_then(|r| r.pointer("/body/messages/0/content"))
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("");
|
|
let req1_sys = requests
|
|
.get(1)
|
|
.and_then(|r| r.pointer("/body/messages/0/content"))
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("");
|
|
assert_ne!(
|
|
req0_sys, req1_sys,
|
|
"request[0] and request[1] share identical first-message content — \
|
|
researcher subagent did not build its own context; \
|
|
content: {req0_sys:?}"
|
|
);
|
|
|
|
stack.shutdown();
|
|
}
|
|
|
|
// ─── Super context: harness-driven context-scout happy path ───────────────────
|
|
//
|
|
// Tool surface (src/openhuman/agent_orchestration/tools/agent_prepare_context.rs,
|
|
// src/openhuman/agent_registry/agents/context_scout/agent.toml):
|
|
// - First-turn context prep is harness-driven, not orchestrator-scoped. The
|
|
// harness runs the read-only `context_scout` before the orchestrator's first
|
|
// LLM call and injects the scout's `[context_bundle]` into the user message.
|
|
//
|
|
// Actual LLM request ordering (with registry init):
|
|
// request[0] = context_scout subagent inner loop → model returns the bundle text
|
|
// request[1] = orchestrator synthesis → final text, having read the bundle
|
|
|
|
/// The harness runs `context_scout` before the first orchestrator turn and
|
|
/// injects the returned `[context_bundle]`; the final orchestrator synthesis
|
|
/// reads it. Two upstream requests prove the full scout path ran without
|
|
/// exposing `agent_prepare_context` to the orchestrator.
|
|
#[test]
|
|
fn super_context_happy_path() {
|
|
run_on_agent_stack("super_context_happy_path", super_context_happy_path_inner);
|
|
}
|
|
|
|
/// Regression for the "scout runs, bundle missing" failure: the fast chat-tier
|
|
/// `context_scout` wraps its `[context_bundle]` envelope in a preamble and a
|
|
/// closing line. The harness must extract just the envelope and inject it (not
|
|
/// drop the whole thing, and not leak the surrounding prose into the
|
|
/// orchestrator's context).
|
|
#[test]
|
|
fn super_context_extracts_prose_wrapped_bundle() {
|
|
run_on_agent_stack(
|
|
"super_context_extracts_prose_wrapped_bundle",
|
|
super_context_extracts_prose_wrapped_bundle_inner,
|
|
);
|
|
}
|
|
|
|
async fn super_context_extracts_prose_wrapped_bundle_inner() {
|
|
let _lock = env_lock();
|
|
// The envelope is wrapped in prose on BOTH sides — exactly what the strict
|
|
// whole-output validator used to reject.
|
|
let prose_wrapped = "Sure! Here's what I found for you:\n\n\
|
|
[context_bundle]\n\
|
|
has_enough_context: true\n\
|
|
summary: CTX_CANARY_9 — the user wants the marker phrase (memory).\n\
|
|
recommended_tool_calls:\n\
|
|
[/context_bundle]\n\n\
|
|
Hope that helps — let me know if you want me to dig deeper!";
|
|
reset_script(vec![
|
|
// request[0]: harness-driven context_scout returns a prose-wrapped bundle.
|
|
text_completion(prose_wrapped),
|
|
// request[1]: orchestrator reads the *extracted* bundle and synthesizes.
|
|
text_completion("Prepared. CTX_CANARY_9 noted."),
|
|
]);
|
|
let stack = boot_stack_with_super_context(true).await;
|
|
|
|
let mut events = spawn_sse_collector(format!(
|
|
"{}/events?client_id=harness-prepctx-prose",
|
|
stack.rpc_base
|
|
));
|
|
send_web_chat(
|
|
&stack.rpc_base,
|
|
400,
|
|
"harness-prepctx-prose",
|
|
"thread-prepctx-prose",
|
|
"prepare context for the marker",
|
|
)
|
|
.await;
|
|
|
|
let done = wait_for_terminal(&mut events, Duration::from_secs(120)).await;
|
|
assert_eq!(
|
|
done.get("event").and_then(Value::as_str),
|
|
Some("chat_done"),
|
|
"expected chat_done for prose-wrapped super_context: {done}"
|
|
);
|
|
|
|
let requests = with_captured(|c| c.clone());
|
|
let last_messages = serde_json::to_string(
|
|
requests
|
|
.last()
|
|
.and_then(|r| r.pointer("/body/messages"))
|
|
.unwrap_or(&Value::Null),
|
|
)
|
|
.unwrap_or_default();
|
|
// The extracted envelope (with its canary) must reach the orchestrator …
|
|
assert!(
|
|
last_messages.contains("[context_bundle]") && last_messages.contains("CTX_CANARY_9"),
|
|
"extracted bundle missing from synthesis request; messages: {last_messages}"
|
|
);
|
|
// … but the wrapping prose must NOT — we inject only the envelope.
|
|
assert!(
|
|
!last_messages.contains("Here's what I found")
|
|
&& !last_messages.contains("Hope that helps"),
|
|
"scout's wrapping prose leaked into the orchestrator context; messages: {last_messages}"
|
|
);
|
|
|
|
stack.shutdown();
|
|
}
|
|
|
|
async fn super_context_happy_path_inner() {
|
|
let _lock = env_lock();
|
|
let scout_bundle = "[context_bundle]\n\
|
|
has_enough_context: true\n\
|
|
summary: CTX_CANARY_7 — the user wants the marker phrase (memory).\n\
|
|
recommended_tool_calls:\n\
|
|
\x20 - tool: spawn_worker_thread\n\
|
|
\x20 args: {\"prompt\": \"act on the marker\"}\n\
|
|
\x20 why: execute the prepared plan\n\
|
|
[/context_bundle]";
|
|
reset_script(vec![
|
|
// request[0]: harness-driven context_scout subagent returns the bundle.
|
|
text_completion(scout_bundle),
|
|
// request[1]: Orchestrator reads the injected bundle and synthesizes.
|
|
text_completion("Prepared. CTX_CANARY_7 noted; next I'd spawn a worker."),
|
|
]);
|
|
let stack = boot_stack_with_super_context(true).await;
|
|
|
|
let mut events = spawn_sse_collector(format!(
|
|
"{}/events?client_id=harness-prepctx",
|
|
stack.rpc_base
|
|
));
|
|
send_web_chat(
|
|
&stack.rpc_base,
|
|
400,
|
|
"harness-prepctx",
|
|
"thread-prepctx",
|
|
"prepare context for the marker",
|
|
)
|
|
.await;
|
|
|
|
let done = wait_for_terminal(&mut events, Duration::from_secs(120)).await;
|
|
assert_eq!(
|
|
done.get("event").and_then(Value::as_str),
|
|
Some("chat_done"),
|
|
"expected chat_done for super_context: {done}"
|
|
);
|
|
let full_response = done
|
|
.get("full_response")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or_else(|| panic!("chat_done missing 'full_response': {done}"));
|
|
assert!(
|
|
full_response.contains("CTX_CANARY_7"),
|
|
"final response missing scout canary; full_response: {full_response}\nevent: {done}"
|
|
);
|
|
|
|
let requests = with_captured(|c| c.clone());
|
|
assert!(
|
|
requests.len() >= 2,
|
|
"expected ≥2 upstream requests (context_scout + orchestrator), got {};\n{}",
|
|
requests.len(),
|
|
serde_json::to_string_pretty(&requests).unwrap_or_default()
|
|
);
|
|
|
|
let orchestrator_tools = serde_json::to_string(
|
|
requests
|
|
.last()
|
|
.and_then(|r| r.pointer("/body/tools"))
|
|
.unwrap_or(&Value::Null),
|
|
)
|
|
.unwrap_or_default();
|
|
assert!(
|
|
!orchestrator_tools.contains("agent_prepare_context"),
|
|
"orchestrator should not see agent_prepare_context in its tool schema; tools: {orchestrator_tools}"
|
|
);
|
|
|
|
// The synthesis turn must carry the scout's bundle in the user message —
|
|
// proves the [context_bundle] flowed into the orchestrator's context without
|
|
// an orchestrator tool call.
|
|
let last_messages = serde_json::to_string(
|
|
requests
|
|
.last()
|
|
.and_then(|r| r.pointer("/body/messages"))
|
|
.unwrap_or(&Value::Null),
|
|
)
|
|
.unwrap_or_default();
|
|
assert!(
|
|
last_messages.contains("[context_bundle]") && last_messages.contains("CTX_CANARY_7"),
|
|
"synthesis request missing the scout bundle as a tool result; messages: {last_messages}"
|
|
);
|
|
|
|
// request[1] (context_scout) must build a different context than request[0]
|
|
// (orchestrator) — proves a genuinely separate scout agent ran.
|
|
let req0_sys = requests
|
|
.first()
|
|
.and_then(|r| r.pointer("/body/messages/0/content"))
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("");
|
|
let req1_sys = requests
|
|
.get(1)
|
|
.and_then(|r| r.pointer("/body/messages/0/content"))
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("");
|
|
assert_ne!(
|
|
req0_sys, req1_sys,
|
|
"request[0] and request[1] share identical first-message content — \
|
|
context_scout did not build its own context"
|
|
);
|
|
|
|
stack.shutdown();
|
|
}
|
|
|
|
// ─── Task 4: Subagent clarification flow ──────────────────────────────────────
|
|
//
|
|
// Exercises the ask_user_clarification path via scheduler_agent
|
|
// (delegate_name = "schedule_task"), which has `ask_user_clarification` in its
|
|
// [tools] named list (src/openhuman/agent_registry/agents/scheduler_agent/agent.toml:22).
|
|
//
|
|
// Architecture note — why the full spawn_subagent→[SUBAGENT_AWAITING_USER] path
|
|
// is not exercised here:
|
|
//
|
|
// spawn_subagent (SpawnSubagentTool, spawn_subagent.rs:465-506) is the ONLY tool
|
|
// that produces [SUBAGENT_AWAITING_USER] envelopes and the continue_subagent tool
|
|
// resumes them. But spawn_subagent is NOT in the orchestrator's [tools] named list
|
|
// (orchestrator/agent.toml:160-227), so the visible_tool_names filter
|
|
// (agent_tool_exec.rs:77-87) blocks it. That path requires a src/ change.
|
|
//
|
|
// What this test DOES exercise:
|
|
//
|
|
// The ArchetypeDelegationTool path (dispatch.rs). scheduler_agent is delegated to
|
|
// via the synthesised `schedule_task` tool. dispatch_subagent (dispatch.rs:113-130)
|
|
// calls run_subagent. The scripted LLM returns ask_user_clarification for the
|
|
// scheduler_agent inner loop. However, ask_user_clarification is NOT registered in
|
|
// all_tools_with_runtime (tools/ops.rs), so it is absent from the subagent's
|
|
// allowed_names (subagent_runner/ops/runner.rs:483-490). SubagentToolSource
|
|
// (tool_source.rs:66-102) therefore returns success=false for the blocked call.
|
|
// The early-exit condition (engine/core.rs:676) requires outcome.success, so
|
|
// early-exit does NOT fire. The scheduler_agent loops back for a second LLM call
|
|
// and returns its text output, which dispatch_subagent forwards as the
|
|
// schedule_task tool result. The orchestrator surfaces this to the user.
|
|
// On turn 2 the user's reply and the full turn-1 context are present.
|
|
//
|
|
// Actual LLM request ordering (4 upstream calls total):
|
|
// request[0] = orchestrator turn 1 → schedule_task delegation tool call returned
|
|
// request[1] = scheduler_agent first iter → tries ask_user_clarification (blocked,
|
|
// success=false; early-exit does NOT fire; loop continues)
|
|
// request[2] = scheduler_agent second iter → returns text with clarification question
|
|
// (this becomes the schedule_task tool result and turn-1 response)
|
|
// request[3] = orchestrator turn 2 with "version 2" user reply in full context →
|
|
// synthesis; turn 2 ends (chat_done with ANSWER_CANARY_V2)
|
|
|
|
/// Orchestrator delegates to scheduler_agent via `schedule_task` (delegate_name);
|
|
/// scheduler_agent's ask_user_clarification call is blocked (not in parent's tool
|
|
/// registry) so the subagent loops and returns the question as text instead;
|
|
/// dispatch_subagent forwards this as the schedule_task tool result; the orchestrator
|
|
/// surfaces the question (turn 1 ends with WHICH_VERSION_CANARY); the user replies
|
|
/// "version 2"; the orchestrator synthesizes the final answer with full turn-1 context
|
|
/// present (turn 2 ends with ANSWER_CANARY_V2).
|
|
///
|
|
/// The full spawn_subagent → [SUBAGENT_AWAITING_USER] → continue_subagent path
|
|
/// requires adding spawn_subagent to the orchestrator's named tools
|
|
/// (src/openhuman/agent_registry/agents/orchestrator/agent.toml) — a src/ change
|
|
/// outside the scope of this test file.
|
|
#[test]
|
|
fn subagent_clarification_flow() {
|
|
run_on_agent_stack(
|
|
"subagent_clarification_flow",
|
|
subagent_clarification_flow_inner,
|
|
);
|
|
}
|
|
|
|
async fn subagent_clarification_flow_inner() {
|
|
let _lock = env_lock();
|
|
reset_script(vec![
|
|
// ── turn 1 ──
|
|
// request[0]: Orchestrator calls schedule_task (scheduler_agent's delegate_name).
|
|
tool_call_completion(
|
|
"schedule_task",
|
|
json!({ "prompt": "Schedule a weekly reminder" }),
|
|
),
|
|
// request[1]: scheduler_agent first iter → tries ask_user_clarification.
|
|
// ask_user_clarification is NOT in all_tools_with_runtime (tools/ops.rs), so
|
|
// SubagentToolSource returns success=false. Early-exit requires success=true,
|
|
// so it does NOT fire; the scheduler_agent loops back for a second LLM call.
|
|
tool_call_completion(
|
|
"ask_user_clarification",
|
|
json!({ "question": "WHICH_VERSION_CANARY?" }),
|
|
),
|
|
// request[2]: scheduler_agent second iter → text output with the clarification
|
|
// question. This becomes the schedule_task tool result forwarded to the
|
|
// orchestrator by dispatch_subagent.
|
|
text_completion("I need clarification: WHICH_VERSION_CANARY?"),
|
|
// ── turn 2 (user replied "version 2") ──
|
|
// request[3]: Orchestrator processes user reply with full turn-1 context →
|
|
// synthesizes final answer; turn 2 ends here.
|
|
text_completion("Final: ANSWER_CANARY_V2"),
|
|
]);
|
|
let stack = boot_stack().await;
|
|
|
|
let mut events = spawn_sse_collector(format!(
|
|
"{}/events?client_id=harness-clarify",
|
|
stack.rpc_base
|
|
));
|
|
|
|
// ── turn 1: clarification question must reach the user ──
|
|
send_web_chat(
|
|
&stack.rpc_base,
|
|
400,
|
|
"harness-clarify",
|
|
"thread-clarify",
|
|
"schedule a weekly reminder",
|
|
)
|
|
.await;
|
|
let first = wait_for_terminal(&mut events, Duration::from_secs(120)).await;
|
|
assert_eq!(
|
|
first.get("event").and_then(Value::as_str),
|
|
Some("chat_done"),
|
|
"turn-1 expected chat_done: {first}"
|
|
);
|
|
let first_response = first
|
|
.get("full_response")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or_else(|| panic!("turn-1 chat_done missing 'full_response': {first}"));
|
|
assert!(
|
|
first_response.contains("WHICH_VERSION_CANARY"),
|
|
"clarification question not surfaced to user; full_response: {first_response}\nevent: {first}"
|
|
);
|
|
|
|
// ── turn 2: resume with answer → final response must reach the user ──
|
|
send_web_chat(
|
|
&stack.rpc_base,
|
|
401,
|
|
"harness-clarify",
|
|
"thread-clarify",
|
|
"version 2",
|
|
)
|
|
.await;
|
|
let second = wait_for_terminal(&mut events, Duration::from_secs(120)).await;
|
|
assert_eq!(
|
|
second.get("event").and_then(Value::as_str),
|
|
Some("chat_done"),
|
|
"turn-2 expected chat_done: {second}"
|
|
);
|
|
let second_response = second
|
|
.get("full_response")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or_else(|| panic!("turn-2 chat_done missing 'full_response': {second}"));
|
|
assert!(
|
|
second_response.contains("ANSWER_CANARY_V2"),
|
|
"turn-2 flow did not complete with answer canary; full_response: {second_response}\nevent: {second}"
|
|
);
|
|
|
|
let requests = with_captured(|c| c.clone());
|
|
let serialized = serde_json::to_string(&requests).unwrap_or_default();
|
|
|
|
// ── No "Unknown tool:" in any captured request ──
|
|
// Proves schedule_task (synthesised from scheduler_agent's delegate_name) was
|
|
// recognised by the orchestrator — registry init worked.
|
|
assert!(
|
|
!serialized.contains("Unknown tool:"),
|
|
"found 'Unknown tool:' in captured requests — delegation was broken; \
|
|
requests: {}",
|
|
serde_json::to_string_pretty(&requests).unwrap_or_default()
|
|
);
|
|
|
|
// ── scheduler_agent actually ran (≥4 upstream requests) ──
|
|
// request[0] = orchestrator (schedule_task call),
|
|
// request[1] = scheduler_agent first iter (ask_user_clarification blocked),
|
|
// request[2] = scheduler_agent second iter (text output with question),
|
|
// request[3] = orchestrator turn-2 synthesis (turn-2 end).
|
|
assert!(
|
|
requests.len() >= 4,
|
|
"expected ≥4 upstream requests (orchestrator + scheduler_agent x2 + orchestrator turn-2 synthesis), \
|
|
got {};\nall requests: {}",
|
|
requests.len(),
|
|
serde_json::to_string_pretty(&requests).unwrap_or_default()
|
|
);
|
|
|
|
// ── request[1] (scheduler_agent first iter) must differ from request[0] (orchestrator) ──
|
|
// Proves a genuinely separate scheduler_agent context ran, not the orchestrator re-called.
|
|
let req0_sys = requests
|
|
.first()
|
|
.and_then(|r| r.pointer("/body/messages/0/content"))
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("");
|
|
let req1_sys = requests
|
|
.get(1)
|
|
.and_then(|r| r.pointer("/body/messages/0/content"))
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("");
|
|
assert_ne!(
|
|
req0_sys, req1_sys,
|
|
"request[0] and request[1] share identical first-message content — \
|
|
scheduler_agent did not build its own context; \
|
|
content: {req0_sys:?}"
|
|
);
|
|
|
|
// ── Some turn-2 request's messages must contain the clarification question ──
|
|
// Proves the scheduler_agent's text output (forwarded by dispatch_subagent as the
|
|
// schedule_task tool result) was persisted in the thread history and appears in
|
|
// turn-2 context (multi-turn state persistence).
|
|
let turn2_messages_contain_question = requests.iter().any(|req| {
|
|
req.pointer("/body/messages")
|
|
.and_then(Value::as_array)
|
|
.map(|msgs| {
|
|
msgs.iter().any(|m| {
|
|
let content = match m.get("content") {
|
|
Some(Value::String(s)) => s.as_str().to_string(),
|
|
Some(Value::Array(arr)) => arr
|
|
.iter()
|
|
.filter_map(|part| {
|
|
part.get("text").and_then(Value::as_str).map(str::to_string)
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join(" "),
|
|
_ => String::new(),
|
|
};
|
|
content.contains("WHICH_VERSION_CANARY")
|
|
})
|
|
})
|
|
.unwrap_or(false)
|
|
});
|
|
assert!(
|
|
turn2_messages_contain_question,
|
|
"WHICH_VERSION_CANARY not found in any turn-2 request messages — \
|
|
turn-1 clarification question was not persisted in thread history; \
|
|
requests: {}",
|
|
serde_json::to_string_pretty(&requests).unwrap_or_default()
|
|
);
|
|
|
|
stack.shutdown();
|
|
}
|
|
|
|
// ─── Task 5: Approval gate tests ─────────────────────────────────────────────
|
|
//
|
|
// Architecture notes for file_write approval:
|
|
//
|
|
// `FileWriteTool::external_effect_with_args` (src/openhuman/tools/impl/filesystem/file_write.rs:65)
|
|
// only returns `true` when the target file ALREADY EXISTS at `action_dir/path`.
|
|
// Logic: "exists = edit → prompt; new = create → free". The default action_dir
|
|
// is `~/OpenHuman/projects` (derived from the HOME env var that boot_stack
|
|
// overrides to a tempdir). Tests therefore pre-create the target file under
|
|
// `$HOME/OpenHuman/projects/` so that `external_effect_with_args` sees an
|
|
// existing file and returns `true`, routing the call through the approval gate.
|
|
//
|
|
// The approval gate fires only for WebChat-origin turns
|
|
// (gate.rs:278-370 — WebChat falls through to the parking flow).
|
|
// `channel_web_chat` scopes AgentTurnOrigin::WebChat + APPROVAL_CHAT_CONTEXT
|
|
// around the agent run, so SSE events carry `approval_request`.
|
|
//
|
|
// GLOBAL_GATE is a process-wide OnceLock — first install wins. The
|
|
// `ensure_approval_gate` helper is idempotent; all approval tests call it.
|
|
//
|
|
// `OPENHUMAN_APPROVAL_TTL_SECS` (Task 6 production change in gate.rs) is read
|
|
// per-intercept via `effective_ttl()`. Tests set it before `send_web_chat` and
|
|
// restore it on drop via EnvVarGuard.
|
|
|
|
fn ensure_approval_gate() {
|
|
use openhuman_core::core::event_bus;
|
|
use openhuman_core::openhuman::approval::ApprovalGate;
|
|
|
|
// The global event bus must be initialized before registering subscribers.
|
|
// `build_core_http_router` does NOT call `bootstrap_core_runtime`, so the bus
|
|
// is not initialized by boot_stack. Initialize it here (idempotent: OnceLock).
|
|
event_bus::init_global(event_bus::DEFAULT_CAPACITY);
|
|
|
|
let mut cfg: openhuman_core::openhuman::config::Config = toml::from_str(
|
|
r#"api_url = "http://127.0.0.1:1"
|
|
default_model = "e2e-mock-model"
|
|
default_temperature = 0.7
|
|
chat_onboarding_completed = true
|
|
|
|
[secrets]
|
|
encrypt = false
|
|
"#,
|
|
)
|
|
.expect("gate config must parse");
|
|
|
|
// `toml::from_str` leaves `workspace_dir` empty (it is `#[serde(skip)]`).
|
|
// Without this fix the gate's SQLite audit store writes to `./approval/approval.db`
|
|
// (cwd — the repo root). Point it at a process-stable tempdir instead so the
|
|
// repo root stays clean regardless of test order.
|
|
let gate_workspace =
|
|
GATE_WORKSPACE.get_or_init(|| tempfile::TempDir::new().expect("GATE_WORKSPACE tempdir"));
|
|
cfg.workspace_dir = gate_workspace.path().to_path_buf();
|
|
|
|
// GLOBAL_GATE is a OnceLock — first call installs, subsequent calls return the
|
|
// same gate. The session_id must start with "session-" (debug_assert in gate.rs).
|
|
let _ = ApprovalGate::init_global(cfg, "session-agent-harness-e2e");
|
|
|
|
// NOTE: We intentionally do NOT call register_approval_surface_subscriber() here.
|
|
// That function uses an OnceLock so it only registers once per process. If it fires
|
|
// on an early test's tokio runtime (e.g. approval_gate_installed_after_ensure), the
|
|
// background task is tied to that runtime and dies when it drops. Subsequent tests
|
|
// then have no bridge and never see the approval_request SSE event.
|
|
//
|
|
// Instead, each test that depends on the approval_request SSE event calls
|
|
// fresh_approval_surface_subscription() and holds the returned SubscriptionHandle
|
|
// for the test's duration. This spawns the bridge task on the current (per-test)
|
|
// runtime so it lives exactly as long as needed.
|
|
}
|
|
|
|
/// Register a per-test approval surface bridge on the **current** tokio runtime.
|
|
///
|
|
/// Bridges `DomainEvent::ApprovalRequested` → `approval_request` SSE events for the
|
|
/// duration of the current test. Each approval test MUST call this and store the
|
|
/// returned handle in a local variable (prefix `_` to keep the binding alive without
|
|
/// triggering unused-variable warnings).
|
|
///
|
|
/// Background: the process-global `register_approval_surface_subscriber()` is OnceLock-
|
|
/// guarded and spawns its task on whichever runtime first calls it. When that runtime
|
|
/// drops (end of the first test), the task is cancelled and all subsequent tests in the
|
|
/// same binary lose the bridge silently. This per-test helper avoids the issue by
|
|
/// registering a fresh subscription on each test's own runtime.
|
|
fn register_approval_bridge() -> Option<openhuman_core::core::event_bus::SubscriptionHandle> {
|
|
openhuman_core::openhuman::channels::providers::web::fresh_approval_surface_subscription()
|
|
}
|
|
|
|
/// Pre-create a file in the action_dir so file_write sees it as an existing
|
|
/// file and external_effect_with_args returns true (triggering the approval gate).
|
|
/// The action_dir is `$HOME/OpenHuman/projects/` where HOME is set to `home`.
|
|
fn pre_create_for_approval(home: &Path, filename: &str) -> std::path::PathBuf {
|
|
let action_dir = home.join("OpenHuman").join("projects");
|
|
std::fs::create_dir_all(&action_dir)
|
|
.unwrap_or_else(|e| panic!("create action_dir {action_dir:?}: {e}"));
|
|
let target = action_dir.join(filename);
|
|
std::fs::write(&target, b"placeholder for approval gate test")
|
|
.unwrap_or_else(|e| panic!("pre-create {target:?}: {e}"));
|
|
target
|
|
}
|
|
|
|
// ─── 5.1 ensure_approval_gate helper ─────────────────────────────────────────
|
|
|
|
/// Sanity: ensure_approval_gate installs the gate and ApprovalGate::try_global
|
|
/// returns Some after the call. OnceLock means subsequent calls are no-ops.
|
|
#[test]
|
|
fn approval_gate_installed_after_ensure() {
|
|
run_on_agent_stack(
|
|
"approval_gate_installed_after_ensure",
|
|
approval_gate_installed_after_ensure_inner,
|
|
);
|
|
}
|
|
|
|
async fn approval_gate_installed_after_ensure_inner() {
|
|
let _lock = env_lock();
|
|
use openhuman_core::openhuman::approval::ApprovalGate;
|
|
ensure_approval_gate();
|
|
assert!(
|
|
ApprovalGate::try_global().is_some(),
|
|
"ApprovalGate::try_global() must return Some after ensure_approval_gate()"
|
|
);
|
|
}
|
|
|
|
// ─── 5.2 approval_gate_approve_flow ──────────────────────────────────────────
|
|
//
|
|
// Architecture: the orchestrator delegates to code_executor via the `run_code`
|
|
// tool (code_executor's delegate_name in agent.toml:3). code_executor has
|
|
// file_write in its tool surface (agent.toml:named). The subagent runs inside
|
|
// the orchestrator's WebChat task-local context (dispatch_subagent does NOT
|
|
// re-scope turn_origin or APPROVAL_CHAT_CONTEXT), so file_write inside the
|
|
// subagent parks at the approval gate and publishes approval_request SSE.
|
|
//
|
|
// LLM request ordering (4 calls total):
|
|
// request[0] = orchestrator → run_code delegation tool call
|
|
// request[1] = code_executor → file_write tool call (approval parks)
|
|
// request[2] = code_executor → text completion after approve
|
|
// request[3] = orchestrator synthesis
|
|
|
|
/// Orchestrator delegates to code_executor via `run_code`; code_executor calls
|
|
/// file_write (on an existing file) → approval gate parks in the subagent's
|
|
/// inherited WebChat context → approval_request surfaces over SSE → approve_once
|
|
/// resumes → subagent completes → orchestrator synthesizes with APPROVED_WRITE_CANARY.
|
|
/// The file IS written under the tempdir.
|
|
#[test]
|
|
fn approval_gate_approve_flow() {
|
|
run_on_agent_stack(
|
|
"approval_gate_approve_flow",
|
|
approval_gate_approve_flow_inner,
|
|
);
|
|
}
|
|
|
|
async fn approval_gate_approve_flow_inner() {
|
|
let _lock = env_lock();
|
|
let _ttl = EnvVarGuard::set("OPENHUMAN_APPROVAL_TTL_SECS", "120");
|
|
ensure_approval_gate();
|
|
// Register a fresh approval bridge on the current runtime. Each approval test needs
|
|
// its own per-runtime bridge so the background task does not die when a previous
|
|
// test's runtime drops (see register_approval_bridge docstring for details).
|
|
let _approval_bridge = register_approval_bridge();
|
|
reset_script(vec![
|
|
// request[0]: Orchestrator delegates to code_executor via run_code.
|
|
// run_code (ArchetypeDelegationTool) requires "prompt" key; empty/missing → error.
|
|
tool_call_completion(
|
|
"run_code",
|
|
json!({ "prompt": "write approval-canary.txt with APPROVED_WRITE_CANARY" }),
|
|
),
|
|
// request[1]: code_executor calls file_write → gate parks.
|
|
tool_call_completion(
|
|
"file_write",
|
|
json!({ "path": "approval-canary.txt", "content": "APPROVED_WRITE_CANARY" }),
|
|
),
|
|
// request[2]: code_executor text after approval.
|
|
text_completion("File written: APPROVED_WRITE_CANARY"),
|
|
// request[3]: Orchestrator synthesis.
|
|
text_completion("Done. File written: APPROVED_WRITE_CANARY"),
|
|
]);
|
|
let stack = boot_stack().await;
|
|
|
|
// Pre-create the file so file_write sees it as an existing file and
|
|
// external_effect_with_args returns true → approval gate intercepts.
|
|
let home = stack._tmp.path().to_path_buf();
|
|
pre_create_for_approval(&home, "approval-canary.txt");
|
|
|
|
let mut events = spawn_sse_collector(format!(
|
|
"{}/events?client_id=harness-approve",
|
|
stack.rpc_base
|
|
));
|
|
send_web_chat(
|
|
&stack.rpc_base,
|
|
500,
|
|
"harness-approve",
|
|
"thread-approve",
|
|
"write the file",
|
|
)
|
|
.await;
|
|
|
|
// Wait for the approval_request SSE event.
|
|
// Actual shape (src/openhuman/channels/providers/web/event_bus.rs:195-224):
|
|
// { "event": "approval_request", "data": { "request_id": "...", "tool_name": "...",
|
|
// "action_summary": "...", "args_redacted": {...} }, ... }
|
|
let approval = wait_for_event(&mut events, "approval_request", Duration::from_secs(60)).await;
|
|
let request_id = approval
|
|
.pointer("/data/request_id")
|
|
.or_else(|| approval.get("request_id"))
|
|
.and_then(Value::as_str)
|
|
.unwrap_or_else(|| {
|
|
panic!("approval_request missing request_id; all received SSE: {approval}")
|
|
})
|
|
.to_string();
|
|
assert!(
|
|
approval.to_string().contains("file_write"),
|
|
"approval_request must mention file_write tool; event: {approval}"
|
|
);
|
|
|
|
// Approve the tool call.
|
|
let decide = post_json_rpc(
|
|
&stack.rpc_base,
|
|
501,
|
|
"openhuman.approval_decide",
|
|
json!({ "request_id": request_id, "decision": "approve_once" }),
|
|
)
|
|
.await;
|
|
assert_no_jsonrpc_error(&decide, "approval_decide approve");
|
|
|
|
// Turn must complete with the canary in full_response.
|
|
let done = wait_for_terminal(&mut events, Duration::from_secs(60)).await;
|
|
assert_eq!(
|
|
done.get("event").and_then(Value::as_str),
|
|
Some("chat_done"),
|
|
"expected chat_done after approve; got: {done}"
|
|
);
|
|
let full_response = done
|
|
.get("full_response")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or_else(|| panic!("chat_done missing full_response: {done}"));
|
|
assert!(
|
|
full_response.contains("APPROVED_WRITE_CANARY"),
|
|
"full_response must contain APPROVED_WRITE_CANARY; got: {full_response}"
|
|
);
|
|
|
|
// The file was actually written under the temp HOME (action_dir = $HOME/OpenHuman/projects/).
|
|
// Walk the tempdir to confirm the file exists with the written content.
|
|
let action_dir = stack._tmp.path().join("OpenHuman").join("projects");
|
|
let canary_path = action_dir.join("approval-canary.txt");
|
|
let content = std::fs::read_to_string(&canary_path)
|
|
.unwrap_or_else(|e| panic!("approval-canary.txt missing after approve: {e}"));
|
|
assert!(
|
|
content.contains("APPROVED_WRITE_CANARY"),
|
|
"approval-canary.txt must contain APPROVED_WRITE_CANARY after approve; got: {content:?}"
|
|
);
|
|
|
|
stack.shutdown();
|
|
}
|
|
|
|
// ─── 5.3 approval_gate_deny_flow ─────────────────────────────────────────────
|
|
|
|
/// Denied tool call: tool does NOT execute; the second scripted text tells the
|
|
/// agent to acknowledge the denial; turn completes with DENIAL_ACK_CANARY.
|
|
/// denied-canary.txt content must remain as the placeholder (not the canary).
|
|
#[test]
|
|
fn approval_gate_deny_flow() {
|
|
run_on_agent_stack("approval_gate_deny_flow", approval_gate_deny_flow_inner);
|
|
}
|
|
|
|
async fn approval_gate_deny_flow_inner() {
|
|
let _lock = env_lock();
|
|
let _ttl = EnvVarGuard::set("OPENHUMAN_APPROVAL_TTL_SECS", "120");
|
|
ensure_approval_gate();
|
|
let _approval_bridge = register_approval_bridge();
|
|
// Same delegation chain as approve_flow: orchestrator → run_code → code_executor
|
|
// → file_write. After denial, code_executor receives the denial marker from the
|
|
// gate and returns a text response; orchestrator synthesizes with DENIAL_ACK_CANARY.
|
|
reset_script(vec![
|
|
// request[0]: Orchestrator delegates to code_executor.
|
|
tool_call_completion("run_code", json!({ "prompt": "write denied-canary.txt" })),
|
|
// request[1]: code_executor calls file_write → gate parks, user denies.
|
|
tool_call_completion(
|
|
"file_write",
|
|
json!({ "path": "denied-canary.txt", "content": "DENIED_WRITE_CANARY" }),
|
|
),
|
|
// request[2]: code_executor text after denial (gate returns POLICY_DENIED_MARKER).
|
|
text_completion("Understood — the write was denied. DENIAL_ACK_CANARY"),
|
|
// request[3]: Orchestrator synthesis.
|
|
text_completion("Acknowledged: DENIAL_ACK_CANARY"),
|
|
]);
|
|
let stack = boot_stack().await;
|
|
|
|
// Pre-create the file so file_write sees it as an existing file.
|
|
let home = stack._tmp.path().to_path_buf();
|
|
pre_create_for_approval(&home, "denied-canary.txt");
|
|
|
|
let mut events =
|
|
spawn_sse_collector(format!("{}/events?client_id=harness-deny", stack.rpc_base));
|
|
send_web_chat(
|
|
&stack.rpc_base,
|
|
510,
|
|
"harness-deny",
|
|
"thread-deny",
|
|
"write the file",
|
|
)
|
|
.await;
|
|
|
|
let approval = wait_for_event(&mut events, "approval_request", Duration::from_secs(60)).await;
|
|
let request_id = approval
|
|
.pointer("/data/request_id")
|
|
.or_else(|| approval.get("request_id"))
|
|
.and_then(Value::as_str)
|
|
.unwrap_or_else(|| panic!("approval_request missing request_id: {approval}"))
|
|
.to_string();
|
|
|
|
let decide = post_json_rpc(
|
|
&stack.rpc_base,
|
|
511,
|
|
"openhuman.approval_decide",
|
|
json!({ "request_id": request_id, "decision": "deny" }),
|
|
)
|
|
.await;
|
|
assert_no_jsonrpc_error(&decide, "approval_decide deny");
|
|
|
|
let done = wait_for_terminal(&mut events, Duration::from_secs(60)).await;
|
|
assert_eq!(
|
|
done.get("event").and_then(Value::as_str),
|
|
Some("chat_done"),
|
|
"expected chat_done after deny; got: {done}"
|
|
);
|
|
let full_response = done
|
|
.get("full_response")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or_else(|| panic!("chat_done missing full_response: {done}"));
|
|
assert!(
|
|
full_response.contains("DENIAL_ACK_CANARY"),
|
|
"full_response must contain DENIAL_ACK_CANARY; got: {full_response}"
|
|
);
|
|
|
|
// The denied file_write must not have overwritten the placeholder.
|
|
// The pre-created file must still contain exactly the original placeholder string.
|
|
let action_dir = stack._tmp.path().join("OpenHuman").join("projects");
|
|
let canary_path = action_dir.join("denied-canary.txt");
|
|
let content = std::fs::read_to_string(&canary_path)
|
|
.expect("denied-canary.txt must exist after deny flow");
|
|
assert_eq!(
|
|
content, "placeholder for approval gate test",
|
|
"denied file_write must leave the file content unchanged (placeholder only); got: {content:?}"
|
|
);
|
|
|
|
stack.shutdown();
|
|
}
|
|
|
|
// ─── 5.4 subagent_with_approval_gate ─────────────────────────────────────────
|
|
//
|
|
// Architecture: The approval gate fires for file_write inside a subagent context
|
|
// only when the subagent run carries a WebChat turn origin. `dispatch_subagent`
|
|
// (src/openhuman/agent_orchestration/tools/dispatch.rs) invokes `run_subagent`
|
|
// which runs the subagent's tool loop inside the SAME task that the orchestrator's
|
|
// WebChat turn started in. Because `APPROVAL_CHAT_CONTEXT` and `turn_origin` are
|
|
// tokio task-locals (not thread-locals), and `run_subagent` does NOT re-scope them,
|
|
// the subagent inherits the WebChat origin from the orchestrator's task scope.
|
|
// Therefore file_write inside a ArchetypeDelegationTool subagent CAN trigger the
|
|
// approval gate and publish approval_request events.
|
|
//
|
|
// code_executor has delegate_name = "run_code" (src/openhuman/agent_registry/
|
|
// agents/code_executor/agent.toml:3). The orchestrator synthesizes a `run_code`
|
|
// delegation tool from this. code_executor has file_write in its tool surface.
|
|
// The researcher agent does NOT have file_write.
|
|
//
|
|
// Actual LLM request ordering:
|
|
// request[0] = orchestrator → run_code delegation tool call
|
|
// request[1] = code_executor subagent → file_write tool call (approval parks)
|
|
// request[2] = code_executor subagent → text completion after approve
|
|
// request[3] = orchestrator → synthesis with SUBAGENT_WRITE_CANARY
|
|
|
|
/// Orchestrator delegates to code_executor via the `run_code` tool (code_executor's
|
|
/// delegate_name); the code_executor subagent calls file_write (on an existing file) →
|
|
/// the approval gate parks inside the subagent's inherited WebChat context →
|
|
/// approval_request fires → approve_once resumes it → subagent completes →
|
|
/// orchestrator synthesizes. Three-plus upstream requests confirm the full path.
|
|
#[test]
|
|
fn subagent_with_approval_gate() {
|
|
run_on_agent_stack(
|
|
"subagent_with_approval_gate",
|
|
subagent_with_approval_gate_inner,
|
|
);
|
|
}
|
|
|
|
async fn subagent_with_approval_gate_inner() {
|
|
let _lock = env_lock();
|
|
let _ttl = EnvVarGuard::set("OPENHUMAN_APPROVAL_TTL_SECS", "120");
|
|
ensure_approval_gate();
|
|
let _approval_bridge = register_approval_bridge();
|
|
reset_script(vec![
|
|
// request[0]: Orchestrator delegates to code_executor via run_code.
|
|
// code_executor's delegate_name = "run_code" (agent.toml:3).
|
|
// ArchetypeDelegationTool requires "prompt" key (archetype_delegation.rs:82-89).
|
|
tool_call_completion("run_code", json!({ "prompt": "write the artifact" })),
|
|
// request[1]: code_executor subagent calls file_write → gate parks.
|
|
tool_call_completion(
|
|
"file_write",
|
|
json!({ "path": "subagent-artifact.txt", "content": "SUBAGENT_WRITE_CANARY" }),
|
|
),
|
|
// request[2]: code_executor subagent after approval → text completion.
|
|
text_completion("Artifact written: SUBAGENT_WRITE_CANARY"),
|
|
// request[3]: Orchestrator synthesis.
|
|
text_completion("All done: SUBAGENT_WRITE_CANARY"),
|
|
]);
|
|
let stack = boot_stack().await;
|
|
|
|
// Pre-create the target file so file_write sees it as existing.
|
|
let home = stack._tmp.path().to_path_buf();
|
|
pre_create_for_approval(&home, "subagent-artifact.txt");
|
|
|
|
let mut events = spawn_sse_collector(format!(
|
|
"{}/events?client_id=harness-subapproval",
|
|
stack.rpc_base
|
|
));
|
|
send_web_chat(
|
|
&stack.rpc_base,
|
|
530,
|
|
"harness-subapproval",
|
|
"thread-subapproval",
|
|
"delegate the write",
|
|
)
|
|
.await;
|
|
|
|
// The approval gate fires because the subagent inherits the orchestrator's
|
|
// WebChat task-local origin (turn_origin + APPROVAL_CHAT_CONTEXT are not
|
|
// re-scoped by dispatch_subagent/run_subagent — src/openhuman/agent/harness/
|
|
// subagent_runner/ and src/openhuman/agent_orchestration/tools/dispatch.rs).
|
|
// If approval_request never fires within 120s, the event JSON is dumped.
|
|
let approval = wait_for_event(&mut events, "approval_request", Duration::from_secs(120)).await;
|
|
let request_id = approval
|
|
.pointer("/data/request_id")
|
|
.or_else(|| approval.get("request_id"))
|
|
.and_then(Value::as_str)
|
|
.unwrap_or_else(|| {
|
|
panic!("subagent approval_request missing request_id; event: {approval}")
|
|
})
|
|
.to_string();
|
|
assert!(
|
|
approval.to_string().contains("file_write"),
|
|
"subagent approval_request must mention file_write; event: {approval}"
|
|
);
|
|
|
|
let decide = post_json_rpc(
|
|
&stack.rpc_base,
|
|
531,
|
|
"openhuman.approval_decide",
|
|
json!({ "request_id": request_id, "decision": "approve_once" }),
|
|
)
|
|
.await;
|
|
assert_no_jsonrpc_error(&decide, "approval_decide subagent approve");
|
|
|
|
let done = wait_for_terminal(&mut events, Duration::from_secs(120)).await;
|
|
assert_eq!(
|
|
done.get("event").and_then(Value::as_str),
|
|
Some("chat_done"),
|
|
"expected chat_done after subagent+approval; got: {done}"
|
|
);
|
|
let full_response = done
|
|
.get("full_response")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or_else(|| panic!("chat_done missing full_response: {done}"));
|
|
assert!(
|
|
full_response.contains("SUBAGENT_WRITE_CANARY"),
|
|
"full_response must contain SUBAGENT_WRITE_CANARY; got: {full_response}"
|
|
);
|
|
|
|
// Three-plus upstream requests: orchestrator + code_executor(file_write) +
|
|
// code_executor(text after approve) + orchestrator synthesis.
|
|
let requests = with_captured(|c| c.clone());
|
|
assert!(
|
|
requests.len() >= 3,
|
|
"expected ≥3 upstream requests (orchestrator + code_executor x2 + orchestrator synthesis), \
|
|
got {};\nrequests: {}",
|
|
requests.len(),
|
|
serde_json::to_string_pretty(&requests).unwrap_or_default()
|
|
);
|
|
|
|
let all_serialized = serde_json::to_string(&requests).unwrap_or_default();
|
|
assert!(
|
|
!all_serialized.contains("Unknown tool:"),
|
|
"found 'Unknown tool:' — run_code delegation was not synthesised; requests: {}",
|
|
serde_json::to_string_pretty(&requests).unwrap_or_default()
|
|
);
|
|
|
|
// The file must have been written with the canary content, proving that the
|
|
// approved tool execution actually ran (not just that the decision propagated).
|
|
let action_dir = stack._tmp.path().join("OpenHuman").join("projects");
|
|
let artifact_path = action_dir.join("subagent-artifact.txt");
|
|
let artifact_content = std::fs::read_to_string(&artifact_path)
|
|
.unwrap_or_else(|e| panic!("subagent-artifact.txt missing after approve: {e}"));
|
|
assert!(
|
|
artifact_content.contains("SUBAGENT_WRITE_CANARY"),
|
|
"subagent-artifact.txt must contain SUBAGENT_WRITE_CANARY after approve; got: {artifact_content:?}"
|
|
);
|
|
|
|
stack.shutdown();
|
|
}
|
|
|
|
// ─── 5.5 approval_gate_timeout ───────────────────────────────────────────────
|
|
|
|
/// No decision within the TTL → gate auto-denies; turn completes with
|
|
/// TIMEOUT_ACK_CANARY (not a hang). The file's content must not be overwritten.
|
|
#[test]
|
|
fn approval_gate_timeout() {
|
|
run_on_agent_stack("approval_gate_timeout", approval_gate_timeout_inner);
|
|
}
|
|
|
|
async fn approval_gate_timeout_inner() {
|
|
let _lock = env_lock();
|
|
// 2-second TTL via OPENHUMAN_APPROVAL_TTL_SECS → effective_ttl() in gate.rs.
|
|
let _ttl = EnvVarGuard::set("OPENHUMAN_APPROVAL_TTL_SECS", "2");
|
|
ensure_approval_gate();
|
|
let _approval_bridge = register_approval_bridge();
|
|
// Same delegation chain as approve/deny: orchestrator → run_code → code_executor
|
|
// → file_write. The gate parks and TTL-denies after 2 seconds. code_executor
|
|
// receives the denial, returns text; orchestrator synthesizes with TIMEOUT_ACK_CANARY.
|
|
reset_script(vec![
|
|
// request[0]: Orchestrator delegates to code_executor.
|
|
tool_call_completion("run_code", json!({ "prompt": "write timeout-canary.txt" })),
|
|
// request[1]: code_executor calls file_write → gate parks, TTL expires.
|
|
tool_call_completion(
|
|
"file_write",
|
|
json!({ "path": "timeout-canary.txt", "content": "TIMEOUT_WRITE_CANARY" }),
|
|
),
|
|
// request[2]: code_executor text after TTL auto-denial.
|
|
text_completion("The write timed out awaiting approval. TIMEOUT_ACK_CANARY"),
|
|
// request[3]: Orchestrator synthesis.
|
|
text_completion("Acknowledged: TIMEOUT_ACK_CANARY"),
|
|
]);
|
|
let stack = boot_stack().await;
|
|
|
|
// Pre-create so file_write's external_effect_with_args returns true.
|
|
let home = stack._tmp.path().to_path_buf();
|
|
pre_create_for_approval(&home, "timeout-canary.txt");
|
|
|
|
let mut events = spawn_sse_collector(format!(
|
|
"{}/events?client_id=harness-timeout",
|
|
stack.rpc_base
|
|
));
|
|
send_web_chat(
|
|
&stack.rpc_base,
|
|
520,
|
|
"harness-timeout",
|
|
"thread-timeout",
|
|
"write the file",
|
|
)
|
|
.await;
|
|
|
|
// Approval fires and we deliberately don't decide.
|
|
// The 2s TTL (via effective_ttl()) auto-denies after expiry.
|
|
let _approval = wait_for_event(&mut events, "approval_request", Duration::from_secs(60)).await;
|
|
|
|
// Turn must still complete (no hang) after the TTL auto-denial.
|
|
let done = wait_for_terminal(&mut events, Duration::from_secs(60)).await;
|
|
assert_eq!(
|
|
done.get("event").and_then(Value::as_str),
|
|
Some("chat_done"),
|
|
"expected chat_done after timeout; got: {done}"
|
|
);
|
|
let full_response = done
|
|
.get("full_response")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or_else(|| panic!("chat_done missing full_response: {done}"));
|
|
assert!(
|
|
full_response.contains("TIMEOUT_ACK_CANARY"),
|
|
"full_response must contain TIMEOUT_ACK_CANARY after TTL auto-deny; got: {full_response}"
|
|
);
|
|
|
|
// The file's content must remain the placeholder (not the canary).
|
|
// Use .expect() so a missing file fails loudly rather than vacuously passing.
|
|
let action_dir = stack._tmp.path().join("OpenHuman").join("projects");
|
|
let canary_path = action_dir.join("timeout-canary.txt");
|
|
let content = std::fs::read_to_string(&canary_path)
|
|
.expect("timeout-canary.txt must exist after timeout flow");
|
|
assert_eq!(
|
|
content, "placeholder for approval gate test",
|
|
"timed-out file_write must leave the file content unchanged (placeholder only); got: {content:?}"
|
|
);
|
|
|
|
stack.shutdown();
|
|
}
|
|
|
|
// ─── Task 7: Max iterations + empty provider response ────────────────────────
|
|
//
|
|
// max_iterations_exceeded:
|
|
// Default max_tool_iterations = 10 (tool_loop.rs:15).
|
|
// Circuit breakers (REPEAT_FAILURE_THRESHOLD=3 on failing calls,
|
|
// NO_PROGRESS_FAILURE_THRESHOLD=6 on consecutive fails) only fire on
|
|
// success=false outcomes. We must pick a tool that:
|
|
// (a) EXISTS in all_tools_with_runtime (ops.rs) — "Unknown tool" returns
|
|
// success=false and trips NO_PROGRESS_FAILURE_THRESHOLD=6 first.
|
|
// (b) SUCCEEDS each call — so consecutive stays at 0.
|
|
// (c) Has VARYING args — so REPEAT_OUTPUT_THRESHOLD=4 cannot fire.
|
|
//
|
|
// ask_user_clarification is in the orchestrator's named list (agent.toml:162)
|
|
// but NOT in all_tools_with_runtime (ops.rs), so it returns "Unknown tool"
|
|
// with success=false and trips the no-progress breaker at 6, NOT the
|
|
// max-iterations cap. Verified by running the test once and observing:
|
|
// "Stopping: 6 tool calls in a row failed with no progress..."
|
|
//
|
|
// resolve_time IS in ops.rs (line 192) and in orchestrator named (agent.toml:173).
|
|
// It's a pure chrono calculation, no I/O, always succeeds. Varying the
|
|
// `expression` arg (format!("{i}m ago")) gives a different hash each iteration,
|
|
// preventing REPEAT_OUTPUT_THRESHOLD from firing. Queuing 12 calls trips the
|
|
// max_tool_iterations cap at 10 (DEFAULT_MAX_TOOL_ITERATIONS, tool_loop.rs:15).
|
|
// AgentError::MaxIterationsExceeded → "Agent exceeded maximum tool iterations (10)"
|
|
// (error.rs:89-90; MAX_ITERATIONS_ERROR_PREFIX at error.rs:176).
|
|
//
|
|
// empty_provider_response:
|
|
// Provider returns { "content": "" } with no tool_calls → agent sees no text
|
|
// and no tool invocation → AgentError::EmptyProviderResponse fires.
|
|
// Display (error.rs:96): "The model returned an empty response. Please try
|
|
// again." Lowercased → contains "empty". skips_sentry() = true for both
|
|
// variants (error.rs:148-153).
|
|
|
|
/// Agent loops past max_tool_iterations (10) → user-facing max-iterations error,
|
|
/// surfaced as a terminal event (not a hang, not a crash).
|
|
///
|
|
/// resolve_time (ops.rs:192, orchestrator/agent.toml:173) always returns
|
|
/// ToolResult::success (pure chrono calculation), so neither the repeat-failure
|
|
/// (REPEAT_FAILURE_THRESHOLD=3) nor the no-progress circuit breaker
|
|
/// (NO_PROGRESS_FAILURE_THRESHOLD=6) can fire before the max-iterations cap.
|
|
/// Varying the expression arg each iteration avoids REPEAT_OUTPUT_THRESHOLD=4.
|
|
///
|
|
/// Note: ask_user_clarification is NOT in all_tools_with_runtime (not in
|
|
/// ops.rs), so it returns "Unknown tool" (success=false) and would trip
|
|
/// NO_PROGRESS_FAILURE_THRESHOLD=6 at iteration 6 instead of max-iterations at 10.
|
|
#[test]
|
|
fn max_iterations_exceeded() {
|
|
run_on_agent_stack("max_iterations_exceeded", max_iterations_exceeded_inner);
|
|
}
|
|
|
|
async fn max_iterations_exceeded_inner() {
|
|
let _lock = env_lock();
|
|
// 12 tool calls > default max_tool_iterations (10). Each uses a unique
|
|
// expression to prevent REPEAT_OUTPUT_THRESHOLD from firing first.
|
|
// resolve_time is a pure computation tool (no I/O) that always succeeds.
|
|
// The required parameter name is "expr" (resolve_time.rs schema).
|
|
let responses: Vec<Value> = (0..12)
|
|
.map(|i| tool_call_completion("resolve_time", json!({ "expr": format!("{}m ago", i + 1) })))
|
|
.collect();
|
|
reset_script(responses);
|
|
let stack = boot_stack().await;
|
|
|
|
let mut events = spawn_sse_collector(format!(
|
|
"{}/events?client_id=harness-maxiter",
|
|
stack.rpc_base
|
|
));
|
|
send_web_chat(
|
|
&stack.rpc_base,
|
|
600,
|
|
"harness-maxiter",
|
|
"thread-maxiter",
|
|
"loop forever",
|
|
)
|
|
.await;
|
|
|
|
let terminal = wait_for_terminal(&mut events, Duration::from_secs(180)).await;
|
|
let serialized = terminal.to_string();
|
|
// AgentError::MaxIterationsExceeded is handled gracefully at the session layer:
|
|
// turn_checkpoint.rs:62 intercepts it and renders a user-friendly `chat_done`
|
|
// message: "I reached the tool-call limit for this turn ({max_iterations} steps),
|
|
// so I paused here." This is the reachable surface from the web-chat channel.
|
|
// The lower-level display "Agent exceeded maximum tool iterations (N)" (error.rs:89-90,
|
|
// prefix const error.rs:176) is only visible in sub-agent checkpoints
|
|
// (checkpoint.rs:31-40), not in the top-level orchestrator turn.
|
|
assert!(
|
|
serialized.contains("tool-call limit")
|
|
|| serialized.contains("tool_call_limit")
|
|
|| serialized.contains("maximum tool iterations")
|
|
|| serialized.contains("Agent exceeded"),
|
|
"expected max-iterations surface (tool-call limit or similar); got: {serialized}"
|
|
);
|
|
|
|
stack.shutdown();
|
|
}
|
|
|
|
/// Provider returns a completely empty completion (no text, no tool calls) →
|
|
/// AgentError::EmptyProviderResponse → graceful terminal event, not a hang.
|
|
///
|
|
/// Display (error.rs:96): "The model returned an empty response. Please try
|
|
/// again." Lowercased → contains "empty". skips_sentry() = true (error.rs:151).
|
|
#[test]
|
|
fn empty_provider_response() {
|
|
run_on_agent_stack("empty_provider_response", empty_provider_response_inner);
|
|
}
|
|
|
|
async fn empty_provider_response_inner() {
|
|
let _lock = env_lock();
|
|
// No text, no tool_calls — scripted_chat_completions returns an entry with
|
|
// only "content": "" which the agent sees as an empty completion.
|
|
reset_script(vec![json!({ "content": "" })]);
|
|
let stack = boot_stack().await;
|
|
|
|
let mut events =
|
|
spawn_sse_collector(format!("{}/events?client_id=harness-empty", stack.rpc_base));
|
|
send_web_chat(
|
|
&stack.rpc_base,
|
|
610,
|
|
"harness-empty",
|
|
"thread-empty",
|
|
"say nothing",
|
|
)
|
|
.await;
|
|
|
|
let terminal = wait_for_terminal(&mut events, Duration::from_secs(60)).await;
|
|
// Either chat_error with the empty-response message or chat_done with graceful fallback.
|
|
// AgentError::EmptyProviderResponse → "The model returned an empty response. ..."
|
|
// Lowercased → contains "empty".
|
|
let serialized = terminal.to_string().to_lowercase();
|
|
assert!(
|
|
serialized.contains("empty") || serialized.contains("no response"),
|
|
"expected empty-response handling; got: {terminal}"
|
|
);
|
|
|
|
stack.shutdown();
|
|
}
|
|
|
|
// ─── Task 8: Provider error retry ────────────────────────────────────────────
|
|
//
|
|
// ReliableProvider retries on 5xx (reliable.rs:426-507).
|
|
// Default provider_retries = 2 (runtime.rs:107-109) → max_retries = 2.
|
|
// Loop: attempt 0..=2 (3 attempts total). On attempt 0 the mock returns 500;
|
|
// on attempt 1 it returns the canary text. The harness does NOT use
|
|
// provider_backoff_ms = 500ms with backoff; the minimum is 50ms
|
|
// (reliable.rs:352, base_backoff_ms.max(50)). Both requests appear in the
|
|
// CAPTURED_COMPLETION_REQUESTS queue because each attempt calls the mock.
|
|
|
|
/// First upstream call 500s; ReliableProvider retries (provider_retries=2,
|
|
/// reliable.rs:426-507); second attempt succeeds with RETRY_SUCCESS_CANARY.
|
|
/// ≥2 captured upstream requests confirm the retry path was actually exercised.
|
|
#[test]
|
|
fn provider_error_retry() {
|
|
run_on_agent_stack("provider_error_retry", provider_error_retry_inner);
|
|
}
|
|
|
|
async fn provider_error_retry_inner() {
|
|
let _lock = env_lock();
|
|
reset_script(vec![
|
|
// Attempt 0: scripted upstream returns 500.
|
|
error_completion(500, "scripted transient upstream failure"),
|
|
// Attempt 1 (first retry): scripted upstream returns the canary.
|
|
text_completion("RETRY_SUCCESS_CANARY"),
|
|
]);
|
|
let stack = boot_stack().await;
|
|
|
|
let mut events =
|
|
spawn_sse_collector(format!("{}/events?client_id=harness-retry", stack.rpc_base));
|
|
send_web_chat(
|
|
&stack.rpc_base,
|
|
700,
|
|
"harness-retry",
|
|
"thread-retry",
|
|
"hello",
|
|
)
|
|
.await;
|
|
|
|
let done = wait_for_terminal(&mut events, Duration::from_secs(120)).await;
|
|
assert_eq!(
|
|
done.get("event").and_then(Value::as_str),
|
|
Some("chat_done"),
|
|
"expected chat_done after retry; got: {done}"
|
|
);
|
|
let full_response = done
|
|
.get("full_response")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or_else(|| panic!("chat_done missing full_response: {done}"));
|
|
assert!(
|
|
full_response.contains("RETRY_SUCCESS_CANARY"),
|
|
"full_response must contain RETRY_SUCCESS_CANARY after retry; got: {full_response}"
|
|
);
|
|
|
|
// Both the 500 attempt and the successful retry hit the upstream mock.
|
|
let count = with_captured(|c| c.len());
|
|
assert!(
|
|
count >= 2,
|
|
"expected ≥2 upstream attempts (500 + retry), got {count}"
|
|
);
|
|
|
|
stack.shutdown();
|
|
}
|
|
|
|
// ─── Task 9: Parallel fan-out + multi-hop chain ───────────────────────────────
|
|
//
|
|
// parallel_subagent_fanout:
|
|
// spawn_parallel_agents is in the orchestrator's named tools (agent.toml:165)
|
|
// and is registered via ops.rs:163. Requires ≥2 tasks, each { agent_id, prompt }.
|
|
// The orchestrator's subagents.allowlist includes "researcher", so
|
|
// agent_id:"researcher" is valid. children run via join_all (spawn_parallel_agents.rs
|
|
// ~line 322 — "let futures = prepared.into_iter().map(…)"). Both children
|
|
// consume from the same global FIFO scripted-response queue. Because
|
|
// join_all spawns futures concurrently but the queue pop is under a Mutex,
|
|
// one child gets the first completion and the other gets the second. Both
|
|
// carry distinct canaries; the synthesis quotes both.
|
|
// LLM request ordering (4 upstream calls):
|
|
// request[0] = orchestrator → spawn_parallel_agents tool call
|
|
// request[1,2] = researcher child 1 & child 2 (order nondeterministic,
|
|
// both return distinct canaries)
|
|
// request[3] = orchestrator synthesis with both canaries
|
|
//
|
|
// multi_hop_delegation_chain:
|
|
// Depth-1 subagents (researcher, code_executor, etc.) do NOT have spawn
|
|
// tools in their named lists. Verified: researcher/agent.toml has only web/
|
|
// file tools; code_executor/agent.toml has code/file tools. Neither contains
|
|
// spawn_subagent, spawn_worker_thread, or spawn_parallel_agents. The only
|
|
// agents with spawn tools are orchestrator and trigger_reactor (loader.rs:383,
|
|
// 527). trigger_reactor is not in the orchestrator's subagents.allowlist.
|
|
// MAX_SPAWN_DEPTH=3 (spawn_depth_context.rs:16) is structurally unreachable
|
|
// with the current built-in agent graph; the cap is a safety net for
|
|
// runtime-registered agents.
|
|
//
|
|
// Fallback (plan Task 9, step 9.2 fallback): orchestrator → researcher (via
|
|
// `research`) → researcher scripted to call ask_user_clarification (not in
|
|
// researcher's named tools → SubagentToolSource::execute returns a blocked
|
|
// response, tool loop continues) → researcher second LLM call returns
|
|
// DEPTH2_CANARY text → dispatch_subagent forwards as `research` tool result
|
|
// → orchestrator synthesis. The three-level synthesis path (user turn →
|
|
// researcher subagent → tool-loop continuation → orchestrator synthesis) is
|
|
// the deepest path reachable with built-in agents without src/ changes.
|
|
// LLM request ordering (4 upstream calls):
|
|
// request[0] = orchestrator → `research` delegation
|
|
// request[1] = researcher (inner loop) → ask_user_clarification (blocked)
|
|
// request[2] = researcher (inner loop continuation) → DEPTH2_CANARY text
|
|
// request[3] = orchestrator synthesis
|
|
|
|
/// spawn_parallel_agents with 2 researcher tasks: both children consume from
|
|
/// the global scripted FIFO; both canaries appear in the final synthesis.
|
|
/// Orchestrator allowlist (agent.toml) includes "researcher" so both tasks pass
|
|
/// the allowlist check in spawn_parallel_agents.rs:223.
|
|
/// ≥4 upstream requests and no "Unknown tool:" confirm the full fan-out path ran.
|
|
#[test]
|
|
fn parallel_subagent_fanout() {
|
|
run_on_agent_stack("parallel_subagent_fanout", parallel_subagent_fanout_inner);
|
|
}
|
|
|
|
async fn parallel_subagent_fanout_inner() {
|
|
let _lock = env_lock();
|
|
reset_script(vec![
|
|
// request[0]: Orchestrator issues spawn_parallel_agents with 2 researcher tasks.
|
|
tool_call_completion(
|
|
"spawn_parallel_agents",
|
|
json!({ "tasks": [
|
|
{ "agent_id": "researcher", "prompt": "Find alpha canary" },
|
|
{ "agent_id": "researcher", "prompt": "Find beta canary" }
|
|
]}),
|
|
),
|
|
// request[1] + request[2]: The two researcher children consume from the
|
|
// FIFO queue concurrently via join_all. Order between children is
|
|
// non-deterministic; both carry distinct canaries so the synthesis test
|
|
// is order-agnostic. Both children are single-turn (text only → no inner
|
|
// tool loop → one LLM call each).
|
|
text_completion("PARALLEL_ALPHA_CANARY"),
|
|
text_completion("PARALLEL_BETA_CANARY"),
|
|
// request[3]: Orchestrator receives both results and synthesizes.
|
|
text_completion("Both done: PARALLEL_ALPHA_CANARY and PARALLEL_BETA_CANARY"),
|
|
]);
|
|
let stack = boot_stack().await;
|
|
|
|
let mut events = spawn_sse_collector(format!(
|
|
"{}/events?client_id=harness-parallel",
|
|
stack.rpc_base
|
|
));
|
|
send_web_chat(
|
|
&stack.rpc_base,
|
|
800,
|
|
"harness-parallel",
|
|
"thread-parallel",
|
|
"fan out",
|
|
)
|
|
.await;
|
|
|
|
let done = wait_for_terminal(&mut events, Duration::from_secs(180)).await;
|
|
assert_eq!(
|
|
done.get("event").and_then(Value::as_str),
|
|
Some("chat_done"),
|
|
"expected chat_done for parallel fanout: {done}"
|
|
);
|
|
let full_response = done
|
|
.get("full_response")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or_else(|| panic!("chat_done missing full_response: {done}"));
|
|
assert!(
|
|
full_response.contains("PARALLEL_ALPHA_CANARY")
|
|
&& full_response.contains("PARALLEL_BETA_CANARY"),
|
|
"synthesis must contain both canaries; full_response: {full_response}"
|
|
);
|
|
|
|
// ≥4 upstream requests: orchestrator + 2 researcher children + orchestrator synthesis.
|
|
let requests = with_captured(|c| c.clone());
|
|
assert!(
|
|
requests.len() >= 4,
|
|
"expected ≥4 upstream requests (orchestrator + 2 researchers + synthesis), got {};\
|
|
\nrequests: {}",
|
|
requests.len(),
|
|
serde_json::to_string_pretty(&requests).unwrap_or_default()
|
|
);
|
|
|
|
// No "Unknown tool:" — spawn_parallel_agents was synthesised and ran successfully.
|
|
let all_serialized = serde_json::to_string(&requests).unwrap_or_default();
|
|
assert!(
|
|
!all_serialized.contains("Unknown tool:"),
|
|
"found 'Unknown tool:' — spawn_parallel_agents was not available; requests: {}",
|
|
serde_json::to_string_pretty(&requests).unwrap_or_default()
|
|
);
|
|
|
|
// ── Last upstream request (orchestrator synthesis) must carry BOTH child
|
|
// canaries in its messages ── proves both children's results were forwarded
|
|
// into the orchestrator's synthesis context, not merely that the scripted
|
|
// synthesis text echoed them.
|
|
let last_messages = requests
|
|
.last()
|
|
.unwrap()
|
|
.pointer("/body/messages")
|
|
.and_then(Value::as_array)
|
|
.cloned()
|
|
.unwrap_or_else(|| {
|
|
panic!(
|
|
"last upstream request missing /body/messages; request: {}",
|
|
serde_json::to_string_pretty(requests.last().unwrap()).unwrap_or_default()
|
|
)
|
|
});
|
|
let serialized = serde_json::to_string(&last_messages).unwrap();
|
|
assert!(
|
|
serialized.contains("PARALLEL_ALPHA_CANARY"),
|
|
"synthesis request missing child result PARALLEL_ALPHA_CANARY; messages: {serialized}"
|
|
);
|
|
assert!(
|
|
serialized.contains("PARALLEL_BETA_CANARY"),
|
|
"synthesis request missing child result PARALLEL_BETA_CANARY; messages: {serialized}"
|
|
);
|
|
|
|
stack.shutdown();
|
|
}
|
|
|
|
/// Delegation two levels deep (orchestrator → researcher → tool loop continues):
|
|
/// orchestrator delegates to researcher via `research`; researcher scripted to
|
|
/// call ask_user_clarification (blocked — not in researcher named tools →
|
|
/// SubagentToolSource returns error); researcher loops and returns DEPTH2_CANARY;
|
|
/// dispatch_subagent forwards the result; orchestrator synthesizes.
|
|
///
|
|
/// Depth behavior discovered: researcher/agent.toml has only web/file tools
|
|
/// (no spawn_subagent, spawn_worker_thread, spawn_parallel_agents). MAX_SPAWN_DEPTH=3
|
|
/// (spawn_depth_context.rs:16) is unreachable with built-in agents; it guards
|
|
/// runtime/workspace agents. The three-level synthesis (user-turn root →
|
|
/// researcher subagent → orchestrator synthesis) is the deepest path available
|
|
/// without src/ changes. Documented per plan Task 9 step 9.2 fallback.
|
|
///
|
|
/// Intentionally shares the blocked-clarification mechanic with
|
|
/// `subagent_clarification_flow`; differs in delegate surface (research vs
|
|
/// schedule_task) and single-turn shape.
|
|
#[test]
|
|
fn multi_hop_delegation_chain() {
|
|
run_on_agent_stack(
|
|
"multi_hop_delegation_chain",
|
|
multi_hop_delegation_chain_inner,
|
|
);
|
|
}
|
|
|
|
async fn multi_hop_delegation_chain_inner() {
|
|
let _lock = env_lock();
|
|
reset_script(vec![
|
|
// request[0]: Orchestrator delegates to researcher via `research`
|
|
// (researcher's delegate_name, agent.toml:3).
|
|
tool_call_completion("research", json!({ "prompt": "deep question" })),
|
|
// request[1]: Researcher first inner LLM call → scripts ask_user_clarification.
|
|
// ask_user_clarification is NOT in researcher's named tools (researcher/agent.toml:21-50),
|
|
// so SubagentToolSource returns a blocked/error result (tool_source.rs:36).
|
|
// The researcher subagent loop continues to a second LLM call.
|
|
tool_call_completion(
|
|
"ask_user_clarification",
|
|
json!({ "question": "depth-2 clarification?" }),
|
|
),
|
|
// request[2]: Researcher second inner LLM call → text result.
|
|
// This becomes the `research` tool result forwarded by dispatch_subagent.
|
|
text_completion("DEPTH2_CANARY"),
|
|
// request[3]: Orchestrator receives the research result and synthesizes.
|
|
text_completion("Final answer: DEPTH2_CANARY"),
|
|
]);
|
|
let stack = boot_stack().await;
|
|
|
|
let mut events = spawn_sse_collector(format!(
|
|
"{}/events?client_id=harness-multihop",
|
|
stack.rpc_base
|
|
));
|
|
send_web_chat(
|
|
&stack.rpc_base,
|
|
810,
|
|
"harness-multihop",
|
|
"thread-multihop",
|
|
"go deep",
|
|
)
|
|
.await;
|
|
|
|
let done = wait_for_terminal(&mut events, Duration::from_secs(180)).await;
|
|
assert_eq!(
|
|
done.get("event").and_then(Value::as_str),
|
|
Some("chat_done"),
|
|
"expected chat_done for multi-hop delegation: {done}"
|
|
);
|
|
let full_response = done
|
|
.get("full_response")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or_else(|| panic!("chat_done missing full_response: {done}"));
|
|
assert!(
|
|
full_response.contains("DEPTH2_CANARY"),
|
|
"synthesis must contain DEPTH2_CANARY; full_response: {full_response}"
|
|
);
|
|
|
|
// ≥4 upstream requests prove the full delegation path ran (≥3 would
|
|
// false-pass if the researcher inner loop early-exited):
|
|
// request[0] = orchestrator (research call),
|
|
// request[1] = researcher first iter (ask_user_clarification → blocked),
|
|
// request[2] = researcher second iter (DEPTH2_CANARY text),
|
|
// request[3] = orchestrator synthesis.
|
|
let requests = with_captured(|c| c.clone());
|
|
assert!(
|
|
requests.len() >= 4,
|
|
"expected ≥4 upstream requests (orchestrator + researcher x2 + synthesis), got {};\
|
|
\nrequests: {}",
|
|
requests.len(),
|
|
serde_json::to_string_pretty(&requests).unwrap_or_default()
|
|
);
|
|
|
|
// No "Unknown tool:" for `research` — delegation was synthesised correctly.
|
|
let all_serialized = serde_json::to_string(&requests).unwrap_or_default();
|
|
assert!(
|
|
!all_serialized.contains("Unknown tool:"),
|
|
"found 'Unknown tool:' — `research` delegation was not synthesised; requests: {}",
|
|
serde_json::to_string_pretty(&requests).unwrap_or_default()
|
|
);
|
|
|
|
stack.shutdown();
|
|
}
|
|
|
|
// ─── Task 10: Streaming tool-call accumulation (issue test 13) ───────────────
|
|
//
|
|
// This module runs at the Agent level using a ScriptedProvider (same pattern as
|
|
// tests/agent_session_turn_raw_coverage_e2e.rs). It does NOT use the HTTP
|
|
// scripted-upstream + SSE stack above: the RPC/SSE stack doesn't expose
|
|
// per-delta streaming observability that would let us assert the exact fragment
|
|
// sequence reaching the progress channel.
|
|
//
|
|
// HONESTY CHECK — where does accumulation actually live?
|
|
//
|
|
// Read src/openhuman/agent/harness/engine/core.rs:370-448:
|
|
//
|
|
// provider.chat(ChatRequest { stream: delta_tx_opt.as_ref(), … }).await
|
|
// // returns the COMPLETE ChatResponse — tool_calls already fully assembled
|
|
//
|
|
// let (display_text, calls) = parser.parse(&resp);
|
|
// let native_calls = resp.tool_calls; // ← DISPATCH IS FROM THIS FIELD
|
|
//
|
|
// The `ProviderDelta::ToolCallArgsDelta` stream events flow into
|
|
// `spawn_delta_forwarder` (progress.rs:329-370), which maps them to
|
|
// `AgentProgress::ToolCallArgsDelta` for the UI/progress sink. They do NOT
|
|
// participate in dispatch: tool arguments used for execution come from
|
|
// `ChatResponse.tool_calls[i].arguments` which the provider returned as a
|
|
// complete, already-assembled string.
|
|
//
|
|
// In the REAL HTTP providers (compatible_stream_native.rs:322,405-425) the
|
|
// accumulation buffer (`entry.arguments.push_str(args)`) IS what builds
|
|
// `ChatResponse.tool_calls[i].arguments` before it is returned. Accumulation
|
|
// happens inside the provider before returning the final `ChatResponse`; the
|
|
// engine loop consumes only the finished product.
|
|
//
|
|
// ScriptedProvider injects stream_events directly then returns the
|
|
// pre-assembled ChatResponse — so the progress-channel deltas are
|
|
// independent of dispatch in this test.
|
|
//
|
|
// What this test asserts:
|
|
// 1. The tool receives the FULL argument set (from ChatResponse.tool_calls).
|
|
// 2. The progress channel carries ToolCallArgsDelta events whose concatenated
|
|
// deltas form the full args JSON — proves the UI path receives the chunks.
|
|
// 3. ToolCallCompleted fires exactly once with success=true.
|
|
// 4. Final answer is "stream final".
|
|
|
|
mod streaming_support {
|
|
use async_trait::async_trait;
|
|
use openhuman_core::openhuman::agent::dispatcher::NativeToolDispatcher;
|
|
use openhuman_core::openhuman::agent::Agent;
|
|
use openhuman_core::openhuman::agent_memory::memory_loader::MemoryLoader;
|
|
use openhuman_core::openhuman::config::{AgentConfig, ContextConfig, MemoryConfig};
|
|
use openhuman_core::openhuman::inference::provider::{
|
|
ChatRequest, ChatResponse, Provider, ProviderDelta, ToolCall, UsageInfo,
|
|
};
|
|
use openhuman_core::openhuman::memory::Memory;
|
|
use openhuman_core::openhuman::memory_store;
|
|
use openhuman_core::openhuman::tools::traits::ToolCallOptions;
|
|
use openhuman_core::openhuman::tools::{
|
|
PermissionLevel, Tool, ToolContent, ToolResult, ToolScope as RuntimeToolScope,
|
|
};
|
|
use serde_json::json;
|
|
use std::collections::VecDeque;
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
use std::sync::{Arc, Mutex};
|
|
use tempfile::TempDir;
|
|
|
|
// ── ScriptedProvider ────────────────────────────────────────────────────
|
|
// Copied (minimal) from tests/agent_session_turn_raw_coverage_e2e.rs:76-152.
|
|
|
|
pub struct ScriptedProvider {
|
|
pub responses: Mutex<VecDeque<anyhow::Result<ChatResponse>>>,
|
|
pub stream_events: Vec<ProviderDelta>,
|
|
pub native_tools: bool,
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Provider for ScriptedProvider {
|
|
fn capabilities(
|
|
&self,
|
|
) -> openhuman_core::openhuman::inference::provider::traits::ProviderCapabilities {
|
|
openhuman_core::openhuman::inference::provider::traits::ProviderCapabilities {
|
|
native_tool_calling: self.native_tools,
|
|
vision: false,
|
|
}
|
|
}
|
|
|
|
async fn chat_with_system(
|
|
&self,
|
|
_system_prompt: Option<&str>,
|
|
message: &str,
|
|
_model: &str,
|
|
_temperature: f64,
|
|
) -> anyhow::Result<String> {
|
|
Ok(format!("summary: {message}"))
|
|
}
|
|
|
|
async fn chat(
|
|
&self,
|
|
request: ChatRequest<'_>,
|
|
_model: &str,
|
|
_temperature: f64,
|
|
) -> anyhow::Result<ChatResponse> {
|
|
if let Some(stream) = request.stream {
|
|
for event in &self.stream_events {
|
|
stream.send(event.clone()).await.ok();
|
|
}
|
|
}
|
|
self.responses
|
|
.lock()
|
|
.unwrap()
|
|
.pop_front()
|
|
.unwrap_or_else(|| Ok(text_response_s("default scripted final")))
|
|
}
|
|
}
|
|
|
|
// ── Response helpers ────────────────────────────────────────────────────
|
|
// Distinct names (suffix `_s`) to avoid shadowing the HTTP-level helpers
|
|
// defined in the parent module.
|
|
|
|
pub fn text_response_s(text: &str) -> ChatResponse {
|
|
ChatResponse {
|
|
text: Some(text.to_string()),
|
|
tool_calls: vec![],
|
|
usage: Some(UsageInfo {
|
|
input_tokens: 10,
|
|
output_tokens: 5,
|
|
context_window: 16_000,
|
|
cached_input_tokens: 2,
|
|
cache_creation_tokens: 0,
|
|
reasoning_tokens: 0,
|
|
charged_amount_usd: 0.0002,
|
|
}),
|
|
reasoning_content: None,
|
|
}
|
|
}
|
|
|
|
pub fn native_tool_response_s(id: &str, name: &str, args: serde_json::Value) -> ChatResponse {
|
|
ChatResponse {
|
|
text: Some(String::new()),
|
|
tool_calls: vec![ToolCall {
|
|
id: id.to_string(),
|
|
name: name.to_string(),
|
|
arguments: args.to_string(),
|
|
extra_content: None,
|
|
}],
|
|
usage: Some(UsageInfo {
|
|
input_tokens: 15,
|
|
output_tokens: 4,
|
|
context_window: 16_000,
|
|
cached_input_tokens: 3,
|
|
cache_creation_tokens: 0,
|
|
reasoning_tokens: 0,
|
|
charged_amount_usd: 0.0003,
|
|
}),
|
|
reasoning_content: None,
|
|
}
|
|
}
|
|
|
|
// ── workspace / memory helpers ──────────────────────────────────────────
|
|
// Copied from agent_session_turn_raw_coverage_e2e.rs:503-553.
|
|
|
|
pub fn workspace_s(label: &str) -> (TempDir, PathBuf) {
|
|
let root = std::env::current_dir()
|
|
.unwrap()
|
|
.join("target")
|
|
.join(format!(
|
|
"agent-harness-e2e-stream-{label}-{}",
|
|
uuid::Uuid::new_v4()
|
|
));
|
|
std::fs::create_dir_all(&root).unwrap();
|
|
let temp = TempDir::new_in(root.parent().unwrap()).unwrap();
|
|
let path = temp.path().join(label);
|
|
std::fs::create_dir_all(&path).unwrap();
|
|
(temp, path)
|
|
}
|
|
|
|
fn memory_for_workspace_s(path: &Path) -> Arc<dyn Memory> {
|
|
let cfg = MemoryConfig {
|
|
backend: "none".to_string(),
|
|
..MemoryConfig::default()
|
|
};
|
|
Arc::from(memory_store::create_memory(&cfg, path).unwrap())
|
|
}
|
|
|
|
struct NullMemoryLoader;
|
|
|
|
#[async_trait]
|
|
impl MemoryLoader for NullMemoryLoader {
|
|
async fn load_context(
|
|
&self,
|
|
_memory: &dyn Memory,
|
|
_user_message: &str,
|
|
) -> anyhow::Result<String> {
|
|
Ok(String::new())
|
|
}
|
|
}
|
|
|
|
pub fn agent_with_s(
|
|
provider: Arc<dyn Provider>,
|
|
tools: Vec<Box<dyn Tool>>,
|
|
workspace_path: PathBuf,
|
|
config: AgentConfig,
|
|
) -> Agent {
|
|
Agent::builder()
|
|
.provider_arc(provider)
|
|
.tools(tools)
|
|
.memory(memory_for_workspace_s(&workspace_path))
|
|
.memory_loader(Box::new(NullMemoryLoader))
|
|
.tool_dispatcher(Box::new(NativeToolDispatcher))
|
|
.workspace_dir(workspace_path)
|
|
.event_context("stream-accum-session", "stream-accum-channel")
|
|
.agent_definition_name("round17/orchestrator")
|
|
.config(config)
|
|
// These are deterministic scripted-mock orchestrator turns. The
|
|
// default-on first-turn "super context" pass (#4085) would spawn a
|
|
// context_scout and add an extra model call the scripts don't expect,
|
|
// breaking every orchestrator test here. Disable it for the harness.
|
|
.context_config(ContextConfig {
|
|
super_context_enabled: false,
|
|
..ContextConfig::default()
|
|
})
|
|
.auto_save(true)
|
|
.explicit_preferences_enabled(false)
|
|
.build()
|
|
.unwrap()
|
|
}
|
|
|
|
// ── EchoTool ─────────────────────────────────────────────────────────────
|
|
// Minimal Tool impl whose execute asserts args["value"] == "STREAMED_ARG_CANARY"
|
|
// (panicking with the actual args otherwise) and increments a counter.
|
|
|
|
pub struct EchoTool {
|
|
pub name: &'static str,
|
|
pub calls: Arc<AtomicUsize>,
|
|
}
|
|
|
|
impl EchoTool {
|
|
pub fn boxed(name: &'static str, calls: Arc<AtomicUsize>) -> Box<dyn Tool> {
|
|
Box::new(Self { name, calls })
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Tool for EchoTool {
|
|
fn name(&self) -> &str {
|
|
self.name
|
|
}
|
|
|
|
fn description(&self) -> &str {
|
|
"echo tool for streaming accumulation tests"
|
|
}
|
|
|
|
fn parameters_schema(&self) -> serde_json::Value {
|
|
json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"value": { "type": "string" }
|
|
},
|
|
"required": ["value"]
|
|
})
|
|
}
|
|
|
|
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
|
self.execute_with_options(args, ToolCallOptions::default())
|
|
.await
|
|
}
|
|
|
|
async fn execute_with_options(
|
|
&self,
|
|
args: serde_json::Value,
|
|
_options: ToolCallOptions,
|
|
) -> anyhow::Result<ToolResult> {
|
|
self.calls.fetch_add(1, Ordering::SeqCst);
|
|
let got = args
|
|
.get("value")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or_default();
|
|
assert_eq!(
|
|
got, "STREAMED_ARG_CANARY",
|
|
"EchoTool received wrong args — dispatch must use the FULL assembled argument \
|
|
string from ChatResponse.tool_calls, not partial delta fragments.\n\
|
|
Expected: \"STREAMED_ARG_CANARY\"\n\
|
|
Got: \"{got}\"\n\
|
|
Full args: {args}"
|
|
);
|
|
Ok(ToolResult {
|
|
content: vec![ToolContent::Text {
|
|
text: format!("echoed:{got}"),
|
|
}],
|
|
is_error: false,
|
|
markdown_formatted: None,
|
|
})
|
|
}
|
|
|
|
fn permission_level(&self) -> PermissionLevel {
|
|
PermissionLevel::ReadOnly
|
|
}
|
|
|
|
fn scope(&self) -> RuntimeToolScope {
|
|
RuntimeToolScope::All
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Tool-call arguments streamed in chunks (ProviderDelta::ToolCallArgsDelta)
|
|
/// arrive on the progress channel as UI deltas; the tool executes with the
|
|
/// FULL argument set from ChatResponse.tool_calls (assembled by the provider).
|
|
///
|
|
/// IMPORTANT — dispatch path (verified in engine/core.rs:440-448):
|
|
///
|
|
/// Dispatch uses `resp.tool_calls` from the final `ChatResponse`, NOT from
|
|
/// accumulated stream deltas. The `ProviderDelta::ToolCallArgsDelta` events
|
|
/// flow only to the progress channel (UI streaming) via `spawn_delta_forwarder`
|
|
/// (src/openhuman/agent/harness/engine/progress.rs:329-370).
|
|
///
|
|
/// In the real HTTP providers (compatible_stream_native.rs:322,405-425) the
|
|
/// fragment accumulation buffer (`entry.arguments.push_str(args)`) IS what
|
|
/// builds `ChatResponse.tool_calls[i].arguments`. Accumulation happens inside
|
|
/// the provider before returning the final `ChatResponse`; the engine loop
|
|
/// consumes only the finished product.
|
|
///
|
|
/// ScriptedProvider injects stream_events directly then returns the
|
|
/// pre-assembled ChatResponse — so the progress-channel deltas are
|
|
/// independent of dispatch in this test.
|
|
///
|
|
/// What this test asserts:
|
|
/// 1. Tool executes exactly once — no double-dispatch.
|
|
/// 2. Tool receives `args["value"] == "STREAMED_ARG_CANARY"` — the full,
|
|
/// assembled argument from ChatResponse.tool_calls (EchoTool panics on mismatch).
|
|
/// 3. Progress channel carries 4 ToolCallArgsDelta events whose concatenated
|
|
/// delta strings reassemble to the original full_args JSON.
|
|
/// 4. ToolCallCompleted fires with tool_name == "echo_tool" and success == true.
|
|
/// 5. Final answer is "stream final".
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn streaming_tool_call_accumulation() {
|
|
use openhuman_core::openhuman::agent::progress::AgentProgress;
|
|
use openhuman_core::openhuman::inference::provider::ProviderDelta;
|
|
use std::sync::Mutex;
|
|
use streaming_support::{
|
|
agent_with_s, native_tool_response_s, text_response_s, workspace_s, EchoTool,
|
|
ScriptedProvider,
|
|
};
|
|
|
|
let _lock = env_lock();
|
|
let (_temp, workspace_path) = workspace_s("stream-accum");
|
|
let _ws = EnvVarGuard::set_to_path("OPENHUMAN_WORKSPACE", &workspace_path);
|
|
let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
|
|
|
// The full args JSON to be split into 4 ProviderDelta::ToolCallArgsDelta chunks.
|
|
// Chunks cut at arbitrary byte offsets including mid-key ("{"value / ":"STREA")
|
|
// to exercise accumulation logic in real streaming providers.
|
|
let full_args = r#"{"value":"STREAMED_ARG_CANARY"}"#;
|
|
// full_args length = 31 bytes. Splits: 0..8 / 8..16 / 16..24 / 24..31
|
|
// chunk 0: {"value (8 chars, stops mid-key delimiter)
|
|
// chunk 1: ":"STREA (8 chars, crosses key→value boundary)
|
|
// chunk 2: MED_ARG_ (8 chars, mid-value)
|
|
// chunk 3: CANARY"} (7 chars, tail)
|
|
let chunk0 = full_args[0..8].to_string(); // {"value
|
|
let chunk1 = full_args[8..16].to_string(); // ":"STREA
|
|
let chunk2 = full_args[16..24].to_string(); // MED_ARG_
|
|
let chunk3 = full_args[24..].to_string(); // CANARY"}
|
|
|
|
let provider = std::sync::Arc::new(ScriptedProvider {
|
|
responses: Mutex::new(
|
|
vec![
|
|
// Response 1: the native tool call with the FULL assembled arguments.
|
|
// Dispatch uses this, not the stream deltas.
|
|
Ok(native_tool_response_s(
|
|
"stream-1",
|
|
"echo_tool",
|
|
serde_json::from_str(full_args).unwrap(),
|
|
)),
|
|
// Response 2: final text after the tool result.
|
|
Ok(text_response_s("stream final")),
|
|
]
|
|
.into(),
|
|
),
|
|
stream_events: vec![
|
|
// ToolCallStart arrives first so the UI can open the live row.
|
|
ProviderDelta::ToolCallStart {
|
|
call_id: "stream-1".to_string(),
|
|
tool_name: "echo_tool".to_string(),
|
|
},
|
|
// Four argument fragments — mid-key / mid-value splits.
|
|
ProviderDelta::ToolCallArgsDelta {
|
|
call_id: "stream-1".to_string(),
|
|
delta: chunk0,
|
|
},
|
|
ProviderDelta::ToolCallArgsDelta {
|
|
call_id: "stream-1".to_string(),
|
|
delta: chunk1,
|
|
},
|
|
ProviderDelta::ToolCallArgsDelta {
|
|
call_id: "stream-1".to_string(),
|
|
delta: chunk2,
|
|
},
|
|
ProviderDelta::ToolCallArgsDelta {
|
|
call_id: "stream-1".to_string(),
|
|
delta: chunk3,
|
|
},
|
|
],
|
|
native_tools: true,
|
|
});
|
|
|
|
let mut agent = agent_with_s(
|
|
provider,
|
|
vec![EchoTool::boxed("echo_tool", calls.clone())],
|
|
workspace_path,
|
|
AgentConfig {
|
|
max_tool_iterations: 4,
|
|
..AgentConfig::default()
|
|
},
|
|
);
|
|
let (progress_tx, mut progress_rx) = tokio::sync::mpsc::channel(64);
|
|
agent.set_on_progress(Some(progress_tx));
|
|
|
|
// Run the turn. EchoTool::execute panics with context if it receives wrong
|
|
// args, validating that dispatch used the full assembled ChatResponse.tool_calls.
|
|
let answer = agent.turn("stream the tool call").await.unwrap();
|
|
assert_eq!(
|
|
answer, "stream final",
|
|
"final answer must be 'stream final'"
|
|
);
|
|
assert_eq!(
|
|
calls.load(std::sync::atomic::Ordering::SeqCst),
|
|
1,
|
|
"echo_tool must execute exactly once (no double-dispatch)"
|
|
);
|
|
|
|
// Drain the progress channel and run UI-path assertions.
|
|
let mut all_progress = Vec::new();
|
|
while let Ok(ev) = progress_rx.try_recv() {
|
|
all_progress.push(ev);
|
|
}
|
|
|
|
// ── Assert 4: ToolCallCompleted fires with success=true ─────────────────
|
|
let completed = all_progress.iter().find(|ev| {
|
|
matches!(
|
|
ev,
|
|
AgentProgress::ToolCallCompleted {
|
|
tool_name, success, ..
|
|
} if tool_name == "echo_tool" && *success
|
|
)
|
|
});
|
|
assert!(
|
|
completed.is_some(),
|
|
"expected ToolCallCompleted{{tool_name=echo_tool, success=true}} in progress channel;\n\
|
|
got: {all_progress:?}"
|
|
);
|
|
|
|
// ── Assert 3: ToolCallArgsDelta events carry the 4 fragments ────────────
|
|
// progress.rs:spawn_delta_forwarder maps ProviderDelta::ToolCallArgsDelta
|
|
// → AgentProgress::ToolCallArgsDelta{tool_name: "", delta, ...}.
|
|
// ProviderDelta::ToolCallStart → AgentProgress::ToolCallArgsDelta{tool_name: "echo_tool", delta: ""}.
|
|
// Filter to iteration 1 only (tool-call dispatch iteration).
|
|
// ScriptedProvider fires stream_events on every chat() call, so iteration 2
|
|
// (the final-text response) also emits the same delta sequence — we want
|
|
// only the iteration that carried the actual tool call.
|
|
let arg_deltas: Vec<_> = all_progress
|
|
.iter()
|
|
.filter(|ev| {
|
|
matches!(
|
|
ev,
|
|
AgentProgress::ToolCallArgsDelta { call_id, delta, iteration, .. }
|
|
if call_id == "stream-1" && !delta.is_empty() && *iteration == 1
|
|
)
|
|
})
|
|
.collect();
|
|
|
|
assert_eq!(
|
|
arg_deltas.len(),
|
|
4,
|
|
"expected 4 non-empty ToolCallArgsDelta progress events for call_id=stream-1 iteration=1;\n\
|
|
got {}: {arg_deltas:?}",
|
|
arg_deltas.len()
|
|
);
|
|
|
|
// Concatenated deltas must reassemble to the original full_args JSON.
|
|
let accumulated: String = arg_deltas
|
|
.iter()
|
|
.filter_map(|ev| {
|
|
if let AgentProgress::ToolCallArgsDelta { delta, .. } = ev {
|
|
Some(delta.as_str())
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.collect();
|
|
assert_eq!(
|
|
accumulated, full_args,
|
|
"concatenated ToolCallArgsDelta progress events must equal the original full_args JSON;\n\
|
|
expected: {full_args:?}\n\
|
|
got: {accumulated:?}"
|
|
);
|
|
|
|
// Sanity: ToolCallStart fires as a ToolCallArgsDelta{delta:""} marker
|
|
// (progress.rs:347-353 maps ProviderDelta::ToolCallStart this way).
|
|
let has_start_marker = all_progress.iter().any(|ev| {
|
|
matches!(
|
|
ev,
|
|
AgentProgress::ToolCallArgsDelta {
|
|
call_id,
|
|
tool_name,
|
|
delta,
|
|
iteration,
|
|
..
|
|
} if call_id == "stream-1" && tool_name == "echo_tool" && delta.is_empty() && *iteration == 1
|
|
)
|
|
});
|
|
assert!(
|
|
has_start_marker,
|
|
"expected a ToolCallArgsDelta{{call_id=stream-1, tool_name=echo_tool, delta=''}} \
|
|
start-marker (from ProviderDelta::ToolCallStart mapping in progress.rs:347-353);\n\
|
|
got: {all_progress:?}"
|
|
);
|
|
}
|
|
|
|
/// Needed for streaming_tool_call_accumulation.
|
|
use openhuman_core::openhuman::config::AgentConfig;
|
|
|
|
// ─── Case 13 (provider-level): SSE tool-arg accumulation ──────────────────────
|
|
//
|
|
// The `streaming_tool_call_accumulation` test above drives the engine + UI
|
|
// delta forwarding through a ScriptedProvider that returns a *pre-assembled*
|
|
// ChatResponse — it never exercises the real provider's chunk-by-chunk
|
|
// accumulation. The accumulation that issue #3471 case 13 targets lives in
|
|
// `OpenAiCompatibleProvider::stream_native_chat`
|
|
// (src/openhuman/inference/provider/compatible_stream_native.rs:~320 and
|
|
// ~405-425): `entry.arguments.push_str(args)` glues partial `function.arguments`
|
|
// fragments from successive SSE chunks into one JSON string, which only parses
|
|
// once the stream completes. Nothing else covers that path beyond its error-frame
|
|
// unit tests.
|
|
//
|
|
// This test stands up a real axum SSE upstream that emits OpenAI-style
|
|
// `chat.completion.chunk` frames whose `function.arguments` fragments are split
|
|
// at awkward byte offsets (mid-key, mid-value), points a real
|
|
// `OpenAiCompatibleProvider` at it, and drives `provider.chat()` with a live
|
|
// delta receiver. It asserts the provider:
|
|
// - reassembles exactly one tool call with `name == "echo_tool"`,
|
|
// - produces an `arguments` string that parses AND equals the canonical JSON,
|
|
// - forwards a `ToolCallStart` + ≥3 `ToolCallArgsDelta` whose concatenation
|
|
// is the full JSON.
|
|
|
|
/// The JSON the upstream streams back, split across SSE chunks. Chosen so the
|
|
/// splits land mid-key and mid-value, the worst case for naive accumulation.
|
|
const SSE_TOOL_ARGS_JSON: &str = r#"{"value":"SSE_STREAM_CANARY","n":42}"#;
|
|
|
|
/// Build the four awkward `function.arguments` fragments from
|
|
/// [`SSE_TOOL_ARGS_JSON`]. Concatenated they reproduce the JSON byte-for-byte;
|
|
/// individually each is invalid JSON, forcing the provider to accumulate before
|
|
/// parsing. Returned as owned Strings so the SSE task can take them by value.
|
|
fn sse_tool_arg_fragments() -> [String; 4] {
|
|
let s = SSE_TOOL_ARGS_JSON;
|
|
// Byte offsets land mid-key (`{"valu`), across the key→value boundary,
|
|
// mid-value, and the tail. len = 36.
|
|
// 0..6 {"valu
|
|
// 6..18 e":"SSE_STRE
|
|
// 18..30 AM_CANARY","
|
|
// 30..36 n":42}
|
|
[
|
|
s[0..6].to_string(),
|
|
s[6..18].to_string(),
|
|
s[18..30].to_string(),
|
|
s[30..].to_string(),
|
|
]
|
|
}
|
|
|
|
/// One OpenAI-style `chat.completion.chunk` SSE frame for a tool-call delta.
|
|
/// `id`/`name` are only set on the first fragment (`include_header`); later
|
|
/// fragments carry just the `function.arguments` continuation, exactly as real
|
|
/// providers stream them.
|
|
fn sse_tool_chunk_frame(arguments: &str, include_header: bool) -> String {
|
|
let function = if include_header {
|
|
json!({ "name": "echo_tool", "arguments": arguments })
|
|
} else {
|
|
json!({ "arguments": arguments })
|
|
};
|
|
let mut tool_call = json!({
|
|
"index": 0,
|
|
"function": function,
|
|
});
|
|
if include_header {
|
|
tool_call["id"] = json!("call_sse_canary");
|
|
tool_call["type"] = json!("function");
|
|
}
|
|
let chunk = json!({
|
|
"id": "chatcmpl-sse-canary",
|
|
"object": "chat.completion.chunk",
|
|
"choices": [{ "index": 0, "delta": { "tool_calls": [tool_call] }, "finish_reason": null }],
|
|
});
|
|
format!("data: {chunk}\n\n")
|
|
}
|
|
|
|
/// The terminal `finish_reason: "tool_calls"` frame followed by `[DONE]`.
|
|
fn sse_tool_finish_frames() -> String {
|
|
let finish = json!({
|
|
"id": "chatcmpl-sse-canary",
|
|
"object": "chat.completion.chunk",
|
|
"choices": [{ "index": 0, "delta": {}, "finish_reason": "tool_calls" }],
|
|
});
|
|
format!("data: {finish}\n\ndata: [DONE]\n\n")
|
|
}
|
|
|
|
/// axum handler: assert the provider asked for streaming with native tools, then
|
|
/// stream the split tool-arg fragments back as `text/event-stream`.
|
|
async fn sse_tool_args_handler(Json(body): Json<Value>) -> axum::response::Response {
|
|
use axum::body::Body;
|
|
use axum::http::header::CONTENT_TYPE;
|
|
use axum::response::IntoResponse;
|
|
|
|
// The streaming path is only taken when the provider set stream:true and
|
|
// forwarded the native tool spec; assert both so a regression that drops
|
|
// either can't make this test silently pass through a non-streaming branch.
|
|
assert_eq!(
|
|
body.get("stream").and_then(Value::as_bool),
|
|
Some(true),
|
|
"provider must request stream:true on the native streaming path; body: {body}"
|
|
);
|
|
let tool_names: Vec<&str> = body
|
|
.get("tools")
|
|
.and_then(Value::as_array)
|
|
.map(|tools| {
|
|
tools
|
|
.iter()
|
|
.filter_map(|t| t.pointer("/function/name").and_then(Value::as_str))
|
|
.collect()
|
|
})
|
|
.unwrap_or_default();
|
|
assert!(
|
|
tool_names.contains(&"echo_tool"),
|
|
"provider must forward the native echo_tool spec; tools in body: {tool_names:?}; body: {body}"
|
|
);
|
|
|
|
let [f0, f1, f2, f3] = sse_tool_arg_fragments();
|
|
// First fragment carries id+name; the remaining three are pure arg
|
|
// continuations. A finish frame + [DONE] close the stream.
|
|
let frames: Vec<String> = vec![
|
|
sse_tool_chunk_frame(&f0, true),
|
|
sse_tool_chunk_frame(&f1, false),
|
|
sse_tool_chunk_frame(&f2, false),
|
|
sse_tool_chunk_frame(&f3, false),
|
|
sse_tool_finish_frames(),
|
|
];
|
|
let body_stream = tokio_stream::iter(
|
|
frames
|
|
.into_iter()
|
|
.map(|frame| Ok::<_, std::convert::Infallible>(axum::body::Bytes::from(frame))),
|
|
);
|
|
|
|
(
|
|
StatusCode::OK,
|
|
[(CONTENT_TYPE, "text/event-stream")],
|
|
Body::from_stream(body_stream),
|
|
)
|
|
.into_response()
|
|
}
|
|
|
|
fn sse_tool_args_router() -> Router {
|
|
Router::new().route("/chat/completions", post(sse_tool_args_handler))
|
|
}
|
|
|
|
/// Provider-level coverage for issue #3471 case 13: the real
|
|
/// `OpenAiCompatibleProvider` accumulates `function.arguments` fragments split
|
|
/// across SSE chunks into one valid JSON string, and forwards the ordered
|
|
/// `ToolCallStart` → `ToolCallArgsDelta*` events to the live receiver.
|
|
///
|
|
/// Unlike `streaming_tool_call_accumulation` (which uses a ScriptedProvider that
|
|
/// returns a pre-assembled response), this drives the actual provider HTTP +
|
|
/// SSE-parse path against an in-test upstream, so the `entry.arguments.push_str`
|
|
/// accumulation in compatible_stream_native.rs is what assembles the final
|
|
/// `ChatResponse.tool_calls[0].arguments`.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn provider_sse_tool_args_accumulation() {
|
|
use openhuman_core::openhuman::inference::provider::compatible::{
|
|
AuthStyle, OpenAiCompatibleProvider,
|
|
};
|
|
use openhuman_core::openhuman::inference::provider::{
|
|
ChatMessage, ChatRequest, Provider, ProviderDelta,
|
|
};
|
|
use openhuman_core::openhuman::tools::ToolSpec;
|
|
|
|
let _lock = env_lock();
|
|
|
|
// Stand up the SSE upstream on an ephemeral port.
|
|
let (addr, server) = serve_on_ephemeral(sse_tool_args_router()).await;
|
|
let base_url = format!("http://{addr}");
|
|
|
|
// Real provider, Bearer auth with a non-empty credential so
|
|
// credential_for_request() does not short-circuit. base_url has no path, so
|
|
// chat_completions_url() targets `<base_url>/chat/completions` — the route
|
|
// the upstream serves.
|
|
let provider = OpenAiCompatibleProvider::new(
|
|
"e2e-sse-canary",
|
|
&base_url,
|
|
Some("test-key"),
|
|
AuthStyle::Bearer,
|
|
);
|
|
|
|
// A native tool spec so the streaming request carries `tools` (and the
|
|
// handler's assertion that the provider forwarded echo_tool passes).
|
|
let tools = vec![ToolSpec {
|
|
name: "echo_tool".to_string(),
|
|
description: "Echo the provided value back.".to_string(),
|
|
parameters: json!({
|
|
"type": "object",
|
|
"properties": { "value": { "type": "string" }, "n": { "type": "number" } },
|
|
"required": ["value"],
|
|
}),
|
|
}];
|
|
let messages = vec![ChatMessage::user("call echo_tool")];
|
|
|
|
// Drain the delta receiver concurrently — the provider sends on it while the
|
|
// chat() future is still in flight, so a non-concurrent recv would deadlock.
|
|
let (delta_tx, mut delta_rx) = tokio::sync::mpsc::channel::<ProviderDelta>(64);
|
|
let collector = tokio::spawn(async move {
|
|
let mut deltas = Vec::new();
|
|
while let Some(delta) = delta_rx.recv().await {
|
|
deltas.push(delta);
|
|
}
|
|
deltas
|
|
});
|
|
|
|
let request = ChatRequest {
|
|
messages: &messages,
|
|
tools: Some(&tools),
|
|
stream: Some(&delta_tx),
|
|
max_tokens: None,
|
|
};
|
|
let response = provider
|
|
.chat(request, "e2e-sse-model", 0.0)
|
|
.await
|
|
.unwrap_or_else(|e| panic!("provider.chat() over SSE failed: {e:#}"));
|
|
|
|
// Dropping the sender lets the collector task finish and yield the deltas.
|
|
drop(delta_tx);
|
|
let deltas = collector.await.expect("delta collector task panicked");
|
|
|
|
// ── Assert 1: exactly one tool call, name echo_tool ───────────────────────
|
|
assert_eq!(
|
|
response.tool_calls.len(),
|
|
1,
|
|
"expected exactly one accumulated tool call; got {}: {:?}",
|
|
response.tool_calls.len(),
|
|
response.tool_calls
|
|
);
|
|
let tool_call = &response.tool_calls[0];
|
|
assert_eq!(
|
|
tool_call.name, "echo_tool",
|
|
"accumulated tool call must be echo_tool; got {:?}",
|
|
tool_call.name
|
|
);
|
|
|
|
// ── Assert 2: accumulated arguments parse AND equal the canonical JSON ─────
|
|
let expected: Value = json!({ "value": "SSE_STREAM_CANARY", "n": 42 });
|
|
let parsed: Value = serde_json::from_str(&tool_call.arguments).unwrap_or_else(|e| {
|
|
panic!(
|
|
"accumulated tool-call arguments must be valid JSON (proves SSE fragments were glued, \
|
|
not mangled); parse error: {e}; raw arguments: {:?}",
|
|
tool_call.arguments
|
|
)
|
|
});
|
|
assert_eq!(
|
|
parsed, expected,
|
|
"accumulated arguments must equal the canonical JSON exactly; \
|
|
got {parsed} from raw {:?}",
|
|
tool_call.arguments
|
|
);
|
|
|
|
// ── Assert 3: ToolCallStart + ≥3 ToolCallArgsDelta, concatenation == JSON ──
|
|
let start_count = deltas
|
|
.iter()
|
|
.filter(|d| {
|
|
matches!(
|
|
d,
|
|
ProviderDelta::ToolCallStart { tool_name, .. } if tool_name == "echo_tool"
|
|
)
|
|
})
|
|
.count();
|
|
assert_eq!(
|
|
start_count, 1,
|
|
"expected exactly one ToolCallStart for echo_tool; got {start_count}; deltas: {deltas:?}"
|
|
);
|
|
|
|
let arg_deltas: Vec<String> = deltas
|
|
.iter()
|
|
.filter_map(|d| match d {
|
|
ProviderDelta::ToolCallArgsDelta { delta, .. } => Some(delta.clone()),
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
assert!(
|
|
arg_deltas.len() >= 3,
|
|
"expected ≥3 ToolCallArgsDelta events (split fragments); got {}: {arg_deltas:?}",
|
|
arg_deltas.len()
|
|
);
|
|
let concatenated: String = arg_deltas.concat();
|
|
assert_eq!(
|
|
concatenated, SSE_TOOL_ARGS_JSON,
|
|
"concatenated ToolCallArgsDelta deltas must reproduce the full arguments JSON exactly; \
|
|
got {concatenated:?}"
|
|
);
|
|
|
|
server.abort();
|
|
}
|