//! Tests for the tool-calling round loop in `mod.rs`'s `turn`, split out //! to keep the production file and its plain single-round test sibling //! under the project's file-length guideline. Shares that sibling's //! fake-server harness rather than keeping its own copy. use super::tests::{CapturedRequest, attach_collector, fake_server, sent_messages, sse_response}; use super::*; use std::sync::Mutex; use crate::bus::{Flow, Listener}; use crate::campaign::GameTime; use crate::context::ContextStack; use crate::knowledge::fixtures; use rand::SeedableRng; use rand::rngs::StdRng; use serde_json::json; use tempfile::TempDir; fn empty_context() -> Arc { Arc::new(ContextStack::new("", &[], Arc::new(fixtures::mount(&[])))) } /// Builds a `Dm` whose toolbox holds a single dice tool seeded from /// `seed`, so a test can assert on tool-call structure without a real /// random result. fn dm_with_seeded_dice(api_base: String, seed: u64) -> Dm { let toolbox = Toolbox::new(vec![Box::new(DiceTool::new(StdRng::seed_from_u64(seed)))]); Dm::with_toolbox( Config { api_base, api_key: "sk-test".to_string(), model: "gpt-4o-mini".to_string(), reasoning_effort: None, worlds_root: None, }, toolbox, empty_context(), None, ) .unwrap() } /// Parses a captured request body as JSON. fn sent_body(request: &CapturedRequest) -> serde_json::Value { serde_json::from_str(&request.body).unwrap() } /// Dice-tool call arguments as the model would send them: a notation and /// a visibility. fn roll_args(notation: &str, visibility: &str) -> String { json!({"notation": notation, "visibility": visibility}).to_string() } /// One tool call for a round's response, owning its id, function name, /// and raw arguments text. struct RoundCall { id: String, name: String, arguments: String, } /// Builds a `RoundCall` from `id`, `name`, and `arguments`. fn call(id: &str, name: &str, arguments: &str) -> RoundCall { RoundCall { id: id.to_string(), name: name.to_string(), arguments: arguments.to_string(), } } /// Builds an SSE body for one round: `narration` as a single content /// delta, omitted when empty, then one chunk carrying every call in /// `calls` at ascending indexes, omitted when empty, then `[DONE]`. fn round_body(narration: &str, calls: &[RoundCall]) -> String { let mut body = String::new(); if !narration.is_empty() { body.push_str(&format!( "data: {{\"choices\":[{{\"delta\":{{\"content\":{}}},\"finish_reason\":null}}]}}\n\n", json!(narration) )); } if !calls.is_empty() { let fragments: Vec = calls .iter() .enumerate() .map(|(index, call)| { format!( "{{\"index\":{index},\"id\":\"{}\",\"function\":{{\"name\":\"{}\",\"arguments\":{}}}}}", call.id, call.name, json!(call.arguments) ) }) .collect(); body.push_str(&format!( "data: {{\"choices\":[{{\"delta\":{{\"tool_calls\":[{}]}},\"finish_reason\":null}}]}}\n\n", fragments.join(",") )); } body.push_str("data: [DONE]\n\n"); body } /// An SSE response for one round: `narration` and `calls`, see /// `round_body`. fn round_response(narration: &str, calls: &[RoundCall]) -> Vec { sse_response(&round_body(narration, calls)) } /// A round that narrates `text` and calls nothing, ending the turn. fn reply_response(text: &str) -> Vec { round_response(text, &[]) } /// One chunk carrying a single content delta. fn content_chunk(text: &str) -> String { format!( "data: {{\"choices\":[{{\"delta\":{{\"content\":{}}},\"finish_reason\":null}}]}}\n\n", json!(text) ) } /// One chunk carrying a single tool-call fragment at `index`, with `id`, /// `name`, and `arguments` all present in this one fragment. fn tool_call_chunk(index: usize, id: &str, name: &str, arguments: &str) -> String { format!( "data: {{\"choices\":[{{\"delta\":{{\"tool_calls\":[{{\"index\":{index},\"id\":\"{id}\",\"function\":{{\"name\":\"{name}\",\"arguments\":{}}}}}]}},\"finish_reason\":null}}]}}\n\n", json!(arguments) ) } #[test] fn a_tool_round_then_a_reply_the_second_request_carries_the_tool_result() { let (url, requests, server) = fake_server(vec![ round_response("", &[call("call_1", "roll", &roll_args("1d20", "public"))]), reply_response("You find a hidden trap!"), ]); let mut dm = dm_with_seeded_dice(url, 0); let collector = attach_collector(&mut dm); dm.turn(Speaker::Player, "I search for traps.").unwrap(); assert_eq!( collector.get_done(), Some("You find a hidden trap!".to_string()) ); server.join().unwrap(); let first = sent_body(&requests.recv().unwrap()); assert!( first["tools"] .as_array() .is_some_and(|tools| !tools.is_empty()) ); let messages = sent_messages(&requests.recv().unwrap()); assert_eq!(messages[2]["role"], "assistant"); assert_eq!(messages[2]["tool_calls"][0]["id"], "call_1"); assert_eq!(messages[3]["role"], "tool"); assert_eq!(messages[3]["tool_call_id"], "call_1"); assert!(messages[3]["content"].as_str().unwrap().contains("total")); } #[test] fn a_post_turn_transcript_write_failure_appends_a_warning_instead_of_failing_the_turn() { use std::os::unix::fs::PermissionsExt; let mark_args = json!({ "time": "#d2-0100", "event": "Morning breaks", "visibility": "secret", }) .to_string(); let (url, _requests, server) = fake_server(vec![ round_response("", &[call("call_1", "mark", &mark_args)]), reply_response("You wake up."), ]); let world = TempDir::new().unwrap(); let campaign = Campaign::open(world.path()).unwrap(); // Seed day 1 so the turn's own player line only ever appends to a // day file that already exists. campaign .append_narration(GameTime::parse("#d1-0000").unwrap(), "(seed)") .unwrap(); // Nothing new can be created in the transcript directory, so day 2's // file, which the end-of-turn narration would open for the first // time after the mid-turn mark advances the clock, cannot be // written. std::fs::set_permissions( world.path().join("transcript"), PermissionsExt::from_mode(0o500), ) .unwrap(); let mut dm = Dm::new( Config { api_base: url, api_key: "sk-test".to_string(), model: "gpt-4o-mini".to_string(), reasoning_effort: None, worlds_root: None, }, Arc::new(fixtures::mount(&[])), &[], Some(campaign), ) .unwrap(); // With the bus architecture, a transcript write failure is silently // ignored by CampaignListener (the old code appended a warning to the // reply). The turn should still succeed; the warning is no longer // part of the turn's output. dm.turn(Speaker::Player, "I sleep.").unwrap(); server.join().unwrap(); // Turn succeeded without any warning in the reply. } #[test] fn a_completed_multi_round_turns_history_carries_every_round_message_in_order() { let (url, requests, server) = fake_server(vec![ round_response("", &[call("call_1", "roll", &roll_args("1d20", "secret"))]), reply_response("You find nothing."), sse_response("data: [DONE]\n\n"), ]); let mut dm = dm_with_seeded_dice(url, 0); dm.turn(Speaker::Player, "I search for traps.").unwrap(); dm.turn(Speaker::Player, "I move on.").unwrap(); server.join().unwrap(); requests.recv().unwrap(); requests.recv().unwrap(); let messages = sent_messages(&requests.recv().unwrap()); assert_eq!(messages.len(), 6); assert_eq!(messages[1]["role"], "user"); assert_eq!(messages[1]["content"], "I search for traps."); assert_eq!(messages[2]["role"], "assistant"); assert_eq!(messages[2]["tool_calls"][0]["id"], "call_1"); assert_eq!(messages[3]["role"], "tool"); assert_eq!(messages[3]["tool_call_id"], "call_1"); assert_eq!(messages[4]["role"], "assistant"); assert_eq!(messages[4]["content"], "You find nothing."); assert_eq!(messages[5]["role"], "user"); assert_eq!(messages[5]["content"], "I move on."); } #[test] fn a_secret_tool_call_emits_no_tool_event() { let (url, requests, server) = fake_server(vec![ round_response("", &[call("call_1", "roll", &roll_args("1d20", "secret"))]), reply_response("The room is quiet."), ]); let mut dm = dm_with_seeded_dice(url, 0); let count = Arc::new(Mutex::new(0usize)); { struct CountTools(Arc>); impl Listener for CountTools { fn tool(&mut self, _display: Option<&Text<'static>>) -> Flow { *self.0.lock().unwrap() += 1; Flow::Continue } } dm.bus.add(Box::new(CountTools(Arc::clone(&count)))); dm.turn(Speaker::Player, "I check for danger.").unwrap(); } assert_eq!(*count.lock().unwrap(), 0); server.join().unwrap(); requests.recv().unwrap(); requests.recv().unwrap(); } #[test] fn bad_notation_retries_invisibly_with_no_tool_event() { let (url, requests, server) = fake_server(vec![ round_response( "", &[call("call_1", "roll", &roll_args("banana", "public"))], ), reply_response("You swing and miss."), ]); let mut dm = dm_with_seeded_dice(url, 0); let count = Arc::new(Mutex::new(0usize)); { struct CountTools(Arc>); impl Listener for CountTools { fn tool(&mut self, _display: Option<&Text<'static>>) -> Flow { *self.0.lock().unwrap() += 1; Flow::Continue } } dm.bus.add(Box::new(CountTools(Arc::clone(&count)))); dm.turn(Speaker::Player, "I attack.").unwrap(); } assert_eq!(*count.lock().unwrap(), 0); server.join().unwrap(); requests.recv().unwrap(); let messages = sent_messages(&requests.recv().unwrap()); assert_eq!(messages[3]["role"], "tool"); assert!(!messages[3]["content"].as_str().unwrap().is_empty()); } #[test] fn bad_json_arguments_become_a_tool_result_naming_the_problem() { let (url, requests, server) = fake_server(vec![ round_response("", &[call("call_1", "roll", "not json")]), reply_response("You fumble."), ]); let mut dm = dm_with_seeded_dice(url, 0); dm.turn(Speaker::Player, "I roll.").unwrap(); server.join().unwrap(); requests.recv().unwrap(); let messages = sent_messages(&requests.recv().unwrap()); assert_eq!(messages[3]["role"], "tool"); assert_eq!(messages[3]["tool_call_id"], "call_1"); let content = messages[3]["content"].as_str().unwrap(); assert!(content.contains("not valid JSON")); assert!(content.contains("roll")); } #[test] fn a_multi_call_round_dispatches_in_order() { let (url, requests, server) = fake_server(vec![ round_response( "", &[ call("call_1", "roll", &roll_args("1d4", "public")), call("call_2", "roll", &roll_args("1d6", "public")), ], ), reply_response("Two rolls land."), ]); let mut dm = dm_with_seeded_dice(url, 0); let count = Arc::new(Mutex::new(0usize)); { struct CountTools(Arc>); impl Listener for CountTools { fn tool(&mut self, _display: Option<&Text<'static>>) -> Flow { *self.0.lock().unwrap() += 1; Flow::Continue } } dm.bus.add(Box::new(CountTools(Arc::clone(&count)))); dm.turn(Speaker::Player, "I roll twice.").unwrap(); } assert_eq!(*count.lock().unwrap(), 2); server.join().unwrap(); requests.recv().unwrap(); let messages = sent_messages(&requests.recv().unwrap()); assert_eq!(messages[3]["tool_call_id"], "call_1"); assert_eq!(messages[4]["tool_call_id"], "call_2"); } #[test] fn a_completed_calls_tool_event_arrives_before_the_stream_finishes_generating_the_next_one() { let body = format!( "{}{}{}{}data: [DONE]\n\n", content_chunk("You search the room. "), tool_call_chunk(0, "call_1", "roll", &roll_args("1d20", "public")), tool_call_chunk(1, "call_2", "roll", &roll_args("1d6", "public")), content_chunk(" Nothing happens."), ); let (url, _requests, server) = fake_server(vec![sse_response(&body), reply_response("You move on.")]); let mut dm = dm_with_seeded_dice(url, 0); let event_kinds: Arc>> = Arc::new(Mutex::new(Vec::new())); { struct EventTracker(Arc>>); impl Listener for EventTracker { fn delta(&mut self, _text: &str) -> Flow { self.0.lock().unwrap().push("text"); Flow::Continue } fn tool(&mut self, _display: Option<&Text<'static>>) -> Flow { self.0.lock().unwrap().push("tool"); Flow::Continue } } dm.bus.add(Box::new(EventTracker(Arc::clone(&event_kinds)))); dm.turn(Speaker::Player, "I search and act.").unwrap(); } // Call 1 completes, and dispatches, the moment call 2's fragment // arrives; call 2 itself completes only once the round's stream // ends. So the tool event for call 1 lands between the round's two // narration deltas, rather than after both calls have finished // streaming. The final "text" is the next round's reply, which ends // the turn. assert_eq!( event_kinds.lock().unwrap().clone(), vec!["text", "tool", "text", "tool", "text"] ); server.join().unwrap(); } #[test] fn a_narrated_round_resets_the_tool_only_counter() { let before = MAX_TOOL_ONLY_ROUNDS - 1; let mut responses: Vec> = (0..before) .map(|_| round_response("", &[call("call_x", "roll", &roll_args("1d20", "secret"))])) .collect(); responses.push(round_response( "Something rustles nearby.", &[call("call_reset", "roll", &roll_args("1d20", "secret"))], )); responses.push(round_response( "", &[call("call_after", "roll", &roll_args("1d20", "secret"))], )); responses.push(reply_response("The night falls quiet.")); let (url, requests, server) = fake_server(responses); let mut dm = dm_with_seeded_dice(url, 0); let collector = attach_collector(&mut dm); dm.turn(Speaker::Player, "I keep watch.").unwrap(); assert_eq!( collector.get_done(), Some("The night falls quiet.".to_string()) ); server.join().unwrap(); for _ in 0..(before + 2) { requests.recv().unwrap(); } let last = sent_body(&requests.recv().unwrap()); assert!( last["tools"] .as_array() .is_some_and(|tools| !tools.is_empty()) ); } #[test] fn the_request_after_max_tool_only_rounds_omits_tools_and_carries_the_note() { let mut responses: Vec> = (0..MAX_TOOL_ONLY_ROUNDS) .map(|_| round_response("", &[call("call_x", "roll", &roll_args("1d20", "secret"))])) .collect(); responses.push(reply_response("You come up empty.")); let (url, requests, server) = fake_server(responses); let mut dm = dm_with_seeded_dice(url, 0); let collector = attach_collector(&mut dm); dm.turn(Speaker::Player, "I keep searching.").unwrap(); assert_eq!(collector.get_done(), Some("You come up empty.".to_string())); server.join().unwrap(); for _ in 0..MAX_TOOL_ONLY_ROUNDS { requests.recv().unwrap(); } let withheld_request = requests.recv().unwrap(); let body = sent_body(&withheld_request); assert!(body.get("tools").is_none()); let messages = sent_messages(&withheld_request); let last_message = messages.last().unwrap(); assert_eq!(last_message["role"], "system"); assert_eq!(last_message["content"], TOOLS_WITHHELD.trim_end()); } #[test] fn a_cancel_on_a_tool_event_leaves_history_untouched() { let (url, requests, server) = fake_server(vec![ round_response( "You reach for the lever.", &[call("call_1", "roll", &roll_args("1d20", "public"))], ), reply_response("fresh"), ]); let mut dm = dm_with_seeded_dice(url, 0); // Add a listener that breaks on tool events struct BreakOnTool; impl crate::bus::Listener for BreakOnTool { fn tool(&mut self, _display: Option<&Text<'static>>) -> crate::bus::Flow { crate::bus::Flow::Break } fn delta(&mut self, _text: &str) -> crate::bus::Flow { crate::bus::Flow::Continue } } dm.bus.add(Box::new(BreakOnTool)); dm.turn(Speaker::Player, "I pull the lever.").unwrap(); dm.turn(Speaker::Player, "a fresh input").unwrap(); server.join().unwrap(); requests.recv().unwrap(); let messages = sent_messages(&requests.recv().unwrap()); assert_eq!(messages.len(), 2); assert_eq!(messages[1]["content"], "a fresh input"); } #[test] fn a_withheld_rounds_tool_calls_are_not_dispatched_and_the_turn_ends() { let mut responses: Vec> = (0..MAX_TOOL_ONLY_ROUNDS) .map(|_| round_response("", &[call("call_x", "roll", &roll_args("1d20", "secret"))])) .collect(); responses.push(round_response( "Despite everything, here is what happens.", &[call("call_ghost", "roll", &roll_args("1d20", "public"))], )); responses.push(sse_response("data: [DONE]\n\n")); let (url, requests, server) = fake_server(responses); let mut dm = dm_with_seeded_dice(url, 0); let count = Arc::new(Mutex::new(0usize)); { struct CountTools(Arc>); impl Listener for CountTools { fn tool(&mut self, _display: Option<&Text<'static>>) -> Flow { *self.0.lock().unwrap() += 1; Flow::Continue } } dm.bus.add(Box::new(CountTools(Arc::clone(&count)))); dm.turn(Speaker::Player, "I keep rolling.").unwrap(); } assert_eq!(*count.lock().unwrap(), 0); dm.turn(Speaker::Player, "a fresh input").unwrap(); server.join().unwrap(); for _ in 0..(MAX_TOOL_ONLY_ROUNDS + 1) { requests.recv().unwrap(); } let messages = sent_messages(&requests.recv().unwrap()); let last_assistant = messages .iter() .rev() .find(|message| message["role"] == "assistant") .unwrap(); assert_eq!( last_assistant["content"], "Despite everything, here is what happens." ); assert!(last_assistant.get("tool_calls").is_none()); assert!( messages .iter() .all(|message| message["tool_call_id"] != "call_ghost") ); } #[test] fn a_turn_that_narrates_every_round_is_withheld_at_the_round_cap() { let mut responses: Vec> = (0..MAX_ROUNDS) .map(|round| { round_response( &format!("Round {round}. "), &[call("call_x", "roll", &roll_args("1d20", "secret"))], ) }) .collect(); responses.push(reply_response("The night ends quietly.")); let (url, requests, server) = fake_server(responses); let mut dm = dm_with_seeded_dice(url, 0); let collector = attach_collector(&mut dm); dm.turn(Speaker::Player, "I keep watch all night.").unwrap(); assert_eq!( collector.get_done(), Some("The night ends quietly.".to_string()) ); server.join().unwrap(); for _ in 0..MAX_ROUNDS { requests.recv().unwrap(); } let withheld_request = requests.recv().unwrap(); let body = sent_body(&withheld_request); assert!(body.get("tools").is_none()); let messages = sent_messages(&withheld_request); let last_message = messages.last().unwrap(); assert_eq!(last_message["role"], "system"); assert_eq!(last_message["content"], TOOLS_WITHHELD.trim_end()); }