diff --git a/plans/0005-tool-calls.md b/plans/0005-tool-calls.md --- a/plans/0005-tool-calls.md +++ b/plans/0005-tool-calls.md @@ -30,7 +30,12 @@ durable history. The guard matters most for `secret` rolls, where a stuck model renders nothing and the player has nothing to Escape from; each round resends the whole history, so an unbounded loop grows the bill quadratically. Escape still works between - rounds and calls for everything visible. + rounds and calls for everything visible. Two backstops make the + bound absolute: a withheld response ends the turn no matter what + it contains (tool calls in it are not dispatched, since none were + offered), and a turn that keeps narrating while it rolls gets the + same withheld round at an absolute cap of 24 rounds. A turn sends + at most 25 requests. - **History is all or nothing.** A turn joins the DM's history only when it completes: every round's assistant message, its tool calls, @@ -115,8 +120,11 @@ narration streamed so far this round moves from the live region into the transcript, then the tool line inserts, then the next round streams into an empty live region. The thinking indicator returns between rounds on its own, because "busy with an empty live region" - is exactly its condition. `Done` carries the final round's narration - only, since earlier rounds already flushed. + is exactly its condition. `Done` carries the final round's + narration only. The screen finishes the turn from the narration it + accumulated rather than from the `Done` payload, so a round whose + only tool calls were secret, which flushes nothing, still lands + its narration in the transcript. - **Wide tool lines wrap in `play`.** `insert()` wraps plain strings today; styled text gets span-aware wrapping there too. A public diff --git a/src/chat/client.rs b/src/chat/client.rs --- a/src/chat/client.rs +++ b/src/chat/client.rs @@ -1,8 +1,11 @@ +use std::collections::BTreeMap; use std::fmt; use std::io; use crate::chat::sse::SseReader; -use crate::chat::wire::{ChatChunk, ChatRequest, Message}; +use crate::chat::wire::{ + ChatChunk, ChatRequest, Message, ToolCall, ToolCallDelta, ToolCallFunction, +}; /// The maximum length of a status error's body, in characters. const STATUS_ERROR_BODY_LIMIT: usize = 2_000; @@ -15,15 +18,21 @@ pub model: String, } impl Client { - /// Starts a streaming chat completion for `messages`. + /// Starts a streaming chat completion for `messages`, offering `tools`. /// /// Sends `messages` with `self.model` to `{api_base}/chat/completions`. - /// A trailing slash on `api_base` is tolerated. - pub fn stream(&self, messages: &[Message]) -> Result { + /// A trailing slash on `api_base` is tolerated. An empty `tools` omits + /// the field from the request rather than sending an empty array. + pub fn stream( + &self, + messages: &[Message], + tools: &[serde_json::Value], + ) -> Result { let request = ChatRequest { model: self.model.clone(), messages: messages.to_vec(), stream: true, + tools: tools.to_vec(), }; let body = serde_json::to_vec(&request).expect("ChatRequest always serializes to JSON"); @@ -59,14 +68,66 @@ /// A streamed chat completion response, yielding each text delta as it /// arrives. /// /// The stream ends when the server sends `[DONE]` or closes the connection. +/// Alongside the text, it assembles any tool calls the response made; +/// collect them with `into_tool_calls` once the stream ends. pub struct ChatStream { events: SseReader>, + tool_calls: BTreeMap, +} + +/// A tool call being assembled from fragments, keyed by its index in the +/// response. +#[derive(Default)] +struct PartialToolCall { + id: String, + name: String, + arguments: String, } impl ChatStream { fn new(reader: ureq::BodyReader<'static>) -> Self { Self { events: SseReader::new(reader), + tool_calls: BTreeMap::new(), + } + } + + /// Consumes the stream and returns its assembled tool calls in index + /// order. + /// + /// Empty when the response carried no tool calls, such as a plain text + /// reply. Call this once the stream has run to completion. + pub fn into_tool_calls(self) -> Vec { + self.tool_calls + .into_values() + .map(|partial| ToolCall { + id: partial.id, + kind: "function".to_string(), + function: ToolCallFunction { + name: partial.name, + arguments: partial.arguments, + }, + }) + .collect() + } + + /// Merges one chunk's tool-call fragments into the calls assembled so + /// far. + fn absorb_tool_call_fragments(&mut self, fragments: Vec) { + for fragment in fragments { + let partial = self.tool_calls.entry(fragment.index).or_default(); + if let Some(id) = fragment.id { + partial.id = id; + } + let Some(function) = fragment.function else { + continue; + }; + if let Some(name) = function.name { + partial.name = name; + } + if let Some(arguments) = function.arguments { + partial.arguments.push_str(&arguments); + } } } } @@ -87,6 +148,9 @@ }; let Some(choice) = chunk.choices.into_iter().next() else { continue; }; + if let Some(fragments) = choice.delta.tool_calls { + self.absorb_tool_call_fragments(fragments); + } let Some(content) = choice.delta.content else { continue; }; @@ -238,6 +302,8 @@ fn a_message() -> Vec { vec![Message { role: crate::chat::wire::Role::User, content: "I open the door.".to_string(), + tool_calls: None, + tool_call_id: None, }] } @@ -250,7 +316,7 @@ let (url, requests, server) = fake_server(sse_response(body)); let client = client_for(url); let deltas: Vec = client - .stream(&a_message()) + .stream(&a_message(), &[]) .unwrap() .map(|delta| delta.unwrap()) .collect(); @@ -268,6 +334,21 @@ let sent: serde_json::Value = serde_json::from_str(&request.body).unwrap(); assert_eq!(sent["model"], "gpt-4o-mini"); assert_eq!(sent["stream"], true); assert_eq!(sent["messages"][0]["content"], "I open the door."); + assert!(sent.get("tools").is_none()); + } + + #[test] + fn stream_puts_the_given_tools_on_the_request() { + let (url, requests, server) = fake_server(sse_response("data: [DONE]\n\n")); + let client = client_for(url); + let tools = vec![serde_json::json!({"type": "function", "function": {"name": "roll"}})]; + + client.stream(&a_message(), &tools).unwrap().for_each(drop); + + server.join().unwrap(); + let request = requests.recv().unwrap(); + let sent: serde_json::Value = serde_json::from_str(&request.body).unwrap(); + assert_eq!(sent["tools"], serde_json::json!(tools)); } #[test] @@ -275,7 +356,7 @@ fn a_trailing_slash_on_api_base_does_not_double_the_path() { let (url, requests, server) = fake_server(sse_response("data: [DONE]\n\n")); let client = client_for(format!("{url}/")); - client.stream(&a_message()).unwrap().for_each(drop); + client.stream(&a_message(), &[]).unwrap().for_each(drop); server.join().unwrap(); let request = requests.recv().unwrap(); @@ -291,7 +372,7 @@ "server exploded", )); let client = client_for(url); - let error = client.stream(&a_message()).err().unwrap(); + let error = client.stream(&a_message(), &[]).err().unwrap(); assert!(matches!(error, ChatError::Status { status: 500, .. })); assert_eq!( @@ -306,7 +387,7 @@ fn a_chunk_that_is_not_valid_json_yields_a_parse_error() { let (url, _requests, server) = fake_server(sse_response("data: not json\n\n")); let client = client_for(url); - let error = client.stream(&a_message()).unwrap().next().unwrap(); + let error = client.stream(&a_message(), &[]).unwrap().next().unwrap(); assert!(matches!(error, Err(ChatError::Parse { .. }))); server.join().unwrap(); @@ -323,7 +404,7 @@ let (url, _requests, server) = fake_server(sse_response(body)); let client = client_for(url); let deltas: Vec = client - .stream(&a_message()) + .stream(&a_message(), &[]) .unwrap() .map(|delta| delta.unwrap()) .collect(); @@ -333,13 +414,100 @@ server.join().unwrap(); } #[test] + fn a_plain_text_response_assembles_to_no_tool_calls() { + let body = "data: {\"choices\":[{\"delta\":{\"content\":\"Hello\"},\"finish_reason\":null}]}\n\n\ + data: [DONE]\n\n"; + let (url, _requests, server) = fake_server(sse_response(body)); + let client = client_for(url); + + let mut stream = client.stream(&a_message(), &[]).unwrap(); + stream.by_ref().for_each(drop); + + assert_eq!(stream.into_tool_calls(), vec![]); + server.join().unwrap(); + } + + #[test] + fn tool_call_argument_fragments_assemble_across_chunks() { + let body = "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"function\":{\"name\":\"roll\",\"arguments\":\"\"}}]},\"finish_reason\":null}]}\n\n\ + data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"notation\\\":\"}}]},\"finish_reason\":null}]}\n\n\ + data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\"1d20\\\"}\"}}]},\"finish_reason\":null}]}\n\n\ + data: [DONE]\n\n"; + let (url, _requests, server) = fake_server(sse_response(body)); + let client = client_for(url); + + let mut stream = client.stream(&a_message(), &[]).unwrap(); + stream.by_ref().for_each(drop); + + assert_eq!( + stream.into_tool_calls(), + vec![ToolCall { + id: "call_1".to_string(), + kind: "function".to_string(), + function: ToolCallFunction { + name: "roll".to_string(), + arguments: "{\"notation\":\"1d20\"}".to_string(), + }, + }] + ); + server.join().unwrap(); + } + + #[test] + fn a_fragment_with_an_id_but_no_function_field_still_lets_later_fragments_fill_it_in() { + let body = "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_1\"}]},\"finish_reason\":null}]}\n\n\ + data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"name\":\"roll\",\"arguments\":\"{}\"}}]},\"finish_reason\":null}]}\n\n\ + data: [DONE]\n\n"; + let (url, _requests, server) = fake_server(sse_response(body)); + let client = client_for(url); + + let mut stream = client.stream(&a_message(), &[]).unwrap(); + stream.by_ref().for_each(drop); + + assert_eq!( + stream.into_tool_calls(), + vec![ToolCall { + id: "call_1".to_string(), + kind: "function".to_string(), + function: ToolCallFunction { + name: "roll".to_string(), + arguments: "{}".to_string(), + }, + }] + ); + server.join().unwrap(); + } + + #[test] + fn tool_calls_at_distinct_indexes_assemble_into_separate_calls_in_index_order() { + let body = "data: {\"choices\":[{\"delta\":{\"tool_calls\":[\ + {\"index\":0,\"id\":\"call_1\",\"function\":{\"name\":\"roll\",\"arguments\":\"{}\"}},\ + {\"index\":1,\"id\":\"call_2\",\"function\":{\"name\":\"roll\",\"arguments\":\"{}\"}}\ + ]},\"finish_reason\":null}]}\n\n\ + data: [DONE]\n\n"; + let (url, _requests, server) = fake_server(sse_response(body)); + let client = client_for(url); + + let mut stream = client.stream(&a_message(), &[]).unwrap(); + stream.by_ref().for_each(drop); + + let ids: Vec = stream + .into_tool_calls() + .into_iter() + .map(|call| call.id) + .collect(); + assert_eq!(ids, vec!["call_1".to_string(), "call_2".to_string()]); + server.join().unwrap(); + } + + #[test] fn eof_without_done_ends_the_stream_cleanly() { let body = "data: {\"choices\":[{\"delta\":{\"content\":\"Hi\"},\"finish_reason\":null}]}\n\n"; let (url, _requests, server) = fake_server(sse_response(body)); let client = client_for(url); - let mut stream = client.stream(&a_message()).unwrap(); + let mut stream = client.stream(&a_message(), &[]).unwrap(); assert_eq!(stream.next().unwrap().unwrap(), "Hi"); assert!(stream.next().is_none()); @@ -363,7 +531,7 @@ .into_bytes(); let (url, _requests, server) = fake_server(response); let client = client_for(url); - let mut stream = client.stream(&a_message()).unwrap(); + let mut stream = client.stream(&a_message(), &[]).unwrap(); assert_eq!(stream.next().unwrap().unwrap(), "Hi"); assert!(matches!(stream.next(), Some(Err(ChatError::Io(_))))); @@ -377,7 +545,7 @@ let addr = listener.local_addr().unwrap(); drop(listener); let client = client_for(format!("http://{addr}")); - let error = client.stream(&a_message()).err().unwrap(); + let error = client.stream(&a_message(), &[]).err().unwrap(); assert!(matches!(error, ChatError::Transport(_))); } @@ -425,7 +593,7 @@ let addr = listener.local_addr().unwrap(); drop(listener); let client = client_for(format!("http://{addr}")); - let error = client.stream(&a_message()).err().unwrap(); + let error = client.stream(&a_message(), &[]).err().unwrap(); assert!( error diff --git a/src/chat/mod.rs b/src/chat/mod.rs --- a/src/chat/mod.rs +++ b/src/chat/mod.rs @@ -4,4 +4,7 @@ pub mod wire; pub use client::{ChatError, ChatStream, Client}; pub use sse::SseReader; -pub use wire::{ChatChunk, ChatRequest, ChunkChoice, Delta, Message, Role}; +pub use wire::{ + ChatChunk, ChatRequest, ChunkChoice, Delta, FunctionDelta, Message, Role, ToolCall, + ToolCallDelta, ToolCallFunction, +}; diff --git a/src/chat/wire.rs b/src/chat/wire.rs --- a/src/chat/wire.rs +++ b/src/chat/wire.rs @@ -6,13 +6,25 @@ pub struct ChatRequest { pub model: String, pub messages: Vec, pub stream: bool, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub tools: Vec, } /// One message in a chat history. +/// +/// `content` is always a string. An assistant message that only called +/// tools carries an empty one. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Message { pub role: Role, pub content: String, + /// Tool calls this assistant message made. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool_calls: Option>, + /// The id of the tool call this message answers, on a tool result + /// message. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool_call_id: Option, } /// Who sent a message. @@ -22,6 +34,24 @@ pub enum Role { System, User, Assistant, + Tool, +} + +/// One tool call a model made, assembled from a streamed response. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ToolCall { + pub id: String, + #[serde(rename = "type")] + pub kind: String, + pub function: ToolCallFunction, +} + +/// The function half of a tool call: its name and its arguments as raw +/// JSON text, exactly as the API sends it. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ToolCallFunction { + pub name: String, + pub arguments: String, } /// One streamed chunk of a chat completion response. @@ -47,6 +77,33 @@ /// chunk of a response or a chunk that only sets `finish_reason`. #[derive(Debug, Clone, PartialEq, Deserialize)] pub struct Delta { pub content: Option, + /// Tool-call fragments in this chunk, one per call that progressed. + #[serde(default)] + pub tool_calls: Option>, +} + +/// One tool call's progress in a single streamed chunk. +/// +/// A call is split across many chunks that share its `index`. The first +/// fragment for an index carries `id` and the function name; later +/// fragments carry a piece of the arguments string. A round may hold +/// several calls at distinct indexes. +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct ToolCallDelta { + pub index: usize, + #[serde(default)] + pub id: Option, + #[serde(default)] + pub function: Option, +} + +/// The function half of a tool-call fragment. +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct FunctionDelta { + #[serde(default)] + pub name: Option, + #[serde(default)] + pub arguments: Option, } #[cfg(test)] @@ -54,21 +111,27 @@ mod tests { use super::*; use serde_json::json; + /// A plain message with no tool fields set, for tests that only care + /// about role and content. + fn message(role: Role, content: &str) -> Message { + Message { + role, + content: content.to_string(), + tool_calls: None, + tool_call_id: None, + } + } + #[test] fn chat_request_serializes_to_the_expected_json() { let request = ChatRequest { model: "gpt-4o-mini".to_string(), messages: vec![ - Message { - role: Role::System, - content: "You are a dungeon master.".to_string(), - }, - Message { - role: Role::User, - content: "I open the door.".to_string(), - }, + message(Role::System, "You are a dungeon master."), + message(Role::User, "I open the door."), ], stream: true, + tools: vec![], }; let value = serde_json::to_value(&request).unwrap(); @@ -87,15 +150,121 @@ ); } #[test] + fn chat_request_serializes_tools_when_present() { + let request = ChatRequest { + model: "gpt-4o-mini".to_string(), + messages: vec![], + stream: true, + tools: vec![json!({"type": "function", "function": {"name": "roll"}})], + }; + + let value = serde_json::to_value(&request).unwrap(); + + assert_eq!( + value["tools"], + json!([{"type": "function", "function": {"name": "roll"}}]) + ); + } + + #[test] fn assistant_role_serializes_lowercase() { + let message = message(Role::Assistant, "You see a torch-lit hall."); + + let value = serde_json::to_value(&message).unwrap(); + + assert_eq!(value["role"], json!("assistant")); + } + + #[test] + fn tool_role_serializes_lowercase() { + let message = message(Role::Tool, "17"); + + let value = serde_json::to_value(&message).unwrap(); + + assert_eq!(value["role"], json!("tool")); + } + + #[test] + fn a_message_with_no_tool_fields_omits_them_from_the_json() { + let value = serde_json::to_value(message(Role::User, "I open the door.")).unwrap(); + + assert!(value.get("tool_calls").is_none()); + assert!(value.get("tool_call_id").is_none()); + } + + #[test] + fn an_assistant_message_serializes_its_tool_calls() { let message = Message { - role: Role::Assistant, - content: "You see a torch-lit hall.".to_string(), + tool_calls: Some(vec![ToolCall { + id: "call_1".to_string(), + kind: "function".to_string(), + function: ToolCallFunction { + name: "roll".to_string(), + arguments: "{\"notation\":\"1d20\"}".to_string(), + }, + }]), + ..message(Role::Assistant, "") + }; + + let value = serde_json::to_value(&message).unwrap(); + + assert_eq!( + value["tool_calls"], + json!([{ + "id": "call_1", + "type": "function", + "function": {"name": "roll", "arguments": "{\"notation\":\"1d20\"}"}, + }]) + ); + } + + #[test] + fn a_tool_message_serializes_its_tool_call_id() { + let message = Message { + tool_call_id: Some("call_1".to_string()), + ..message(Role::Tool, "17") }; let value = serde_json::to_value(&message).unwrap(); - assert_eq!(value["role"], json!("assistant")); + assert_eq!(value["tool_call_id"], json!("call_1")); + } + + #[test] + fn a_message_deserializes_with_no_tool_fields_present() { + let message: Message = serde_json::from_value(json!({ + "role": "user", + "content": "I open the door.", + })) + .unwrap(); + + assert_eq!(message.tool_calls, None); + assert_eq!(message.tool_call_id, None); + } + + #[test] + fn a_tool_call_round_trips_through_json() { + let call = ToolCall { + id: "call_1".to_string(), + kind: "function".to_string(), + function: ToolCallFunction { + name: "roll".to_string(), + arguments: "{\"notation\":\"1d20\"}".to_string(), + }, + }; + + let value = serde_json::to_value(&call).unwrap(); + assert_eq!( + value, + json!({ + "id": "call_1", + "type": "function", + "function": {"name": "roll", "arguments": "{\"notation\":\"1d20\"}"}, + }) + ); + + let parsed: ToolCall = serde_json::from_value(value).unwrap(); + assert_eq!(parsed, call); } #[test] @@ -113,6 +282,7 @@ ChatChunk { choices: vec![ChunkChoice { delta: Delta { content: Some("Hello".to_string()), + tool_calls: None, }, finish_reason: None, }], @@ -129,7 +299,13 @@ ], })) .unwrap(); - assert_eq!(chunk.choices[0].delta, Delta { content: None }); + assert_eq!( + chunk.choices[0].delta, + Delta { + content: None, + tool_calls: None, + } + ); } #[test] @@ -168,5 +344,94 @@ fn chunk_deserializes_empty_choices() { let chunk: ChatChunk = serde_json::from_value(json!({"choices": []})).unwrap(); assert_eq!(chunk, ChatChunk { choices: vec![] }); + } + + #[test] + fn a_delta_deserializes_the_first_fragment_of_a_tool_call() { + let delta: Delta = serde_json::from_value(json!({ + "tool_calls": [ + {"index": 0, "id": "call_1", "function": {"name": "roll", "arguments": ""}}, + ], + })) + .unwrap(); + + assert_eq!( + delta.tool_calls, + Some(vec![ToolCallDelta { + index: 0, + id: Some("call_1".to_string()), + function: Some(FunctionDelta { + name: Some("roll".to_string()), + arguments: Some(String::new()), + }), + }]) + ); + } + + #[test] + fn a_delta_deserializes_a_fragment_with_no_function_field() { + let delta: Delta = serde_json::from_value(json!({ + "tool_calls": [ + {"index": 0, "id": "call_1"}, + ], + })) + .unwrap(); + + assert_eq!( + delta.tool_calls, + Some(vec![ToolCallDelta { + index: 0, + id: Some("call_1".to_string()), + function: None, + }]) + ); + } + + #[test] + fn a_delta_deserializes_a_later_fragment_carrying_only_arguments() { + let delta: Delta = serde_json::from_value(json!({ + "tool_calls": [ + {"index": 0, "function": {"arguments": "{\"notation\":"}}, + ], + })) + .unwrap(); + + assert_eq!( + delta.tool_calls, + Some(vec![ToolCallDelta { + index: 0, + id: None, + function: Some(FunctionDelta { + name: None, + arguments: Some("{\"notation\":".to_string()), + }), + }]) + ); + } + + #[test] + fn a_delta_deserializes_fragments_for_two_calls_at_distinct_indexes() { + let delta: Delta = serde_json::from_value(json!({ + "tool_calls": [ + {"index": 0, "id": "call_1", "function": {"name": "roll", "arguments": ""}}, + {"index": 1, "id": "call_2", "function": {"name": "roll", "arguments": ""}}, + ], + })) + .unwrap(); + + let indexes: Vec = delta + .tool_calls + .unwrap() + .iter() + .map(|fragment| fragment.index) + .collect(); + assert_eq!(indexes, vec![0, 1]); + } + + #[test] + fn a_delta_with_no_tool_calls_field_deserializes_to_none() { + let delta: Delta = serde_json::from_value(json!({"content": "Hi"})).unwrap(); + + assert_eq!(delta.tool_calls, None); } } diff --git a/src/cli.rs b/src/cli.rs --- a/src/cli.rs +++ b/src/cli.rs @@ -59,11 +59,11 @@ Roll { /// Dice notation to roll, like `d20` or `2d6+3`. notation: String, }, - /// Talk to the dungeon master in the terminal. + /// Talk to a DM with no world and no character, to try out tools and rules. // Coverage builds leave this command out, along with the terminal it // needs. See `src/play/mod.rs`. #[cfg(not(coverage))] - Play { + Sandbox { /// The base URL of the OpenAI-compatible API to talk to. #[arg(long, value_name = "URL")] api_base: Option, @@ -97,7 +97,7 @@ command: SrdCommand::Verify, }) => run_srd_verify(layer_root), Some(Command::Roll { notation }) => run_roll(¬ation), #[cfg(not(coverage))] - Some(Command::Play { api_base, model }) => { + Some(Command::Sandbox { api_base, model }) => { crate::play::run(&crate::config::Overrides { api_base, model }) } } diff --git a/src/dm/dm_tests.rs b/src/dm/dm_tests.rs new file mode 100644 --- /dev/null +++ b/src/dm/dm_tests.rs @@ -0,0 +1,297 @@ +//! 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. + +use super::*; +use std::collections::HashMap; +use std::io::{self, BufRead, Read, Write}; +use std::net::TcpListener; +use std::sync::mpsc::{self, Receiver}; +use std::thread::JoinHandle; + +/// One HTTP request as the fake server saw it. +pub(super) struct CapturedRequest { + pub(super) body: String, +} + +/// Serves `responses` in order, one per accepted connection, and sends +/// each request it received back through the returned channel in the +/// same order. +/// +/// Each entry in `responses` must be a complete HTTP/1.1 response, +/// including status line and headers. A write that fails is ignored: a +/// cancelled turn can close the connection before the server finishes +/// writing. +pub(super) fn fake_server( + responses: Vec>, +) -> (String, Receiver, JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let (sender, receiver) = mpsc::channel(); + let handle = std::thread::spawn(move || { + for response in responses { + let (mut stream, _) = listener.accept().unwrap(); + let captured = read_request(&stream); + let _ = stream.write_all(&response); + sender.send(captured).unwrap(); + } + }); + (format!("http://{addr}"), receiver, handle) +} + +/// Reads one HTTP/1.1 request's body from `stream`, skipping the +/// request line and headers. +fn read_request(stream: &std::net::TcpStream) -> CapturedRequest { + let mut reader = io::BufReader::new(stream); + let mut request_line = String::new(); + reader.read_line(&mut request_line).unwrap(); + + let mut headers = HashMap::new(); + loop { + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + let line = line.trim_end(); + if line.is_empty() { + break; + } + let (name, value) = line.split_once(':').unwrap(); + headers.insert(name.trim().to_lowercase(), value.trim().to_string()); + } + + let content_length = headers + .get("content-length") + .and_then(|value| value.parse::().ok()) + .unwrap_or(0); + let mut body = vec![0u8; content_length]; + reader.read_exact(&mut body).unwrap(); + + CapturedRequest { + body: String::from_utf8(body).unwrap(), + } +} + +/// Builds a canned HTTP/1.1 response with `status`, a body of +/// `content_type`, and `Connection: close`. +fn http_response(status: &str, content_type: &str, body: &str) -> Vec { + format!( + "HTTP/1.1 {status}\r\n\ + Content-Type: {content_type}\r\n\ + Content-Length: {}\r\n\ + Connection: close\r\n\ + \r\n\ + {body}", + body.len() + ) + .into_bytes() +} + +pub(super) fn sse_response(body: &str) -> Vec { + http_response("200 OK", "text/event-stream", body) +} + +fn dm_for(api_base: String) -> Dm { + Dm::new(Config { + api_base, + api_key: "sk-test".to_string(), + model: "gpt-4o-mini".to_string(), + }) +} + +/// Discards every event and keeps streaming. Pass this to `turn` in +/// tests that do not check the narration or tool events themselves. +pub(super) fn ignore_event(_event: TurnDelta) -> ControlFlow<()> { + ControlFlow::Continue(()) +} + +/// 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() +} + +#[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 mut deltas = Vec::new(); + + let turn = dm + .turn("I open the door.", &mut |event| { + if let TurnDelta::Text(text) = event { + deltas.push(text); + } + ControlFlow::Continue(()) + }) + .unwrap(); + + assert_eq!(deltas, vec!["Hello".to_string(), " world".to_string()]); + assert_eq!(turn, Turn::Reply("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("I open the door.", &mut ignore_event).unwrap(); + + server.join().unwrap(); + let messages = sent_messages(&requests.recv().unwrap()); + assert_eq!(messages[0]["role"], "system"); + assert_eq!(messages[0]["content"], SYSTEM_PROMPT.trim_end()); + let last = messages.last().unwrap(); + assert_eq!(last["role"], "user"); + assert_eq!(last["content"], "I open the door."); +} + +#[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("I open the door.", &mut ignore_event).unwrap(); + dm.turn("I step inside.", &mut ignore_event).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("500 Internal Server Error", "text/plain", "server exploded"), + sse_response("data: [DONE]\n\n"), + ]); + let mut dm = dm_for(url); + + let error = dm.turn("a doomed input", &mut ignore_event); + + assert!(error.is_err()); + + dm.turn("a fresh input", &mut ignore_event).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("a doomed input", &mut ignore_event); + + assert!(error.is_err()); + + dm.turn("a fresh input", &mut ignore_event).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 turn = dm.turn("silence", &mut ignore_event).unwrap(); + + assert_eq!(turn, Turn::Reply(String::new())); + + dm.turn("again", &mut ignore_event).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 turn = dm + .turn("I open the door.", &mut |_event| ControlFlow::Break(())) + .unwrap(); + + assert_eq!(turn, Turn::Cancelled("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 turn = dm + .turn("a doomed input", &mut |_event| ControlFlow::Break(())) + .unwrap(); + + assert!(matches!(turn, Turn::Cancelled(_))); + + dm.turn("a fresh input", &mut ignore_event).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"); +} diff --git a/src/dm/dm_tool_round_tests.rs b/src/dm/dm_tool_round_tests.rs new file mode 100644 --- /dev/null +++ b/src/dm/dm_tool_round_tests.rs @@ -0,0 +1,442 @@ +//! 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, fake_server, ignore_event, sent_messages, sse_response}; +use super::*; + +use rand::SeedableRng; +use rand::rngs::StdRng; +use serde_json::json; + +/// 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(), + }, + toolbox, + ) +} + +/// 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, &[]) +} + +#[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 turn = dm.turn("I search for traps.", &mut ignore_event).unwrap(); + + assert_eq!(turn, Turn::Reply("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_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("I search for traps.", &mut ignore_event).unwrap(); + dm.turn("I move on.", &mut ignore_event).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 mut tool_events = 0; + + dm.turn("I check for danger.", &mut |event| { + if matches!(event, TurnDelta::Tool(_)) { + tool_events += 1; + } + ControlFlow::Continue(()) + }) + .unwrap(); + + assert_eq!(tool_events, 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 mut tool_events = 0; + + let turn = dm + .turn("I attack.", &mut |event| { + if matches!(event, TurnDelta::Tool(_)) { + tool_events += 1; + } + ControlFlow::Continue(()) + }) + .unwrap(); + + assert_eq!(tool_events, 0); + assert_eq!(turn, Turn::Reply("You swing and miss.".to_string())); + 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("I roll.", &mut ignore_event).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 mut tool_events = 0; + + dm.turn("I roll twice.", &mut |event| { + if matches!(event, TurnDelta::Tool(_)) { + tool_events += 1; + } + ControlFlow::Continue(()) + }) + .unwrap(); + + assert_eq!(tool_events, 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_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 turn = dm.turn("I keep watch.", &mut ignore_event).unwrap(); + + assert_eq!(turn, Turn::Reply("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 turn = dm.turn("I keep searching.", &mut ignore_event).unwrap(); + + assert_eq!(turn, Turn::Reply("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); + + let turn = dm + .turn("I pull the lever.", &mut |event| match event { + TurnDelta::Tool(_) => ControlFlow::Break(()), + TurnDelta::Text(_) => ControlFlow::Continue(()), + }) + .unwrap(); + + assert_eq!( + turn, + Turn::Cancelled("You reach for the lever.".to_string()) + ); + + dm.turn("a fresh input", &mut ignore_event).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 mut tool_events = 0; + + let turn = dm + .turn("I keep rolling.", &mut |event| { + if matches!(event, TurnDelta::Tool(_)) { + tool_events += 1; + } + ControlFlow::Continue(()) + }) + .unwrap(); + + assert_eq!( + turn, + Turn::Reply("Despite everything, here is what happens.".to_string()) + ); + assert_eq!(tool_events, 0); + + dm.turn("a fresh input", &mut ignore_event).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 turn = dm + .turn("I keep watch all night.", &mut ignore_event) + .unwrap(); + + assert_eq!(turn, Turn::Reply("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()); +} diff --git a/src/dm/mod.rs b/src/dm/mod.rs --- a/src/dm/mod.rs +++ b/src/dm/mod.rs @@ -1,15 +1,48 @@ -//! The dungeon master: the system prompt, the message history, and one -//! streaming turn of conversation. +//! The dungeon master: the system prompt, the message history, and the +//! round loop that runs one player turn. use std::ops::ControlFlow; + +use rand::rngs::StdRng; +use ratatui::text::Text; +use serde_json::Value; use crate::chat::{ChatError, Client, Message, Role}; use crate::config::Config; +use tools::Toolbox; +use tools::dice::DiceTool; + +pub mod tools; // TODO: Phase 6, the context stack (plans/0000-roadmap.md), replaces this // fixed prompt file with rules, world, and player knowledge layered per turn. const SYSTEM_PROMPT: &str = include_str!("system-prompt.md"); +/// Told to the model in place of its tools on a withheld round: it has +/// been rolling without narrating, so it should narrate now; the tools +/// return next turn. +const TOOLS_WITHHELD: &str = include_str!("tools-withheld.md"); + +/// How many consecutive tool-only rounds, rounds that called tools and +/// narrated nothing, are allowed before the tools are withheld for one +/// round. +/// +/// Guards against a model that keeps rolling and never tells the player +/// what happened, which would otherwise spend tokens for as long as it +/// keeps calling tools. Gentle: the withheld round forces a text reply +/// instead of failing the turn, and the tools return on the next turn. +const MAX_TOOL_ONLY_ROUNDS: usize = 8; + +/// How many rounds one turn may run before it is forced to end. +/// +/// Guards a model that narrates a little every round while still calling +/// tools: narration resets [`MAX_TOOL_ONLY_ROUNDS`]'s counter every time, +/// so that guard never trips, yet every round resends the whole history +/// and spends more tokens than the last. Generous, because a heavy +/// combat turn with several participants can take a handful of rounds to +/// resolve. +const MAX_ROUNDS: usize = 24; + /// What one turn produced. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Turn { @@ -20,358 +53,207 @@ /// unchanged, and the reply collected so far is returned for display. Cancelled(String), } -/// A dungeon master session: a chat client and the history of the -/// conversation so far. +/// One piece of a turn as it runs, passed to `Dm::turn`'s callback. +#[derive(Debug, Clone, PartialEq)] +pub enum TurnDelta { + /// A piece of the reply's narration, as it streams in. + Text(String), + /// A dispatched tool call's transcript line. Fires once per call whose + /// visibility shows something; a secret call fires nothing. + Tool(Text<'static>), +} + +/// A dungeon master session: a chat client, the tools it can call +/// mid-turn, and the history of the conversation so far. pub struct Dm { client: Client, + toolbox: Toolbox, history: Vec, } impl Dm { /// Builds a `Dm` from `config`, seeding the history with the system - /// prompt. + /// prompt and the toolbox with the dice tool. + /// + /// The dice tool rolls with a `StdRng` seeded from `rand::make_rng` + /// rather than the thread-local `rand::rng()` directly: a `Dm` moves + /// onto the worker thread that runs its turns, and the thread-local + /// generator does not move between threads. pub fn new(config: Config) -> Self { + let rng: StdRng = rand::make_rng(); + let toolbox = Toolbox::new(vec![Box::new(DiceTool::new(rng))]); + Self::with_toolbox(config, toolbox) + } + + /// Builds a `Dm` from `config` and `toolbox`, for callers that need a + /// toolbox other than the default, such as a test with a seeded dice + /// tool or a fake one. + pub fn with_toolbox(config: Config, toolbox: Toolbox) -> Self { let client = Client { api_base: config.api_base, api_key: config.api_key, model: config.model, }; - let history = vec![Message { - role: Role::System, - content: SYSTEM_PROMPT.trim_end().to_string(), - }]; - Self { client, history } + let history = vec![system_message(SYSTEM_PROMPT.trim_end())]; + Self { + client, + toolbox, + history, + } } - /// Sends the history plus `input` as a new user message, streaming the - /// reply through `on_delta` as it arrives. + /// Runs a turn from `input` as a loop of rounds, streaming narration + /// and tool events through `on_delta` as they arrive. /// - /// `on_delta` returns `Continue` to keep streaming or `Break` to stop. - /// On a completed stream, appends the user message and the assistant - /// reply to the history and returns `Turn::Reply`. On `Break`, the - /// stream stops, the history is unchanged, and the result is - /// `Turn::Cancelled` with the reply collected so far. On failure, the - /// history is unchanged. + /// Each round sends the history, `input`, and the rounds so far in + /// this turn, streams a response, and checks whether it made any tool + /// calls. A round with no calls ends the turn: `input`, every + /// message from an earlier round, and the final reply join the + /// history in order, and this returns `Turn::Reply` carrying the + /// final round's narration. A round with calls runs each one in + /// order through the toolbox, appends the assistant's call and each + /// tool's result to the turn, and goes again. + /// + /// After [`MAX_TOOL_ONLY_ROUNDS`] rounds in a row that called tools + /// and narrated nothing, or after [`MAX_ROUNDS`] rounds regardless of + /// what they narrated, the next round omits the tools and carries a + /// note in their place instead, asking the model to narrate. Narrating + /// resets the tool-only count but not the round count. + /// + /// A withheld round's response ends the turn no matter what it + /// contains: its narration joins the history, and any tool calls in + /// it, which no provider should send since the request offered no + /// tools, are not dispatched and not recorded. A turn issues at most + /// `MAX_ROUNDS + 1` requests, exactly one of which can be withheld. + /// + /// `on_delta` returns `Continue` to keep going or `Break` to cancel. + /// On `Break`, the history is unchanged and the result is + /// `Turn::Cancelled` with the current round's narration collected so + /// far. On failure, the history is unchanged. pub fn turn( &mut self, input: &str, - on_delta: &mut dyn FnMut(&str) -> ControlFlow<()>, + on_delta: &mut dyn FnMut(TurnDelta) -> ControlFlow<()>, ) -> Result { - let mut messages = self.history.clone(); - messages.push(Message { - role: Role::User, - content: input.to_string(), - }); - - let mut reply = String::new(); - for delta in self.client.stream(&messages)? { - let delta = delta?; - reply.push_str(&delta); - if on_delta(&delta).is_break() { - return Ok(Turn::Cancelled(reply)); - } - } - - self.history.push(Message { + let user_message = Message { role: Role::User, content: input.to_string(), - }); - self.history.push(Message { - role: Role::Assistant, - content: reply.clone(), - }); - - Ok(Turn::Reply(reply)) - } -} + tool_calls: None, + tool_call_id: None, + }; -#[cfg(test)] -mod tests { - use super::*; - use std::collections::HashMap; - use std::io::{self, BufRead, Read, Write}; - use std::net::TcpListener; - use std::sync::mpsc::{self, Receiver}; - use std::thread::JoinHandle; + let mut round_messages: Vec = Vec::new(); + let mut tool_only_rounds: usize = 0; + let mut rounds: usize = 0; - /// One HTTP request as the fake server saw it. - struct CapturedRequest { - body: String, - } + loop { + let withheld = tool_only_rounds >= MAX_TOOL_ONLY_ROUNDS || rounds >= MAX_ROUNDS; - /// Serves `responses` in order, one per accepted connection, and sends - /// each request it received back through the returned channel in the - /// same order. - /// - /// Each entry in `responses` must be a complete HTTP/1.1 response, - /// including status line and headers. A write that fails is ignored: a - /// cancelled turn can close the connection before the server finishes - /// writing. - fn fake_server(responses: Vec>) -> (String, Receiver, JoinHandle<()>) { - let listener = TcpListener::bind("127.0.0.1:0").unwrap(); - let addr = listener.local_addr().unwrap(); - let (sender, receiver) = mpsc::channel(); - let handle = std::thread::spawn(move || { - for response in responses { - let (mut stream, _) = listener.accept().unwrap(); - let captured = read_request(&stream); - let _ = stream.write_all(&response); - sender.send(captured).unwrap(); + let mut messages = self.history.clone(); + messages.push(user_message.clone()); + messages.extend(round_messages.iter().cloned()); + let definitions = if withheld { + Vec::new() + } else { + self.toolbox.definitions() + }; + if withheld { + messages.push(system_message(TOOLS_WITHHELD.trim_end())); } - }); - (format!("http://{addr}"), receiver, handle) - } - - /// Reads one HTTP/1.1 request's body from `stream`, skipping the - /// request line and headers. - fn read_request(stream: &std::net::TcpStream) -> CapturedRequest { - let mut reader = io::BufReader::new(stream); - let mut request_line = String::new(); - reader.read_line(&mut request_line).unwrap(); - let mut headers = HashMap::new(); - loop { - let mut line = String::new(); - reader.read_line(&mut line).unwrap(); - let line = line.trim_end(); - if line.is_empty() { - break; + let mut stream = self.client.stream(&messages, &definitions)?; + let mut narration = String::new(); + for delta in &mut stream { + let delta = delta?; + narration.push_str(&delta); + if on_delta(TurnDelta::Text(delta)).is_break() { + return Ok(Turn::Cancelled(narration)); + } } - let (name, value) = line.split_once(':').unwrap(); - headers.insert(name.trim().to_lowercase(), value.trim().to_string()); - } - - let content_length = headers - .get("content-length") - .and_then(|value| value.parse::().ok()) - .unwrap_or(0); - let mut body = vec![0u8; content_length]; - reader.read_exact(&mut body).unwrap(); - - CapturedRequest { - body: String::from_utf8(body).unwrap(), - } - } - - /// Builds a canned HTTP/1.1 response with `status`, a body of - /// `content_type`, and `Connection: close`. - fn http_response(status: &str, content_type: &str, body: &str) -> Vec { - format!( - "HTTP/1.1 {status}\r\n\ - Content-Type: {content_type}\r\n\ - Content-Length: {}\r\n\ - Connection: close\r\n\ - \r\n\ - {body}", - body.len() - ) - .into_bytes() - } - - fn sse_response(body: &str) -> Vec { - http_response("200 OK", "text/event-stream", body) - } - - fn dm_for(api_base: String) -> Dm { - Dm::new(Config { - api_base, - api_key: "sk-test".to_string(), - model: "gpt-4o-mini".to_string(), - }) - } - - /// Discards every delta and keeps streaming. Pass this to `turn` in - /// tests that do not check the streamed text itself. - fn ignore_delta(_delta: &str) -> ControlFlow<()> { - ControlFlow::Continue(()) - } - - /// Parses a captured request body as JSON and returns its `messages` - /// array. - fn sent_messages(request: &CapturedRequest) -> Vec { - let value: serde_json::Value = serde_json::from_str(&request.body).unwrap(); - value["messages"].as_array().unwrap().clone() - } - - #[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 mut deltas = Vec::new(); - - let turn = dm - .turn("I open the door.", &mut |delta| { - deltas.push(delta.to_string()); - ControlFlow::Continue(()) - }) - .unwrap(); - - assert_eq!(deltas, vec!["Hello".to_string(), " world".to_string()]); - assert_eq!(turn, Turn::Reply("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("I open the door.", &mut ignore_delta).unwrap(); - - server.join().unwrap(); - let messages = sent_messages(&requests.recv().unwrap()); - assert_eq!(messages[0]["role"], "system"); - assert_eq!(messages[0]["content"], SYSTEM_PROMPT.trim_end()); - let last = messages.last().unwrap(); - assert_eq!(last["role"], "user"); - assert_eq!(last["content"], "I open the door."); - } - - #[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("I open the door.", &mut ignore_delta).unwrap(); - dm.turn("I step inside.", &mut ignore_delta).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("500 Internal Server Error", "text/plain", "server exploded"), - sse_response("data: [DONE]\n\n"), - ]); - let mut dm = dm_for(url); + let calls = stream.into_tool_calls(); - let error = dm.turn("a doomed input", &mut ignore_delta); + if withheld || calls.is_empty() { + self.history.push(user_message); + self.history.extend(round_messages); + self.history.push(assistant_message(narration.clone())); + return Ok(Turn::Reply(narration)); + } - assert!(error.is_err()); + round_messages.push(Message { + role: Role::Assistant, + content: narration.clone(), + tool_calls: Some(calls.clone()), + tool_call_id: None, + }); - dm.turn("a fresh input", &mut ignore_delta).unwrap(); + for call in &calls { + let args = match serde_json::from_str::(&call.function.arguments) { + Ok(args) => args, + Err(_) => { + round_messages.push(tool_message( + &call.id, + format!( + "arguments for `{}` were not valid JSON: `{}`", + call.function.name, call.function.arguments + ), + )); + continue; + } + }; - server.join().unwrap(); - requests.recv().unwrap(); - let messages = sent_messages(&requests.recv().unwrap()); + let outcome = self.toolbox.call(&call.function.name, &args); + if let Some(display) = outcome.display + && on_delta(TurnDelta::Tool(display)).is_break() + { + return Ok(Turn::Cancelled(narration)); + } + round_messages.push(tool_message(&call.id, outcome.for_model)); + } - assert_eq!(messages.len(), 2); - assert_eq!(messages[1]["content"], "a fresh input"); + tool_only_rounds = if narration.is_empty() { + tool_only_rounds + 1 + } else { + 0 + }; + rounds += 1; + } } - - #[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("a doomed input", &mut ignore_delta); - - assert!(error.is_err()); - - dm.turn("a fresh input", &mut ignore_delta).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 `Role::System` message with `content`. +fn system_message(content: &str) -> Message { + Message { + role: Role::System, + content: content.to_string(), + tool_calls: None, + tool_call_id: None, } - - #[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 turn = dm.turn("silence", &mut ignore_delta).unwrap(); - - assert_eq!(turn, Turn::Reply(String::new())); - - dm.turn("again", &mut ignore_delta).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"], ""); +/// A `Role::Assistant` message with `content` and no tool calls. +fn assistant_message(content: String) -> Message { + Message { + role: Role::Assistant, + content, + tool_calls: None, + tool_call_id: None, } - - #[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 turn = dm - .turn("I open the door.", &mut |_delta| ControlFlow::Break(())) - .unwrap(); +} - assert_eq!(turn, Turn::Cancelled("Hello".to_string())); - server.join().unwrap(); +/// A `Role::Tool` message answering `call_id` with `content`. +fn tool_message(call_id: &str, content: String) -> Message { + Message { + role: Role::Tool, + content, + tool_calls: None, + tool_call_id: Some(call_id.to_string()), } - - #[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 turn = dm - .turn("a doomed input", &mut |_delta| ControlFlow::Break(())) - .unwrap(); - - assert!(matches!(turn, Turn::Cancelled(_))); - - dm.turn("a fresh input", &mut ignore_delta).unwrap(); +} - server.join().unwrap(); - requests.recv().unwrap(); - let messages = sent_messages(&requests.recv().unwrap()); +#[cfg(test)] +#[path = "dm_tests.rs"] +mod tests; - assert_eq!(messages.len(), 2); - assert_eq!(messages[1]["content"], "a fresh input"); - } -} +#[cfg(test)] +#[path = "dm_tool_round_tests.rs"] +mod tool_round_tests; diff --git a/src/dm/system-prompt.md b/src/dm/system-prompt.md --- a/src/dm/system-prompt.md +++ b/src/dm/system-prompt.md @@ -1,1 +1,7 @@ You are the Dungeon Master of a solo Dungeons & Dragons 5th edition adventure. Narrate events in the second person, as if you speak directly to the player. Keep each reply to a few short paragraphs. + +Tools handle the game's mechanical parts, like dice rolls. Roll real dice through a tool instead of inventing a result. + +Every tool call takes a `visibility`. Use `public` when a player at a real table would see the dice. Use `screened` or `secret` to keep a roll from the player until its outcome should come out: `screened` shows them that something happened behind the screen, `secret` shows them nothing. + +Narrate between rolls. Don't run a long silent stretch of tool calls with nothing said in between. diff --git a/src/dm/tools-withheld.md b/src/dm/tools-withheld.md new file mode 100644 --- /dev/null +++ b/src/dm/tools-withheld.md @@ -0,0 +1,1 @@ +You have called tools several times in a row without narrating what happened. The tools are set aside for this reply. Tell the player what has happened so far. The tools return on your next turn. diff --git a/src/dm/tools/dice.md b/src/dm/tools/dice.md new file mode 100644 --- /dev/null +++ b/src/dm/tools/dice.md @@ -0,0 +1,1 @@ +Roll dice with 5e notation. Terms join with `+` and `-`: `2d6+3`, `d20-1`. A die is `XdY`; `X` defaults to 1, `d%` means d100. Sizes: 2, 3, 4, 6, 8, 10, 12, 20, 100. Group suffixes, any order: `kh3`/`kl1` keep highest/lowest, `dh1`/`dl1` drop highest/lowest, `ro1` reroll once on a 1 (`ro<3`, `ro>18` for ranges), `r2` reroll until it stops matching, `mi2` treat lower rolls as 2. Examples: `4d6kh3` ability score, `2d20kh1+5` advantage, `8d6` fireball. diff --git a/src/dm/tools/dice.rs b/src/dm/tools/dice.rs new file mode 100644 --- /dev/null +++ b/src/dm/tools/dice.rs @@ -0,0 +1,201 @@ +//! The `roll` tool: parses and rolls dice notation for the DM. +//! +//! `execute` turns tool arguments into an `Outcome`, the roll plus its +//! optional reason. `render` turns that outcome into the lines the +//! transcript shows. `call` runs the two in sequence. + +use rand::Rng; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span, Text}; +use serde_json::{Value, json}; + +use crate::dice::{self, RollResult}; + +use super::{Tool, ToolReply}; + +const DICE_MD: &str = include_str!("dice.md"); + +/// The die shown at the front of a public roll's line. +const DIE_MARKER: &str = "🎲"; + +/// What the transcript shows for a screened roll: no numbers, just that a +/// roll happened behind the screen. +const SCREENED_LINE: &str = "⚁ the DM rolls behind the screen"; + +/// The `roll` tool: parses 5e dice notation and rolls it with the RNG it +/// owns. +pub struct DiceTool { + rng: R, +} + +impl DiceTool { + /// Builds a dice tool that rolls with `rng`. + pub fn new(rng: R) -> Self { + Self { rng } + } +} + +/// A roll this tool produced: the completed roll, and the reason the DM +/// gave for it, if any. +#[derive(Debug)] +struct Outcome { + reason: Option, + result: RollResult, +} + +/// Turns tool arguments into a rolled `Outcome`. +/// +/// `notation` is required and must be a string; `reason` is optional but +/// must be a string when present. Any other key in `args` is ignored. A +/// notation that fails to parse reports `DiceError`'s own message +/// unchanged. +fn execute(args: &Value, rng: &mut impl Rng) -> Result { + let notation = match args.get("notation") { + None => { + return Err( + "`notation` is required; give dice notation like `2d6+3` or `d20-1`".to_string(), + ); + } + Some(Value::String(notation)) => notation, + Some(other) => { + return Err(format!( + "`notation` was `{other}`, but it must be a string like `2d6+3`" + )); + } + }; + + let reason = match args.get("reason") { + None => None, + Some(Value::String(reason)) => Some(reason.clone()), + Some(other) => { + return Err(format!( + "`reason` was `{other}`, but it must be a string like `Perception check`" + )); + } + }; + + let parsed = dice::parse(notation).map_err(|error| error.to_string())?; + let result = dice::roll(&parsed, rng); + Ok(Outcome { reason, result }) +} + +/// Renders an `Outcome` as the public and screened transcript lines. +fn render(outcome: &Outcome) -> (Text<'static>, Text<'static>) { + (public_line(outcome), screened_line()) +} + +/// The public line: the reason when given, the notation, every face in +/// rolled order with dropped and superseded faces dimmed, the modifier +/// when nonzero, and the total. +fn public_line(outcome: &Outcome) -> Text<'static> { + let result = &outcome.result; + let reason_prefix = outcome + .reason + .as_ref() + .map(|reason| format!("{reason}: ")) + .unwrap_or_default(); + + let mut spans = vec![Span::raw(format!( + "{DIE_MARKER} {reason_prefix}{} → [", + result.notation + ))]; + spans.extend(face_spans(result)); + spans.push(Span::raw("]")); + if result.modifier != 0 { + spans.push(Span::raw(format!(" {:+}", result.modifier))); + } + spans.push(Span::raw(format!(" = {}", result.total))); + + Text::from(Line::from(spans)) +} + +/// One span per face any die showed, in rolled order, separated by `, `. +/// A face a reroll superseded, or a die's final face when the die was +/// dropped, renders dim; a kept final face renders plain. +fn face_spans(result: &RollResult) -> Vec> { + let mut spans = Vec::new(); + for die in &result.dice { + let last = die.rolls.len() - 1; + for (index, value) in die.rolls.iter().enumerate() { + if !spans.is_empty() { + spans.push(Span::raw(", ")); + } + let superseded = index < last; + spans.push(face_span(*value, superseded || !die.kept)); + } + } + spans +} + +/// One die face, dim when `dim` is true. +fn face_span(value: u32, dim: bool) -> Span<'static> { + let text = value.to_string(); + if dim { + Span::styled(text, dim_style()) + } else { + Span::raw(text) + } +} + +/// The screened line: a roll happened, with no numbers. +fn screened_line() -> Text<'static> { + Text::from(Line::from(Span::styled(SCREENED_LINE, dim_style()))) +} + +fn dim_style() -> Style { + Style::new().add_modifier(Modifier::DIM) +} + +/// The tool result text: the reason on its own line when given, then the +/// roll's story in full, faces included, so the DM can narrate a near miss. +fn for_model(outcome: &Outcome) -> String { + let story = dice::story::tell(&outcome.result); + match &outcome.reason { + Some(reason) => format!("{reason}\n{story}"), + None => story, + } +} + +impl Tool for DiceTool { + fn name(&self) -> &'static str { + "roll" + } + + fn definition(&self) -> Value { + json!({ + "type": "function", + "function": { + "name": "roll", + "description": DICE_MD.trim_end(), + "parameters": { + "type": "object", + "properties": { + "notation": { + "type": "string", + "description": "Dice notation to roll, like `d20` or `2d6+3`.", + }, + "reason": { + "type": "string", + "description": "What the roll is for. Shown to the player on public rolls, like `Perception check`.", + }, + }, + "required": ["notation"], + }, + }, + }) + } + + fn call(&mut self, args: &Value) -> Result { + let outcome = execute(args, &mut self.rng)?; + let (public, screened) = render(&outcome); + Ok(ToolReply { + for_model: for_model(&outcome), + public, + screened, + }) + } +} + +#[cfg(test)] +#[path = "dice_tests.rs"] +mod tests; diff --git a/src/dm/tools/dice_tests.rs b/src/dm/tools/dice_tests.rs new file mode 100644 --- /dev/null +++ b/src/dm/tools/dice_tests.rs @@ -0,0 +1,302 @@ +//! Tests for `dice.rs`, split out to keep the production file under the +//! project's file-length guideline. + +use super::*; +use rand::SeedableRng; +use rand::rngs::StdRng; +use serde_json::json; + +/// Runs `execute` against `notation` alone with a fresh `StdRng` seeded +/// from `seed`, so a test can assert on an exact roll. +fn rolled(notation: &str, seed: u64) -> Outcome { + let mut rng = StdRng::seed_from_u64(seed); + execute(&json!({ "notation": notation }), &mut rng).unwrap() +} + +/// Runs `execute` against `notation` and `reason` with a fresh `StdRng` +/// seeded from `seed`. +fn rolled_with_reason(notation: &str, reason: &str, seed: u64) -> Outcome { + let mut rng = StdRng::seed_from_u64(seed); + execute(&json!({ "notation": notation, "reason": reason }), &mut rng).unwrap() +} + +fn tool() -> DiceTool { + DiceTool::new(StdRng::seed_from_u64(0)) +} + +// --- Rendering: the public line ------------------------------------------ + +#[test] +fn a_public_roll_with_a_reason_shows_the_reason_notation_faces_modifier_and_total() { + let outcome = rolled_with_reason("1d20+3", "Perception check", 0); + + let (public, _) = render(&outcome); + + assert_eq!( + public, + Text::from(Line::from(vec![ + Span::raw("🎲 Perception check: 1d20+3 → ["), + Span::raw("17"), + Span::raw("]"), + Span::raw(" +3"), + Span::raw(" = 20"), + ])) + ); +} + +#[test] +fn a_public_roll_without_a_reason_omits_the_reason_prefix() { + let outcome = rolled("1d20+3", 0); + + let (public, _) = render(&outcome); + + assert_eq!( + public, + Text::from(Line::from(vec![ + Span::raw("🎲 1d20+3 → ["), + Span::raw("17"), + Span::raw("]"), + Span::raw(" +3"), + Span::raw(" = 20"), + ])) + ); +} + +#[test] +fn a_dropped_die_renders_dim() { + let outcome = rolled("2d20kh", 0); + + let (public, _) = render(&outcome); + + assert_eq!( + public, + Text::from(Line::from(vec![ + Span::raw("🎲 2d20kh → ["), + Span::raw("17"), + Span::raw(", "), + Span::styled("15", dim_style()), + Span::raw("]"), + Span::raw(" = 17"), + ])) + ); +} + +#[test] +fn a_rerolled_dies_superseded_face_renders_dim_before_its_final_face() { + let outcome = rolled("d20ro1", 37); + + let (public, _) = render(&outcome); + + assert_eq!( + public, + Text::from(Line::from(vec![ + Span::raw("🎲 d20ro1 → ["), + Span::styled("1", dim_style()), + Span::raw(", "), + Span::raw("11"), + Span::raw("]"), + Span::raw(" = 11"), + ])) + ); +} + +#[test] +fn the_modifier_is_omitted_from_the_public_line_when_it_is_zero() { + let outcome = rolled("1d3", 0); + + let (public, _) = render(&outcome); + + assert_eq!( + public, + Text::from(Line::from(vec![ + Span::raw("🎲 1d3 → ["), + Span::raw("3"), + Span::raw("]"), + Span::raw(" = 3"), + ])) + ); +} + +#[test] +fn a_negative_modifier_shows_its_sign() { + let outcome = rolled("1d20-3", 0); + + let (public, _) = render(&outcome); + + assert_eq!( + public, + Text::from(Line::from(vec![ + Span::raw("🎲 1d20-3 → ["), + Span::raw("17"), + Span::raw("]"), + Span::raw(" -3"), + Span::raw(" = 14"), + ])) + ); +} + +// --- Rendering: the screened line ---------------------------------------- + +#[test] +fn the_screened_line_is_a_single_dim_line_with_no_numbers() { + let outcome = rolled("1d20", 0); + + let (_, screened) = render(&outcome); + + assert_eq!( + screened, + Text::from(Line::from(Span::styled( + "⚁ the DM rolls behind the screen", + dim_style() + ))) + ); +} + +// --- execute: argument validation ---------------------------------------- + +#[test] +fn missing_notation_names_the_fix() { + let mut rng = StdRng::seed_from_u64(0); + + let error = execute(&json!({}), &mut rng).unwrap_err(); + + assert_eq!( + error, + "`notation` is required; give dice notation like `2d6+3` or `d20-1`" + ); +} + +#[test] +fn a_non_string_notation_names_the_value_and_the_fix() { + let mut rng = StdRng::seed_from_u64(0); + + let error = execute(&json!({ "notation": 42 }), &mut rng).unwrap_err(); + + assert_eq!( + error, + "`notation` was `42`, but it must be a string like `2d6+3`" + ); +} + +#[test] +fn a_non_string_reason_names_the_value_and_the_fix() { + let mut rng = StdRng::seed_from_u64(0); + + let error = execute(&json!({ "notation": "d20", "reason": 5 }), &mut rng).unwrap_err(); + + assert_eq!( + error, + "`reason` was `5`, but it must be a string like `Perception check`" + ); +} + +#[test] +fn bad_notation_passes_through_dice_errors_message_unchanged() { + let mut rng = StdRng::seed_from_u64(0); + + let error = execute(&json!({ "notation": "banana" }), &mut rng).unwrap_err(); + + assert_eq!(error, dice::parse("banana").unwrap_err().to_string()); +} + +#[test] +fn unknown_extra_keys_are_ignored() { + let mut rng = StdRng::seed_from_u64(0); + + let outcome = execute(&json!({ "notation": "1d20", "sneaky": true }), &mut rng).unwrap(); + + assert_eq!(outcome.result.total, 17); +} + +// --- for_model ------------------------------------------------------------- + +#[test] +fn for_model_carries_the_reason_on_its_own_line_then_the_story() { + let outcome = rolled_with_reason("1d20+3", "Perception check", 0); + + let text = for_model(&outcome); + + assert_eq!( + text, + format!("Perception check\n{}", dice::story::tell(&outcome.result)) + ); +} + +#[test] +fn for_model_is_just_the_story_when_there_is_no_reason() { + let outcome = rolled("1d20+3", 0); + + let text = for_model(&outcome); + + assert_eq!(text, dice::story::tell(&outcome.result)); +} + +// --- The Tool trait ---------------------------------------------------------- + +#[test] +fn the_tools_name_is_roll() { + assert_eq!(tool().name(), "roll"); +} + +#[test] +fn the_definition_names_the_function_roll() { + let definition = tool().definition(); + + assert_eq!(definition["function"]["name"], json!("roll")); +} + +#[test] +fn the_definition_has_a_nonempty_description() { + let definition = tool().definition(); + + let description = definition["function"]["description"].as_str().unwrap(); + assert!(!description.is_empty()); +} + +#[test] +fn notation_is_required_and_reason_is_not() { + let definition = tool().definition(); + + assert_eq!( + definition["function"]["parameters"]["required"], + json!(["notation"]) + ); +} + +#[test] +fn the_definition_declares_no_visibility_parameter() { + let definition = tool().definition(); + + assert!(definition["function"]["parameters"]["properties"]["visibility"].is_null()); +} + +#[test] +fn call_composes_execute_and_render_into_a_reply() { + let mut tool = DiceTool::new(StdRng::seed_from_u64(0)); + + let reply = tool.call(&json!({ "notation": "1d20+3" })).unwrap(); + + assert!(reply.for_model.contains("total: 20")); + assert_eq!( + reply.public, + Text::from(Line::from(vec![ + Span::raw("🎲 1d20+3 → ["), + Span::raw("17"), + Span::raw("]"), + Span::raw(" +3"), + Span::raw(" = 20"), + ])) + ); +} + +#[test] +fn call_propagates_an_execute_error() { + let mut tool = DiceTool::new(StdRng::seed_from_u64(0)); + + let error = tool.call(&json!({})).unwrap_err(); + + assert_eq!( + error, + "`notation` is required; give dice notation like `2d6+3` or `d20-1`" + ); +} diff --git a/src/dm/tools/mod.rs b/src/dm/tools/mod.rs new file mode 100644 --- /dev/null +++ b/src/dm/tools/mod.rs @@ -0,0 +1,234 @@ +//! The DM's toolbox: the seam between the round loop and the tools a +//! turn can call. +//! +//! A tool declares itself in the OpenAI function-calling shape and runs +//! against plain arguments. Visibility, whether the player sees the +//! call's full result, a screened hint, or nothing, is not a tool +//! concern: the `Toolbox` adds the parameter to every declaration, +//! strips it from the arguments before a tool runs, and applies it to +//! the tool's reply afterward. + +use ratatui::text::Text; +use serde_json::{Value, json}; + +pub mod dice; + +const VISIBILITY_DESCRIPTION: &str = include_str!("visibility.md"); + +/// A capability the DM can call mid-turn. +/// +/// A tool never sees the `visibility` argument the model sent; the +/// `Toolbox` strips it before `call` runs. `Send` because a `Dm` moves +/// its toolbox onto the worker thread that runs its turns. +pub trait Tool: Send { + /// The tool's name, as the model calls it. + fn name(&self) -> &'static str; + + /// This tool's declaration in the OpenAI function-calling shape, + /// without the `visibility` parameter. `Toolbox::definitions` adds + /// it before the declaration reaches the model. + fn definition(&self) -> Value; + + /// Runs the tool with `args`, already stripped of `visibility`. + /// + /// `Err` becomes the tool result sent back to the model, so its text + /// must name the problem and the fix. The player never sees it. + fn call(&mut self, args: &Value) -> Result; +} + +/// What a tool call produced: the text the model sees, and the two ways +/// the transcript can render it, depending on the call's visibility. +#[derive(Debug)] +pub struct ToolReply { + /// The tool result message sent back to the model. + pub for_model: String, + /// The line shown in the transcript when the call is public. + pub public: Text<'static>, + /// The line shown in the transcript when the call is screened: the + /// same event with its numbers left out. + pub screened: Text<'static>, +} + +/// How much of a tool call the player sees. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Visibility { + /// The player sees the full result. + Public, + /// The player sees that something happened behind the DM's screen, + /// with no numbers. + Screened, + /// The player sees nothing. + Secret, +} + +impl Visibility { + const VALUES: [&'static str; 3] = ["public", "screened", "secret"]; + + /// Reads `value` as one of the three visibility levels. + fn parse(value: &Value) -> Result { + match value.as_str() { + Some("public") => Ok(Visibility::Public), + Some("screened") => Ok(Visibility::Screened), + Some("secret") => Ok(Visibility::Secret), + _ => Err(format!( + "`visibility` was `{value}`, but it must be one of: {}", + Visibility::VALUES.join(", ") + )), + } + } +} + +/// What dispatching one tool call produced: the tool result for the +/// model, and, unless the call was secret, what the transcript shows. +pub struct ToolOutcome { + /// The tool result message sent back to the model. + pub for_model: String, + /// What the transcript shows, or `None` when the call was secret or + /// the call itself failed. + pub display: Option>, +} + +/// The DM's set of callable tools. +/// +/// Each tool owns its own state; adding a tool is one more entry in the +/// `Vec` passed to `new`. +pub struct Toolbox { + tools: Vec>, +} + +impl Toolbox { + /// Builds a toolbox from `tools`. + /// + /// In debug builds, panics if a tool's `name()` disagrees with its own + /// `definition()`, or if two tools share a name: either mistake would + /// route a call to the wrong tool, or make one undispatchable, and is + /// cheaper to catch here than to chase through a chat transcript. + pub fn new(tools: Vec>) -> Self { + debug_assert!( + tools + .iter() + .all(|tool| tool.definition()["function"]["name"].as_str() == Some(tool.name())), + "a tool's definition()'s function.name must equal its name()" + ); + let mut seen = std::collections::HashSet::new(); + debug_assert!( + tools.iter().all(|tool| seen.insert(tool.name())), + "two tools cannot share a name" + ); + Self { tools } + } + + /// Every tool's declaration, each with a required `visibility` + /// parameter added to its JSON schema. + pub fn definitions(&self) -> Vec { + self.tools + .iter() + .map(|tool| with_visibility(tool.definition())) + .collect() + } + + /// Dispatches a call named `name` with `args` to its matching tool. + /// + /// Strips `visibility` from `args` before the tool sees them, then + /// applies it to the tool's reply: a public or screened call shows + /// its matching line, a secret call shows nothing. A problem at any + /// step, an unknown name, arguments that are not a JSON object, a + /// missing or invalid `visibility`, or the tool's own `Err`, becomes + /// the tool result with nothing to display. + pub fn call(&mut self, name: &str, args: &Value) -> ToolOutcome { + let Some(tool) = self.tools.iter_mut().find(|tool| tool.name() == name) else { + return error(unknown_tool(name, &self.tools)); + }; + + let Some(object) = args.as_object() else { + return error(format!( + "arguments for `{name}` must be a JSON object, not `{args}`" + )); + }; + + let Some(raw_visibility) = object.get("visibility") else { + return error(format!( + "`visibility` is required and was missing; use one of: {}", + Visibility::VALUES.join(", ") + )); + }; + + let visibility = match Visibility::parse(raw_visibility) { + Ok(visibility) => visibility, + Err(message) => return error(message), + }; + + let mut stripped = object.clone(); + stripped.remove("visibility"); + + match tool.call(&Value::Object(stripped)) { + Ok(reply) => outcome(reply, visibility), + Err(message) => error(message), + } + } +} + +/// Names every tool that exists, for an unknown-name error. +fn unknown_tool(name: &str, tools: &[Box]) -> String { + let names: Vec<&str> = tools.iter().map(|tool| tool.name()).collect(); + format!( + "no tool is named `{name}`; the tools that exist are: {}", + names.join(", ") + ) +} + +/// Adds the required `visibility` parameter to a tool's declaration, +/// preserving whatever parameters the tool already declared. A +/// declaration that omits `properties` or `required`, valid JSON Schema +/// for a tool that takes no arguments of its own, gets an empty one in +/// its place before `visibility` is added. +fn with_visibility(mut definition: Value) -> Value { + let parameters = &mut definition["function"]["parameters"]; + + if !parameters["properties"].is_object() { + parameters["properties"] = json!({}); + } + parameters["properties"]["visibility"] = json!({ + "type": "string", + "enum": Visibility::VALUES, + "description": VISIBILITY_DESCRIPTION.trim_end(), + }); + + if !parameters["required"].is_array() { + parameters["required"] = json!([]); + } + parameters["required"] + .as_array_mut() + .expect("required was normalized to an array above") + .push(json!("visibility")); + + definition +} + +/// Applies `visibility` to a tool's reply, picking what the transcript +/// shows. +fn outcome(reply: ToolReply, visibility: Visibility) -> ToolOutcome { + let display = match visibility { + Visibility::Public => Some(reply.public), + Visibility::Screened => Some(reply.screened), + Visibility::Secret => None, + }; + ToolOutcome { + for_model: reply.for_model, + display, + } +} + +/// An error outcome: the message becomes the tool result, and nothing +/// shows in the transcript, because the player never sees the DM's +/// fumbles. +fn error(message: String) -> ToolOutcome { + ToolOutcome { + for_model: message, + display: None, + } +} + +#[cfg(test)] +#[path = "tools_tests.rs"] +mod tests; diff --git a/src/dm/tools/tools_tests.rs b/src/dm/tools/tools_tests.rs new file mode 100644 --- /dev/null +++ b/src/dm/tools/tools_tests.rs @@ -0,0 +1,273 @@ +use super::*; +use serde_json::json; + +/// A tool double for exercising the toolbox: `Echo` echoes its +/// arguments back as the tool result alongside canned display lines, +/// and `Failing` always returns the given error. +enum FakeTool { + Echo, + Failing(String), +} + +impl Tool for FakeTool { + fn name(&self) -> &'static str { + "fake" + } + + fn definition(&self) -> Value { + json!({ + "type": "function", + "function": { + "name": "fake", + "description": "a fake tool for tests", + "parameters": { + "type": "object", + "properties": {"target": {"type": "string"}}, + "required": ["target"], + }, + }, + }) + } + + fn call(&mut self, args: &Value) -> Result { + match self { + FakeTool::Echo => Ok(ToolReply { + for_model: args.to_string(), + public: Text::raw("public line"), + screened: Text::raw("screened line"), + }), + FakeTool::Failing(message) => Err(message.clone()), + } + } +} + +/// A second minimal tool, used only to prove an unknown-name error +/// lists every tool that exists, not just the first. +struct OtherTool; + +impl Tool for OtherTool { + fn name(&self) -> &'static str { + "other" + } + + fn definition(&self) -> Value { + json!({ + "type": "function", + "function": { + "name": "other", + "description": "a second fake tool for tests", + "parameters": {"type": "object", "properties": {}, "required": []}, + }, + }) + } + + fn call(&mut self, _args: &Value) -> Result { + unreachable!("OtherTool is never called in these tests") + } +} + +/// A tool whose declaration has no `properties` and no `required`, +/// which is valid JSON Schema for a tool that takes no arguments of its +/// own. Proves `Toolbox::definitions` still produces a well-formed +/// schema instead of panicking. +struct BareTool; + +impl Tool for BareTool { + fn name(&self) -> &'static str { + "bare" + } + + fn definition(&self) -> Value { + json!({ + "type": "function", + "function": { + "name": "bare", + "description": "a tool with no parameters of its own", + "parameters": {"type": "object"}, + }, + }) + } + + fn call(&mut self, _args: &Value) -> Result { + unreachable!("BareTool is never called in these tests") + } +} + +fn toolbox(tool: FakeTool) -> Toolbox { + Toolbox::new(vec![Box::new(tool)]) +} + +/// A tool whose `name()` disagrees with its own `definition()`, to prove +/// `Toolbox::new` catches the mismatch instead of silently misrouting +/// calls. +struct MisnamedTool; + +impl Tool for MisnamedTool { + fn name(&self) -> &'static str { + "misnamed" + } + + fn definition(&self) -> Value { + json!({ + "type": "function", + "function": { + "name": "not-misnamed", + "description": "a tool whose declaration lies about its own name", + "parameters": {"type": "object", "properties": {}, "required": []}, + }, + }) + } + + fn call(&mut self, _args: &Value) -> Result { + unreachable!("MisnamedTool is never called in these tests") + } +} + +#[test] +#[should_panic(expected = "must equal its name()")] +fn a_tool_whose_definition_disagrees_with_its_name_panics() { + Toolbox::new(vec![Box::new(MisnamedTool)]); +} + +#[test] +#[should_panic(expected = "cannot share a name")] +fn two_tools_with_the_same_name_panic() { + Toolbox::new(vec![Box::new(FakeTool::Echo), Box::new(FakeTool::Echo)]); +} + +/// Builds a one-tool toolbox and calls `fake` with `args`. +fn call(tool: FakeTool, args: Value) -> ToolOutcome { + toolbox(tool).call("fake", &args) +} + +#[test] +fn definitions_add_the_required_visibility_parameter() { + let definitions = toolbox(FakeTool::Echo).definitions(); + + let parameters = &definitions[0]["function"]["parameters"]; + assert_eq!( + parameters["properties"]["visibility"]["enum"], + json!(["public", "screened", "secret"]) + ); + assert_eq!(parameters["required"], json!(["target", "visibility"])); +} + +#[test] +fn definitions_preserve_the_tools_own_parameters() { + let definitions = toolbox(FakeTool::Echo).definitions(); + + assert_eq!( + definitions[0]["function"]["parameters"]["properties"]["target"], + json!({"type": "string"}) + ); +} + +#[test] +fn definitions_add_visibility_to_a_tool_with_no_properties_or_required() { + let definitions = Toolbox::new(vec![Box::new(BareTool)]).definitions(); + + let parameters = &definitions[0]["function"]["parameters"]; + assert_eq!(parameters["required"], json!(["visibility"])); + assert_eq!( + parameters["properties"]["visibility"]["enum"], + json!(["public", "screened", "secret"]) + ); +} + +#[test] +fn a_tool_receives_its_arguments_without_visibility() { + let outcome = call( + FakeTool::Echo, + json!({"target": "goblin", "visibility": "public"}), + ); + + assert_eq!(outcome.for_model, json!({"target": "goblin"}).to_string()); +} + +#[test] +fn a_public_call_shows_the_tools_public_line() { + let outcome = call( + FakeTool::Echo, + json!({"target": "goblin", "visibility": "public"}), + ); + + assert_eq!(outcome.display, Some(Text::raw("public line"))); +} + +#[test] +fn a_screened_call_shows_the_tools_screened_line() { + let outcome = call( + FakeTool::Echo, + json!({"target": "goblin", "visibility": "screened"}), + ); + + assert_eq!(outcome.display, Some(Text::raw("screened line"))); +} + +#[test] +fn a_secret_call_shows_nothing_but_still_answers_the_model() { + let outcome = call( + FakeTool::Echo, + json!({"target": "goblin", "visibility": "secret"}), + ); + + assert_eq!(outcome.display, None); + assert_eq!(outcome.for_model, json!({"target": "goblin"}).to_string()); +} + +#[test] +fn an_unknown_tool_name_lists_every_tool_that_exists() { + let mut toolbox = Toolbox::new(vec![Box::new(FakeTool::Echo), Box::new(OtherTool)]); + + let outcome = toolbox.call("nope", &json!({"visibility": "public"})); + + assert_eq!( + outcome.for_model, + "no tool is named `nope`; the tools that exist are: fake, other" + ); + assert_eq!(outcome.display, None); +} + +#[test] +fn arguments_that_are_not_a_json_object_produce_an_error() { + let outcome = toolbox(FakeTool::Echo).call("fake", &json!("not an object")); + + assert_eq!( + outcome.for_model, + "arguments for `fake` must be a JSON object, not `\"not an object\"`" + ); +} + +#[test] +fn missing_visibility_says_it_is_required() { + let outcome = call(FakeTool::Echo, json!({"target": "goblin"})); + + assert_eq!( + outcome.for_model, + "`visibility` is required and was missing; use one of: public, screened, secret" + ); +} + +#[test] +fn invalid_visibility_names_the_bad_value_and_the_three_choices() { + let outcome = call( + FakeTool::Echo, + json!({"target": "goblin", "visibility": "loud"}), + ); + + assert_eq!( + outcome.for_model, + "`visibility` was `\"loud\"`, but it must be one of: public, screened, secret" + ); +} + +#[test] +fn a_tools_error_becomes_the_tool_result_with_no_display() { + let outcome = call( + FakeTool::Failing("no target named that".to_string()), + json!({"visibility": "public"}), + ); + + assert_eq!(outcome.for_model, "no target named that"); + assert_eq!(outcome.display, None); +} diff --git a/src/dm/tools/visibility.md b/src/dm/tools/visibility.md new file mode 100644 --- /dev/null +++ b/src/dm/tools/visibility.md @@ -0,0 +1,7 @@ +How much of this call the player sees. Choose one of three values. + +- `public`: the player sees the full result. +- `screened`: the player sees only that something happened behind the DM's screen. No numbers show. +- `secret`: the player sees nothing at all. + +Use `public` for a roll the player makes in the open. Use `screened` for a roll the player would notice but not see the details of, like a monster's stealth check. Use `secret` for a roll the player must not learn about yet, like a hidden trap's save. diff --git a/src/play/mod.rs b/src/play/mod.rs --- a/src/play/mod.rs +++ b/src/play/mod.rs @@ -1,4 +1,4 @@ -//! `storied play`: the inline terminal where the conversation happens. +//! The inline terminal where a conversation with the DM happens. //! //! The game runs on two threads. The worker owns the `Dm` and does one turn //! at a time; the main thread reads keys, redraws, and shows what the diff --git a/src/play/screen.rs b/src/play/screen.rs --- a/src/play/screen.rs +++ b/src/play/screen.rs @@ -2,7 +2,7 @@ //! The render loop: the live region where a reply streams, the prompt the //! player types on, and the transcript above them both. use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::mpsc::{Receiver, Sender}; +use std::sync::mpsc::{Receiver, Sender, TryRecvError}; use ratatui::Frame; use ratatui::Terminal; @@ -16,7 +16,7 @@ use super::history::History; use super::keys::{Key, Keys}; use super::thinking; use super::worker::TurnEvent; -use super::wrap::wrap; +use super::wrap::{wrap, wrap_spans}; /// The rows the inline viewport takes at the bottom of the terminal. pub const VIEWPORT_HEIGHT: u16 = 12; @@ -29,6 +29,11 @@ const INTERRUPTED: &str = "(interrupted)"; /// The marker in front of the input line and the player's transcript lines. const PLAYER_MARKER: &str = "> "; + +/// Shown when the worker thread is gone. The channel disconnects only if +/// the thread panicked; without this, a turn in progress would leave +/// `busy` set forever and the prompt would never accept input again. +const WORKER_GONE: &str = "the storyteller thread is gone; restart storied to continue"; /// Storied's own asides: the opening banner and the empty-reply line. fn aside() -> Style { @@ -159,18 +164,40 @@ Ok(()) } /// Takes in everything the worker has reported since the last pass. +/// +/// A disconnected channel means the worker thread is gone. If a turn was +/// running, that turn will never finish on its own, so this reports the +/// failure and frees the prompt. Once that happens, `busy` is false, and +/// later passes see the same disconnection but say nothing more: the +/// channel stays dead forever, so without that check this would repeat +/// the failure line on every pass for the rest of the session. fn drain( screen: &mut Screen, terminal: &mut Terminal, turns: &Receiver, ) -> Result<(), B::Error> { - while let Ok(event) = turns.try_recv() { + loop { + let event = match turns.try_recv() { + Ok(event) => event, + Err(TryRecvError::Empty) => return Ok(()), + Err(TryRecvError::Disconnected) => { + if screen.busy { + insert(terminal, WORKER_GONE, failure())?; + settle(screen); + } + return Ok(()); + } + }; match event { TurnEvent::Delta(text) => screen.live.push_str(&text), - TurnEvent::Done(text) => finish(screen, terminal, &text)?, - TurnEvent::Cancelled(text) => { - if !text.is_empty() { - insert(terminal, &text, reply())?; + TurnEvent::Tool(text) => { + flush(screen, terminal)?; + insert_text(terminal, text)?; + } + TurnEvent::Done(_) => finish(screen, terminal)?, + TurnEvent::Cancelled(_) => { + if !screen.live.is_empty() { + insert(terminal, &screen.live, reply())?; } insert(terminal, INTERRUPTED, aside())?; settle(screen); @@ -181,19 +208,36 @@ settle(screen); } } } +} + +/// Moves the narration streamed so far this round from the live region +/// into the transcript, so a tool line that follows lands after it. The +/// turn stays busy: this only empties the live region, it does not end +/// the turn. +/// +/// Narration of only whitespace inserts nothing, the same as `finish` +/// treats a whitespace-only reply: a round that calls a tool without +/// narrating first would otherwise leave a blank row before the tool +/// line. +fn flush(screen: &mut Screen, terminal: &mut Terminal) -> Result<(), B::Error> { + if !screen.live.trim().is_empty() { + insert(terminal, &screen.live, reply())?; + } + screen.live.clear(); Ok(()) } /// Moves a finished reply out of the live region and into the transcript. -fn finish( - screen: &mut Screen, - terminal: &mut Terminal, - text: &str, -) -> Result<(), B::Error> { - if text.trim().is_empty() { +/// +/// Reads the live region rather than the event's own payload: a round +/// that narrates and then calls a secret tool emits no tool event, so +/// nothing flushes, and the live region is the only place that has seen +/// every delta of the turn. +fn finish(screen: &mut Screen, terminal: &mut Terminal) -> Result<(), B::Error> { + if screen.live.trim().is_empty() { insert(terminal, NOTHING_SAID, aside())?; } else { - insert(terminal, text, reply())?; + insert(terminal, &screen.live, reply())?; } settle(screen); Ok(()) @@ -221,6 +265,23 @@ let height = rows.len() as u16 + 1; terminal.insert_before(height, |buffer| { for (row, line) in rows.iter().enumerate() { buffer.set_string(0, row as u16, line, style); + } + }) +} + +/// Wraps `text` to the terminal width, preserving each span's style, and +/// inserts it above the viewport with one blank row after it. A tool +/// composes its own styling; this adds none of its own. +fn insert_text( + terminal: &mut Terminal, + text: Text<'static>, +) -> Result<(), B::Error> { + let width = terminal.size()?.width; + let rows = wrap_spans(text, width as usize); + let height = rows.len() as u16 + 1; + terminal.insert_before(height, |buffer| { + for (row, line) in rows.iter().enumerate() { + buffer.set_line(0, row as u16, line, width); } }) } @@ -283,3 +344,7 @@ #[cfg(test)] #[path = "screen_tests.rs"] mod tests; + +#[cfg(test)] +#[path = "screen_turn_tests.rs"] +mod turn_tests; diff --git a/src/play/screen_tests.rs b/src/play/screen_tests.rs --- a/src/play/screen_tests.rs +++ b/src/play/screen_tests.rs @@ -1,9 +1,13 @@ -//! Tests for `screen.rs`, split out to keep the production file under the -//! project's file-length guideline. +//! Tests for `screen.rs`'s prompt: typing, cursor movement, and history +//! recall, plus the harness both this file and `screen_turn_tests.rs` +//! play scripts through. The turn half is split into that sibling file +//! to keep both under the project's file-length guideline; it shares +//! this file's harness rather than keeping its own copy, so `play`'s +//! test-only type parameter is monomorphized once, not twice. use std::collections::VecDeque; use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::AtomicBool; use std::sync::mpsc::{self, Receiver, Sender, TryRecvError}; use ratatui::backend::TestBackend; @@ -13,29 +17,32 @@ use ratatui::{Terminal, TerminalOptions, Viewport}; use super::*; -/// One thing that happens while the loop runs: the player presses a key, or -/// the worker reports on the turn. -enum Step { +/// One thing that happens while the loop runs: the player presses a key, +/// the worker reports on the turn, or the worker thread disconnects. +pub(super) enum Step { Press(Key), Turn(TurnEvent), + Disconnect, } -fn press(key: Key) -> Step { +pub(super) fn press(key: Key) -> Step { Step::Press(key) } -fn typing(text: &str) -> Vec { +pub(super) fn typing(text: &str) -> Vec { text.chars().map(|c| press(Key::Char(c))).collect() } /// Plays back `steps`, one per pass of the render loop, then quits. /// /// A `Turn` step sends its event on the worker channel and reports no key, -/// so the loop sees it on its next pass. Quitting at the end of the script -/// keeps every test bounded. +/// so the loop sees it on its next pass. A `Disconnect` step drops the +/// sending half of that channel, the same as a worker thread that panics +/// mid-turn, and likewise reports no key. Quitting at the end of the +/// script keeps every test bounded. struct Script { steps: VecDeque, - turns: Sender, + turns: Option>, } impl Keys for Script { @@ -43,7 +50,11 @@ fn next_key(&mut self) -> Option { match self.steps.pop_front() { Some(Step::Press(key)) => Some(key), Some(Step::Turn(event)) => { - self.turns.send(event).unwrap(); + self.turns.as_ref().unwrap().send(event).unwrap(); + None + } + Some(Step::Disconnect) => { + self.turns = None; None } None => Some(Key::Quit), @@ -52,22 +63,22 @@ } } /// Everything a played-back script leaves behind. -struct Played { +pub(super) struct Played { terminal: Terminal, inputs: Receiver, - cancel: Arc, + pub(super) cancel: Arc, /// Held open so an unsubmitted input reads as empty, not disconnected. _sender: Sender, } impl Played { /// The rows that scrolled above the viewport: the transcript. - fn transcript(&self) -> String { + pub(super) fn transcript(&self) -> String { buffer_text(self.terminal.backend().scrollback()) } /// The rows of the viewport itself: the live region and the prompt. - fn viewport(&self) -> String { + pub(super) fn viewport(&self) -> String { buffer_text(self.terminal.backend().buffer()) } @@ -77,7 +88,7 @@ self.viewport().lines().last().unwrap().to_string() } /// The one input the player submitted. - fn submitted(&self) -> String { + pub(super) fn submitted(&self) -> String { self.inputs.try_recv().unwrap() } } @@ -97,7 +108,7 @@ } /// Runs the loop over `steps` on a terminal 40 columns wide, where the /// viewport fills the screen so every inserted row lands in scrollback. -fn play_script(steps: Vec) -> Played { +pub(super) fn play_script(steps: Vec) -> Played { let mut backend = TestBackend::new(40, VIEWPORT_HEIGHT); backend.set_cursor_position(Position::new(0, 0)).unwrap(); let mut terminal = Terminal::with_options( @@ -111,7 +122,7 @@ let (input_sender, inputs) = mpsc::channel(); let (turn_sender, turns) = mpsc::channel(); let mut keys = Script { steps: steps.into(), - turns: turn_sender, + turns: Some(turn_sender), }; let mut history = History::in_memory(); let cancel = Arc::new(AtomicBool::new(false)); @@ -402,105 +413,9 @@ assert_eq!(played.submitted(), "hi"); assert_eq!(played.inputs.try_recv(), Err(TryRecvError::Empty)); } -#[test] -fn a_finished_turn_lets_the_next_input_through() { - let mut steps = typing("hi"); - steps.push(press(Key::Enter)); - steps.push(Step::Turn(TurnEvent::Done("You wake.".to_string()))); - steps.extend(typing("again")); - steps.push(press(Key::Enter)); - - let played = play_script(steps); - - assert_eq!(played.submitted(), "hi"); - assert_eq!(played.submitted(), "again"); -} - -#[test] -fn deltas_show_in_the_live_region() { - let steps = vec![ - Step::Turn(TurnEvent::Delta("You ".to_string())), - Step::Turn(TurnEvent::Delta("wake.".to_string())), - ]; - - let played = play_script(steps); - - assert!(played.viewport().starts_with("You wake.")); -} - -#[test] -fn the_live_region_shows_the_tail_of_a_long_reply() { - let reply = (1..=20) - .map(|line| format!("line {line}")) - .collect::>() - .join("\n"); - let steps = vec![Step::Turn(TurnEvent::Delta(reply))]; - - let played = play_script(steps); - - assert!(played.viewport().contains("line 20")); - assert!(!played.viewport().contains("line 1\n")); -} - -#[test] -fn a_finished_reply_goes_to_the_transcript() { - let steps = vec![Step::Turn(TurnEvent::Done("You wake.".to_string()))]; - - let played = play_script(steps); - - assert!(played.transcript().contains("You wake.")); -} - -#[test] -fn a_finished_reply_clears_the_live_region() { - let steps = vec![ - Step::Turn(TurnEvent::Delta("You wake.".to_string())), - Step::Turn(TurnEvent::Done("You wake.".to_string())), - ]; - - let played = play_script(steps); - - assert!(played.viewport().starts_with('\n')); -} - -#[test] -fn a_long_reply_wraps_to_the_width() { - let reply = "You wake in a cell that smells of wet stone and old smoke."; - let steps = vec![Step::Turn(TurnEvent::Done(reply.to_string()))]; - - let played = play_script(steps); - - assert!( - played - .transcript() - .contains("You wake in a cell that smells of wet") - ); - assert!(played.transcript().contains("stone and old smoke.")); -} - -#[test] -fn an_empty_reply_says_the_dm_said_nothing() { - let steps = vec![Step::Turn(TurnEvent::Done(String::new()))]; - - let played = play_script(steps); - - assert!(played.transcript().contains("(the DM says nothing)")); -} - -#[test] -fn a_failed_turn_shows_the_error_in_the_transcript() { - let steps = vec![Step::Turn(TurnEvent::Failed( - "the server said no".to_string(), - ))]; - - let played = play_script(steps); - - assert!(played.transcript().contains("the server said no")); -} - /// Ends the turn started by the last `Enter`, so the next input goes /// through. -fn done() -> Step { +pub(super) fn done() -> Step { Step::Turn(TurnEvent::Done("ok".to_string())) } @@ -562,133 +477,3 @@ let played = play_script(steps); assert_eq!(played.prompt(), "> hi"); } - -#[test] -fn a_failed_turn_lets_the_next_input_through() { - let mut steps = typing("hi"); - steps.push(press(Key::Enter)); - steps.push(Step::Turn(TurnEvent::Failed("no".to_string()))); - steps.extend(typing("again")); - steps.push(press(Key::Enter)); - - let played = play_script(steps); - - assert_eq!(played.submitted(), "hi"); - assert_eq!(played.submitted(), "again"); -} - -#[test] -fn escape_mid_turn_sets_the_cancel_flag() { - let mut steps = typing("hi"); - steps.push(press(Key::Enter)); - steps.push(press(Key::Cancel)); - - let played = play_script(steps); - - assert!(played.cancel.load(Ordering::Relaxed)); -} - -#[test] -fn escape_while_idle_does_nothing() { - let played = play_script(vec![press(Key::Cancel)]); - - assert!(!played.cancel.load(Ordering::Relaxed)); -} - -#[test] -fn a_cancelled_turn_shows_the_partial_text_then_the_interrupted_aside() { - let mut steps = typing("hi"); - steps.push(press(Key::Enter)); - steps.push(press(Key::Cancel)); - steps.push(Step::Turn(TurnEvent::Cancelled("You wa".to_string()))); - - let played = play_script(steps); - - let transcript = played.transcript(); - let partial = transcript.find("You wa").unwrap(); - let interrupted = transcript.find("(interrupted)").unwrap(); - assert!(partial < interrupted); -} - -#[test] -fn a_cancelled_turn_with_no_partial_text_inserts_only_the_aside() { - let steps = vec![Step::Turn(TurnEvent::Cancelled(String::new()))]; - - let played = play_script(steps); - - let transcript = played.transcript(); - let lines: Vec<&str> = transcript.lines().collect(); - let banner = lines.iter().position(|line| *line == "a banner").unwrap(); - let interrupted = lines - .iter() - .position(|line| *line == "(interrupted)") - .unwrap(); - assert_eq!(interrupted - banner, 2); -} - -#[test] -fn a_cancelled_turn_lets_the_next_input_through() { - let mut steps = typing("hi"); - steps.push(press(Key::Enter)); - steps.push(press(Key::Cancel)); - steps.push(Step::Turn(TurnEvent::Cancelled("partial".to_string()))); - steps.extend(typing("again")); - steps.push(press(Key::Enter)); - - let played = play_script(steps); - - assert_eq!(played.submitted(), "hi"); - assert_eq!(played.submitted(), "again"); -} - -/// Whether `c` is one of the sparkle glyphs the thinking indicator -/// pulses through. -fn is_sparkle(c: char) -> bool { - c == '\u{B7}' || ('\u{2726}'..='\u{2739}').contains(&c) -} - -#[test] -fn a_submit_with_no_delta_yet_shows_the_thinking_indicator() { - let mut steps = typing("hi"); - steps.push(press(Key::Enter)); - - let played = play_script(steps); - - assert!(played.viewport().chars().any(is_sparkle)); - assert!(played.viewport().contains(thinking::phrase(0))); -} - -#[test] -fn the_first_delta_replaces_the_thinking_indicator() { - let mut steps = typing("hi"); - steps.push(press(Key::Enter)); - steps.push(Step::Turn(TurnEvent::Delta("You wake.".to_string()))); - - let played = play_script(steps); - - assert!(played.viewport().starts_with("You wake.")); - assert!(!played.viewport().contains(thinking::phrase(0))); -} - -#[test] -fn a_second_turn_shows_the_next_phrase() { - let mut steps = typing("hi"); - steps.push(press(Key::Enter)); - steps.push(done()); - steps.extend(typing("again")); - steps.push(press(Key::Enter)); - - let played = play_script(steps); - - assert!(played.viewport().contains(thinking::phrase(1))); -} - -#[test] -fn the_thinking_indicator_never_reaches_scrollback() { - let mut steps = typing("hi"); - steps.push(press(Key::Enter)); - - let played = play_script(steps); - - assert!(!played.transcript().contains(thinking::phrase(0))); -} diff --git a/src/play/screen_turn_tests.rs b/src/play/screen_turn_tests.rs new file mode 100644 --- /dev/null +++ b/src/play/screen_turn_tests.rs @@ -0,0 +1,356 @@ +//! Tests for `screen.rs`'s side of a turn: the live region, the +//! transcript a turn's events land in, and the thinking indicator. +//! `screen_tests.rs` covers the other half, the prompt and its history, +//! and this file plays scripts through its harness rather than keeping +//! its own copy. + +use std::sync::atomic::Ordering; + +use ratatui::text::Text; + +use super::tests::{Step, done, play_script, press, typing}; +use super::*; + +#[test] +fn a_finished_turn_lets_the_next_input_through() { + let mut steps = typing("hi"); + steps.push(press(Key::Enter)); + steps.push(Step::Turn(TurnEvent::Done("You wake.".to_string()))); + steps.extend(typing("again")); + steps.push(press(Key::Enter)); + + let played = play_script(steps); + + assert_eq!(played.submitted(), "hi"); + assert_eq!(played.submitted(), "again"); +} + +#[test] +fn deltas_show_in_the_live_region() { + let steps = vec![ + Step::Turn(TurnEvent::Delta("You ".to_string())), + Step::Turn(TurnEvent::Delta("wake.".to_string())), + ]; + + let played = play_script(steps); + + assert!(played.viewport().starts_with("You wake.")); +} + +#[test] +fn the_live_region_shows_the_tail_of_a_long_reply() { + let reply = (1..=20) + .map(|line| format!("line {line}")) + .collect::>() + .join("\n"); + let steps = vec![Step::Turn(TurnEvent::Delta(reply))]; + + let played = play_script(steps); + + assert!(played.viewport().contains("line 20")); + assert!(!played.viewport().contains("line 1\n")); +} + +#[test] +fn a_finished_reply_goes_to_the_transcript() { + let steps = vec![ + Step::Turn(TurnEvent::Delta("You wake.".to_string())), + Step::Turn(TurnEvent::Done("You wake.".to_string())), + ]; + + let played = play_script(steps); + + assert!(played.transcript().contains("You wake.")); +} + +#[test] +fn a_finished_reply_clears_the_live_region() { + let steps = vec![ + Step::Turn(TurnEvent::Delta("You wake.".to_string())), + Step::Turn(TurnEvent::Done("You wake.".to_string())), + ]; + + let played = play_script(steps); + + assert!(played.viewport().starts_with('\n')); +} + +#[test] +fn a_long_reply_wraps_to_the_width() { + let reply = "You wake in a cell that smells of wet stone and old smoke."; + let steps = vec![ + Step::Turn(TurnEvent::Delta(reply.to_string())), + Step::Turn(TurnEvent::Done(reply.to_string())), + ]; + + let played = play_script(steps); + + assert!( + played + .transcript() + .contains("You wake in a cell that smells of wet") + ); + assert!(played.transcript().contains("stone and old smoke.")); +} + +#[test] +fn an_empty_reply_says_the_dm_said_nothing() { + let steps = vec![Step::Turn(TurnEvent::Done(String::new()))]; + + let played = play_script(steps); + + assert!(played.transcript().contains("(the DM says nothing)")); +} + +#[test] +fn a_failed_turn_shows_the_error_in_the_transcript() { + let steps = vec![Step::Turn(TurnEvent::Failed( + "the server said no".to_string(), + ))]; + + let played = play_script(steps); + + assert!(played.transcript().contains("the server said no")); +} + +#[test] +fn a_tool_event_flushes_narration_then_inserts_the_tool_line() { + let mut steps = typing("hi"); + steps.push(press(Key::Enter)); + steps.push(Step::Turn(TurnEvent::Delta("You wake.".to_string()))); + steps.push(Step::Turn(TurnEvent::Tool(Text::raw("a tool line")))); + + let played = play_script(steps); + + let transcript = played.transcript(); + let narration = transcript.find("You wake.").unwrap(); + let tool = transcript.find("a tool line").unwrap(); + assert!(narration < tool); +} + +#[test] +fn whitespace_only_narration_flushes_without_a_blank_row() { + let steps = vec![ + Step::Turn(TurnEvent::Delta(" ".to_string())), + Step::Turn(TurnEvent::Tool(Text::raw("a tool line"))), + ]; + + let played = play_script(steps); + + let transcript = played.transcript(); + let lines: Vec<&str> = transcript.lines().collect(); + let banner = lines.iter().position(|line| *line == "a banner").unwrap(); + let tool = lines + .iter() + .position(|line| *line == "a tool line") + .unwrap(); + assert_eq!(tool - banner, 2); +} + +#[test] +fn a_tool_event_with_no_narration_inserts_only_the_tool_line() { + let steps = vec![Step::Turn(TurnEvent::Tool(Text::raw("a tool line")))]; + + let played = play_script(steps); + + let transcript = played.transcript(); + let lines: Vec<&str> = transcript.lines().collect(); + let banner = lines.iter().position(|line| *line == "a banner").unwrap(); + let tool = lines + .iter() + .position(|line| *line == "a tool line") + .unwrap(); + assert_eq!(tool - banner, 2); +} + +#[test] +fn a_secret_roll_between_narrated_rounds_keeps_every_word_in_the_transcript() { + let steps = vec![ + Step::Turn(TurnEvent::Delta("You edge along the wall. ".to_string())), + Step::Turn(TurnEvent::Delta("Nothing stirs.".to_string())), + Step::Turn(TurnEvent::Done("Nothing stirs.".to_string())), + ]; + + let played = play_script(steps); + + let transcript = played.transcript(); + assert_eq!(transcript.matches("You edge along the wall.").count(), 1); + assert_eq!(transcript.matches("Nothing stirs.").count(), 1); +} + +#[test] +fn a_wide_tool_line_wraps_instead_of_truncating() { + let line = "a tool line so wide it cannot fit in forty columns at all"; + let steps = vec![Step::Turn(TurnEvent::Tool(Text::raw(line)))]; + + let played = play_script(steps); + + let transcript = played.transcript(); + assert!(transcript.contains("a tool line so wide it cannot fit in")); + assert!(transcript.contains("forty columns at all")); +} + +#[test] +fn a_failed_turn_lets_the_next_input_through() { + let mut steps = typing("hi"); + steps.push(press(Key::Enter)); + steps.push(Step::Turn(TurnEvent::Failed("no".to_string()))); + steps.extend(typing("again")); + steps.push(press(Key::Enter)); + + let played = play_script(steps); + + assert_eq!(played.submitted(), "hi"); + assert_eq!(played.submitted(), "again"); +} + +#[test] +fn a_disconnected_worker_shows_a_failure_line_and_frees_the_prompt() { + let mut steps = typing("hi"); + steps.push(press(Key::Enter)); + steps.push(Step::Disconnect); + steps.push(press(Key::Cancel)); + + let played = play_script(steps); + + let transcript = played.transcript(); + assert_eq!( + transcript + .matches("the storyteller thread is gone; restart") + .count(), + 1 + ); + assert!(transcript.contains("storied to continue")); + assert_eq!(played.submitted(), "hi"); + // Escape is a no-op while idle; a stuck `busy` flag would still be + // catching it and setting the cancel flag here. + assert!(!played.cancel.load(Ordering::Relaxed)); +} + +#[test] +fn a_disconnect_while_idle_shows_nothing() { + let played = play_script(vec![Step::Disconnect]); + + assert!( + !played + .transcript() + .contains("the storyteller thread is gone") + ); +} + +#[test] +fn escape_mid_turn_sets_the_cancel_flag() { + let mut steps = typing("hi"); + steps.push(press(Key::Enter)); + steps.push(press(Key::Cancel)); + + let played = play_script(steps); + + assert!(played.cancel.load(Ordering::Relaxed)); +} + +#[test] +fn escape_while_idle_does_nothing() { + let played = play_script(vec![press(Key::Cancel)]); + + assert!(!played.cancel.load(Ordering::Relaxed)); +} + +#[test] +fn a_cancelled_turn_shows_the_partial_text_then_the_interrupted_aside() { + let mut steps = typing("hi"); + steps.push(press(Key::Enter)); + steps.push(Step::Turn(TurnEvent::Delta("You wa".to_string()))); + steps.push(press(Key::Cancel)); + steps.push(Step::Turn(TurnEvent::Cancelled("You wa".to_string()))); + + let played = play_script(steps); + + let transcript = played.transcript(); + let partial = transcript.find("You wa").unwrap(); + let interrupted = transcript.find("(interrupted)").unwrap(); + assert!(partial < interrupted); +} + +#[test] +fn a_cancelled_turn_with_no_partial_text_inserts_only_the_aside() { + let steps = vec![Step::Turn(TurnEvent::Cancelled(String::new()))]; + + let played = play_script(steps); + + let transcript = played.transcript(); + let lines: Vec<&str> = transcript.lines().collect(); + let banner = lines.iter().position(|line| *line == "a banner").unwrap(); + let interrupted = lines + .iter() + .position(|line| *line == "(interrupted)") + .unwrap(); + assert_eq!(interrupted - banner, 2); +} + +#[test] +fn a_cancelled_turn_lets_the_next_input_through() { + let mut steps = typing("hi"); + steps.push(press(Key::Enter)); + steps.push(press(Key::Cancel)); + steps.push(Step::Turn(TurnEvent::Cancelled("partial".to_string()))); + steps.extend(typing("again")); + steps.push(press(Key::Enter)); + + let played = play_script(steps); + + assert_eq!(played.submitted(), "hi"); + assert_eq!(played.submitted(), "again"); +} + +/// Whether `c` is one of the sparkle glyphs the thinking indicator +/// pulses through. +fn is_sparkle(c: char) -> bool { + c == '\u{B7}' || ('\u{2726}'..='\u{2739}').contains(&c) +} + +#[test] +fn a_submit_with_no_delta_yet_shows_the_thinking_indicator() { + let mut steps = typing("hi"); + steps.push(press(Key::Enter)); + + let played = play_script(steps); + + assert!(played.viewport().chars().any(is_sparkle)); + assert!(played.viewport().contains(thinking::phrase(0))); +} + +#[test] +fn the_first_delta_replaces_the_thinking_indicator() { + let mut steps = typing("hi"); + steps.push(press(Key::Enter)); + steps.push(Step::Turn(TurnEvent::Delta("You wake.".to_string()))); + + let played = play_script(steps); + + assert!(played.viewport().starts_with("You wake.")); + assert!(!played.viewport().contains(thinking::phrase(0))); +} + +#[test] +fn a_second_turn_shows_the_next_phrase() { + let mut steps = typing("hi"); + steps.push(press(Key::Enter)); + steps.push(done()); + steps.extend(typing("again")); + steps.push(press(Key::Enter)); + + let played = play_script(steps); + + assert!(played.viewport().contains(thinking::phrase(1))); +} + +#[test] +fn the_thinking_indicator_never_reaches_scrollback() { + let mut steps = typing("hi"); + steps.push(press(Key::Enter)); + + let played = play_script(steps); + + assert!(!played.transcript().contains(thinking::phrase(0))); +} diff --git a/src/play/worker.rs b/src/play/worker.rs --- a/src/play/worker.rs +++ b/src/play/worker.rs @@ -6,13 +6,17 @@ use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::{self, Receiver, Sender}; -use crate::dm::{Dm, Turn}; +use ratatui::text::Text; + +use crate::dm::{Dm, Turn, TurnDelta}; /// What one turn tells the render loop while it runs. #[derive(Debug, Clone, PartialEq, Eq)] pub enum TurnEvent { /// A piece of the reply, as it streams in. Delta(String), + /// A dispatched tool call's transcript line. + Tool(Text<'static>), /// The whole reply. The turn is over. Done(String), /// The player cancelled the turn. Holds the reply collected so far. @@ -62,8 +66,15 @@ /// false at the start of each turn. fn run(mut dm: Dm, requests: &Receiver, replies: &Sender, cancel: &AtomicBool) { for input in requests { cancel.store(false, Ordering::Relaxed); - let mut on_delta = |delta: &str| { - let _ = replies.send(TurnEvent::Delta(delta.to_string())); + let mut on_delta = |event: TurnDelta| { + match event { + TurnDelta::Text(delta) => { + let _ = replies.send(TurnEvent::Delta(delta)); + } + TurnDelta::Tool(text) => { + let _ = replies.send(TurnEvent::Tool(text)); + } + } if cancel.load(Ordering::Relaxed) { ControlFlow::Break(()) } else { @@ -223,6 +234,32 @@ server.join().unwrap(); } #[test] + fn a_tool_call_sends_the_tool_line_before_the_next_rounds_reply() { + let tool_round = "data: {\"choices\":[{\"delta\":{\"tool_calls\":[\ + {\"index\":0,\"id\":\"call_1\",\"function\":{\"name\":\"roll\",\ + \"arguments\":\"{\\\"notation\\\":\\\"1d20\\\",\\\"visibility\\\":\\\"public\\\"}\"}}\ + ]},\"finish_reason\":null}]}\n\n\ + data: [DONE]\n\n"; + let reply = "data: {\"choices\":[{\"delta\":{\"content\":\"You proceed.\"},\"finish_reason\":null}]}\n\n\ + data: [DONE]\n\n"; + let (url, server) = fake_server(vec![tool_round, reply]); + let worker = Worker::spawn(dm_for(url)); + + worker.inputs.send("I roll.".to_string()).unwrap(); + + assert!(matches!(worker.events.recv().unwrap(), TurnEvent::Tool(_))); + assert_eq!( + worker.events.recv().unwrap(), + TurnEvent::Delta("You proceed.".to_string()) + ); + assert_eq!( + worker.events.recv().unwrap(), + TurnEvent::Done("You proceed.".to_string()) + ); + server.join().unwrap(); + } + + #[test] fn a_failed_turn_sends_the_error_text() { let worker = Worker::spawn(dm_for(dead_address())); @@ -267,6 +304,32 @@ ); assert_eq!( worker.events.recv().unwrap(), TurnEvent::Done("Hi.".to_string()) + ); + server.join().unwrap(); + } + + #[test] + fn cancelling_during_a_tool_only_round_ends_the_turn_without_a_further_round() { + let tool_round = "data: {\"choices\":[{\"delta\":{\"tool_calls\":[\ + {\"index\":0,\"id\":\"call_1\",\"function\":{\"name\":\"roll\",\ + \"arguments\":\"{\\\"notation\\\":\\\"1d20\\\",\\\"visibility\\\":\\\"public\\\"}\"}}\ + ]},\"finish_reason\":null}]}\n\n\ + data: [DONE]\n\n"; + let (url, ready, resume, server) = staggered_server(tool_round, vec![]); + let worker = Worker::spawn(dm_for(url)); + + worker.inputs.send("I roll.".to_string()).unwrap(); + + // The worker's request lands only after it resets the cancel flag + // for this turn, so setting it here cannot be undone by the reset. + ready.recv().unwrap(); + worker.cancel.store(true, Ordering::Relaxed); + resume.send(()).unwrap(); + + assert!(matches!(worker.events.recv().unwrap(), TurnEvent::Tool(_))); + assert_eq!( + worker.events.recv().unwrap(), + TurnEvent::Cancelled(String::new()) ); server.join().unwrap(); } diff --git a/src/play/wrap.rs b/src/play/wrap.rs --- a/src/play/wrap.rs +++ b/src/play/wrap.rs @@ -1,5 +1,9 @@ -//! Plain word wrapping. The play loop wraps text itself so it knows how -//! many rows a block of text needs before it inserts it above the viewport. +//! Plain and styled word wrapping. The play loop wraps text itself so it +//! knows how many rows a block of text needs before it inserts it above +//! the viewport. + +use ratatui::style::Style; +use ratatui::text::{Line, Span, Text}; /// Wraps `text` to `width` characters, one output line per row of the /// terminal. @@ -51,8 +55,130 @@ pieces.push(piece); pieces } +/// Wraps `text` to `width` columns, one output line per row of the +/// terminal, keeping every span's style on the row it lands on. +/// +/// A multi-line `Text` wraps one [`Line`] at a time; a newline inside a +/// span's own content is not one of those line breaks, it collapses to a +/// space like any other run of whitespace, the same way [`wrap`] +/// collapses runs of whitespace within one line. A span longer than +/// `width` hard-splits, and the style carries onto its continuation +/// rows. A style boundary that falls in the middle of a word breaks the +/// row's spans at that point but never breaks the word itself. +pub fn wrap_spans(text: Text<'static>, width: usize) -> Vec> { + let width = width.max(1); + text.lines + .into_iter() + .flat_map(|line| wrap_styled_line(&line, width)) + .collect() +} + +/// One character of a line, paired with the style it renders in. +type StyledChar = (char, Style); + +/// Wraps one line, greedily filling each row, the same way [`wrap_line`] +/// wraps plain text. +fn wrap_styled_line(line: &Line<'static>, width: usize) -> Vec> { + let words = styled_words(line); + let mut rows = Vec::new(); + let mut row: Vec = Vec::new(); + for word in words { + for piece in break_styled_word(word, width) { + if row.is_empty() { + row = piece; + } else if row.len() + 1 + piece.len() <= width { + row.push((' ', Style::default())); + row.extend(piece); + } else { + rows.push(std::mem::replace(&mut row, piece)); + } + } + } + rows.push(row); + rows.into_iter().map(styled_line).collect() +} + +/// `line`'s characters, in order, each paired with the style it renders +/// in: its span's style patched onto the line's own, the same style +/// [`ratatui::buffer::Buffer::set_line`] applies when it draws the line. +fn styled_chars(line: &Line<'static>) -> Vec { + line.spans + .iter() + .flat_map(|span| { + let style = line.style.patch(span.style); + span.content.chars().map(move |c| (c, style)) + }) + .collect() +} + +/// `line` split into words, the same way [`str::split_whitespace`] would: +/// runs of non-whitespace characters, with the whitespace between them +/// dropped. +fn styled_words(line: &Line<'static>) -> Vec> { + let mut words = Vec::new(); + let mut word = Vec::new(); + for character in styled_chars(line) { + if character.0.is_whitespace() { + if !word.is_empty() { + words.push(std::mem::take(&mut word)); + } + } else { + word.push(character); + } + } + if !word.is_empty() { + words.push(word); + } + words +} + +/// Cuts `word` into pieces of at most `width` characters, the same way +/// [`break_word`] cuts plain text. A word that fits comes back whole. +fn break_styled_word(word: Vec, width: usize) -> Vec> { + let mut pieces = Vec::new(); + let mut piece = Vec::new(); + for character in word { + if piece.len() == width { + pieces.push(std::mem::take(&mut piece)); + } + piece.push(character); + } + pieces.push(piece); + pieces +} + +/// Turns a row of styled characters into a line, one span per run of +/// characters that share a style. +fn styled_line(row: Vec) -> Line<'static> { + let mut spans = Vec::new(); + let mut current_style = None; + let mut current_text = String::new(); + for (character, style) in row { + if current_style != Some(style) { + if !current_text.is_empty() { + spans.push(Span::styled( + std::mem::take(&mut current_text), + current_style.expect("current_style is set whenever current_text is nonempty"), + )); + } + current_style = Some(style); + } + current_text.push(character); + } + if !current_text.is_empty() { + spans.push(Span::styled( + current_text, + current_style.expect("current_style is set whenever current_text is nonempty"), + )); + } + Line::from(spans) +} + #[cfg(test)] mod tests { + use ratatui::style::{Modifier, Style}; + use ratatui::text::{Line, Span, Text}; + use super::*; #[test] @@ -107,5 +233,79 @@ #[test] fn a_width_of_zero_wraps_at_one_character() { assert_eq!(wrap("ab", 0), vec!["a", "b"]); + } + + #[test] + fn a_fitting_line_of_spans_is_left_alone() { + let dim = Style::new().add_modifier(Modifier::DIM); + let text = Text::from(Line::from(vec![Span::raw("roll "), Span::styled("3", dim)])); + + assert_eq!( + wrap_spans(text, 40), + vec![Line::from(vec![Span::raw("roll "), Span::styled("3", dim)])] + ); + } + + #[test] + fn span_wrap_splits_on_spaces() { + let text = Text::from(Line::from(Span::raw("the door creaks open"))); + + assert_eq!( + wrap_spans(text, 10), + vec![ + Line::raw("the door"), + Line::raw("creaks"), + Line::raw("open"), + ] + ); + } + + #[test] + fn span_wrap_preserves_each_spans_style_across_rows() { + let dim = Style::new().add_modifier(Modifier::DIM); + let text = Text::from(Line::from(vec![ + Span::raw("roll "), + Span::styled("dropped", dim), + Span::raw(" kept"), + ])); + + let rows = wrap_spans(text, 13); + + assert_eq!( + rows, + vec![ + Line::from(vec![Span::raw("roll "), Span::styled("dropped", dim)]), + Line::from(vec![Span::raw("kept")]), + ] + ); + } + + #[test] + fn a_span_longer_than_the_width_hard_splits() { + let text = Text::from(Line::from(Span::raw("antidisestablishment"))); + + assert_eq!( + wrap_spans(text, 8), + vec![ + Line::raw("antidise"), + Line::raw("stablish"), + Line::raw("ment"), + ] + ); + } + + #[test] + fn a_hard_split_keeps_its_span_style() { + let dim = Style::new().add_modifier(Modifier::DIM); + let text = Text::from(Line::from(Span::styled("antidisestablishment", dim))); + + assert_eq!( + wrap_spans(text, 8), + vec![ + Line::from(Span::styled("antidise", dim)), + Line::from(Span::styled("stablish", dim)), + Line::from(Span::styled("ment", dim)), + ] + ); } }