diff --git a/src/agent/runner.rs b/src/agent/runner.rs index de1ccaad..62ecf7f3 100644 --- a/src/agent/runner.rs +++ b/src/agent/runner.rs @@ -123,6 +123,8 @@ pub struct AgentRunner { } pub fn convert_history(session: &Session) -> Vec { + use rig::OneOrMany; + use rig::completion::message::AssistantContent; let (summary, first_kept) = session.compacted_context(); let mut messages = Vec::new(); @@ -136,8 +138,63 @@ pub fn convert_history(session: &Session) -> Vec { for msg in &session.messages[first_kept..] { match msg.role { MessageRole::User => messages.push(Message::user(msg.content.to_string())), - MessageRole::Assistant => messages.push(Message::assistant(msg.content.to_string())), MessageRole::System => messages.push(Message::system(msg.content.to_string())), + MessageRole::Assistant => { + // Phase 3: if this assistant message has structured + // tool calls, emit a single Assistant message with + // text + tool_use content parts, followed by ONE + // tool_result User message per call. The pairing + // matches opencode's `toModelMessagesEffect` + // (`message-v2.ts:630-899`); Anthropic + OpenAI + // reject orphan tool_use blocks so we always emit a + // result, marking Interrupted/Failed as error text + // rather than skipping. Bare assistant messages + // (no tool_calls) keep the prior simple shape. + if msg.tool_calls.is_empty() { + messages.push(Message::assistant(msg.content.to_string())); + continue; + } + + // Build the Assistant message's content blocks: text + // first (if any) then each ToolCall. + let mut parts: Vec = Vec::new(); + if !msg.content.is_empty() { + parts.push(AssistantContent::text(msg.content.to_string())); + } + for tc in &msg.tool_calls { + parts.push(AssistantContent::tool_call( + tc.id.clone(), + tc.name.clone(), + tc.args.clone(), + )); + } + // OneOrMany::many requires at least one element; we + // always have at least one ToolCall here since + // tool_calls is non-empty. + let content = if parts.len() == 1 { + OneOrMany::one(parts.pop().unwrap()) + } else { + OneOrMany::many(parts).expect("non-empty parts vec") + }; + messages.push(Message::Assistant { id: None, content }); + + // One User tool_result per call. State maps to: + // Completed → result text verbatim + // Interrupted → "[Tool execution was interrupted]" + // Failed → "[Tool error: ]" + for tc in &msg.tool_calls { + let body = match &tc.state { + crate::session::ToolCallState::Completed { result } => result.clone(), + crate::session::ToolCallState::Interrupted => { + "[Tool execution was interrupted]".to_string() + } + crate::session::ToolCallState::Failed { error } => { + format!("[Tool error: {}]", error) + } + }; + messages.push(Message::tool_result(tc.id.clone(), body)); + } + } } } @@ -217,6 +274,7 @@ where outcome.had_tool_calls = true; let _ = event_tx .send(AgentEvent::ToolCall { + id: CompactString::from(tool_call.id), name: CompactString::from(tool_call.function.name), args: tool_call.function.arguments, }) @@ -238,6 +296,7 @@ where } let _ = event_tx .send(AgentEvent::ToolResult { + id: CompactString::from(tool_result.id), output: CompactString::from(output), }) .await; diff --git a/src/event.rs b/src/event.rs index 72ddfb58..a992b2ef 100644 --- a/src/event.rs +++ b/src/event.rs @@ -5,10 +5,20 @@ pub enum AgentEvent { Token(CompactString), Reasoning(CompactString), ToolCall { + /// Provider call id (rig's `ToolCall.id`). Empty for older + /// rig versions or providers that don't emit one; the UI + /// uses it to pair this call with the corresponding + /// `ToolResult` event for structured persistence (Phase 3). + id: CompactString, name: CompactString, args: serde_json::Value, }, ToolResult { + /// Matching call id from the `ToolCall` event. Empty if the + /// provider didn't emit one — the UI falls back to + /// positional pairing (this result belongs to the most- + /// recent unanswered ToolCall in the same turn). + id: CompactString, output: CompactString, }, Error(CompactString), diff --git a/src/extras/acp/mod.rs b/src/extras/acp/mod.rs index 0d15f515..baf11d75 100644 --- a/src/extras/acp/mod.rs +++ b/src/extras/acp/mod.rs @@ -209,7 +209,7 @@ async fn run_prompt( ); let _ = cx.send_notification(notif); } - AgentEvent::ToolCall { name, args } => { + AgentEvent::ToolCall { id: _, name, args } => { let args_str = args.to_string(); let call_id = ToolCallId::new(uuid::Uuid::new_v4().to_string()); last_tool_call_id = Some(call_id.clone()); @@ -221,7 +221,7 @@ async fn run_prompt( ); let _ = cx.send_notification(notif); } - AgentEvent::ToolResult { output } => { + AgentEvent::ToolResult { id: _, output } => { // Use the most recent ToolCall id so the client can // correlate result → call. Falls back to an empty id // only if a stray ToolResult arrives without a prior diff --git a/src/session/mod.rs b/src/session/mod.rs index cd52059b..7fdb23a9 100644 --- a/src/session/mod.rs +++ b/src/session/mod.rs @@ -14,6 +14,48 @@ pub enum MessageRole { System, } +/// State of a tool call attached to an assistant message. Mirrors +/// opencode's `ToolPart.state` (`message-v2.ts:310-320`). The point +/// of preserving state — rather than just "this tool ran" — is so +/// that resumed sessions can emit a paired tool_result block to the +/// LLM even for tool calls that didn't complete (e.g. user hit +/// Ctrl+C mid-execution). Anthropic + OpenAI reject orphan tool_use +/// blocks; we always emit a result, even if its content is an +/// interrupted marker. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum ToolCallState { + /// Tool ran to completion. `result` is the output text the LLM + /// would see (the same string the UI rendered in the chamber). + Completed { result: String }, + /// Tool was dispatched but the agent was aborted before its + /// result came back. Resumed sessions emit a tool_result with + /// "[Tool execution was interrupted]" so the LLM knows the + /// effect is undefined. + Interrupted, + /// Tool dispatched but the call errored (e.g. permission denied, + /// runtime panic). `error` is the message the LLM saw. + Failed { error: String }, +} + +/// One tool invocation attached to an assistant message. We keep +/// the original call id (rig's `ToolCall.id`) so resumed sessions +/// emit tool_result blocks with the right correlation id. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ToolCallEntry { + /// Provider-supplied call id (e.g. `tooluse_abc123` for + /// Anthropic, `call_xyz` for OpenAI). Used as the + /// `tool_use_id` / `tool_call_id` correlation on resume. + pub id: String, + /// Tool name as the LLM saw it (`bash`, `read`, `mcp_tool:...`). + pub name: String, + /// Arguments the LLM sent. JSON value so it round-trips + /// without re-parsing. + pub args: serde_json::Value, + /// Outcome — completed, interrupted, or failed. + pub state: ToolCallState, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SessionMessage { pub role: MessageRole, @@ -31,6 +73,16 @@ pub struct SessionMessage { /// chat messages with plugin entries by timestamp. #[serde(default)] pub timestamp: i64, + /// Tool calls + results attached to this assistant message. + /// Empty for User / System messages and for assistants that + /// didn't invoke any tools. Phase 3 added persistence so + /// resumed sessions re-emit structured tool_use/tool_result + /// blocks to the LLM instead of only the assistant's text; + /// previously the LLM lost all context of prior tool work on + /// session resume. Defaulted on deserialize for back-compat + /// with pre-Phase-3 session files. + #[serde(default)] + pub tool_calls: Vec, } /// Generate a fresh message id. Extracted for `#[serde(default = ...)]`. @@ -282,6 +334,21 @@ impl Session { } pub fn add_message(&mut self, role: MessageRole, content: &str) { + self.add_message_with_tool_calls(role, content, Vec::new()); + } + + /// Same as `add_message` but attaches structured tool-call + /// entries to the new message. Used by the runner to persist + /// assistant turns that invoked tools so `convert_history` + /// can re-emit structured tool_use/tool_result blocks on + /// session resume. Empty `tool_calls` is equivalent to the + /// plain `add_message`. + pub fn add_message_with_tool_calls( + &mut self, + role: MessageRole, + content: &str, + tool_calls: Vec, + ) { // Make sure tree + store mirror any messages that were loaded // from a pre-P4b/P4c session file BEFORE we append the new // one — otherwise the rebuild would re-insert this new message @@ -299,6 +366,7 @@ impl Session { estimated_tokens: tokens, id: id.clone(), timestamp, + tool_calls, }; self.messages.push(msg.clone()); self.message_store.insert(id.clone(), msg); @@ -597,6 +665,7 @@ impl Session { estimated_tokens: summary_tokens, id: summary_id.clone(), timestamp: summary_ts, + tool_calls: Vec::new(), }; // Collect the IDs of the messages we're about to drop so we @@ -1243,6 +1312,147 @@ mod tests { assert_eq!(s.messages.len(), 1); } + /// Phase 3 — tool calls round-trip through serde with default + /// for back-compat. Old session files without the field + /// deserialize into an empty Vec. + #[test] + fn session_message_tool_calls_default_when_field_missing() { + let json = r#"{ + "role": "assistant", + "content": "Done.", + "estimated_tokens": 5 + }"#; + let msg: SessionMessage = serde_json::from_str(json).unwrap(); + assert!( + msg.tool_calls.is_empty(), + "missing field must default to []" + ); + } + + /// Round-trip: write a message WITH tool_calls, read back, fields intact. + #[test] + fn session_message_tool_calls_roundtrip() { + let mut s = Session::new("p", "m", 0); + let calls = vec![ + ToolCallEntry { + id: "tc_1".to_string(), + name: "bash".to_string(), + args: serde_json::json!({"cmd": "ls"}), + state: ToolCallState::Completed { + result: "file1\nfile2".to_string(), + }, + }, + ToolCallEntry { + id: "tc_2".to_string(), + name: "read".to_string(), + args: serde_json::json!({"path": "/tmp/x"}), + state: ToolCallState::Interrupted, + }, + ]; + s.add_message_with_tool_calls(MessageRole::Assistant, "Let me check.", calls.clone()); + + let blob = serde_json::to_string(&s).unwrap(); + let s2: Session = serde_json::from_str(&blob).unwrap(); + let last = s2.messages.last().unwrap(); + assert_eq!(last.tool_calls.len(), 2); + assert_eq!(last.tool_calls[0].id, "tc_1"); + assert!(matches!( + last.tool_calls[0].state, + ToolCallState::Completed { .. }, + )); + assert!(matches!( + last.tool_calls[1].state, + ToolCallState::Interrupted, + )); + } + + /// Convert history materializes prior tool calls as structured + /// rig Message blocks (Assistant with ToolCall content + + /// User with ToolResult content). Without this, resumed sessions + /// lose tool-call context and the LLM may re-call the same + /// tools. Matches opencode's `message-v2.ts:630-899` pattern. + #[test] + fn convert_history_emits_tool_use_and_tool_result_blocks() { + let mut s = Session::new("p", "m", 0); + s.add_message(MessageRole::User, "list files"); + s.add_message_with_tool_calls( + MessageRole::Assistant, + "Here:", + vec![ToolCallEntry { + id: "tc_42".to_string(), + name: "bash".to_string(), + args: serde_json::json!({"cmd": "ls"}), + state: ToolCallState::Completed { + result: "a\nb".to_string(), + }, + }], + ); + + let history = crate::agent::runner::convert_history(&s); + // Expect: User("list files"), Assistant(text + tool_use), + // User(tool_result). 3 messages total. + assert_eq!(history.len(), 3, "history shape: {:#?}", history); + + // Last is a User with tool_result content carrying the id. + match &history[2] { + rig::completion::Message::User { content } => { + let s = format!("{:?}", content); + assert!(s.contains("tc_42"), "tool_result missing call id: {s}"); + // Debug format escapes newlines, so check the + // escaped form. The underlying string still has the + // real newline; this is just an assertion-side + // formatting consideration. + assert!( + s.contains("a\\nb") || s.contains("a\nb"), + "tool_result missing output: {s}", + ); + } + other => panic!("expected User tool_result message; got {other:?}"), + } + + // Middle is Assistant with both text and a ToolCall. + match &history[1] { + rig::completion::Message::Assistant { content, .. } => { + let s = format!("{:?}", content); + assert!(s.contains("tc_42"), "tool_use missing id: {s}"); + assert!(s.contains("\"bash\""), "tool_use missing name: {s}"); + } + other => panic!("expected Assistant message; got {other:?}"), + } + } + + /// Interrupted tool calls must be emitted as tool_result with + /// an "[interrupted]" marker, NOT skipped. Anthropic + OpenAI + /// reject orphan tool_use blocks; opencode handles this + /// (`message-v2.ts:848-857`) by emitting an error tool_result. + #[test] + fn convert_history_pairs_interrupted_tool_calls_with_error_marker() { + let mut s = Session::new("p", "m", 0); + s.add_message_with_tool_calls( + MessageRole::Assistant, + "About to bash...", + vec![ToolCallEntry { + id: "tc_99".to_string(), + name: "bash".to_string(), + args: serde_json::json!({"cmd": "sleep 9999"}), + state: ToolCallState::Interrupted, + }], + ); + + let history = crate::agent::runner::convert_history(&s); + // 2 messages: Assistant(text + tool_use) + User(tool_result-interrupted). + assert_eq!(history.len(), 2); + let last_str = format!("{:?}", &history[1]); + assert!( + last_str.contains("tc_99"), + "interrupted result must reference call id: {last_str}", + ); + assert!( + last_str.contains("interrupted") || last_str.contains("Interrupted"), + "interrupted result must say so: {last_str}", + ); + } + /// Phase 2 — compress drops a parent that has a sibling branch /// underneath. The sibling subtree must also be pruned; /// otherwise its nodes have `parent` pointing at a removed id @@ -1289,6 +1499,7 @@ mod tests { estimated_tokens: 1, id: sib1_id.clone(), timestamp: 0, + tool_calls: Vec::new(), }, ); s.message_store.insert( @@ -1299,6 +1510,7 @@ mod tests { estimated_tokens: 1, id: sib2_id.clone(), timestamp: 0, + tool_calls: Vec::new(), }, ); diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 2737c411..30973e5a 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -104,9 +104,10 @@ fn capture_partial_on_abort( session: &mut crate::session::Session, why: &str, tool_calls_in_turn: u32, + tool_calls_buf: &mut Vec, ) -> bool { let trimmed = response_buf.trim_end(); - if trimmed.is_empty() { + if trimmed.is_empty() && tool_calls_buf.is_empty() { response_buf.clear(); return false; } @@ -123,7 +124,18 @@ fn capture_partial_on_abort( } else { format!("[interrupted by user ({})]", why) }; - let stashed = format!("{}\n\n{}", trimmed, trailer); + let stashed = if trimmed.is_empty() { + trailer + } else { + format!("{}\n\n{}", trimmed, trailer) + }; + // Phase 3: persist the tool-call entries too. Any entry still + // in Interrupted state at abort time stays Interrupted (the + // matching ToolResult never arrived). Completed entries keep + // their state — they ran fully before the user cancelled. + // `convert_history` will emit tool_result blocks for both + // states on resume so the LLM never sees orphan tool_use. + let calls = std::mem::take(tool_calls_buf); // Capture the message's token estimate before add_message so we // can also bump `total_tokens` in lockstep with // `total_estimated_tokens` — matches the Done / Interjected @@ -131,7 +143,7 @@ fn capture_partial_on_abort( // placeholder; kept consistent so the abort case doesn't look // like a zero-token turn). let est = crate::session::Session::estimate_tokens(&stashed); - session.add_message(crate::session::MessageRole::Assistant, &stashed); + session.add_message_with_tool_calls(crate::session::MessageRole::Assistant, &stashed, calls); session.total_tokens = session.total_tokens.saturating_add(est); response_buf.clear(); true @@ -454,6 +466,16 @@ pub async fn run_interactive( // ran but their results aren't in the preserved text. Reset // when a new agent run starts (alongside response_buf clear). let mut tool_calls_this_run: u32 = 0; + // Structured tool-call records for the current agent run. + // Populated from `AgentEvent::ToolCall` (state: Interrupted) and + // updated to `Completed{result}` on the matching `ToolResult`. + // Attached to the assistant message on `Done` / `Interjected`, + // or all remaining pending entries marked Interrupted on abort + // (Ctrl+C / Esc). Persists to the session JSON; on resume, + // `convert_history` re-emits each as a structured tool_use + + // tool_result block so the LLM doesn't re-call the same tools. + // Mirrors opencode's `ToolPart` lifecycle. + let mut tool_calls_buf: Vec = Vec::new(); // Per-turn streaming state for the plugin hooks. The batcher // collects tokens since the last `on-message-update` dispatch so // we don't round-trip into Janet for every single token; the @@ -805,6 +827,7 @@ pub async fn run_interactive( session, "Ctrl+C", tool_calls_this_run, + &mut tool_calls_buf, ); // Whether or not we stashed, the run // is over — reset the counter so a @@ -982,6 +1005,7 @@ pub async fn run_interactive( session, "Esc", tool_calls_this_run, + &mut tool_calls_buf, ); tool_calls_this_run = 0; let msg = if stashed { @@ -1552,8 +1576,19 @@ pub async fn run_interactive( renderer.render_viewport()?; agent_line_started = true; } - AgentEvent::ToolCall { name, args } => { + AgentEvent::ToolCall { id, name, args } => { was_reasoning = false; + // Phase 3: persist as structured entry. Start + // in Interrupted state so that if the user + // aborts before the result arrives, the saved + // session captures the right state. The + // matching `ToolResult` flips it to Completed. + tool_calls_buf.push(crate::session::ToolCallEntry { + id: id.to_string(), + name: name.to_string(), + args: args.clone(), + state: crate::session::ToolCallState::Interrupted, + }); // Track for the abort-trailer warning: when // the user later hits Ctrl+C / Esc, the // saved partial reply notes how many tool @@ -1601,7 +1636,24 @@ pub async fn run_interactive( // longer dispatches it here — that would double- // fire the hook per tool call. } - AgentEvent::ToolResult { output } => { + AgentEvent::ToolResult { id, output } => { + // Phase 3: pair the result with its call. + // Prefer id-match; fall back to the most- + // recent Interrupted (pending) entry for + // providers that don't emit ids. + let target = if !id.is_empty() { + tool_calls_buf.iter_mut().rev().find(|e| e.id == id.as_str()) + } else { + tool_calls_buf + .iter_mut() + .rev() + .find(|e| matches!(e.state, crate::session::ToolCallState::Interrupted)) + }; + if let Some(entry) = target { + entry.state = crate::session::ToolCallState::Completed { + result: output.to_string(), + }; + } let show_details = cfg.show_tool_details.unwrap_or(true); let max_chars = cfg.resolve_tool_result_max_chars(); let show_diff = cfg.resolve_show_edit_diff(); @@ -1777,7 +1829,15 @@ pub async fn run_interactive( renderer.write_line("", Color::White)?; renderer.write_line("", Color::White)?; - session.add_message(MessageRole::Assistant, &response); + // Phase 3: persist structured tool calls + // alongside the assistant text so the next + // resume sees the full tool_use/tool_result + // pairs in convert_history. + session.add_message_with_tool_calls( + MessageRole::Assistant, + &response, + std::mem::take(&mut tool_calls_buf), + ); // TODO(cost-tracking): `tokens` here is the heuristic // estimate (text.len()/4) and `cost` is always 0.0 — // these accumulate into placeholder fields and won't @@ -2054,11 +2114,27 @@ pub async fn run_interactive( // history. Even truncated, it lets the LLM see what // it had said when the user spoke up. if !partial_response.is_empty() { - session.add_message(MessageRole::Assistant, &partial_response); + // Phase 3: same structured persistence + // as the Done branch. Any pending entries + // (tool calls without a result yet) keep + // their Interrupted state — the LLM + // sees [Tool execution was interrupted] + // tool_result on resume. + session.add_message_with_tool_calls( + MessageRole::Assistant, + &partial_response, + std::mem::take(&mut tool_calls_buf), + ); // TODO(cost-tracking): same caveat as the Done // branch — `tokens` is an estimate, not actual // provider usage. Wire after rig usage plumbing. session.total_tokens = session.total_tokens.saturating_add(tokens); + } else { + // No partial text but maybe pending tool + // calls — drop them; the session already + // captured them via prior turns or they + // were a single-call abort with no text. + tool_calls_buf.clear(); } // Run ended (interjection-style) — reset the // per-run tool-call counter alongside the @@ -3320,12 +3396,66 @@ mod tests { // sees on the next turn what it had been saying. Mirrors // opencode's `finalizeInterruptedAssistant` in // `packages/opencode/src/session/prompt.ts`. + /// Phase 3 — abort with pending tool calls preserves them as + /// structured entries on the stashed message. Pending entries + /// stay Interrupted (no matching result arrived); on resume, + /// `convert_history` will emit a [Tool execution was + /// interrupted] tool_result so the LLM sees paired blocks. + #[test] + fn capture_partial_on_abort_preserves_pending_tool_calls_as_interrupted() { + let mut session = crate::session::Session::new("p", "m", 100_000); + let mut buf = String::from("Running bash..."); + let mut calls = vec![ + crate::session::ToolCallEntry { + id: "tc_abc".to_string(), + name: "bash".to_string(), + args: serde_json::json!({"cmd": "sleep 99"}), + state: crate::session::ToolCallState::Interrupted, + }, + crate::session::ToolCallEntry { + id: "tc_xyz".to_string(), + name: "read".to_string(), + args: serde_json::json!({"path": "/etc/hostname"}), + state: crate::session::ToolCallState::Completed { + result: "myhost".to_string(), + }, + }, + ]; + let stashed = capture_partial_on_abort(&mut buf, &mut session, "Ctrl+C", 2, &mut calls); + assert!(stashed); + assert!(calls.is_empty(), "tool_calls_buf must be drained on stash"); + + let last = session.messages.last().unwrap(); + assert_eq!(last.tool_calls.len(), 2); + let interrupted = last + .tool_calls + .iter() + .find(|e| e.id == "tc_abc") + .expect("missing interrupted entry"); + assert!(matches!( + interrupted.state, + crate::session::ToolCallState::Interrupted, + )); + let completed = last + .tool_calls + .iter() + .find(|e| e.id == "tc_xyz") + .expect("missing completed entry"); + match &completed.state { + crate::session::ToolCallState::Completed { result } => { + assert_eq!(result, "myhost"); + } + other => panic!("expected Completed; got {other:?}"), + } + } + #[test] fn capture_partial_on_abort_stashes_partial_with_trailer() { let mut session = crate::session::Session::new("openrouter", "test-model", 100_000); let baseline = session.messages.len(); let mut buf = String::from("I was about to explain that"); - let stashed = capture_partial_on_abort(&mut buf, &mut session, "Ctrl+C", 0); + let stashed = + capture_partial_on_abort(&mut buf, &mut session, "Ctrl+C", 0, &mut Vec::new()); assert!(stashed); assert_eq!(session.messages.len(), baseline + 1); let last = session.messages.last().unwrap(); @@ -3351,7 +3481,8 @@ mod tests { let mut session = crate::session::Session::new("openrouter", "test-model", 100_000); let baseline = session.messages.len(); let mut buf = String::new(); - let stashed = capture_partial_on_abort(&mut buf, &mut session, "Ctrl+C", 0); + let stashed = + capture_partial_on_abort(&mut buf, &mut session, "Ctrl+C", 0, &mut Vec::new()); assert!(!stashed); assert_eq!(session.messages.len(), baseline); } @@ -3363,7 +3494,7 @@ mod tests { let mut session = crate::session::Session::new("openrouter", "test-model", 100_000); let baseline = session.messages.len(); let mut buf = String::from(" \n\n\t "); - let stashed = capture_partial_on_abort(&mut buf, &mut session, "Esc", 0); + let stashed = capture_partial_on_abort(&mut buf, &mut session, "Esc", 0, &mut Vec::new()); assert!(!stashed); assert_eq!(session.messages.len(), baseline); } @@ -3379,7 +3510,8 @@ mod tests { fn capture_partial_on_abort_trailer_notes_tool_calls() { let mut session = crate::session::Session::new("openrouter", "test-model", 100_000); let mut buf = String::from("I deleted the file"); - let stashed = capture_partial_on_abort(&mut buf, &mut session, "Ctrl+C", 2); + let stashed = + capture_partial_on_abort(&mut buf, &mut session, "Ctrl+C", 2, &mut Vec::new()); assert!(stashed); let content = &session.messages.last().unwrap().content; assert!( @@ -3406,7 +3538,7 @@ mod tests { fn capture_partial_on_abort_trailer_handles_singular_tool_call() { let mut session = crate::session::Session::new("openrouter", "test-model", 100_000); let mut buf = String::from("Running tests now"); - capture_partial_on_abort(&mut buf, &mut session, "Esc", 1); + capture_partial_on_abort(&mut buf, &mut session, "Esc", 1, &mut Vec::new()); let content = &session.messages.last().unwrap().content; assert!( content.contains("1 tool call ran"), @@ -3556,7 +3688,7 @@ mod tests { let mut buf = String::from( "A reasonably long partial response that should produce a non-zero token estimate.", ); - capture_partial_on_abort(&mut buf, &mut session, "Ctrl+C", 0); + capture_partial_on_abort(&mut buf, &mut session, "Ctrl+C", 0, &mut Vec::new()); // Both fields advanced by the same amount (the stashed // message's estimated_tokens). Without the parity fix, only // total_estimated_tokens moved.