1use 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
25static BACKTICK_REGEX: std::sync::LazyLock<Regex> =
28 std::sync::LazyLock::new(|| Regex::new(r"(?m)^```+").expect("valid backtick regex"));
29
30static 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
35fn markdown_escape(text: &str) -> String {
42 let mut escape = "```".to_string();
43
44 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 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
63fn remove_system_reminders(text: &str) -> String {
67 SYSTEM_REMINDER_REGEX.replace_all(text, "").to_string()
68}
69
70#[derive(Debug)]
74pub struct NotificationConverter {
75 tool_use_cache: DashMap<String, ToolUseEntry>,
77 cwd: Option<std::path::PathBuf>,
79}
80
81impl Default for NotificationConverter {
82 fn default() -> Self {
83 Self::new()
84 }
85}
86
87impl NotificationConverter {
88 pub fn new() -> Self {
90 Self {
91 tool_use_cache: DashMap::new(),
92 cwd: None,
93 }
94 }
95
96 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 pub fn convert_message(&self, message: &Message, session_id: &str) -> Vec<SessionNotification> {
119 let start_time = Instant::now();
120
121 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 vec![]
139 }
140 Message::User(_) => {
141 vec![]
143 }
144 Message::ControlCancelRequest(_) => {
145 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 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 SdkContentBlock::Text(_) => {
182 }
184 SdkContentBlock::Thinking(_) => {
185 }
187 SdkContentBlock::ToolUse(tool_use) => {
188 self.cache_tool_use(tool_use);
190 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 notifications.push(self.make_image_message(session_id, image));
213 }
214 }
215 }
216
217 notifications
218 }
219
220 #[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 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 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 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 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 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 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 }
329 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 "input_json_delta" | "citations_delta" | "signature_delta" => {}
360 _ => {
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 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 Some("content_block_stop" | "message_start" | "message_delta" |
383"message_stop") => vec![],
384 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 fn convert_result_message(
399 &self,
400 _result: &ResultMessage,
401 _session_id: &SessionId,
402 ) -> Vec<SessionNotification> {
403 vec![]
406 }
407
408 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 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 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 pub fn clear_cache(&self) {
430 self.tool_use_cache.clear();
431 }
432
433 #[allow(dead_code, clippy::unused_self)]
440 fn make_agent_message(&self, session_id: &SessionId, text: &str) -> SessionNotification {
441 SessionNotification::new(
443 session_id.clone(),
444 SessionUpdate::AgentMessageChunk(ContentChunk::new(AcpContentBlock::Text(
445 TextContent::new(text),
446 ))),
447 )
448 }
449
450 #[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 #[allow(dead_code, clippy::unused_self)]
466 fn make_agent_thought(&self, session_id: &SessionId, thought: &str) -> SessionNotification {
467 SessionNotification::new(
469 session_id.clone(),
470 SessionUpdate::AgentThoughtChunk(ContentChunk::new(AcpContentBlock::Text(
471 TextContent::new(thought),
472 ))),
473 )
474 }
475
476 #[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 #[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 (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 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 #[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 let title = if tool_use.name == "Bash" {
544 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 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 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) .raw_input(tool_use.input.clone());
577
578 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 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 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 let raw_output = serde_json::json!({
645 "content": output,
646 "is_error": is_error
647 });
648
649 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 notifications
669 }
670
671 #[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 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 let status = match status_str {
693 "in_progress" => PlanEntryStatus::InProgress,
694 "completed" => PlanEntryStatus::Completed,
695 _ => PlanEntryStatus::Pending,
696 };
697
698 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 fn build_tool_result_content(
721 &self,
722 entry: &ToolUseEntry,
723 output: &str,
724 is_error: bool,
725 ) -> Vec<ToolCallContent> {
726 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 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 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 let diff = Diff::new(file_path, content);
776 vec![ToolCallContent::Diff(diff)]
777 }
778 }
779 "Read" if !is_error => {
780 let cleaned = remove_system_reminders(output);
783 let wrapped = markdown_escape(&cleaned);
784 vec![wrapped.into()]
785 }
786 _ if is_error => {
787 let wrapped = format!("```\n{}\n```", output);
790 vec![wrapped.into()]
791 }
792 _ => {
793 vec![output.to_string().into()]
795 }
796 }
797 }
798
799 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 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 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 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 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 assert!(matches!(notification.update, SessionUpdate::ToolCall(_)));
930 if let SessionUpdate::ToolCall(tool_call) = ¬ification.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 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) = ¬ification.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 let converter = NotificationConverter::new();
1020 let session_id = SessionId::new("session-1");
1021
1022 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 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 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) = ¬ification.update {
1080 assert_eq!(update.tool_call_id.0.as_ref(), "tool_789");
1081 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}