Something went wrong. Try again.
A 5e storytelling engine with an LLM DM
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450//! Tests for the plain single-round behavior of `mod.rs`, split out to//! keep the production file under the project's file-length guideline.//! The tool-calling round loop has its own sibling test file, which//! shares this file's fake-server harness rather than keeping its own//! copy. The harness itself lives in `chat::testing`, shared with//! `chat::client`'s own tests.
use std::sync::{Arc, Mutex};
use super::*;use crate::bus::{Flow, Listener};use crate::knowledge::fixtures;use tempfile::TempDir;
use crate::chat::testing::http_response;// Re-exported for `dm_tool_round_tests.rs`, which shares this harness// rather than keeping its own copy.pub(super) use crate::chat::testing::{CapturedRequest, fake_server, sse_response};
pub(super) fn dm_for(api_base: String) -> Dm { Dm::new( Config { api_base, api_key: "sk-test".to_string(), model: "gpt-4o-mini".to_string(), reasoning_effort: None, worlds_root: None, }, Arc::new(fixtures::mount(&[])), &[], None, ) .unwrap()}
/// A `Dm` whose config sets `reasoning_effort`, so a test can assert the/// value reaches the request.fn dm_with_reasoning_effort(api_base: String, reasoning_effort: &str) -> Dm { Dm::new( Config { api_base, api_key: "sk-test".to_string(), model: "gpt-4o-mini".to_string(), reasoning_effort: Some(reasoning_effort.to_string()), worlds_root: None, }, Arc::new(fixtures::mount(&[])), &[], None, ) .unwrap()}
/// A `Dm` whose toolbox is the full default: dice, knowledge, and the/// campaign tools, backed by a fresh campaign in `world`.pub(super) fn dm_with_campaign(api_base: String, world: &TempDir) -> Dm { Dm::new( Config { api_base, api_key: "sk-test".to_string(), model: "gpt-4o-mini".to_string(), reasoning_effort: None, worlds_root: None, }, Arc::new(fixtures::mount(&[])), &[], Some(Campaign::open(world.path()).unwrap()), ) .unwrap()}
// ---------------------------------------------------------------------------// Test helpers: a collecting listener// ---------------------------------------------------------------------------
/// A listener that records events for assertions.#[derive(Clone)]pub(super) struct TestCollector { inner: Arc<Mutex<Collected>>,}
#[derive(Default)]struct Collected { deltas: Vec<String>, done: Option<String>, cancelled: Option<String>, break_on_delta: bool,}
impl TestCollector { fn new() -> Self { Self { inner: Arc::new(Mutex::new(Collected::default())), } }
/// Makes this listener return `Break` on the next `delta` call. fn break_on_delta(self) -> Self { self.inner.lock().unwrap().break_on_delta = true; self }
pub(super) fn deltas(&self) -> Vec<String> { self.inner.lock().unwrap().deltas.clone() }
pub(super) fn get_done(&self) -> Option<String> { self.inner.lock().unwrap().done.clone() }
pub(super) fn get_cancelled(&self) -> Option<String> { self.inner.lock().unwrap().cancelled.clone() }}
impl Listener for TestCollector { fn delta(&mut self, text: &str) -> Flow { let mut state = self.inner.lock().unwrap(); state.deltas.push(text.to_string()); if state.break_on_delta { Flow::Break } else { Flow::Continue } }
fn done(&mut self, reply: &str) { self.inner.lock().unwrap().done = Some(reply.to_string()); }
fn cancelled(&mut self, partial: &str) { self.inner.lock().unwrap().cancelled = Some(partial.to_string()); }}
/// Attaches a recording listener to the DM's session-scoped bus and/// returns it for assertions.pub(super) fn attach_collector(dm: &mut Dm) -> TestCollector { let collector = TestCollector::new(); dm.bus.add(Box::new(collector.clone())); collector}
/// Parses a captured request body as JSON and returns its `messages`/// array.pub(super) fn sent_messages(request: &CapturedRequest) -> Vec<serde_json::Value> { let value: serde_json::Value = serde_json::from_str(&request.body).unwrap(); value["messages"].as_array().unwrap().clone()}
// ---------------------------------------------------------------------------// Tests// ---------------------------------------------------------------------------
#[test]fn a_turn_streams_deltas_in_order_and_returns_the_assembled_reply() { let body = "data: {\"choices\":[{\"delta\":{\"content\":\"Hello\"},\"finish_reason\":null}]}\n\n\ data: {\"choices\":[{\"delta\":{\"content\":\" world\"},\"finish_reason\":null}]}\n\n\ data: [DONE]\n\n"; let (url, _requests, server) = fake_server(vec![sse_response(body)]); let mut dm = dm_for(url);
let collector = attach_collector(&mut dm); dm.turn(Speaker::Player, "I open the door.").unwrap();
assert_eq!( collector.deltas(), vec!["Hello".to_string(), " world".to_string()] ); assert_eq!(collector.get_done(), Some("Hello world".to_string())); server.join().unwrap();}
#[test]fn the_request_body_has_the_system_prompt_first_and_the_input_last() { let (url, requests, server) = fake_server(vec![sse_response("data: [DONE]\n\n")]); let mut dm = dm_for(url);
dm.turn(Speaker::Player, "I open the door.").unwrap();
server.join().unwrap(); let messages = sent_messages(&requests.recv().unwrap()); assert_eq!(messages[0]["role"], "system"); // The context stack always includes the built-in base layer, // even with no filesystem layers and an empty mount. assert!(!messages[0]["content"].as_str().unwrap().is_empty()); let last = messages.last().unwrap(); assert_eq!(last["role"], "user"); assert_eq!(last["content"], "I open the door.");}
#[test]fn a_turn_sends_the_configured_reasoning_effort() { let (url, requests, server) = fake_server(vec![sse_response("data: [DONE]\n\n")]); let mut dm = dm_with_reasoning_effort(url, "high");
dm.turn(Speaker::Player, "I open the door.").unwrap();
server.join().unwrap(); let request = requests.recv().unwrap(); let sent: serde_json::Value = serde_json::from_str(&request.body).unwrap(); assert_eq!(sent["reasoning_effort"], "high");}
#[test]fn a_turn_with_a_campaign_logs_its_narration_to_the_transcript() { let body = "data: {\"choices\":[{\"delta\":{\"content\":\"You wake.\"},\"finish_reason\":null}]}\n\n\ data: [DONE]\n\n"; let (url, _requests, server) = fake_server(vec![sse_response(body)]); let world = TempDir::new().unwrap(); let mut dm = dm_with_campaign(url, &world);
dm.turn(Speaker::Player, "I sleep.").unwrap();
server.join().unwrap(); // No mark has set the clock, so the narration carries the campaign's // starting moment, day 1 at midnight, in the shared transcript. The // player's line lands first, then the narration, under one `##` // heading. let transcript = std::fs::read_to_string(world.path().join("transcript/0001.md")).unwrap(); assert_eq!( transcript, "# Day 1\n\n## #d1-0000\nplayer> I sleep.\n\nYou wake.\n\n" ); assert!(!world.path().join("campaign-log/0001.md").exists());}
#[test]fn a_second_turn_sends_the_first_turns_messages_in_the_history() { let first_reply = "data: {\"choices\":[{\"delta\":{\"content\":\"You see a door.\"},\"finish_reason\":null}]}\n\n\ data: [DONE]\n\n"; let (url, requests, server) = fake_server(vec![ sse_response(first_reply), sse_response("data: [DONE]\n\n"), ]); let mut dm = dm_for(url);
dm.turn(Speaker::Player, "I open the door.").unwrap(); dm.turn(Speaker::Player, "I step inside.").unwrap();
server.join().unwrap(); requests.recv().unwrap(); let messages = sent_messages(&requests.recv().unwrap());
assert_eq!(messages.len(), 4); assert_eq!(messages[1]["role"], "user"); assert_eq!(messages[1]["content"], "I open the door."); assert_eq!(messages[2]["role"], "assistant"); assert_eq!(messages[2]["content"], "You see a door."); assert_eq!(messages[3]["role"], "user"); assert_eq!(messages[3]["content"], "I step inside.");}
#[test]fn a_failed_turn_leaves_the_history_unchanged() { let (url, requests, server) = fake_server(vec![ http_response("400 Bad Request", "text/plain", "unknown model"), sse_response("data: [DONE]\n\n"), ]); let mut dm = dm_for(url);
let error = dm.turn(Speaker::Player, "a doomed input");
assert!(error.is_err());
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 an_error_mid_stream_leaves_the_history_unchanged() { let sse_body = "data: {\"choices\":[{\"delta\":{\"content\":\"Hi\"},\"finish_reason\":null}]}\n\n"; let broken = format!( "HTTP/1.1 200 OK\r\n\ Content-Type: text/event-stream\r\n\ Content-Length: {}\r\n\ Connection: close\r\n\ \r\n\ {sse_body}", sse_body.len() + 100, ) .into_bytes(); let (url, requests, server) = fake_server(vec![broken, sse_response("data: [DONE]\n\n")]); let mut dm = dm_for(url);
let error = dm.turn(Speaker::Player, "a doomed input");
assert!(error.is_err());
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 an_empty_reply_appends_and_returns_the_empty_string() { let (url, requests, server) = fake_server(vec![ sse_response("data: [DONE]\n\n"), sse_response("data: [DONE]\n\n"), ]); let mut dm = dm_for(url);
let collector = attach_collector(&mut dm); dm.turn(Speaker::Player, "silence").unwrap(); assert_eq!(collector.get_done(), Some(String::new()));
dm.turn(Speaker::Player, "again").unwrap();
server.join().unwrap(); requests.recv().unwrap(); let messages = sent_messages(&requests.recv().unwrap());
assert_eq!(messages.len(), 4); assert_eq!(messages[1]["content"], "silence"); assert_eq!(messages[2]["role"], "assistant"); assert_eq!(messages[2]["content"], "");}
#[test]fn breaking_on_the_first_delta_returns_cancelled_with_the_partial_text() { let body = "data: {\"choices\":[{\"delta\":{\"content\":\"Hello\"},\"finish_reason\":null}]}\n\n\ data: {\"choices\":[{\"delta\":{\"content\":\" world\"},\"finish_reason\":null}]}\n\n\ data: [DONE]\n\n"; let (url, _requests, server) = fake_server(vec![sse_response(body)]); let mut dm = dm_for(url);
let collector = attach_collector(&mut dm); // Set break-on-delta so the first delta cancels the turn dm.bus.add(Box::new(TestCollector::new().break_on_delta()));
dm.turn(Speaker::Player, "I open the door.").unwrap();
// The first listener collected the delta, the second broke on it. // The turn cancelled with the partial text seen so far. assert_eq!(collector.deltas(), vec!["Hello".to_string()]); assert_eq!(collector.get_cancelled(), Some("Hello".to_string())); server.join().unwrap();}
#[test]fn a_cancelled_turns_history_is_unchanged_for_the_next_request() { let cancelled_reply = "data: {\"choices\":[{\"delta\":{\"content\":\"Hello\"},\"finish_reason\":null}]}\n\n\ data: [DONE]\n\n"; let (url, requests, server) = fake_server(vec![ sse_response(cancelled_reply), sse_response("data: [DONE]\n\n"), ]); let mut dm = dm_for(url);
let collector = attach_collector(&mut dm); dm.bus.add(Box::new(TestCollector::new().break_on_delta())); dm.turn(Speaker::Player, "a doomed input").unwrap();
assert!(collector.get_cancelled().is_some());
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");}
/// A `Dm` over `layers`, so a test can edit a layer's `system.md` while/// the session runs.fn dm_over(api_base: String, layers: &[PathBuf]) -> Result<Dm, String> { Dm::new( Config { api_base, api_key: "sk-test".to_string(), model: "gpt-4o-mini".to_string(), reasoning_effort: None, worlds_root: None, }, Arc::new(fixtures::mount(&[])), layers, None, )}
#[test]fn a_layer_that_cannot_be_read_fails_the_dm_at_startup() { use std::fs; let layer = fixtures::Layer::empty(); fs::write(layer.path().join("system.md"), [0xFF, 0xFE]).unwrap();
let error = dm_over("http://127.0.0.1:0".to_string(), &[layer.path()]) .err() .expect("a layer that cannot be read must fail the dm");
assert!(error.contains("system.md"));}
#[test]fn each_turn_composes_the_system_prompt_again_from_the_layers_on_disk() { let layer = fixtures::Layer::empty(); layer.write("system.md", "The first draft."); let (url, requests, server) = fake_server(vec![ sse_response("data: [DONE]\n\n"), sse_response("data: [DONE]\n\n"), ]); let mut dm = dm_over(url, &[layer.path()]).unwrap();
dm.turn(Speaker::Player, "I open the door.").unwrap(); layer.write("system.md", "The second draft."); dm.turn(Speaker::Player, "I step inside.").unwrap();
server.join().unwrap(); let first = sent_messages(&requests.recv().unwrap()); let second = sent_messages(&requests.recv().unwrap()); assert!( first[0]["content"] .as_str() .unwrap() .ends_with("The first draft.") ); assert!( second[0]["content"] .as_str() .unwrap() .ends_with("The second draft.") );}
#[test]fn a_layer_that_goes_unreadable_mid_session_ends_the_turn_as_an_error() { use std::fs; let layer = fixtures::Layer::empty(); let mut dm = dm_over("http://127.0.0.1:0".to_string(), &[layer.path()]).unwrap(); fs::write(layer.path().join("system.md"), [0xFF, 0xFE]).unwrap();
let error = dm.turn(Speaker::Player, "I open the door.").unwrap_err();
assert!(error.to_string().contains("system.md"));}