Something went wrong. Try again.
A 5e storytelling engine with an LLM DM
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590//! 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<ContextStack> { 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<String> = 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<u8> { sse_response(&round_body(narration, calls))}
/// A round that narrates `text` and calls nothing, ending the turn.fn reply_response(text: &str) -> Vec<u8> { 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<Mutex<usize>>); 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<Mutex<usize>>); 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<Mutex<usize>>); 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<Mutex<Vec<&'static str>>> = Arc::new(Mutex::new(Vec::new())); { struct EventTracker(Arc<Mutex<Vec<&'static str>>>); 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<Vec<u8>> = (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<Vec<u8>> = (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<Vec<u8>> = (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<Mutex<usize>>); 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<Vec<u8>> = (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());}