1use std::collections::HashMap;
58
59use serde_json::{Map, Value};
60
61use super::super::failure::first_cli_error;
62use super::super::types::{AgentEvent, AgentTurn, CompactionTrigger, TokenCounts};
63use super::Translator;
64
65#[derive(Debug, Default)]
66pub struct ClaudeTranslator {
67 pending_tool: Option<PendingToolUse>,
70 #[allow(dead_code)]
76 tool_names: HashMap<String, String>,
77 accumulated_response: String,
81 transcript: Vec<AgentTurn>,
83}
84
85#[derive(Debug)]
86struct PendingToolUse {
87 id: String,
88 name: String,
89 partial_json: String,
90}
91
92impl ClaudeTranslator {
93 pub fn new() -> Self {
94 Self::default()
95 }
96}
97
98impl Translator for ClaudeTranslator {
99 fn translate(&mut self, frame: Value) -> Vec<AgentEvent> {
100 let mut out = Vec::new();
101 let Some(frame_type) = frame.get("type").and_then(|v| v.as_str()) else {
102 return out;
103 };
104 match frame_type {
105 "stream_event" => handle_stream_event(self, &frame, &mut out),
106 "user" => handle_user_message(self, &frame, &mut out),
107 "assistant" => handle_assistant_message(self, &frame, &mut out),
108 "result" => handle_result(self, &frame, &mut out),
109 "system" => handle_system_message(self, &frame, &mut out),
110 _ => {}
111 }
112 out
113 }
114}
115
116fn handle_stream_event(t: &mut ClaudeTranslator, frame: &Value, out: &mut Vec<AgentEvent>) {
117 let Some(event) = frame.get("event") else {
118 return;
119 };
120 let Some(ev_type) = event.get("type").and_then(|v| v.as_str()) else {
121 return;
122 };
123 match ev_type {
124 "content_block_start" => {
125 let Some(block) = event.get("content_block") else {
126 return;
127 };
128 if block.get("type").and_then(|v| v.as_str()) == Some("tool_use") {
129 let id = block.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string();
130 let name = block.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string();
131 t.tool_names.insert(id.clone(), name.clone());
132 t.pending_tool = Some(PendingToolUse {
133 id,
134 name,
135 partial_json: String::new(),
136 });
137 }
138 }
139 "content_block_delta" => {
140 let Some(delta) = event.get("delta") else {
141 return;
142 };
143 match delta.get("type").and_then(|v| v.as_str()) {
144 Some("text_delta") => {
145 if let Some(text) = delta.get("text").and_then(|v| v.as_str()) {
146 t.accumulated_response.push_str(text);
147 out.push(AgentEvent::AssistantText {
148 delta: text.to_string(),
149 });
150 }
151 }
152 Some("input_json_delta") => {
153 if let (Some(pending), Some(partial)) =
154 (&mut t.pending_tool, delta.get("partial_json").and_then(|v| v.as_str()))
155 {
156 pending.partial_json.push_str(partial);
157 }
158 }
159 _ => {} }
161 }
162 "content_block_stop" => {
163 if let Some(pending) = t.pending_tool.take() {
164 let input: Value = if pending.partial_json.is_empty() {
172 Value::Object(Map::new())
173 } else {
174 serde_json::from_str(&pending.partial_json)
178 .unwrap_or(Value::String(pending.partial_json))
179 };
180 out.push(AgentEvent::ToolUse {
181 tool_use_id: pending.id,
182 tool: pending.name,
183 input,
184 });
185 }
186 }
187 "message_start" => {
191 if let Some(content) = event
192 .get("message")
193 .filter(|m| m.get("role").and_then(|v| v.as_str()) == Some("user"))
194 .and_then(|m| m.get("content"))
195 .and_then(|c| c.as_array())
196 {
197 extract_tool_results(t, content, out);
198 }
199 }
200 _ => {} }
202}
203
204fn handle_user_message(t: &mut ClaudeTranslator, frame: &Value, out: &mut Vec<AgentEvent>) {
205 let Some(content) = frame
206 .get("message")
207 .and_then(|m| m.get("content"))
208 .and_then(|c| c.as_array())
209 else {
210 return;
211 };
212 extract_tool_results(t, content, out);
213}
214
215fn extract_tool_results(t: &mut ClaudeTranslator, content: &[Value], out: &mut Vec<AgentEvent>) {
219 for block in content {
220 if block.get("type").and_then(|v| v.as_str()) != Some("tool_result") {
221 continue;
222 }
223 let tool_use_id = block
224 .get("tool_use_id")
225 .and_then(|v| v.as_str())
226 .unwrap_or("")
227 .to_string();
228 let output = block.get("content").cloned().unwrap_or(Value::Null);
231 let is_error = block
232 .get("is_error")
233 .and_then(|v| v.as_bool())
234 .unwrap_or(false);
235 out.push(AgentEvent::ToolResult {
236 tool_use_id: tool_use_id.clone(),
237 output: output.clone(),
238 is_error,
239 });
240 t.transcript.push(AgentTurn {
244 role: "tool_result".to_string(),
245 content: serde_json::json!({
246 "tool_use_id": tool_use_id,
247 "content": output,
248 "is_error": is_error,
249 }),
250 timestamp_ms: now_ms(),
251 });
252 }
253}
254
255fn handle_assistant_message(t: &mut ClaudeTranslator, frame: &Value, _out: &mut Vec<AgentEvent>) {
256 if frame.get("partial").and_then(|v| v.as_bool()) == Some(true) {
265 return;
266 }
267 let Some(message) = frame.get("message") else {
271 return;
272 };
273 let content = message.get("content").cloned().unwrap_or(Value::Null);
274 t.transcript.push(AgentTurn {
275 role: "assistant".to_string(),
276 content,
277 timestamp_ms: now_ms(),
278 });
279}
280
281fn handle_result(t: &mut ClaudeTranslator, frame: &Value, out: &mut Vec<AgentEvent>) {
282 let cost_usd = frame
283 .get("cost_usd")
284 .and_then(|v| v.as_f64())
285 .unwrap_or(0.0);
286 let tokens = parse_usage(frame.get("usage"));
287 out.push(AgentEvent::Cost { cost_usd, tokens });
288
289 if frame.get("is_error").and_then(|v| v.as_bool()) == Some(true) {
295 let api_error_status = frame.get("api_error_status").and_then(|v| v.as_i64());
296 let message = frame
297 .get("result")
298 .and_then(|v| v.as_str())
299 .or_else(|| first_cli_error(frame))
300 .map(|s| s.to_string())
301 .unwrap_or_else(|| match api_error_status {
302 Some(code) if code > 0 => format!("API error {code}"),
303 _ => "Agent encountered an error".to_string(),
304 });
305 t.accumulated_response.clear();
306 t.transcript.clear();
307 out.push(AgentEvent::Error { message });
308 return;
309 }
310
311 let response = frame
312 .get("result")
313 .and_then(|v| v.as_str())
314 .map(|s| s.to_string())
315 .unwrap_or_else(|| std::mem::take(&mut t.accumulated_response));
316 let transcript = std::mem::take(&mut t.transcript);
317 out.push(AgentEvent::Done {
318 response,
319 transcript,
320 });
321}
322
323fn handle_system_message(_t: &mut ClaudeTranslator, frame: &Value, out: &mut Vec<AgentEvent>) {
334 if frame.get("subtype").and_then(|v| v.as_str()) != Some("compact_boundary") {
335 return;
336 }
337 let Some(meta) = field(frame, "compact_metadata", "compactMetadata") else {
338 return;
339 };
340 let Some(trigger) = meta.get("trigger").and_then(|v| v.as_str()).and_then(|s| match s {
341 "auto" => Some(CompactionTrigger::Auto),
342 "manual" => Some(CompactionTrigger::Manual),
343 _ => None, }) else {
345 return;
346 };
347 let count = |snake: &str, camel: &str| field(meta, snake, camel).and_then(|v| v.as_u64());
348 let Some(pre_tokens) = count("pre_tokens", "preTokens") else {
349 return;
350 };
351 let Some(post_tokens) = count("post_tokens", "postTokens") else {
352 return;
353 };
354 let Some(cumulative_dropped_tokens) = count("cumulative_dropped_tokens", "cumulativeDroppedTokens") else {
355 return;
356 };
357 let Some(duration_ms) = count("duration_ms", "durationMs") else {
358 return;
359 };
360 out.push(AgentEvent::CompactionBoundary {
361 trigger,
362 pre_tokens,
363 post_tokens,
364 cumulative_dropped_tokens,
365 duration_ms,
366 });
367}
368
369fn field<'a>(obj: &'a Value, snake: &str, camel: &str) -> Option<&'a Value> {
371 obj.get(snake).or_else(|| obj.get(camel))
372}
373
374pub(crate) fn parse_usage(usage: Option<&Value>) -> TokenCounts {
375 let Some(usage) = usage.and_then(|v| v.as_object()) else {
376 return TokenCounts::default();
377 };
378 let take = |m: &Map<String, Value>, k: &str| -> u64 {
379 m.get(k).and_then(|v| v.as_u64()).unwrap_or(0)
380 };
381 TokenCounts {
382 input: take(usage, "input_tokens"),
383 output: take(usage, "output_tokens"),
384 cache_creation: take(usage, "cache_creation_input_tokens"),
385 cache_read: take(usage, "cache_read_input_tokens"),
386 }
387}
388
389use agentmux_common::time::now_ms;
390
391#[cfg(test)]
392mod tests {
393 use super::*;
394 use serde_json::json;
395
396 fn text_delta(text: &str) -> Value {
397 json!({
398 "type": "stream_event",
399 "event": {
400 "type": "content_block_delta",
401 "delta": { "type": "text_delta", "text": text }
402 }
403 })
404 }
405
406 fn tool_use_start(id: &str, name: &str) -> Value {
407 json!({
408 "type": "stream_event",
409 "event": {
410 "type": "content_block_start",
411 "content_block": { "type": "tool_use", "id": id, "name": name }
412 }
413 })
414 }
415
416 fn input_json_delta(partial: &str) -> Value {
417 json!({
418 "type": "stream_event",
419 "event": {
420 "type": "content_block_delta",
421 "delta": { "type": "input_json_delta", "partial_json": partial }
422 }
423 })
424 }
425
426 fn content_block_stop() -> Value {
427 json!({
428 "type": "stream_event",
429 "event": { "type": "content_block_stop" }
430 })
431 }
432
433 #[test]
434 fn text_delta_emits_assistant_text() {
435 let mut t = ClaudeTranslator::new();
436 let events = t.translate(text_delta("hello"));
437 assert_eq!(events.len(), 1);
438 match &events[0] {
439 AgentEvent::AssistantText { delta } => assert_eq!(delta, "hello"),
440 other => panic!("expected AssistantText, got {other:?}"),
441 }
442 }
443
444 #[test]
445 fn streaming_text_accumulates_for_done_response() {
446 let mut t = ClaudeTranslator::new();
449 t.translate(text_delta("Hello "));
450 t.translate(text_delta("world"));
451 let events = t.translate(json!({ "type": "result", "cost_usd": 0.01 }));
452 assert_eq!(events.len(), 2);
454 match &events[1] {
455 AgentEvent::Done { response, .. } => assert_eq!(response, "Hello world"),
456 other => panic!("expected Done with accumulated text, got {other:?}"),
457 }
458 }
459
460 #[test]
461 fn tool_use_emits_only_on_content_block_stop() {
462 let mut t = ClaudeTranslator::new();
463 assert!(t.translate(tool_use_start("t1", "Bash")).is_empty());
465 assert!(t.translate(input_json_delta(r#"{"command":"#)).is_empty());
467 assert!(t.translate(input_json_delta(r#""ls"}"#)).is_empty());
468 let events = t.translate(content_block_stop());
470 assert_eq!(events.len(), 1);
471 match &events[0] {
472 AgentEvent::ToolUse {
473 tool_use_id,
474 tool,
475 input,
476 } => {
477 assert_eq!(tool_use_id, "t1");
478 assert_eq!(tool, "Bash");
479 assert_eq!(input, &json!({ "command": "ls" }));
480 }
481 other => panic!("expected ToolUse, got {other:?}"),
482 }
483 }
484
485 #[test]
486 fn no_arg_tool_emits_empty_object() {
487 let mut t = ClaudeTranslator::new();
491 t.translate(tool_use_start("t1", "Echo"));
492 let events = t.translate(content_block_stop());
493 assert_eq!(events.len(), 1);
494 match &events[0] {
495 AgentEvent::ToolUse { input, .. } => {
496 assert_eq!(input, &json!({}));
497 }
498 other => panic!("expected ToolUse with empty object input, got {other:?}"),
499 }
500 }
501
502 #[test]
503 fn malformed_tool_input_falls_back_to_raw_string() {
504 let mut t = ClaudeTranslator::new();
505 t.translate(tool_use_start("t1", "Edit"));
506 t.translate(input_json_delta(r#"{"this is bad json"#));
507 let events = t.translate(content_block_stop());
508 assert_eq!(events.len(), 1);
509 match &events[0] {
510 AgentEvent::ToolUse { input, .. } => {
511 assert_eq!(input, &json!(r#"{"this is bad json"#));
514 }
515 other => panic!("expected ToolUse fallback, got {other:?}"),
516 }
517 }
518
519 #[test]
520 fn user_tool_result_emits_event() {
521 let mut t = ClaudeTranslator::new();
522 let events = t.translate(json!({
523 "type": "user",
524 "message": {
525 "content": [{
526 "type": "tool_result",
527 "tool_use_id": "t3",
528 "content": "command output here",
529 "is_error": false
530 }]
531 }
532 }));
533 assert_eq!(events.len(), 1);
534 match &events[0] {
535 AgentEvent::ToolResult {
536 tool_use_id,
537 output,
538 is_error,
539 } => {
540 assert_eq!(tool_use_id, "t3");
541 assert_eq!(output, &json!("command output here"));
542 assert!(!is_error);
543 }
544 other => panic!("expected ToolResult, got {other:?}"),
545 }
546 }
547
548 #[test]
549 fn tool_result_is_error_propagates() {
550 let mut t = ClaudeTranslator::new();
551 let events = t.translate(json!({
552 "type": "user",
553 "message": {
554 "content": [{
555 "type": "tool_result",
556 "tool_use_id": "t4",
557 "content": "permission denied",
558 "is_error": true
559 }]
560 }
561 }));
562 match &events[0] {
563 AgentEvent::ToolResult { is_error, .. } => assert!(is_error),
564 other => panic!("expected ToolResult with is_error, got {other:?}"),
565 }
566 }
567
568 #[test]
569 fn result_emits_cost_then_done_with_explicit_response() {
570 let mut t = ClaudeTranslator::new();
571 let events = t.translate(json!({
572 "type": "result",
573 "cost_usd": 0.0123,
574 "usage": {
575 "input_tokens": 100,
576 "output_tokens": 50,
577 "cache_creation_input_tokens": 0,
578 "cache_read_input_tokens": 200
579 },
580 "result": "final answer text"
581 }));
582 assert_eq!(events.len(), 2);
583 match &events[0] {
584 AgentEvent::Cost { cost_usd, tokens } => {
585 assert_eq!(*cost_usd, 0.0123);
586 assert_eq!(tokens.input, 100);
587 assert_eq!(tokens.output, 50);
588 assert_eq!(tokens.cache_creation, 0);
589 assert_eq!(tokens.cache_read, 200);
590 }
591 other => panic!("expected Cost first, got {other:?}"),
592 }
593 match &events[1] {
594 AgentEvent::Done { response, .. } => {
595 assert_eq!(response, "final answer text");
596 }
597 other => panic!("expected Done second, got {other:?}"),
598 }
599 }
600
601 #[test]
602 fn assistant_message_added_to_transcript() {
603 let mut t = ClaudeTranslator::new();
604 let events = t.translate(json!({
608 "type": "assistant",
609 "message": {
610 "content": [{ "type": "text", "text": "hello" }]
611 }
612 }));
613 assert!(events.is_empty());
614 let done = t.translate(json!({ "type": "result", "cost_usd": 0.0 }));
616 match &done[1] {
617 AgentEvent::Done { transcript, .. } => {
618 assert_eq!(transcript.len(), 1);
619 assert_eq!(transcript[0].role, "assistant");
620 }
621 other => panic!("expected Done, got {other:?}"),
622 }
623 }
624
625 #[test]
626 fn unknown_frame_returns_empty() {
627 let mut t = ClaudeTranslator::new();
628 assert!(t.translate(json!({ "type": "system" })).is_empty());
629 assert!(t.translate(json!({ "type": "stream_event", "event": { "type": "message_stop" } })).is_empty());
630 assert!(t.translate(json!({ "type": "stream_event", "event": { "type": "message_delta" } })).is_empty());
631 }
632
633 fn compact_boundary_frame(trigger: &str) -> Value {
636 json!({
640 "type": "system",
641 "subtype": "compact_boundary",
642 "content": "Conversation compacted",
643 "level": "info",
644 "compactMetadata": {
645 "trigger": trigger,
646 "preTokens": 783_887,
647 "postTokens": 11_775,
648 "cumulativeDroppedTokens": 772_112,
649 "durationMs": 231_606,
650 "preCompactDiscoveredTools": ["Bash", "Edit"],
651 "preservedSegment": {
652 "headUuid": "h1",
653 "anchorUuid": "a1",
654 "tailUuid": "t1"
655 }
656 },
657 "timestamp": "2026-07-21T17:55:35.500Z"
658 })
659 }
660
661 #[test]
662 fn compact_boundary_manual_emits_compaction_boundary() {
663 let mut t = ClaudeTranslator::new();
664 let events = t.translate(compact_boundary_frame("manual"));
665 assert_eq!(events.len(), 1);
666 match &events[0] {
667 AgentEvent::CompactionBoundary {
668 trigger,
669 pre_tokens,
670 post_tokens,
671 cumulative_dropped_tokens,
672 duration_ms,
673 } => {
674 assert_eq!(*trigger, CompactionTrigger::Manual);
675 assert_eq!(*pre_tokens, 783_887);
676 assert_eq!(*post_tokens, 11_775);
677 assert_eq!(*cumulative_dropped_tokens, 772_112);
678 assert_eq!(*duration_ms, 231_606);
679 }
680 other => panic!("expected CompactionBoundary, got {other:?}"),
681 }
682 }
683
684 #[test]
685 fn compact_boundary_auto_trigger_maps_correctly() {
686 let mut t = ClaudeTranslator::new();
687 let events = t.translate(compact_boundary_frame("auto"));
688 assert_eq!(events.len(), 1);
689 match &events[0] {
690 AgentEvent::CompactionBoundary { trigger, .. } => {
691 assert_eq!(*trigger, CompactionTrigger::Auto);
692 }
693 other => panic!("expected CompactionBoundary, got {other:?}"),
694 }
695 }
696
697 fn stdout_compact_boundary_frame(trigger: &str) -> Value {
700 json!({
701 "type": "system",
702 "subtype": "compact_boundary",
703 "uuid": "8c1f4e2a-2b7d-4a51-9a0e-6f3c2d1b0a99",
704 "compact_metadata": {
705 "trigger": trigger,
706 "pre_tokens": 25_040,
707 "post_tokens": 733,
708 "cumulative_dropped_tokens": 24_307,
709 "duration_ms": 1_513
710 },
711 "logical_parent_uuid": "3e9b7c10-5d2f-4c8a-b1e4-7a6d5c4b3a21"
712 })
713 }
714
715 #[test]
716 fn stdout_snake_case_compact_boundary_emits_compaction_boundary() {
717 for (wire, expected) in [("manual", CompactionTrigger::Manual), ("auto", CompactionTrigger::Auto)] {
718 let mut t = ClaudeTranslator::new();
719 let events = t.translate(stdout_compact_boundary_frame(wire));
720 assert_eq!(events.len(), 1, "{wire}");
721 match &events[0] {
722 AgentEvent::CompactionBoundary {
723 trigger,
724 pre_tokens,
725 post_tokens,
726 cumulative_dropped_tokens,
727 duration_ms,
728 } => {
729 assert_eq!(*trigger, expected);
730 assert_eq!(*pre_tokens, 25_040);
731 assert_eq!(*post_tokens, 733);
732 assert_eq!(*cumulative_dropped_tokens, 24_307);
733 assert_eq!(*duration_ms, 1_513);
734 }
735 other => panic!("expected CompactionBoundary, got {other:?}"),
736 }
737 }
738 }
739
740 #[test]
741 fn stdout_snake_case_malformed_field_returns_empty() {
742 let mut t = ClaudeTranslator::new();
743 let mut frame = stdout_compact_boundary_frame("manual");
744 frame["compact_metadata"]["pre_tokens"] = json!("not-a-number");
745 assert!(t.translate(frame).is_empty());
746 let mut frame = stdout_compact_boundary_frame("manual");
747 frame["compact_metadata"].as_object_mut().unwrap().remove("duration_ms");
748 assert!(t.translate(frame).is_empty());
749 }
750
751 #[test]
752 fn system_frame_with_unrelated_subtype_returns_empty() {
753 let mut t = ClaudeTranslator::new();
756 assert!(t
757 .translate(json!({ "type": "system", "subtype": "other_thing" }))
758 .is_empty());
759 }
760
761 #[test]
762 fn compact_boundary_missing_metadata_returns_empty() {
763 let mut t = ClaudeTranslator::new();
764 assert!(t
765 .translate(json!({ "type": "system", "subtype": "compact_boundary" }))
766 .is_empty());
767 }
768
769 #[test]
770 fn compact_boundary_malformed_field_returns_empty_not_panic() {
771 let mut t = ClaudeTranslator::new();
772 let events = t.translate(json!({
773 "type": "system",
774 "subtype": "compact_boundary",
775 "compactMetadata": {
776 "trigger": "manual",
777 "preTokens": "not-a-number",
778 "postTokens": 11_775,
779 "cumulativeDroppedTokens": 772_112,
780 "durationMs": 231_606
781 }
782 }));
783 assert!(events.is_empty());
784 }
785
786 #[test]
787 fn compact_boundary_unrecognized_trigger_returns_empty() {
788 let mut t = ClaudeTranslator::new();
789 let events = t.translate(compact_boundary_frame("something_new"));
790 assert!(events.is_empty());
791 }
792
793 #[test]
794 fn malformed_or_missing_type_returns_empty() {
795 let mut t = ClaudeTranslator::new();
796 assert!(t.translate(json!({})).is_empty());
797 assert!(t.translate(json!(null)).is_empty());
798 assert!(t.translate(json!("not an object")).is_empty());
799 assert!(t.translate(json!(42)).is_empty());
800 }
801
802 #[test]
803 fn skips_partial_assistant_snapshots() {
804 let mut t = ClaudeTranslator::new();
809 t.translate(json!({
811 "type": "assistant",
812 "partial": true,
813 "message": { "content": [{ "type": "text", "text": "h" }] }
814 }));
815 t.translate(json!({
816 "type": "assistant",
817 "partial": true,
818 "message": { "content": [{ "type": "text", "text": "he" }] }
819 }));
820 t.translate(json!({
821 "type": "assistant",
822 "partial": true,
823 "message": { "content": [{ "type": "text", "text": "hello" }] }
824 }));
825 t.translate(json!({
827 "type": "assistant",
828 "message": { "content": [{ "type": "text", "text": "hello" }] }
829 }));
830 let done = t.translate(json!({ "type": "result", "cost_usd": 0.0 }));
831 match &done[1] {
832 AgentEvent::Done { transcript, .. } => {
833 assert_eq!(
834 transcript.len(),
835 1,
836 "only the consolidated turn should land; got {transcript:?}"
837 );
838 }
839 other => panic!("expected Done, got {other:?}"),
840 }
841 }
842
843 #[test]
844 fn tool_result_recorded_in_transcript() {
845 let mut t = ClaudeTranslator::new();
849 t.translate(json!({
851 "type": "assistant",
852 "message": {
853 "content": [{
854 "type": "tool_use",
855 "id": "t1",
856 "name": "Bash",
857 "input": { "command": "ls" }
858 }]
859 }
860 }));
861 t.translate(json!({
863 "type": "user",
864 "message": {
865 "content": [{
866 "type": "tool_result",
867 "tool_use_id": "t1",
868 "content": "output",
869 "is_error": false
870 }]
871 }
872 }));
873 t.translate(json!({
875 "type": "assistant",
876 "message": {
877 "content": [{ "type": "text", "text": "done" }]
878 }
879 }));
880 let done = t.translate(json!({ "type": "result", "cost_usd": 0.0 }));
881 match &done[1] {
882 AgentEvent::Done { transcript, .. } => {
883 assert_eq!(transcript.len(), 3, "got {transcript:?}");
884 assert_eq!(transcript[0].role, "assistant");
885 assert_eq!(transcript[1].role, "tool_result");
886 assert_eq!(transcript[2].role, "assistant");
887 let tr = &transcript[1].content;
890 assert_eq!(tr["tool_use_id"], json!("t1"));
891 assert_eq!(tr["content"], json!("output"));
892 assert_eq!(tr["is_error"], json!(false));
893 }
894 other => panic!("expected Done, got {other:?}"),
895 }
896 }
897
898 #[test]
901 fn result_is_error_emits_error_not_done() {
902 let mut t = ClaudeTranslator::new();
903 t.translate(text_delta("partial output before the failure"));
904 let events = t.translate(json!({
905 "type": "result",
906 "cost_usd": 0.002,
907 "is_error": true,
908 "api_error_status": 429,
909 }));
910 assert_eq!(events.len(), 2, "expected Cost + Error, got {events:?}");
911 assert!(matches!(events[0], AgentEvent::Cost { .. }));
912 match &events[1] {
913 AgentEvent::Error { message } => assert_eq!(message, "API error 429"),
914 other => panic!("expected Error, got {other:?}"),
915 }
916 }
917
918 #[test]
919 fn result_is_error_prefers_explicit_result_text_over_status_code() {
920 let mut t = ClaudeTranslator::new();
921 let events = t.translate(json!({
922 "type": "result",
923 "cost_usd": 0.0,
924 "is_error": true,
925 "result": "network error: connection reset",
926 }));
927 match &events[1] {
928 AgentEvent::Error { message } => {
929 assert_eq!(message, "network error: connection reset")
930 }
931 other => panic!("expected Error, got {other:?}"),
932 }
933 }
934
935 #[test]
936 fn result_is_error_with_no_status_or_text_gets_generic_message() {
937 let mut t = ClaudeTranslator::new();
938 let events = t.translate(json!({
939 "type": "result",
940 "cost_usd": 0.0,
941 "is_error": true,
942 }));
943 match &events[1] {
944 AgentEvent::Error { message } => {
945 assert_eq!(message, "Agent encountered an error")
946 }
947 other => panic!("expected Error, got {other:?}"),
948 }
949 }
950
951 #[test]
952 fn result_is_error_with_only_an_errors_list_shows_its_first_entry() {
953 let mut t = ClaudeTranslator::new();
954 let events = t.translate(json!({
955 "type": "result",
956 "is_error": true,
957 "subtype": "error_during_execution",
958 "errors": ["No conversation found with session ID: s1"],
959 }));
960 match &events[1] {
961 AgentEvent::Error { message } => assert_eq!(message, "No conversation found with session ID: s1"),
962 other => panic!("expected Error, got {other:?}"),
963 }
964 }
965
966 #[test]
967 fn result_without_is_error_still_emits_done_as_before() {
968 let mut t = ClaudeTranslator::new();
969 let events = t.translate(json!({ "type": "result", "cost_usd": 0.0, "result": "ok" }));
970 match &events[1] {
971 AgentEvent::Done { response, .. } => assert_eq!(response, "ok"),
972 other => panic!("expected Done, got {other:?}"),
973 }
974 }
975
976 #[test]
977 fn message_start_with_user_role_extracts_tool_result() {
978 let mut t = ClaudeTranslator::new();
979 let events = t.translate(json!({
980 "type": "stream_event",
981 "event": {
982 "type": "message_start",
983 "message": {
984 "role": "user",
985 "content": [{
986 "type": "tool_result",
987 "tool_use_id": "t9",
988 "content": "output",
989 "is_error": false
990 }]
991 }
992 }
993 }));
994 assert_eq!(events.len(), 1);
995 match &events[0] {
996 AgentEvent::ToolResult {
997 tool_use_id,
998 output,
999 is_error,
1000 } => {
1001 assert_eq!(tool_use_id, "t9");
1002 assert_eq!(output, &json!("output"));
1003 assert!(!is_error);
1004 }
1005 other => panic!("expected ToolResult, got {other:?}"),
1006 }
1007 let done = t.translate(json!({ "type": "result", "cost_usd": 0.0 }));
1009 match &done[1] {
1010 AgentEvent::Done { transcript, .. } => {
1011 assert_eq!(transcript.len(), 1);
1012 assert_eq!(transcript[0].role, "tool_result");
1013 }
1014 other => panic!("expected Done, got {other:?}"),
1015 }
1016 }
1017
1018 #[test]
1019 fn message_start_with_assistant_role_is_ignored() {
1020 let mut t = ClaudeTranslator::new();
1021 let events = t.translate(json!({
1022 "type": "stream_event",
1023 "event": {
1024 "type": "message_start",
1025 "message": { "role": "assistant", "content": [] }
1026 }
1027 }));
1028 assert!(events.is_empty());
1029 }
1030}