diff --git a/crates/maestro-daemon/src/projection.rs b/crates/maestro-daemon/src/projection.rs index c8415f2..be7b1cc 100644 --- a/crates/maestro-daemon/src/projection.rs +++ b/crates/maestro-daemon/src/projection.rs @@ -1,6 +1,6 @@ //! Fold persisted ACP notifications into client-neutral transcript and state. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use maestro_acp::schema::{ContentChunk, SessionNotification, SessionUpdate, ToolCall}; use maestro_protocol::{ @@ -36,6 +36,7 @@ pub struct Projection { pending_tool_updates: HashMap>, state: ReducedState, turn_active: bool, + turn_tool_calls: HashSet, } impl Projection { @@ -110,11 +111,15 @@ impl Projection { self.turn_active = true; self.state.turn.responding = true; self.state.turn.thinking = true; + self.turn_tool_calls.clear(); + self.state.turn.active_tool_calls.clear(); } TurnLifecycle::Finish | TurnLifecycle::Cancel | TurnLifecycle::Failure => { self.turn_active = false; self.state.turn.responding = false; self.state.turn.thinking = false; + self.turn_tool_calls.clear(); + self.state.turn.active_tool_calls.clear(); } } ProjectionUpdate { @@ -159,7 +164,7 @@ impl Projection { TranscriptRowKind::ToolCall }; self.tools.insert(tool_id, canonical.clone()); - self.sync_active_tools(); + self.track_tool_call(&canonical); Some(self.replace( id, if tool_has_diff(&canonical) { @@ -203,7 +208,7 @@ impl Projection { row.data = to_value(tool); row.clone() }; - self.sync_active_tools(); + self.sync_active_tool(&tool_id); Some(TranscriptDelta::Patch { row }) } @@ -238,20 +243,33 @@ impl Projection { } } - fn sync_active_tools(&mut self) { - self.state.turn.active_tool_calls = self - .tools - .iter() - .filter(|(_, tool)| { - matches!( - tool.status, - maestro_acp::schema::ToolCallStatus::Pending - | maestro_acp::schema::ToolCallStatus::InProgress - ) - }) - .map(|(id, _)| id.clone()) - .collect(); - self.state.turn.active_tool_calls.sort(); + fn track_tool_call(&mut self, tool: &ToolCall) { + if self.turn_active { + self.turn_tool_calls.insert(tool.tool_call_id.to_string()); + self.sync_active_tool(&tool.tool_call_id.to_string()); + } + } + + fn sync_active_tool(&mut self, tool_id: &str) { + if !self.turn_active || !self.turn_tool_calls.contains(tool_id) { + return; + } + let active = self.tools.get(tool_id).is_some_and(|tool| { + matches!( + tool.status, + maestro_acp::schema::ToolCallStatus::Pending + | maestro_acp::schema::ToolCallStatus::InProgress + ) + }); + let active_tool_calls = &mut self.state.turn.active_tool_calls; + if active { + if !active_tool_calls.iter().any(|id| id == tool_id) { + active_tool_calls.push(tool_id.to_owned()); + active_tool_calls.sort(); + } + } else { + active_tool_calls.retain(|id| id != tool_id); + } } } @@ -546,4 +564,45 @@ mod tests { assert!(!projection.state().turn.thinking); } } + + fn terminal_turn_drops_unfinished_tools( + terminal: TurnLifecycle, + unfinished_id: &str, + next_id: &str, + ) { + let mut projection = Projection::default(); + projection.turn_lifecycle(TurnLifecycle::Begin); + projection.apply( + 1, + &event(serde_json::json!({ + "sessionUpdate":"tool_call", "toolCallId":unfinished_id, + "title":"Unfinished", "status":"in_progress" + })), + ); + assert_eq!(projection.state().turn.active_tool_calls, [unfinished_id]); + + projection.turn_lifecycle(terminal); + assert!(projection.state().turn.active_tool_calls.is_empty()); + assert_eq!(projection.snapshot(1).rows.len(), 1); + + projection.turn_lifecycle(TurnLifecycle::Begin); + projection.apply( + 2, + &event(serde_json::json!({ + "sessionUpdate":"tool_call", "toolCallId":next_id, + "title":"Next", "status":"pending" + })), + ); + assert_eq!(projection.state().turn.active_tool_calls, [next_id]); + } + + #[test] + fn cancelled_turn_does_not_reactivate_unfinished_tools() { + terminal_turn_drops_unfinished_tools(TurnLifecycle::Cancel, "cancelled-1", "next-1"); + } + + #[test] + fn failed_turn_does_not_reactivate_unfinished_tools() { + terminal_turn_drops_unfinished_tools(TurnLifecycle::Failure, "failed-1", "next-1"); + } }