Files
Afloat c4e3d79dda Honor per-backend conversation context budgets (#559)
Refs #374. A backend can set its own conversation budget (context_tokens in [[ai.backends]]); the conversation is fitted to it before each model attempt, including a fallback with a smaller window, counting history added since the last measured request.
2026-11-03 14:53:55 +07:00

651 lines
34 KiB
Rust

//! End-to-end tests: a tiny std TcpListener HTTP server stands in for the model APIs, and a real
//! engine runs the tools. No real network.
use super::*;
use craft_anything_platform::sync::Mutex;
use serde_json::{Value, json};
use std::io::{BufRead, BufReader, Read, Write};
use std::net::TcpListener;
#[derive(Debug, Clone)]
struct Seen {
path: String,
headers: Vec<(String, String)>,
body: Value,
}
impl Seen {
fn header(&self, k: &str) -> Option<&str> {
self.headers.iter().find(|(n, _)| n.eq_ignore_ascii_case(k)).map(|(_, v)| v.as_str())
}
}
/// Serve canned responses `(status, content_type, body)` in order; record requests.
fn mock(responses: Vec<(u16, &'static str, String)>) -> (String, Arc<Mutex<Vec<Seen>>>) {
let l = TcpListener::bind("127.0.0.1:0").unwrap();
let url = format!("http://{}", l.local_addr().unwrap());
let seen = Arc::new(Mutex::new(Vec::new()));
let s2 = seen.clone();
std::thread::spawn(move || {
for (status, ctype, reply) in responses {
let Ok((stream, _)) = l.accept() else { return };
let mut r = BufReader::new(stream.try_clone().unwrap());
let mut line = String::new();
r.read_line(&mut line).unwrap();
let path = line.split_whitespace().nth(1).unwrap_or_default().to_string();
let mut headers = Vec::new();
let mut len = 0usize;
loop {
let mut h = String::new();
r.read_line(&mut h).unwrap();
let h = h.trim_end().to_string();
if h.is_empty() {
break;
}
if let Some((k, v)) = h.split_once(':') {
if k.eq_ignore_ascii_case("content-length") {
len = v.trim().parse().unwrap();
}
headers.push((k.trim().to_string(), v.trim().to_string()));
}
}
let mut buf = vec![0; len];
r.read_exact(&mut buf).unwrap();
let body = serde_json::from_slice(&buf).unwrap_or(Value::Null);
s2.lock().unwrap().push(Seen { path, headers, body });
let mut w = stream;
let reason = if status == 200 { "OK" } else { "Error" };
write!(w, "HTTP/1.1 {status} {reason}\r\ncontent-type: {ctype}\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{reply}", reply.len()).unwrap();
w.flush().unwrap();
}
});
(url, seen)
}
/// A stand-in server on a free port that answers every request (and any number of them, unlike
/// [`mock`]) with `status` and `body`; its `/v1` address.
pub(crate) fn serve(status: &'static str, body: &'static str) -> String {
let l = TcpListener::bind("127.0.0.1:0").unwrap();
let base = format!("http://{}/v1", l.local_addr().unwrap());
std::thread::spawn(move || {
for mut s in l.incoming().flatten() {
let (mut got, mut buf) = (Vec::new(), [0u8; 4096]);
while !got.windows(4).any(|w| w == b"\r\n\r\n") {
match s.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => got.extend_from_slice(&buf[..n]),
}
}
if !got.is_empty() {
let _ = write!(s, "HTTP/1.1 {status}\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", body.len());
}
}
});
base
}
/// #48: a coding agent's test only runs `--version`, and its report says so.
#[test]
fn a_coding_agent_test_says_it_only_checked_the_version() {
let (h, _d) = hub();
let config = BackendConfig { name: "Agent".into(), kind: BackendKind::AgentCli, model: "opencode".into(), base_url: None, ssh_host: None, enabled: true };
// cargo stands in for the agent's program: it's there wherever these tests run.
let options = BackendOptions { command: Some("cargo".into()), ..Default::default() };
h.add_backend_with(BackendEntry { config, options }, None).unwrap();
let r = h.test_backend("Agent").unwrap();
assert!(r.version_only && r.reply.starts_with("cargo "), "{r:?}");
assert_eq!(serde_json::to_value(&r).unwrap()["version_only"], true, "the CLI's ai test prints it");
}
/// Every known agent with an adapter of its own is offered when adding a coding agent.
#[test]
fn every_agent_with_its_own_adapter_is_offered() {
let adapters = cli_agents::adapters::AgentAdapters::built_in();
let own: Vec<&str> = cli_agents::KNOWN.iter().map(|(id, _, _)| *id).filter(|id| adapters.for_agent(id).ids().contains(id)).collect();
assert!(own.contains(&"maki"), "{own:?}");
for id in own {
assert!(BackendKind::AgentCli.suggested_models().contains(&id), "{id} isn't offered");
}
}
/// #37: doctor asks a local server for its models instead of trusting an open port.
#[test]
fn a_local_server_is_reachable_only_when_it_lists_models() {
let (h, _d) = hub();
add(&h, "Web page", BackendKind::OpenaiCompatible, &serve("404 Not Found", "404 page not found"), None);
add(&h, "Real", BackendKind::OpenaiCompatible, &serve("200 OK", r#"{"data":[{"id":"qwen3-8b"}]}"#), None);
let reach = h.check_reachability();
let of = |n: &str| reach.iter().find(|(name, _)| name == n).map(|(_, r)| r.clone()).unwrap();
assert!(of("Web page").is_err_and(|e| e.contains("404 page not found")), "{reach:?}");
assert!(of("Real").is_ok(), "{reach:?}");
}
fn sse(events: &[Value]) -> String {
events.iter().map(|e| format!("event: {}\r\ndata: {}\r\n\r\n", e["type"].as_str().unwrap_or("message"), e)).collect()
}
fn chat_sse(chunks: &[Value]) -> String {
let mut s: String = chunks.iter().map(|c| format!("data: {c}\n\n")).collect();
s.push_str("data: [DONE]\n\n");
s
}
fn hub() -> (AiHub, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
let engine = Arc::new(Engine::new(craft_anything_engine::testing::session()));
let h = AiHub::with_stores(engine, dir.path().join("config.toml"), KeyStore::file_only(dir.path().join("ai-keys.toml")));
(h, dir)
}
#[test]
fn a_failed_save_changes_nothing() {
let dir = tempfile::tempdir().unwrap();
// The settings "file" can't be written: its folder is a file.
std::fs::write(dir.path().join("blocked"), "").unwrap();
let engine = Arc::new(Engine::new(craft_anything_engine::testing::session()));
let h = AiHub::with_stores(engine, dir.path().join("blocked").join("config.toml"), KeyStore::file_only(dir.path().join("ai-keys.toml")));
let cfg = BackendConfig { name: "Kimi".into(), kind: BackendKind::Openrouter, model: "m".into(), base_url: None, ssh_host: None, enabled: true };
assert!(h.add_backend(cfg, None).is_err());
assert!(h.backends().is_empty(), "the app shows only what was saved");
}
/// #261: one bad price made the whole [ai] section read as empty, and adding a backend saved
/// that over the backends, roles and prices.
#[test]
fn ai_settings_that_cant_be_read_are_never_saved_over() {
let (h, d) = hub();
add(&h, "Claude Code", BackendKind::AgentCli, "http://127.0.0.1:9", None);
add(&h, "Kimi K3 (OpenRouter)", BackendKind::Openrouter, "http://127.0.0.1:9", None);
h.set_role(Role::Builder, Some("Kimi K3 (OpenRouter)")).unwrap();
let file = d.path().join("config.toml");
let mut bad = h.engine.session().settings.clone();
let price: toml::Table = toml::from_str(
"[m]
input_per_mtok = 1.0
",
)
.unwrap();
bad.ai.insert("prices".into(), toml::Value::Table(price));
bad.save_to(&file).unwrap();
h.engine.session().settings = bad;
let before = std::fs::read(&file).unwrap();
let cfg = BackendConfig { name: "Other".into(), kind: BackendKind::Openrouter, model: "m".into(), base_url: None, ssh_host: None, enabled: true };
let e = h.add_backend(cfg, Some("sk-test".into())).unwrap_err();
assert_eq!(e.code, ErrorCode::BadSettings, "{e:?}");
assert!(h.set_role(Role::Builder, None).is_err());
assert!(h.set_enabled("Claude Code", false).is_err());
assert!(h.remove_backend("Claude Code").is_err());
assert_eq!(std::fs::read(&file).unwrap(), before, "the settings file was changed");
assert!(h.keys.get("Other").is_none(), "a refused add stored its key");
}
#[test]
fn the_person_chooses_who_builds() {
let (h, _d) = hub();
add(&h, "Claude Code", BackendKind::AgentCli, "http://127.0.0.1:9", None);
add(&h, "Kimi K3 (OpenRouter)", BackendKind::Openrouter, "http://127.0.0.1:9", Some("sk-test"));
assert_eq!(h.chosen(Role::Builder), None, "automatic until chosen");
assert!(h.set_role(Role::Builder, Some("Nobody")).is_err(), "only a configured backend");
h.set_role(Role::Builder, Some("Kimi K3 (OpenRouter)")).unwrap();
assert_eq!(h.chosen(Role::Builder).as_deref(), Some("Kimi K3 (OpenRouter)"));
// The extractor is chosen the same way, and has no default.
assert_eq!(h.chosen(Role::Extractor), None);
h.set_role(Role::Extractor, Some("Kimi K3 (OpenRouter)")).unwrap();
assert_eq!(h.chosen(Role::Extractor).as_deref(), Some("Kimi K3 (OpenRouter)"));
// A removed backend can't stay the builder.
h.remove_backend("Kimi K3 (OpenRouter)").unwrap();
assert_eq!((h.chosen(Role::Builder), h.chosen(Role::Extractor)), (None, None));
h.set_role(Role::Builder, Some("Claude Code")).unwrap();
h.set_role(Role::Builder, None).unwrap();
assert_eq!(h.chosen(Role::Builder), None, "back to automatic");
}
/// #53: an agent app installed where it isn't looked for gets its program set (and cleared).
#[test]
fn an_agent_apps_program_is_set_and_cleared() {
let (h, _d) = hub();
add(&h, "OpenCode", BackendKind::AgentCli, "http://127.0.0.1:9", None);
let program = |h: &AiHub| h.options().get("OpenCode").and_then(|o| o.command.clone());
assert_eq!(program(&h), None);
assert!(h.set_command("OpenCode", Some(" /opt/oc/bin/opencode ")).unwrap());
assert_eq!(program(&h).as_deref(), Some("/opt/oc/bin/opencode"));
assert!(!h.set_command("Nobody", Some("/x")).unwrap(), "only a configured backend");
assert!(h.set_command("OpenCode", Some(" ")).unwrap());
assert_eq!(program(&h), None, "blank finds it by its usual name again");
}
/// #123, #127: the model an AI uses is changed; Claude Code is offered Claude's models.
#[test]
fn an_ais_model_is_changed() {
let (h, _d) = hub();
let url = serve("200 OK", r#"{"object":"list","data":[{"id":"nomic-embed-text"},{"id":"qwen3:8b"}]}"#);
h.add_server("Box", &url, Some("nomic-embed-text"), None).unwrap();
assert_eq!(h.model_choices("Box").unwrap(), ["nomic-embed-text", "qwen3:8b"], "the server's own list");
assert!(h.set_model("Box", " qwen3:8b ").unwrap());
assert_eq!(h.backends().into_iter().find(|b| b.name == "Box").unwrap().model, "qwen3:8b");
let claude =
BackendConfig { name: "Claude Code".into(), kind: BackendKind::AgentCli, model: "claude-code".into(), base_url: None, ssh_host: None, enabled: true };
h.add_backend(claude, None).unwrap();
assert_eq!(h.model_choices("Claude Code").unwrap(), crate::anthropic::MODELS);
assert!(h.set_model("Claude Code", "claude-sonnet-5-5").unwrap());
let claude = h.options().get("Claude Code").and_then(|o| o.cli_model.clone());
assert_eq!(claude.as_deref(), Some("claude-sonnet-5-5"), "the CLI is asked for it; the agent stays Claude Code");
assert_eq!(h.backends().into_iter().find(|b| b.name == "Claude Code").unwrap().model, "claude-code");
assert!(!h.set_model("Nobody", "m").unwrap());
assert!(h.set_model("Box", " ").is_err());
}
/// Every Claude effort level can be chosen for the Claude API and Claude Code, and only for them.
#[test]
fn a_claude_ais_effort_is_changed() {
let (h, _d) = hub();
let b = |name: &str, kind, model: &str| BackendConfig { name: name.into(), kind, model: model.into(), base_url: None, ssh_host: None, enabled: true };
h.add_backend(b("Claude Code", BackendKind::AgentCli, "claude-code"), None).unwrap();
h.add_backend(b("Codex", BackendKind::AgentCli, "codex"), None).unwrap();
let effort = |h: &AiHub, name: &str| {
let config = h.backends().into_iter().find(|b| b.name == name).unwrap();
crate::effort_in_use(&config, h.options().get(name))
};
assert_eq!(effort(&h, "Claude Code"), Some(Effort::High), "high until it's chosen");
for level in Effort::ALL {
assert!(h.set_effort("Claude Code", Some(level)).unwrap());
assert_eq!(effort(&h, "Claude Code"), Some(level));
}
assert!(h.set_effort("Claude Code", None).unwrap());
assert_eq!(effort(&h, "Claude Code"), Some(Effort::High), "back to the usual level");
assert_eq!(effort(&h, "Codex"), None, "an agent that doesn't take Claude's levels");
assert!(h.set_effort("Codex", Some(Effort::Max)).is_err());
assert!(!h.set_effort("Nobody", Some(Effort::Low)).unwrap());
}
/// #54, #98: a server on another computer is added by its address, with a model that can chat
/// when none is named; one that doesn't answer with its models isn't added.
#[test]
fn a_server_is_added_by_its_address() {
let (h, _d) = hub();
let url = serve("200 OK", r#"{"object":"list","data":[{"id":"nomic-embed-text"},{"id":"qwen3:8b"}]}"#);
assert_eq!(h.add_server(" Studio box ", &format!("{url}/"), None, Some(" sk-test ".into())).unwrap(), "qwen3:8b");
let b = h.backends().into_iter().find(|b| b.name == "Studio box").unwrap();
assert_eq!((b.kind, b.model.as_str(), b.base_url.as_deref()), (BackendKind::OpenaiCompatible, "qwen3:8b", Some(url.as_str())));
assert_eq!(h.add_server("Named", &url, Some("llama3"), None).unwrap(), "llama3", "a named model is kept");
assert!(h.add_server("Web page", &serve("404 Not Found", "nope"), None, None).is_err(), "not a model server");
assert!(h.add_server("Empty", &serve("200 OK", r#"{"object":"list","data":[]}"#), None, None).is_err(), "no models to use");
assert!(h.add_server("No scheme", "192.168.1.20:11434/v1", None, None).is_err());
assert!(h.add_server(" ", &url, None, None).is_err());
assert_eq!(h.backends().len(), 2, "only the ones that answered");
}
#[test]
fn a_backend_context_budget_reaches_the_model() {
let (hub, dir) = hub();
let table: toml::Table = toml::from_str(
"[[backends]]\nname = \"Local\"\nkind = \"openai_compatible\"\nmodel = \"qwen3:8b\"\nbase_url = \"http://127.0.0.1:9/v1\"\ncontext_tokens = 8000\n",
)
.unwrap();
let entry = AiConfig::from_table(&table).unwrap().backends.remove(0);
hub.add_backend_with(entry, None).unwrap();
let slot = hub.build_model(&hub.entry("Local").unwrap()).unwrap();
assert_eq!(slot.context_tokens, 8000);
let saved = AiConfig::from_settings(&Settings::load_from(&dir.path().join("config.toml")));
assert_eq!(saved.get("Local").unwrap().options.context_tokens, Some(8000));
}
/// #374: a remote OpenAI-compatible service was treated as a local model: a 24K-token context
/// and a cost of nothing. Only a server on this computer or its network is local.
#[test]
fn only_an_openai_compatible_server_nearby_is_local() {
let (h, _d) = hub();
for (name, base, local) in [
("Ollama", "http://127.0.0.1:11434/v1", true),
("LM Studio box", "http://192.168.1.20:1234/v1", true),
("Docker Ollama", "http://ollama:11434/v1", true),
("IPv6 link-local", "http://[fe80::1]:11434/v1", true),
("Z.ai", "https://api.z.ai/api/coding/paas/v4", false),
("Public vLLM", "http://8.8.8.8:8000/v1", false),
] {
add(&h, name, BackendKind::OpenaiCompatible, base, None);
let slot = h.build_model(&h.entry(name).unwrap()).unwrap();
assert_eq!(slot.local, local, "{name} at {base}");
}
h.add_backend(
BackendConfig { name: "Default".into(), kind: BackendKind::OpenaiCompatible, model: String::new(), base_url: None, ssh_host: None, enabled: true },
None,
)
.unwrap();
assert!(h.build_model(&h.entry("Default").unwrap()).unwrap().local, "no address means Ollama on this computer");
add(&h, "OpenAI", BackendKind::Openai, "http://127.0.0.1:9/v1", Some("sk-test"));
assert!(!h.build_model(&h.entry("OpenAI").unwrap()).unwrap().local, "only OpenAI-compatible servers can be local");
}
fn add(h: &AiHub, name: &str, kind: BackendKind, base: &str, key: Option<&str>) {
h.add_backend(
BackendConfig { name: name.into(), kind, model: String::new(), base_url: Some(base.into()), ssh_host: None, enabled: true },
key.map(str::to_string),
)
.unwrap();
}
fn run(c: &mut Box<dyn Conversation>, text: &str) -> Vec<AgentEvent> {
c.send(text);
let deadline = Instant::now() + Duration::from_secs(20);
let mut all = Vec::new();
while Instant::now() < deadline {
for e in c.poll() {
let done = matches!(e, AgentEvent::TurnDone { .. });
all.push(e);
if done {
return all;
}
}
std::thread::sleep(Duration::from_millis(5));
}
panic!("timed out: {all:?}");
}
#[test]
fn anthropic_streaming_tool_loop_through_the_engine() {
let turn1 = sse(&[
json!({"type":"message_start","message":{"model":"claude-opus-5-5","usage":{"input_tokens":100,"output_tokens":1}}}),
json!({"type":"ping"}),
json!({"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}),
json!({"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"plan"}}),
json!({"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"SIG"}}),
json!({"type":"content_block_stop","index":0}),
json!({"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}}),
json!({"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"Checking"}}),
json!({"type":"content_block_stop","index":1}),
json!({"type":"content_block_start","index":2,"content_block":{"type":"tool_use","id":"tu_1","name":"core_session_show","input":{}}}),
json!({"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"{"}}),
json!({"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"}"}}),
json!({"type":"content_block_stop","index":2}),
json!({"type":"content_block_start","index":3,"content_block":{"type":"tool_use","id":"tu_2","name":"core_targets_list","input":{}}}),
json!({"type":"content_block_stop","index":3}),
json!({"type":"message_delta","delta":{"stop_reason":"tool_use"},"usage":{"output_tokens":40}}),
json!({"type":"message_stop"}),
]);
let turn2 = sse(&[
json!({"type":"message_start","message":{"model":"claude-opus-5-5","usage":{"input_tokens":200,"cache_read_input_tokens":90}}}),
json!({"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}),
json!({"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" Nothing is open."}}),
json!({"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":10}}),
json!({"type":"message_stop"}),
]);
let (url, seen) = mock(vec![(200, "text/event-stream", turn1), (200, "text/event-stream", turn2)]);
let (h, _d) = hub();
add(&h, "Claude", BackendKind::Anthropic, &url, Some("sk-test-123"));
let mut c = h.start(None, true).unwrap();
let ev = run(&mut c, "How does the session look?");
let text: String = ev.iter().filter_map(|e| if let AgentEvent::TextDelta { text } = e { Some(text.as_str()) } else { None }).collect();
assert_eq!(text, "Checking Nothing is open.");
assert!(ev.iter().any(|e| matches!(e, AgentEvent::ToolFinished { tool, ok: true, .. } if tool == "core_session_show")));
assert!(ev.iter().any(|e| matches!(e, AgentEvent::ToolFinished { tool, ok: true, .. } if tool == "core_targets_list")));
let AgentEvent::TurnDone { backend, input_tokens, output_tokens, cost_usd, .. } = ev.last().unwrap().clone() else { panic!() };
assert_eq!((backend.as_str(), input_tokens, output_tokens), ("Claude", 300, 50));
assert!(cost_usd.unwrap() > 0.0);
let seen = seen.lock().unwrap();
assert_eq!(seen.len(), 2);
let r1 = &seen[0];
assert_eq!(r1.path, "/v1/messages");
assert_eq!(r1.header("x-api-key"), Some("sk-test-123"));
assert_eq!(r1.header("anthropic-version"), Some("2023-06-01"));
assert_eq!(r1.header("anthropic-beta"), Some("server-side-fallback-2026-07-01"));
assert_eq!(r1.header("user-agent"), Some("CraftAnything-dev"));
let b = &r1.body;
assert_eq!(b["model"], "claude-opus-5-5");
assert_eq!(b["stream"], true);
assert_eq!(b["max_tokens"], 64000);
assert_eq!(b["thinking"]["type"], "adaptive");
assert_eq!(b["output_config"]["effort"], "high");
assert_eq!(b["fallbacks"], "default");
assert_eq!(b["tool_choice"]["type"], "auto");
assert_eq!(b["system"][0]["cache_control"]["type"], "ephemeral");
assert!(b["system"][0]["text"].as_str().unwrap().contains("Investigation so far"));
let tools = b["tools"].as_array().unwrap();
let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
let mut sorted = names.clone();
sorted.sort();
assert_eq!(names, sorted, "tool order is deterministic");
assert!(names.contains(&"core_session_show") && names.contains(&"enable_toolset") && names.contains(&"core_handoff_write"));
assert!(!names.contains(&"native_cursor_show"), "non-Core tools wait for enable_toolset");
assert_eq!(tools.last().unwrap()["cache_control"]["type"], "ephemeral");
assert!(tools.iter().all(|t| t["input_schema"]["type"] == "object"));
// Turn 2 replays thinking (with signature) and returns BOTH results in ONE user message.
let msgs = seen[1].body["messages"].as_array().unwrap();
assert_eq!(msgs.len(), 3);
assert_eq!(msgs[1]["content"][0], json!({"type":"thinking","thinking":"plan","signature":"SIG"}));
let results = msgs[2]["content"].as_array().unwrap();
assert_eq!(msgs[2]["role"], "user");
assert_eq!(results.len(), 2);
assert_eq!(results[0]["tool_use_id"], "tu_1");
assert_eq!(results[1]["tool_use_id"], "tu_2");
assert_eq!(results[0]["is_error"], false);
assert!(results[0]["content"].as_str().unwrap().contains("deep"), "test sessions are in Deep mode");
// the key is never stored in settings
let saved = std::fs::read_to_string(_d.path().join("config.toml")).unwrap();
assert!(!saved.contains("sk-test-123"));
}
#[test]
fn anthropic_refusal_and_http_errors() {
let refusal = sse(&[
json!({"type":"message_start","message":{"model":"claude-opus-5-5","usage":{"input_tokens":5}}}),
json!({"type":"message_delta","delta":{"stop_reason":"refusal"},"usage":{"output_tokens":0}}),
json!({"type":"message_stop"}),
]);
let (url, _seen) = mock(vec![
(200, "text/event-stream", refusal),
(401, "application/json", r#"{"type":"error","error":{"type":"authentication_error","message":"invalid x-api-key"}}"#.into()),
]);
let (h, _d) = hub();
add(&h, "Claude", BackendKind::Anthropic, &url, Some("k"));
let mut c = h.start(Some("Claude"), false).unwrap();
let ev = run(&mut c, "q");
assert!(ev.iter().any(|e| matches!(e, AgentEvent::Error { message, .. } if message.contains("declined"))));
let ev = run(&mut c, "q2");
assert!(
ev.iter().any(|e| matches!(e, AgentEvent::Error { message, remediation: Some(r), .. } if message.contains("invalid x-api-key") && r.contains("key"))),
"{ev:?}"
);
}
#[test]
fn openai_streaming_tool_loop() {
let t1 = chat_sse(&[
json!({"model":"gpt-x","choices":[{"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"call_a","type":"function","function":{"name":"core_session_show","arguments":""}}]}}]}),
json!({"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{}"}}]}}]}),
json!({"choices":[{"delta":{},"finish_reason":"tool_calls"}]}),
json!({"choices":[],"usage":{"prompt_tokens":20,"completion_tokens":3}}),
]);
let t2 = chat_sse(&[json!({"choices":[{"delta":{"content":"Nothing open."},"finish_reason":"stop"}]})]);
let (url, seen) = mock(vec![(200, "text/event-stream", t1), (200, "text/event-stream", t2)]);
let (h, _d) = hub();
add(&h, "GPT", BackendKind::Openai, &url, Some("sk-o"));
let mut c = h.start(Some("GPT"), true).unwrap();
let ev = run(&mut c, "status?");
assert!(ev.iter().any(|e| matches!(e, AgentEvent::TextDelta { text } if text == "Nothing open.")));
let seen = seen.lock().unwrap();
assert_eq!(seen[0].path, "/chat/completions");
assert_eq!(seen[0].header("authorization"), Some("Bearer sk-o"));
assert_eq!(seen[0].body["stream_options"]["include_usage"], true);
assert_eq!(seen[0].body["tool_choice"], "auto");
let msgs = seen[1].body["messages"].as_array().unwrap();
assert_eq!(msgs[2]["tool_calls"][0]["id"], "call_a");
assert_eq!(msgs[3]["role"], "tool");
assert_eq!(msgs[3]["tool_call_id"], "call_a");
}
#[test]
fn native_tool_stop_reasons_are_respected_before_execution() {
for (finish, run_tool) in [("length", false), ("content_filter", false), ("stop", true), ("tool_calls", true)] {
let first = chat_sse(&[
json!({"choices":[{"delta":{"tool_calls":[{
"index":0,"id":"call_a","type":"function","function":{"name":"core_session_show","arguments":"{}"}
}]}}]}),
json!({"choices":[{"delta":{},"finish_reason":finish}]}),
]);
let mut responses = vec![(200, "text/event-stream", first)];
if finish != "content_filter" {
let last = chat_sse(&[json!({"choices":[{"delta":{"content":"Done."},"finish_reason":"stop"}]})]);
responses.push((200, "text/event-stream", last));
}
let (url, seen) = mock(responses);
let (h, _dir) = hub();
add(&h, "GPT", BackendKind::Openai, &url, Some("sk-test"));
let mut conversation = h.start(Some("GPT"), false).unwrap();
let events = run(&mut conversation, "status?");
let started = events.iter().filter(|event| matches!(event, AgentEvent::ToolStarted { .. })).count();
let completed = events.iter().filter(|event| matches!(event, AgentEvent::ToolFinished { tool, ok: true, .. } if tool == "core_session_show")).count();
assert_eq!(started, usize::from(run_tool), "finish_reason={finish}: {events:?}");
assert_eq!(completed, usize::from(run_tool), "finish_reason={finish}: {events:?}");
let seen = seen.lock().unwrap();
if finish == "content_filter" {
assert_eq!(seen.len(), 1, "a refused turn must not request a continuation");
assert!(events.iter().any(|event| matches!(event, AgentEvent::Error { message, .. } if message.contains("declined"))));
} else {
assert_eq!(seen.len(), 2);
let messages = seen[1].body["messages"].as_array().unwrap();
let result = messages.last().unwrap();
assert_eq!(result["role"], "tool");
assert_eq!(result["tool_call_id"], "call_a");
assert_eq!(result["content"].as_str().unwrap().starts_with("Not run: the reply hit the output limit"), finish == "length");
assert!(events.iter().any(|event| matches!(event, AgentEvent::TextDelta { text } if text == "Done.")));
}
}
}
fn assert_unexecuted_native_tool_calls_are_paired(finish: &str) {
let first = chat_sse(&[
json!({"choices":[{"delta":{"content":"Stopped before executing.","tool_calls":[
{"index":0,"id":"call_a","type":"function","function":{"name":"core_session_show","arguments":"{}"}},
{"index":1,"id":"call_b","type":"function","function":{"name":"core_session_show","arguments":"{}"}}
]}}]}),
json!({"choices":[{"delta":{},"finish_reason":finish}]}),
]);
let last = chat_sse(&[json!({"choices":[{"delta":{"content":"Done."},"finish_reason":"stop"}]})]);
let (url, seen) = mock(vec![(200, "text/event-stream", first), (200, "text/event-stream", last)]);
let (h, _dir) = hub();
add(&h, "GPT", BackendKind::Openai, &url, Some("sk-test"));
let mut conversation = h.start(Some("GPT"), false).unwrap();
let mut events = run(&mut conversation, "status?");
if finish != "pause_turn" {
assert_eq!(seen.lock().unwrap().len(), 1, "{finish} must wait for the next user turn");
events.extend(run(&mut conversation, "Continue when ready."));
}
assert!(
!events.iter().any(|event| matches!(event, AgentEvent::ToolStarted { .. } | AgentEvent::ToolFinished { .. } | AgentEvent::ConsentNeeded { .. })),
"stopped calls must not run or ask for consent: {events:?}"
);
assert!(!events.iter().any(|event| matches!(event, AgentEvent::Error { .. })), "{events:?}");
assert!(events.iter().any(|event| matches!(event, AgentEvent::TextDelta { text } if text == "Done.")), "{events:?}");
let seen = seen.lock().unwrap();
assert_eq!(seen.len(), 2, "{finish}: one stopped response and one continuation");
let messages = seen[1].body["messages"].as_array().unwrap();
let call_at = messages.iter().position(|message| message["tool_calls"].is_array()).unwrap();
assert_eq!(messages[call_at]["tool_calls"].as_array().unwrap().len(), 2);
assert_eq!(messages[call_at]["content"], "Stopped before executing.");
for (offset, id) in ["call_a", "call_b"].into_iter().enumerate() {
let result = messages.get(call_at + offset + 1);
assert_eq!(result.and_then(|message| message["role"].as_str()), Some("tool"), "{finish}: each retained call needs a result: {messages:?}");
let result = result.unwrap();
assert_eq!(result["tool_call_id"], id);
assert!(result["content"].as_str().unwrap().starts_with("Not run:"), "{result:?}");
}
if finish == "pause_turn" {
assert_eq!(messages.last().unwrap()["tool_call_id"], "call_b", "a pause continues without another user request");
} else {
assert_eq!(messages.last().unwrap()["role"], "user");
assert_eq!(messages.last().unwrap()["content"], "Continue when ready.");
}
}
#[test]
fn paused_native_tool_calls_keep_history_paired() {
assert_unexecuted_native_tool_calls_are_paired("pause_turn");
}
#[test]
fn sequence_stopped_native_tool_calls_keep_history_paired() {
assert_unexecuted_native_tool_calls_are_paired("stop_sequence");
}
#[test]
fn provider_stopped_native_tool_calls_keep_history_paired() {
assert_unexecuted_native_tool_calls_are_paired("provider_stop");
}
#[test]
fn local_server_without_tools_falls_back_to_react() {
let t1 = chat_sse(&[json!({"choices":[{"delta":{"content":"Thought: check\nAction: core_session_show\nAction Input: {}"},"finish_reason":"stop"}]})]);
let t2 = chat_sse(&[json!({"choices":[{"delta":{"content":"Final Answer: nothing is open"},"finish_reason":"stop"}]})]);
let (url, seen) = mock(vec![
(400, "application/json", r#"{"error":{"message":"registry.ollama.ai/library/tiny does not support tools"}}"#.into()),
(200, "text/event-stream", t1),
(200, "text/event-stream", t2),
]);
let (h, _d) = hub();
add(&h, "Tiny", BackendKind::OpenaiCompatible, &format!("{url}/v1"), None);
let mut c = h.start(Some("Tiny"), true).unwrap();
let ev = run(&mut c, "status?");
assert!(ev.iter().any(|e| matches!(e, AgentEvent::ToolFinished { tool, ok: true, .. } if tool == "core_session_show")), "{ev:?}");
assert!(matches!(ev.last(), Some(AgentEvent::TurnDone { cost_usd: Some(c), .. }) if *c == 0.0));
let seen = seen.lock().unwrap();
assert!(seen[0].body.get("tools").is_some());
assert!(seen[0].header("authorization").is_none());
assert!(seen[1].body.get("tools").is_none());
let last = seen[2].body["messages"].as_array().unwrap().last().unwrap().clone();
assert!(last["content"].as_str().unwrap().starts_with("Observation:"));
}
#[test]
fn hub_config_keys_and_test_backend() {
let t = sse(&[
json!({"type":"message_start","message":{"model":"claude-haiku-5-5","usage":{"input_tokens":3}}}),
json!({"type":"content_block_start","index":0,"content_block":{"type":"text","text":"ready"}}),
json!({"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":1}}),
json!({"type":"message_stop"}),
]);
let models = json!({"data":[{"id":"claude-opus-5-5"},{"id":"claude-haiku-5-5"}]}).to_string();
let (url, seen) = mock(vec![(200, "text/event-stream", t), (200, "application/json", models.clone()), (200, "application/json", models)]);
let (h, d) = hub();
assert!(h.backends().is_empty());
assert!(h.start(None, true).is_err());
add(&h, "Claude", BackendKind::Anthropic, &url, Some("k1"));
add(&h, "Local", BackendKind::OpenaiCompatible, "http://127.0.0.1:9/v1", None);
assert_eq!(h.backends().len(), 2);
assert!(h.has_key("Claude") && h.has_key("Local"));
let r = h.test_backend("Claude").unwrap();
assert_eq!((r.reply.as_str(), r.version_only), ("ready", false));
assert_eq!(r.model, "claude-haiku-5-5");
assert_eq!(seen.lock().unwrap()[0].body["output_config"]["effort"], "high");
assert_eq!(h.list_models("Claude").unwrap(), vec!["claude-opus-5-5", "claude-haiku-5-5"]);
let reach = h.check_reachability();
assert_eq!(reach.len(), 2);
assert!(reach[0].1.is_ok(), "{reach:?}");
assert!(reach[1].1.is_err());
// settings persisted to the file; reload shows both
let s = Settings::load_from(&d.path().join("config.toml"));
assert_eq!(AiConfig::from_settings(&s).backends.len(), 2);
assert!(h.remove_backend("Claude").unwrap());
assert!(!h.remove_backend("Claude").unwrap());
assert!(h.test_backend("Claude").is_err());
// missing key is a clear error
h.add_backend(
BackendConfig {
name: "NoKey".into(),
kind: BackendKind::Openrouter,
model: String::new(),
base_url: Some("http://127.0.0.1:9".into()),
ssh_host: None,
enabled: true,
},
None,
)
.unwrap();
if std::env::var("OPENROUTER_API_KEY").is_err() {
let e = h.start(Some("NoKey"), true).err().unwrap();
assert_eq!(e.code, ErrorCode::BadSettings);
}
assert!(h.compare("Local", "Local", true).is_err());
}