//! Tests for one turn: record sequences and tool dispatch. Do not edit. //! The limits and the append-only property are in `limits.rs`; denials and approvals are in //! `turn_broker.rs`. mod support; #[path = "support/turn.rs"] mod turn_support; use loopd::tools::Registry; use proto::{DataClass, LogRecord, SessionId, ToolResponse, TurnEvent}; use support::{Reply, ok_result}; use turn_support::{setup, types}; const CHAT: &str = "/v1/chat/completions"; #[test] fn a_plain_turn() { let s = setup(vec![]); s.server.route(CHAT, vec![Reply::fixture("plain")]); let mut session = s.session("a"); let (result, events) = s.turn(&mut session, "hello"); let outcome = result.unwrap(); assert_eq!( outcome.content, support::expected("plain")["content"].as_str().unwrap() ); assert_eq!( (outcome.usage.prompt_n, outcome.usage.predicted_n), (46, 16) ); assert_eq!( types(session.records()), ["start", "user", "assistant", "usage"] ); assert_eq!( types(&s.home.records("a")), ["start", "user", "assistant", "usage"], "on disk too" ); assert!( events .iter() .any(|e| matches!(e, TurnEvent::Content { .. })) ); assert!( !events .iter() .any(|e| matches!(e, TurnEvent::ToolCallStarted { .. })) ); assert!(s.port.calls().is_empty()); let sent = s.server.requests_to(CHAT); assert_eq!(sent.len(), 1); let body = sent[0].json(); assert_eq!(body["messages"][0]["role"], "system"); assert_eq!( body["messages"][0]["content"], "You are Boxmaker, a test agent." ); assert_eq!( body["messages"][1], serde_json::json!({"role": "user", "content": "hello"}) ); let names: Vec<&str> = body["tools"] .as_array() .unwrap() .iter() .map(|t| t["function"]["name"].as_str().unwrap()) .collect(); assert_eq!(names, ["clock", "find_tool", "call_tool"]); assert_eq!(body["id_slot"], 0); } #[test] fn a_tool_turn_goes_through_the_port_and_records_everything() { let s = setup(vec![ok_result("straylight\n")]); s.server.route( CHAT, vec![Reply::fixture("tool_call"), Reply::fixture("plain")], ); let mut session = s.session("a"); let (result, events) = s.turn(&mut session, "read the hostname"); assert!(result.is_ok(), "{result:?}"); assert_eq!( types(session.records()), [ "start", "user", "assistant", "usage", "tool_result", "assistant", "usage" ] ); let calls = s.port.calls(); assert_eq!(calls.len(), 1); assert_eq!(calls[0].tool, "read_file"); assert_eq!( calls[0].arguments, r#"{"path":"/etc/hostname"}"#, "arguments are passed on unparsed" ); assert_eq!(calls[0].session, SessionId::new("a").unwrap()); assert_eq!(calls[0].call, proto::CallId(1)); match &session.records()[4] { LogRecord::ToolResult { call, tool_call_id, content, class, untrusted, truncated, .. } => { assert_eq!(*call, proto::CallId(1)); assert_eq!( tool_call_id, "wgE8iFI58Zni4WCTiCMNp4TzCcM8ou7F", "the server's id, so the template can pair it" ); assert_eq!(content, "straylight\n"); assert_eq!( (*class, *untrusted, *truncated), (DataClass::Private, true, false) ); } other => panic!("{other:?}"), } let kinds: Vec<&str> = events .iter() .filter_map(|e| match e { TurnEvent::ToolCallStarted { name } => Some(name.as_str()), TurnEvent::ToolResult { name, .. } => Some(name.as_str()), _ => None, }) .collect(); assert_eq!(kinds, ["read_file", "read_file"]); // The second request extends the first: the tool result sits after the assistant turn. let sent = s.server.requests_to(CHAT); let m2 = sent[1].json()["messages"].as_array().unwrap().clone(); assert_eq!(m2[2]["role"], "assistant"); assert_eq!( m2[2]["tool_calls"][0]["id"], "wgE8iFI58Zni4WCTiCMNp4TzCcM8ou7F" ); assert_eq!( m2[3], serde_json::json!({"role": "tool", "tool_call_id": "wgE8iFI58Zni4WCTiCMNp4TzCcM8ou7F", "content": "straylight\n"}) ); } #[test] fn find_tool_and_call_tool_reach_the_port_only_for_the_target() { let s = setup(vec![ok_result("box")]); s.server.route( CHAT, vec![ Reply::fixture("find_tool"), Reply::fixture("call_tool"), Reply::fixture("plain"), ], ); let mut session = s.session("a"); let (result, _) = s.turn(&mut session, "echo box"); assert!(result.is_ok(), "{result:?}"); assert_eq!( types(session.records()), [ "start", "user", "assistant", "usage", "tool_result", "assistant", "usage", "tool_result", "assistant", "usage" ] ); let calls = s.port.calls(); assert_eq!( calls.len(), 1, "find_tool is answered by loopd; only echo reaches the port" ); assert_eq!(calls[0].tool, "echo"); assert_eq!( serde_json::from_str::(&calls[0].arguments).unwrap(), serde_json::json!({"text": "box"}) ); match &session.records()[4] { LogRecord::ToolResult { content, class, untrusted, .. } => { assert!( content.contains("\"name\":\"echo\"") && content.ends_with("Call it with call_tool."), "{content}" ); assert_eq!((*class, *untrusted), (DataClass::Public, false)); } other => panic!("{other:?}"), } } #[test] fn a_call_tool_for_an_unknown_tool_never_reaches_the_port() { // The recorded call_tool asks for "echo"; with echo removed from the registry it is unknown. let mut s = setup(vec![]); s.registry = Registry::new(vec![loopd::tools::Entry { schema: loopd::tools::clock_schema(), core: true, }]); s.server.route( CHAT, vec![Reply::fixture("call_tool"), Reply::fixture("plain")], ); let mut session = s.session("a"); assert!(s.turn(&mut session, "x").0.is_ok()); assert!(s.port.calls().is_empty()); assert!( matches!(&session.records()[4], LogRecord::ToolResult { content, .. } if content.contains("No tool named \"echo\"")) ); } #[test] fn the_result_cap_applies_when_appended() { let mut s = setup(vec![ok_result(&"x".repeat(100))]); s.cfg.r#loop.tool_result_cap = 20; s.server.route( CHAT, vec![Reply::fixture("tool_call"), Reply::fixture("plain")], ); let mut session = s.session("a"); let (result, events) = s.turn(&mut session, "x"); assert!(result.is_ok()); match &session.records()[4] { LogRecord::ToolResult { content, truncated, .. } => { assert_eq!(content, &format!("{}\n[truncated]", "x".repeat(20))); assert!(truncated); } other => panic!("{other:?}"), } assert!(events.iter().any(|e| matches!( e, TurnEvent::ToolResult { truncated: true, .. } ))); let m2 = s.server.requests_to(CHAT)[1].json(); assert_eq!( m2["messages"][3]["content"].as_str().unwrap().len(), 20 + "\n[truncated]".len(), "the model sees the capped text" ); } #[test] fn a_tool_failure_becomes_a_result_the_model_can_read() { let s = setup(vec![ToolResponse::Failed { message: "disk on fire".to_string(), }]); s.server.route( CHAT, vec![Reply::fixture("tool_call"), Reply::fixture("plain")], ); let mut session = s.session("a"); let (result, events) = s.turn(&mut session, "x"); assert!(result.is_ok(), "{result:?}"); match &session.records()[4] { LogRecord::ToolResult { content, class, untrusted, .. } => { assert_eq!(content, "The tool failed: disk on fire"); assert_eq!((*class, *untrusted), (DataClass::Public, false)); } other => panic!("{other:?}"), } assert!( !events .iter() .any(|e| matches!(e, TurnEvent::ToolDenied { .. })), "a failure is not a denial" ); }