claude_code_acp/converter/
notification.rs

1//! Claude SDK Message to ACP SessionNotification conversion
2//!
3//! Converts SDK messages (assistant, system, result, stream events)
4//! into ACP session notifications for the client.
5
6use std::time::Instant;
7
8use claude_code_agent_sdk::{
9    AssistantMessage, ContentBlock as SdkContentBlock, ImageBlock, ImageSource, Message,
10    ResultMessage, StreamEvent, ToolResultBlock, ToolResultContent, ToolUseBlock,
11};
12use dashmap::DashMap;
13use regex::Regex;
14use sacp::schema::{
15    ContentBlock as AcpContentBlock, ContentChunk, Diff, ImageContent, Plan, PlanEntry,
16    PlanEntryPriority, PlanEntryStatus, SessionId, SessionNotification, SessionUpdate, Terminal,
17    TextContent, ToolCall, ToolCallContent, ToolCallId, ToolCallLocation, ToolCallStatus,
18    ToolCallUpdate, ToolCallUpdateFields, ToolKind as AcpToolKind,
19};
20
21use crate::types::{ToolKind, ToolUseEntry};
22
23use super::extract_tool_info;
24
25/// Static regex for finding backtick sequences at start of lines
26/// Used by markdown_escape to determine the appropriate escape sequence
27static BACKTICK_REGEX: std::sync::LazyLock<Regex> =
28    std::sync::LazyLock::new(|| Regex::new(r"(?m)^```+").expect("valid backtick regex"));
29
30/// Static regex for removing SYSTEM_REMINDER blocks
31/// Matches <system-reminder>...</system-reminder> including multiline content
32static SYSTEM_REMINDER_REGEX: std::sync::LazyLock<Regex> =
33    std::sync::LazyLock::new(|| Regex::new(r"(?s)<system-reminder>.*?</system-reminder>").expect("valid system-reminder regex"));
34
35/// Wrap text in markdown code block with appropriate number of backticks
36///
37/// Ensures the text is safely wrapped by using more backticks than any sequence
38/// found in the text itself.
39///
40/// Reference: vendors/claude-code-acp/src/tools.ts:591-599
41fn markdown_escape(text: &str) -> String {
42    let mut escape = "```".to_string();
43
44    // Find all sequences of backticks at the start of lines
45    for cap in BACKTICK_REGEX.captures_iter(text) {
46        let m = cap.get(0).expect("match exists").as_str();
47        while m.len() >= escape.len() {
48            escape.push('`');
49        }
50    }
51
52    // Build the final string
53    let needs_newline = !text.ends_with('\n');
54    format!(
55        "{}\n{}{}{}",
56        escape,
57        text,
58        if needs_newline { "\n" } else { "" },
59        escape
60    )
61}
62
63/// Remove SYSTEM_REMINDER tags and their content from text
64///
65/// Reference: vendors/claude-code-acp/src/tools.ts:430-431
66fn remove_system_reminders(text: &str) -> String {
67    SYSTEM_REMINDER_REGEX.replace_all(text, "").to_string()
68}
69
70/// Notification converter for transforming SDK messages to ACP notifications
71///
72/// Maintains a cache of tool uses to correlate tool_use blocks with their results.
73#[derive(Debug)]
74pub struct NotificationConverter {
75    /// Cache of tool use entries, keyed by tool_use_id
76    tool_use_cache: DashMap<String, ToolUseEntry>,
77    /// Current working directory for relative path display
78    cwd: Option<std::path::PathBuf>,
79}
80
81impl Default for NotificationConverter {
82    fn default() -> Self {
83        Self::new()
84    }
85}
86
87impl NotificationConverter {
88    /// Create a new notification converter
89    pub fn new() -> Self {
90        Self {
91            tool_use_cache: DashMap::new(),
92            cwd: None,
93        }
94    }
95
96    /// Create a new notification converter with working directory
97    ///
98    /// # Arguments
99    ///
100    /// * `cwd` - The current working directory for computing relative paths
101    pub fn with_cwd(cwd: std::path::PathBuf) -> Self {
102        Self {
103            tool_use_cache: DashMap::new(),
104            cwd: Some(cwd),
105        }
106    }
107
108    /// Convert a SDK Message to ACP session update notifications
109    ///
110    /// # Arguments
111    ///
112    /// * `message` - The SDK message to convert
113    /// * `session_id` - The session ID for the notifications
114    ///
115    /// # Returns
116    ///
117    /// A vector of ACP SessionNotification objects
118    pub fn convert_message(&self, message: &Message, session_id: &str) -> Vec<SessionNotification> {
119        let start_time = Instant::now();
120
121        // Determine message type for logging
122        let message_type = match message {
123            Message::Assistant(_) => "Assistant",
124            Message::StreamEvent(_) => "StreamEvent",
125            Message::Result(_) => "Result",
126            Message::System(_) => "System",
127            Message::User(_) => "User",
128            Message::ControlCancelRequest(_) => "ControlCancelRequest",
129        };
130
131        let sid = SessionId::new(session_id.to_string());
132        let notifications = match message {
133            Message::Assistant(assistant) => self.convert_assistant_message(assistant, &sid),
134            Message::StreamEvent(event) => self.convert_stream_event(event, &sid),
135            Message::Result(result) => self.convert_result_message(result, &sid),
136            Message::System(_) => {
137                // System messages are typically internal, not sent as notifications
138                vec![]
139            }
140            Message::User(_) => {
141                // User messages are echoed back, usually not needed
142                vec![]
143            }
144            Message::ControlCancelRequest(_) => {
145                // Internal control messages
146                vec![]
147            }
148        };
149
150        let elapsed = start_time.elapsed();
151        let output_count = notifications.len();
152
153        tracing::trace!(
154            message_type = %message_type,
155            session_id = %session_id,
156            output_count = output_count,
157            conversion_duration_us = elapsed.as_micros(),
158            "Message conversion completed"
159        );
160
161        notifications
162    }
163
164    /// Convert an assistant message
165    ///
166    /// Note: In streaming mode, Text and Thinking blocks are delivered via
167    /// content_block_delta events (StreamEvent), so we skip them here to avoid
168    /// sending the same content twice. Only ToolUse and ToolResult blocks are
169    /// processed from non-streamed messages.
170    fn convert_assistant_message(
171        &self,
172        assistant: &AssistantMessage,
173        session_id: &SessionId,
174    ) -> Vec<SessionNotification> {
175        let mut notifications = Vec::new();
176
177        for block in &assistant.message.content {
178            match block {
179                // Skip Text and Thinking blocks in streaming mode
180                // They are delivered via StreamEvent::content_block_delta
181                SdkContentBlock::Text(_) => {
182                    // Skip - handled by stream events
183                }
184                SdkContentBlock::Thinking(_) => {
185                    // Skip - handled by stream events
186                }
187                SdkContentBlock::ToolUse(tool_use) => {
188                    // Cache the tool use for later correlation with result
189                    self.cache_tool_use(tool_use);
190                    // Special handling for TodoWrite: send Plan instead of ToolCall
191                    // Reference: vendors/claude-code-acp/src/acp-agent.ts lines 1051-1058
192                    let effective_name = tool_use
193                        .name
194                        .strip_prefix("mcp__acp__")
195                        .unwrap_or(&tool_use.name);
196                    if effective_name == "TodoWrite" {
197                        if let Some(notification) =
198                            self.make_plan_from_todo_write(session_id, tool_use)
199                        {
200                            notifications.push(notification);
201                            continue;
202                        }
203                    }
204                    notifications.push(self.make_tool_call(session_id, tool_use));
205                }
206                SdkContentBlock::ToolResult(tool_result) => {
207                    notifications.extend(self.make_tool_result(session_id, tool_result));
208                }
209                SdkContentBlock::Image(image) => {
210                    // Convert SDK Image to ACP Image notification
211                    // Reference: vendors/claude-code-acp/src/acp-agent.ts lines 1027-1035
212                    notifications.push(self.make_image_message(session_id, image));
213                }
214            }
215        }
216
217        notifications
218    }
219
220    /// Convert a stream event (incremental updates)
221    #[allow(clippy::unused_self)]
222    fn convert_stream_event(
223        &self,
224        event: &StreamEvent,
225        session_id: &SessionId,
226    ) -> Vec<SessionNotification> {
227        let event_type = event.event.get("type").and_then(|v| v.as_str());
228
229        match event_type {
230            Some("content_block_start") => {
231                // Handle content_block_start - important for tool calls and results
232                // When streaming is enabled, tool_use and tool_result blocks arrive via content_block_start
233                if let Some(content_block) = event.event.get("content_block") {
234                    if let Some(block_type) = content_block.get("type").and_then(|v| v.as_str()) {
235                        // Handle tool_use types
236                        // Reference: vendors/claude-code-acp/src/acp-agent.ts lines 1047-1049
237                        if matches!(
238                            block_type,
239                            "tool_use" | "server_tool_use" | "mcp_tool_use"
240                        ) {
241                            match serde_json::from_value::<ToolUseBlock>(content_block.clone()) {
242                                Ok(tool_use) => {
243                                    self.cache_tool_use(&tool_use);
244                                    // Special handling for TodoWrite: send Plan instead of ToolCall
245                                    // Reference: vendors/claude-code-acp/src/acp-agent.ts lines 1051-1058
246                                    let effective_name = tool_use
247                                        .name
248                                        .strip_prefix("mcp__acp__")
249                                        .unwrap_or(&tool_use.name);
250                                    if effective_name == "TodoWrite" {
251                                        if let Some(notification) =
252                                            self.make_plan_from_todo_write(session_id, &tool_use)
253                                        {
254                                            return vec![notification];
255                                        }
256                                    }
257                                    return vec![self.make_tool_call(session_id, &tool_use)];
258                                }
259                                Err(e) => {
260                                    tracing::error!(
261                                        session_id = %session_id.0,
262                                        block_type = %block_type,
263                                        error = %e,
264                                        "Failed to parse tool_use block"
265                                    );
266                                }
267                            }
268                        }
269                        // Handle tool_result types
270                        // Reference: vendors/claude-code-acp/src/acp-agent.ts lines 1109-1116
271                        else if matches!(
272                            block_type,
273                            "tool_result"
274                                | "mcp_tool_result"
275                                | "tool_search_tool_result"
276                                | "web_fetch_tool_result"
277                                | "web_search_tool_result"
278                                | "code_execution_tool_result"
279                                | "bash_code_execution_tool_result"
280                                | "text_editor_code_execution_tool_result"
281                        ) {
282                            match serde_json::from_value::<ToolResultBlock>(content_block.clone()) {
283                                Ok(tool_result) => {
284                                    return self.make_tool_result(session_id, &tool_result);
285                                }
286                                Err(e) => {
287                                    tracing::error!(
288                                        session_id = %session_id.0,
289                                        block_type = %block_type,
290                                        error = %e,
291                                        "Failed to parse tool_result block"
292                                    );
293                                }
294                            }
295                        }
296                        // Handle image type
297                        // Reference: vendors/claude-code-acp/src/acp-agent.ts lines 1027-1035
298                        else if block_type == "image" {
299                            match serde_json::from_value::<ImageBlock>(content_block.clone()) {
300                                Ok(image) => {
301                                    return vec![self.make_image_message(session_id, &image)];
302                                }
303                                Err(e) => {
304                                    tracing::error!(
305                                        session_id = %session_id.0,
306                                        block_type = %block_type,
307                                        error = %e,
308                                        "Failed to parse image block"
309                                    );
310                                }
311                            }
312                        }
313                        // Skip known non-notification types
314                        // Reference: vendors/claude-code-acp/src/acp-agent.ts lines 1141-1148
315                        else if matches!(
316                            block_type,
317                            "text"
318                                | "thinking"
319                                | "document"
320                                | "search_result"
321                                | "redacted_thinking"
322                                | "input_json_delta"
323                                | "citations_delta"
324                                | "signature_delta"
325                                | "container_upload"
326                        ) {
327                            // These are handled elsewhere or not needed as notifications
328                        }
329                        // Log unknown block types (like TS's unreachable)
330                        else {
331                            tracing::warn!(
332                                session_id = %session_id.0,
333                                block_type = %block_type,
334                                content_block = ?content_block,
335                                "Unknown content_block type in content_block_start"
336                            );
337                        }
338                    }
339                }
340                vec![]
341            }
342            Some("content_block_delta") => {
343                if let Some(delta) = event.event.get("delta") {
344                    if let Some(delta_type) = delta.get("type").and_then(|v| v.as_str()) {
345                        match delta_type {
346                            "text_delta" => {
347                                if let Some(text) = delta.get("text").and_then(|v| v.as_str()) {
348                                    return vec![self.make_agent_message_chunk(session_id, text)];
349                                }
350                            }
351                            "thinking_delta" => {
352                                if let Some(thinking) =
353                                    delta.get("thinking").and_then(|v| v.as_str())
354                                {
355                                    return vec![self.make_agent_thought_chunk(session_id, thinking)];
356                                }
357                            }
358                            // Skip known delta types that don't need notifications
359                            "input_json_delta" | "citations_delta" | "signature_delta" => {}
360                            // Log unknown delta types
361                            _ => {
362                                tracing::debug!(
363                                    session_id = %session_id.0,
364                                    delta_type = %delta_type,
365                                    "Unknown delta type in content_block_delta"
366                                );
367                            }
368                        }
369                    } else {
370                        // Fallback for delta without explicit type field
371                        if let Some(text) = delta.get("text").and_then(|v| v.as_str()) {
372                            return vec![self.make_agent_message_chunk(session_id, text)];
373                        }
374                        if let Some(thinking) = delta.get("thinking").and_then(|v| v.as_str()) {
375                            return vec![self.make_agent_thought_chunk(session_id, thinking)];
376                        }
377                    }
378                }
379                vec![]
380            }
381            // No content needed for these events
382            Some("content_block_stop" | "message_start" | "message_delta" |
383"message_stop") => vec![],
384            // Log unknown event types (like TS's unreachable)
385            Some(unknown_type) => {
386                tracing::warn!(
387                    session_id = %session_id.0,
388                    event_type = %unknown_type,
389                    "Unknown stream event type"
390                );
391                vec![]
392            }
393            None => vec![],
394        }
395    }
396
397    /// Convert a result message
398    fn convert_result_message(
399        &self,
400        _result: &ResultMessage,
401        _session_id: &SessionId,
402    ) -> Vec<SessionNotification> {
403        // Result messages update usage statistics but don't typically
404        // generate notifications (the prompt response handles completion)
405        vec![]
406    }
407
408    /// Cache a tool use entry
409    fn cache_tool_use(&self, tool_use: &ToolUseBlock) {
410        let entry = ToolUseEntry::new(
411            tool_use.id.clone(),
412            tool_use.name.clone(),
413            tool_use.input.clone(),
414        );
415        self.tool_use_cache.insert(tool_use.id.clone(), entry);
416    }
417
418    /// Get a cached tool use entry
419    pub fn get_tool_use(&self, tool_use_id: &str) -> Option<ToolUseEntry> {
420        self.tool_use_cache.get(tool_use_id).map(|r| r.clone())
421    }
422
423    /// Remove a cached tool use entry
424    pub fn remove_tool_use(&self, tool_use_id: &str) -> Option<ToolUseEntry> {
425        self.tool_use_cache.remove(tool_use_id).map(|(_, v)| v)
426    }
427
428    /// Clear all cached tool uses
429    pub fn clear_cache(&self) {
430        self.tool_use_cache.clear();
431    }
432
433    // === Notification builders ===
434
435    /// Make an agent message notification (full text as chunk)
436    ///
437    /// Currently unused because Text blocks are skipped in convert_assistant_message
438    /// to avoid duplication with stream events.
439    #[allow(dead_code, clippy::unused_self)]
440    fn make_agent_message(&self, session_id: &SessionId, text: &str) -> SessionNotification {
441        // Use AgentMessageChunk since there's no AgentMessage variant
442        SessionNotification::new(
443            session_id.clone(),
444            SessionUpdate::AgentMessageChunk(ContentChunk::new(AcpContentBlock::Text(
445                TextContent::new(text),
446            ))),
447        )
448    }
449
450    /// Make an agent message chunk notification (incremental)
451    #[allow(clippy::unused_self)]
452    fn make_agent_message_chunk(&self, session_id: &SessionId, chunk: &str) -> SessionNotification {
453        SessionNotification::new(
454            session_id.clone(),
455            SessionUpdate::AgentMessageChunk(ContentChunk::new(AcpContentBlock::Text(
456                TextContent::new(chunk),
457            ))),
458        )
459    }
460
461    /// Make an agent thought notification (full thought as chunk)
462    ///
463    /// Currently unused because Thinking blocks are skipped in convert_assistant_message
464    /// to avoid duplication with stream events.
465    #[allow(dead_code, clippy::unused_self)]
466    fn make_agent_thought(&self, session_id: &SessionId, thought: &str) -> SessionNotification {
467        // Use AgentThoughtChunk since there's no separate thought variant
468        SessionNotification::new(
469            session_id.clone(),
470            SessionUpdate::AgentThoughtChunk(ContentChunk::new(AcpContentBlock::Text(
471                TextContent::new(thought),
472            ))),
473        )
474    }
475
476    /// Make an agent thought chunk notification (incremental)
477    #[allow(clippy::unused_self)]
478    fn make_agent_thought_chunk(&self, session_id: &SessionId, chunk: &str) -> SessionNotification {
479        SessionNotification::new(
480            session_id.clone(),
481            SessionUpdate::AgentThoughtChunk(ContentChunk::new(AcpContentBlock::Text(
482                TextContent::new(chunk),
483            ))),
484        )
485    }
486
487    /// Make an image message notification
488    ///
489    /// Converts SDK ImageBlock to ACP ImageContent and wraps in AgentMessageChunk.
490    /// Reference: vendors/claude-code-acp/src/acp-agent.ts lines 1027-1035
491    #[allow(clippy::unused_self)]
492    fn make_image_message(
493        &self,
494        session_id: &SessionId,
495        image: &ImageBlock,
496    ) -> SessionNotification {
497        let (data, mime_type, uri) = match &image.source {
498            ImageSource::Base64 { media_type, data } => {
499                (data.clone(), media_type.clone(), None)
500            }
501            ImageSource::Url { url } => {
502                // For URL-based images, data is empty and uri is set
503                (String::new(), String::new(), Some(url.clone()))
504            }
505        };
506
507        let image_content = ImageContent::new(data, mime_type).uri(uri);
508
509        SessionNotification::new(
510            session_id.clone(),
511            SessionUpdate::AgentMessageChunk(ContentChunk::new(AcpContentBlock::Image(
512                image_content,
513            ))),
514        )
515    }
516
517    /// Map local ToolKind to ACP ToolKind
518    fn map_tool_kind(kind: ToolKind) -> AcpToolKind {
519        match kind {
520            ToolKind::Read => AcpToolKind::Read,
521            ToolKind::Edit => AcpToolKind::Edit,
522            ToolKind::Execute => AcpToolKind::Execute,
523            ToolKind::Search => AcpToolKind::Search,
524            ToolKind::Think => AcpToolKind::Think,
525            ToolKind::Fetch => AcpToolKind::Fetch,
526            ToolKind::SwitchMode | ToolKind::Other => AcpToolKind::default(),
527        }
528    }
529
530    /// Make a tool call notification
531    #[allow(clippy::unused_self)]
532    fn make_tool_call(
533        &self,
534        session_id: &SessionId,
535        tool_use: &ToolUseBlock,
536    ) -> SessionNotification {
537        let tool_info = extract_tool_info(&tool_use.name, &tool_use.input, self.cwd.as_ref());
538
539        let tool_call_id = ToolCallId::new(tool_use.id.clone());
540        let tool_kind = Self::map_tool_kind(tool_info.kind);
541
542        // For Bash tool, include command in title if description is not available
543        let title = if tool_use.name == "Bash" {
544            // Get description or command
545            let description = tool_use.input.get("description").and_then(|v| v.as_str());
546            let command = tool_use.input.get("command").and_then(|v| v.as_str());
547
548            match (description, command) {
549                (Some(desc), _) => desc.to_string(),
550                (None, Some(cmd)) => {
551                    // Truncate long commands for display
552                    if cmd.len() > 80 {
553                        format!("{}...", &cmd[..77])
554                    } else {
555                        cmd.to_string()
556                    }
557                }
558                _ => tool_info.title.clone(),
559            }
560        } else {
561            tool_info.title.clone()
562        };
563
564        // Debug: Log tool call creation
565        tracing::debug!(
566            tool_call_id = %tool_use.id,
567            tool_name = %tool_use.name,
568            title = %title,
569            session_id = %session_id.0,
570            "Creating ToolCall notification for session/update"
571        );
572
573        let mut tool_call = ToolCall::new(tool_call_id, &title)
574            .kind(tool_kind)
575            .status(ToolCallStatus::InProgress) // Tool is being executed
576            .raw_input(tool_use.input.clone());
577
578        // Add locations if present
579        if let Some(ref locations) = tool_info.locations
580            && !locations.is_empty()
581        {
582            let acp_locations: Vec<ToolCallLocation> = locations
583                .iter()
584                .map(|loc| {
585                    let mut location = ToolCallLocation::new(&loc.path);
586                    if let Some(line) = loc.line {
587                        location = location.line(line);
588                    }
589                    location
590                })
591                .collect();
592            tool_call = tool_call.locations(acp_locations);
593        }
594
595        SessionNotification::new(session_id.clone(), SessionUpdate::ToolCall(tool_call))
596    }
597
598    /// Make tool result notifications
599    ///
600    /// Returns a vector of notifications:
601    /// - ToolCallUpdate for all tools
602    /// - Plan notification for TodoWrite tool (when successful)
603    fn make_tool_result(
604        &self,
605        session_id: &SessionId,
606        tool_result: &ToolResultBlock,
607    ) -> Vec<SessionNotification> {
608        let Some(entry) = self.get_tool_use(&tool_result.tool_use_id) else {
609            // Tool call not found in cache - this can happen if:
610            // 1. Tool was never cached (bug in streaming handling)
611            // 2. Tool was already processed and removed
612            // 3. Duplicate tool result received
613            tracing::warn!(
614                session_id = %session_id.0,
615                tool_use_id = %tool_result.tool_use_id,
616                "Tool call not found in cache, skipping tool result notification"
617            );
618            return vec![];
619        };
620
621        tracing::debug!(
622            session_id = %session_id.0,
623            tool_use_id = %tool_result.tool_use_id,
624            tool_name = %entry.name,
625            "Processing tool result notification"
626        );
627
628        let output = match &tool_result.content {
629            Some(ToolResultContent::Text(text)) => text.clone(),
630            Some(ToolResultContent::Blocks(blocks)) => {
631                serde_json::to_string(blocks).unwrap_or_default()
632            }
633            None => String::new(),
634        };
635
636        let is_error = tool_result.is_error.unwrap_or(false);
637        let status = if is_error {
638            ToolCallStatus::Failed
639        } else {
640            ToolCallStatus::Completed
641        };
642
643        // Build raw_output JSON
644        let raw_output = serde_json::json!({
645            "content": output,
646            "is_error": is_error
647        });
648
649        // Build content based on tool type
650        let content = self.build_tool_result_content(&entry, &output, is_error);
651
652        let tool_call_id = ToolCallId::new(tool_result.tool_use_id.clone());
653        let update_fields = ToolCallUpdateFields::new()
654            .status(status)
655            .content(content)
656            .raw_output(raw_output);
657        let update = ToolCallUpdate::new(tool_call_id, update_fields);
658
659        let notifications = vec![SessionNotification::new(
660            session_id.clone(),
661            SessionUpdate::ToolCallUpdate(update),
662        )];
663
664        // Note: Plan notification for TodoWrite is now sent at tool_use time
665        // (in make_plan_from_todo_write), so we don't send it here anymore.
666        // This matches TypeScript behavior: acp-agent.ts lines 1051-1058
667
668        notifications
669    }
670
671    /// Make a Plan notification from TodoWrite tool_use
672    ///
673    /// This is called when we receive a TodoWrite tool_use, to send the Plan
674    /// immediately (instead of waiting for tool_result).
675    /// Reference: vendors/claude-code-acp/src/acp-agent.ts lines 1051-1058
676    #[allow(clippy::unused_self)]
677    fn make_plan_from_todo_write(
678        &self,
679        session_id: &SessionId,
680        tool_use: &ToolUseBlock,
681    ) -> Option<SessionNotification> {
682        // Extract todos from input
683        let todos = tool_use.input.get("todos")?.as_array()?;
684
685        let plan_entries: Vec<PlanEntry> = todos
686            .iter()
687            .filter_map(|todo| {
688                let content = todo.get("content")?.as_str()?;
689                let status_str = todo.get("status")?.as_str()?;
690
691                // Convert TodoWrite status to PlanEntryStatus
692                let status = match status_str {
693                    "in_progress" => PlanEntryStatus::InProgress,
694                    "completed" => PlanEntryStatus::Completed,
695                    _ => PlanEntryStatus::Pending,
696                };
697
698                // TodoWrite doesn't have priority, default to Medium
699                Some(PlanEntry::new(content, PlanEntryPriority::Medium, status))
700            })
701            .collect();
702
703        if plan_entries.is_empty() {
704            return None;
705        }
706
707        let plan = Plan::new(plan_entries);
708        Some(SessionNotification::new(
709            session_id.clone(),
710            SessionUpdate::Plan(plan),
711        ))
712    }
713
714    /// Build tool result content based on tool type
715    ///
716    /// For Edit/Write tools, returns Diff content.
717    /// For Read tool, removes SYSTEM_REMINDER and wraps with markdown.
718    /// For errors, wraps with markdown code block.
719    /// Reference: vendors/claude-code-acp/src/tools.ts toolUpdateFromToolResult
720    fn build_tool_result_content(
721        &self,
722        entry: &ToolUseEntry,
723        output: &str,
724        is_error: bool,
725    ) -> Vec<ToolCallContent> {
726        // Strip mcp__acp__ prefix for matching
727        let effective_name = entry
728            .name
729            .strip_prefix("mcp__acp__")
730            .unwrap_or(&entry.name);
731
732        match effective_name {
733            "Edit" if !is_error => {
734                // Extract file_path, old_string, new_string from input
735                let file_path = entry
736                    .input
737                    .get("file_path")
738                    .and_then(|v| v.as_str())
739                    .unwrap_or("");
740                let old_string = entry
741                    .input
742                    .get("old_string")
743                    .and_then(|v| v.as_str())
744                    .map(String::from);
745                let new_string = entry
746                    .input
747                    .get("new_string")
748                    .and_then(|v| v.as_str())
749                    .unwrap_or("");
750
751                if !file_path.is_empty() && !new_string.is_empty() {
752                    let diff = Diff::new(file_path, new_string).old_text(old_string);
753                    vec![ToolCallContent::Diff(diff)]
754                } else {
755                    vec![output.to_string().into()]
756                }
757            }
758            "Write" if !is_error => {
759                // Extract file_path and content from input
760                let file_path = entry
761                    .input
762                    .get("file_path")
763                    .and_then(|v| v.as_str())
764                    .unwrap_or("");
765                let content = entry
766                    .input
767                    .get("content")
768                    .and_then(|v| v.as_str())
769                    .unwrap_or("");
770
771                if file_path.is_empty() {
772                    vec![output.to_string().into()]
773                } else {
774                    // For Write, old_text is None (new file) or we don't have it
775                    let diff = Diff::new(file_path, content);
776                    vec![ToolCallContent::Diff(diff)]
777                }
778            }
779            "Read" if !is_error => {
780                // Remove SYSTEM_REMINDER and wrap with markdown
781                // Reference: vendors/claude-code-acp/src/tools.ts:430-431
782                let cleaned = remove_system_reminders(output);
783                let wrapped = markdown_escape(&cleaned);
784                vec![wrapped.into()]
785            }
786            _ if is_error => {
787                // Wrap errors with markdown code block
788                // Reference: vendors/claude-code-acp/src/tools.ts:553-556
789                let wrapped = format!("```\n{}\n```", output);
790                vec![wrapped.into()]
791            }
792            _ => {
793                // Default: text content
794                vec![output.to_string().into()]
795            }
796        }
797    }
798
799    /// Build Terminal content for embedding a terminal in tool result
800    ///
801    /// This is used when a tool (like Bash) uses the Terminal API to execute commands.
802    /// The terminal_id is obtained from the `terminal/create` response.
803    ///
804    /// # Arguments
805    ///
806    /// * `terminal_id` - The terminal ID from CreateTerminalResponse
807    ///
808    /// # Returns
809    ///
810    /// A `ToolCallContent::Terminal` that can be included in tool results
811    pub fn build_terminal_content(terminal_id: impl Into<String>) -> ToolCallContent {
812        let terminal = Terminal::new(terminal_id.into());
813        ToolCallContent::Terminal(terminal)
814    }
815
816    /// Build a ToolCallUpdate notification with Terminal content
817    ///
818    /// This is used when a Bash command is executed via the Terminal API.
819    /// The client will embed the terminal output based on the terminal_id.
820    ///
821    /// # Arguments
822    ///
823    /// * `session_id` - The session ID
824    /// * `tool_use_id` - The tool use ID for the Bash call
825    /// * `terminal_id` - The terminal ID from terminal/create
826    /// * `status` - The tool call status
827    ///
828    /// # Returns
829    ///
830    /// A SessionNotification with the ToolCallUpdate
831    pub fn make_terminal_result(
832        &self,
833        session_id: &SessionId,
834        tool_use_id: &str,
835        terminal_id: impl Into<String>,
836        status: ToolCallStatus,
837    ) -> SessionNotification {
838        let terminal_content = Self::build_terminal_content(terminal_id);
839        let tool_call_id = ToolCallId::new(tool_use_id.to_string());
840        let update_fields = ToolCallUpdateFields::new()
841            .status(status)
842            .content(vec![terminal_content]);
843        let update = ToolCallUpdate::new(tool_call_id, update_fields);
844
845        SessionNotification::new(session_id.clone(), SessionUpdate::ToolCallUpdate(update))
846    }
847}
848
849#[cfg(test)]
850mod tests {
851    use super::*;
852    use serde_json::json;
853
854    #[test]
855    fn test_converter_new() {
856        let converter = NotificationConverter::new();
857        assert!(converter.tool_use_cache.is_empty());
858    }
859
860    #[test]
861    fn test_cache_tool_use() {
862        let converter = NotificationConverter::new();
863        let tool_use = ToolUseBlock {
864            id: "tool_123".to_string(),
865            name: "Read".to_string(),
866            input: json!({"file_path": "/test.txt"}),
867        };
868
869        converter.cache_tool_use(&tool_use);
870
871        let cached = converter.get_tool_use("tool_123");
872        assert!(cached.is_some());
873        assert_eq!(cached.unwrap().name, "Read");
874    }
875
876    #[test]
877    fn test_make_agent_message() {
878        let converter = NotificationConverter::new();
879        let session_id = SessionId::new("session-1");
880        let notification = converter.make_agent_message(&session_id, "Hello!");
881
882        assert_eq!(notification.session_id.0.as_ref(), "session-1");
883        // Check that it's an AgentMessageChunk update
884        assert!(matches!(
885            notification.update,
886            SessionUpdate::AgentMessageChunk(_)
887        ));
888    }
889
890    #[test]
891    fn test_make_agent_message_chunk() {
892        let converter = NotificationConverter::new();
893        let session_id = SessionId::new("session-1");
894        let notification = converter.make_agent_message_chunk(&session_id, "chunk");
895
896        // Check that it's an AgentMessageChunk update
897        assert!(matches!(
898            notification.update,
899            SessionUpdate::AgentMessageChunk(_)
900        ));
901    }
902
903    #[test]
904    fn test_make_agent_thought() {
905        let converter = NotificationConverter::new();
906        let session_id = SessionId::new("session-1");
907        let notification = converter.make_agent_thought(&session_id, "thinking...");
908
909        // Check that it's an AgentThoughtChunk update
910        assert!(matches!(
911            notification.update,
912            SessionUpdate::AgentThoughtChunk(_)
913        ));
914    }
915
916    #[test]
917    fn test_make_tool_call() {
918        let converter = NotificationConverter::new();
919        let session_id = SessionId::new("session-1");
920        let tool_use = ToolUseBlock {
921            id: "tool_456".to_string(),
922            name: "Bash".to_string(),
923            input: json!({"command": "ls", "description": "List files"}),
924        };
925
926        let notification = converter.make_tool_call(&session_id, &tool_use);
927
928        // Check that it's a ToolCall update
929        assert!(matches!(notification.update, SessionUpdate::ToolCall(_)));
930        if let SessionUpdate::ToolCall(tool_call) = &notification.update {
931            assert_eq!(tool_call.tool_call_id.0.as_ref(), "tool_456");
932        }
933    }
934
935    #[test]
936    fn test_remove_tool_use() {
937        let converter = NotificationConverter::new();
938        let tool_use = ToolUseBlock {
939            id: "tool_789".to_string(),
940            name: "Edit".to_string(),
941            input: json!({}),
942        };
943
944        converter.cache_tool_use(&tool_use);
945        assert!(converter.get_tool_use("tool_789").is_some());
946
947        let removed = converter.remove_tool_use("tool_789");
948        assert!(removed.is_some());
949        assert!(converter.get_tool_use("tool_789").is_none());
950    }
951
952    #[test]
953    fn test_map_tool_kind() {
954        assert!(matches!(
955            NotificationConverter::map_tool_kind(ToolKind::Read),
956            AcpToolKind::Read
957        ));
958        assert!(matches!(
959            NotificationConverter::map_tool_kind(ToolKind::Edit),
960            AcpToolKind::Edit
961        ));
962        assert!(matches!(
963            NotificationConverter::map_tool_kind(ToolKind::Execute),
964            AcpToolKind::Execute
965        ));
966    }
967
968    #[test]
969    fn test_make_plan_from_todo_write() {
970        let converter = NotificationConverter::new();
971        let session_id = SessionId::new("session-1");
972
973        // Create a TodoWrite tool use
974        let tool_use = ToolUseBlock {
975            id: "todo_123".to_string(),
976            name: "TodoWrite".to_string(),
977            input: json!({
978                "todos": [
979                    {
980                        "content": "Implement feature",
981                        "status": "in_progress",
982                        "activeForm": "Implementing feature"
983                    },
984                    {
985                        "content": "Write tests",
986                        "status": "pending",
987                        "activeForm": "Writing tests"
988                    },
989                    {
990                        "content": "Setup project",
991                        "status": "completed",
992                        "activeForm": "Setting up project"
993                    }
994                ]
995            }),
996        };
997
998        let notification = converter.make_plan_from_todo_write(&session_id, &tool_use);
999
1000        assert!(notification.is_some());
1001        let notification = notification.unwrap();
1002
1003        if let SessionUpdate::Plan(plan) = &notification.update {
1004            assert_eq!(plan.entries.len(), 3);
1005            assert_eq!(plan.entries[0].content, "Implement feature");
1006            assert_eq!(plan.entries[0].status, PlanEntryStatus::InProgress);
1007            assert_eq!(plan.entries[1].content, "Write tests");
1008            assert_eq!(plan.entries[1].status, PlanEntryStatus::Pending);
1009            assert_eq!(plan.entries[2].content, "Setup project");
1010            assert_eq!(plan.entries[2].status, PlanEntryStatus::Completed);
1011        } else {
1012            panic!("Expected Plan update");
1013        }
1014    }
1015
1016    #[test]
1017    fn test_make_tool_result_todowrite_no_duplicate_plan() {
1018        // Since Plan is now sent at tool_use time, tool_result should NOT include Plan
1019        let converter = NotificationConverter::new();
1020        let session_id = SessionId::new("session-1");
1021
1022        // Cache a TodoWrite tool use
1023        let tool_use = ToolUseBlock {
1024            id: "todo_456".to_string(),
1025            name: "TodoWrite".to_string(),
1026            input: json!({
1027                "todos": [
1028                    {
1029                        "content": "Task 1",
1030                        "status": "pending",
1031                        "activeForm": "Doing task 1"
1032                    }
1033                ]
1034            }),
1035        };
1036        converter.cache_tool_use(&tool_use);
1037
1038        // Create a tool result
1039        let tool_result = ToolResultBlock {
1040            tool_use_id: "todo_456".to_string(),
1041            content: Some(ToolResultContent::Text("Todos updated".to_string())),
1042            is_error: Some(false),
1043        };
1044
1045        let notifications = converter.make_tool_result(&session_id, &tool_result);
1046
1047        // Should have only 1 notification: ToolCallUpdate (no Plan, since Plan is sent at tool_use time)
1048        assert_eq!(notifications.len(), 1);
1049        assert!(matches!(
1050            notifications[0].update,
1051            SessionUpdate::ToolCallUpdate(_)
1052        ));
1053    }
1054
1055    #[test]
1056    fn test_build_terminal_content() {
1057        let content = NotificationConverter::build_terminal_content("term-123");
1058        match content {
1059            ToolCallContent::Terminal(terminal) => {
1060                assert_eq!(terminal.terminal_id.0.as_ref(), "term-123");
1061            }
1062            _ => panic!("Expected Terminal content"),
1063        }
1064    }
1065
1066    #[test]
1067    fn test_make_terminal_result() {
1068        let converter = NotificationConverter::new();
1069        let session_id = SessionId::new("session-1");
1070
1071        let notification = converter.make_terminal_result(
1072            &session_id,
1073            "tool_789",
1074            "term-456",
1075            ToolCallStatus::Completed,
1076        );
1077
1078        assert_eq!(notification.session_id.0.as_ref(), "session-1");
1079        if let SessionUpdate::ToolCallUpdate(update) = &notification.update {
1080            assert_eq!(update.tool_call_id.0.as_ref(), "tool_789");
1081            // Check content contains Terminal
1082            let fields = &update.fields;
1083            let content = fields.content.as_ref().expect("content should exist");
1084            assert_eq!(content.len(), 1);
1085            match &content[0] {
1086                ToolCallContent::Terminal(terminal) => {
1087                    assert_eq!(terminal.terminal_id.0.as_ref(), "term-456");
1088                }
1089                _ => panic!("Expected Terminal content"),
1090            }
1091        } else {
1092            panic!("Expected ToolCallUpdate");
1093        }
1094    }
1095}