//! 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>, } #[derive(Default)] struct Collected { deltas: Vec, done: Option, cancelled: Option, 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 { self.inner.lock().unwrap().deltas.clone() } pub(super) fn get_done(&self) -> Option { self.inner.lock().unwrap().done.clone() } pub(super) fn get_cancelled(&self) -> Option { 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 { 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::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")); }