Skip to main content

agentmux_srv/agents/translator/
claude.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Claude Code stream-json → `AgentEvent` translator.
5//!
6//! Mirrors the frontend reference implementation at
7//! `frontend/app/view/agent/providers/claude-translator.ts` so the
8//! drone Agent block can consume the same stream without
9//! round-tripping through the renderer. Handled frames:
10//!
11//! - `stream_event.content_block_start.content_block.type=text` —
12//!   starts a text block; subsequent `text_delta`s belong to it.
13//! - `stream_event.content_block_start.content_block.type=tool_use` —
14//!   starts an in-flight tool_use; subsequent `input_json_delta`s
15//!   accumulate; `content_block_stop` emits the finalized
16//!   `AgentEvent::ToolUse`.
17//! - `stream_event.content_block_delta.delta.type=text_delta` —
18//!   `AgentEvent::AssistantText` (also accumulated into the final
19//!   response).
20//! - `stream_event.content_block_delta.delta.type=input_json_delta` —
21//!   appends to the pending tool_use's partial_json.
22//! - `stream_event.content_block_stop` — flushes the pending
23//!   tool_use.
24//! - `user.message.content[].type=tool_result` — emits
25//!   `AgentEvent::ToolResult`.
26//! - `stream_event.message_start` with `message.role=user` — same
27//!   `tool_result` extraction as above (Anthropic sometimes carries
28//!   tool results here instead of a top-level `user` frame; mirrors
29//!   TS's `handleMessageStart`).
30//! - `result.cost_usd` + `result.usage` — emits `AgentEvent::Cost`.
31//!   Then, if `result.is_error` is true, emits `AgentEvent::Error`
32//!   (mirrors TS's `error_result`); otherwise emits `AgentEvent::Done`
33//!   whose `response` is the explicit `result.result` field if
34//!   present, otherwise the accumulated text from streamed
35//!   text_deltas.
36//! - `system.subtype=compact_boundary` — emits
37//!   `AgentEvent::CompactionBoundary` with the exact trigger/token/
38//!   duration data from `compact_metadata` (stdout, snake_case) or
39//!   `compactMetadata` (transcript, camelCase). Other `system` subtypes
40//!   are still discarded. See
41//!   `docs/specs/SPEC_COMPACTION_DETECTION_AND_HANDLING_2026_07_31.md`
42//!   §4.1 — this used to be silently dropped as an unhandled
43//!   `system` frame.
44//!
45//! Unknown frame types and malformed shapes produce an empty `Vec`
46//! rather than panicking — the runner falls back to whatever the
47//! parallel raw-byte MPS path published, so an unfamiliar frame
48//! degrades gracefully.
49//!
50//! `thinking_delta`, `message_delta` / `message_stop` are discarded
51//! (the agent pane filters them too). `rate_limit_event` (TS:
52//! `provider_waiting`) is also currently discarded — surfacing it
53//! here would need a new `AgentEvent` variant, and `types.rs`
54//! explicitly reserves that decision rather than shipping it
55//! casually; left as a deliberate follow-up, not an oversight.
56
57use 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    /// In-flight tool_use between `content_block_start` (type=tool_use)
68    /// and `content_block_stop`. Cleared on stop.
69    pending_tool: Option<PendingToolUse>,
70    /// Map from `tool_use_id` to its tool name, populated when a
71    /// tool_use lands so a later `tool_result` can carry the name
72    /// for renderers. Phase 1.5 doesn't surface tool name on the
73    /// `ToolResult` event (the id is enough for downstream matching),
74    /// but the map is kept for future use.
75    #[allow(dead_code)]
76    tool_names: HashMap<String, String>,
77    /// Streamed text accumulated since the last assistant turn, used
78    /// as the `Done.response` if the `result` frame has no explicit
79    /// `result` text field.
80    accumulated_response: String,
81    /// Per-turn transcript built up as `assistant` / `user` frames land.
82    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                _ => {} // thinking_delta and other variants — discard
160            }
161        }
162        "content_block_stop" => {
163            if let Some(pending) = t.pending_tool.take() {
164                // Anthropic's stream starts every tool_use with an
165                // implicit `input: {}` and only sends input_json_delta
166                // chunks when there ARE arguments. Treat an empty
167                // partial_json as the canonical no-arg object instead
168                // of falling through to the parse-failure path, which
169                // would emit Value::String("") and break downstream
170                // tool runners expecting an object. Codex P2 on PR #833.
171                let input: Value = if pending.partial_json.is_empty() {
172                    Value::Object(Map::new())
173                } else {
174                    // Non-empty: parse, fall back to the raw string
175                    // on failure so a malformed stream surfaces
176                    // SOMETHING rather than silently dropping.
177                    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        // Anthropic sometimes carries tool_result blocks on the
188        // message_start frame instead of (or in addition to) a
189        // top-level `user` frame. Mirrors TS's `handleMessageStart`.
190        "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        _ => {} // message_delta, message_stop — discard
201    }
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
215/// Shared by `handle_user_message` (top-level `user` frames) and the
216/// `message_start` case in `handle_stream_event` — both carry
217/// `tool_result` blocks in the same `content` array shape.
218fn 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        // `content` on a tool_result may be a string or an array of
229        // content parts — preserve whichever shape arrived.
230        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        // Also record the tool_result in the transcript so Done.transcript
241        // is the full ordered turn list (assistant turns + tool_result
242        // turns), not just the assistant side. Codex P2 on PR #833.
243        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    // Skip partial snapshots — when Claude is launched with
257    // --include-partial-messages it emits top-level `assistant`
258    // frames with `partial: true` for each streaming delta before
259    // the final consolidated turn. Recording every snapshot would
260    // produce duplicate transcript entries per turn. Frontend
261    // translator does the same skip
262    // (frontend/app/view/agent/providers/claude-translator.ts).
263    // Reagent P1 + codex P2 on PR #833.
264    if frame.get("partial").and_then(|v| v.as_bool()) == Some(true) {
265        return;
266    }
267    // Record the turn into the transcript — used as part of `Done`.
268    // Don't emit per-block events here; the streaming path already
269    // emitted them via stream_event deltas.
270    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    // A terminal error is mutually exclusive with Done — mirrors TS's
290    // `error_result` (claude-translator.ts) and gives `AgentEvent::Error`
291    // (types.rs) its first real producer. The runner's own raw-frame
292    // `is_error_result_frame` check (failure.rs) independently fails the
293    // run either way; this only affects what the live event stream shows.
294    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
323/// `type: "system"` frames cover several subtypes; today we only act
324/// on `compact_boundary` (see
325/// `docs/specs/SPEC_COMPACTION_DETECTION_AND_HANDLING_2026_07_31.md`
326/// §4.1). Any other subtype — or a `compact_boundary` frame missing its
327/// metadata / carrying malformed fields — produces no event
328/// rather than a bad one, matching this file's existing philosophy
329/// (see the module doc comment).
330///
331/// The CLI writes the frame to stdout in snake_case (`compact_metadata`,
332/// `pre_tokens`, …) and to its transcript in camelCase; both are read.
333fn 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, // unrecognized trigger string — don't guess, skip the event
344    }) 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
369/// `obj[snake]`, else `obj[camel]`.
370fn 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        // No explicit `result.result` — `Done.response` should be the
447        // concatenation of streamed text_deltas.
448        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        // Expect Cost + Done
453        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        // Start doesn't emit yet.
464        assert!(t.translate(tool_use_start("t1", "Bash")).is_empty());
465        // Delta accumulates, doesn't emit.
466        assert!(t.translate(input_json_delta(r#"{"command":"#)).is_empty());
467        assert!(t.translate(input_json_delta(r#""ls"}"#)).is_empty());
468        // Stop flushes.
469        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        // No input_json_delta between start and stop — codex P2 on
488        // PR #833. The fallback path used to emit Value::String(""),
489        // which broke downstream tool runners. Now emits {}.
490        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                // Falls back to the partial string so the
512                // downstream consumer sees SOMETHING.
513                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        // Assistant frame produces NO events directly (the streaming
605        // path emits the per-block events); it contributes to the
606        // transcript captured at Done.
607        let events = t.translate(json!({
608            "type": "assistant",
609            "message": {
610                "content": [{ "type": "text", "text": "hello" }]
611            }
612        }));
613        assert!(events.is_empty());
614        // The transcript shows up on Done.
615        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    // ── system / compact_boundary (SPEC_COMPACTION_DETECTION_AND_HANDLING) ──
634
635    fn compact_boundary_frame(trigger: &str) -> Value {
636        // Real example frame captured from a live session (see the spec
637        // doc §2) — includes the extra fields (`preCompactDiscoveredTools`,
638        // `preservedSegment`) that the translator ignores.
639        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    /// The stdout form, as Claude Code 2.1.287 writes it: snake_case, no
698    /// `timestamp` (SPEC_COMPACTION_ESTIMATED_PROGRESS_AND_STREAM_FRAMES §2).
699    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        // Only `compact_boundary` is handled specifically — every other
754        // `system` subtype still falls through to no-op.
755        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        // When Claude is launched with --include-partial-messages it
805        // streams partial assistant frames with `partial: true` before
806        // the final consolidated turn. Recording each would duplicate
807        // transcript entries. Reagent P1 on PR #833.
808        let mut t = ClaudeTranslator::new();
809        // Three partial snapshots — all should be ignored.
810        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        // Now the consolidated turn (partial: false / absent).
826        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        // Codex P2 on PR #833: Done.transcript must be the full
846        // ordered turn list (assistant + tool_result), not just the
847        // assistant side. Audit/replay needs the whole conversation.
848        let mut t = ClaudeTranslator::new();
849        // Assistant turn 1.
850        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        // Tool result.
862        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        // Final assistant turn.
874        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                // Verify the tool_result turn carries the structured
888                // content payload — not just the bare output.
889                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    // ── Bug fix regression tests (audit REPORT_REPO_HEALTH_AUDIT_2026_07_20 §1.1) ──
899
900    #[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        // Also lands in the transcript, same as the top-level `user` path.
1008        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}