Skip to main content

agent_abstraction/
event.rs

1//! Normalizing three different JSON streams into one event vocabulary.
2//!
3//! Each agent narrates a run in its own shape. [`Parser`] is fed one output line
4//! at a time and yields [`Event`]s a consumer can render without knowing which
5//! agent produced them, while accumulating the terminal facts (session id, final
6//! text, usage) into a [`Terminal`].
7//!
8//! Two distinct notions of text are kept apart on purpose:
9//! - [`Event::Text`] is the *incremental display stream*, what a GUI appends to
10//!   a transcript as it arrives.
11//! - [`Terminal::text`] is the agent's own *authoritative final answer*, taken
12//!   from its terminal record.
13//!
14//! Concatenating the deltas is not guaranteed to equal the final text (Copilot
15//! emits both; Claude emits only the latter), so a caller that needs the answer
16//! reads `Terminal::text` and never sums the events.
17//!
18//! Every shape here was captured from the live CLIs, except where a comment says
19//! otherwise.
20
21use std::collections::HashMap;
22
23use serde::{Deserialize, Serialize};
24use serde_json::Value;
25
26use crate::agent::{Agent, Format};
27use crate::outcome::{RateLimit, Stop, Usage};
28
29/// One normalized thing an agent did, agent-agnostic so a single renderer works
30/// across all three.
31#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
32#[serde(tag = "kind", rename_all = "snake_case")]
33#[non_exhaustive]
34pub enum Event {
35    /// The session is live. Emitted once, as early as the agent reveals it.
36    Started {
37        /// The native session id.
38        session: String,
39        /// The model actually selected, when named.
40        model: Option<String>,
41    },
42    /// Reasoning text, where the agent exposes it.
43    Thinking(String),
44    /// Assistant text as it arrives.
45    Text(String),
46    /// The agent invoked a tool.
47    ToolCall {
48        /// Correlates with the matching [`Event::ToolResult`], when the agent
49        /// provides an id.
50        id: Option<String>,
51        /// The tool's name.
52        name: String,
53        /// Its arguments, in the agent's own shape.
54        input: Value,
55    },
56    /// A tool returned.
57    ToolResult {
58        /// Correlates with the originating [`Event::ToolCall`].
59        id: Option<String>,
60        /// Whether the tool reported success. `None` when the agent does not say.
61        ok: Option<bool>,
62        /// The observation the model saw.
63        output: String,
64    },
65    /// A quota signal. Reported, never acted on.
66    RateLimit(RateLimit),
67}
68
69/// The ceiling on any single captured buffer.
70///
71/// An agent can stream for hours; `text`, raw stdout and stderr would otherwise
72/// grow without bound and a long run would end in an OOM rather than an answer.
73/// A megabyte is far more prose than any consumer displays, and the fields this
74/// bounds are for reading and diagnosis, never for reconstructing the stream.
75pub const MAX_CAPTURE: usize = 1024 * 1024;
76
77/// The ceiling on a single output line before it is truncated.
78///
79/// [`MAX_CAPTURE`] bounds the *total* kept, but a reader that accumulates until
80/// a newline can exhaust memory on one line that never ends. An agent emitting a
81/// huge tool result as one JSON object is the ordinary case; a broken or hostile
82/// one emitting an endless line is the case this exists for.
83pub const MAX_LINE: usize = 512 * 1024;
84
85/// The ceiling on any single event's payload.
86///
87/// [`MAX_CAPTURE`] bounds what is *kept*, and the channel bounds how many events
88/// are queued, but neither bounds how large one event is. With a 512 KiB line
89/// limit and a 256-deep channel, a stalled consumer could hold roughly 130 MiB
90/// of events. Bounding the payload brings that to about 16 MiB, which is a
91/// number worth being able to state.
92///
93/// 64 KiB is far more than a UI renders of a single tool result and is generous
94/// for a model turn.
95pub const MAX_EVENT_BYTES: usize = 64 * 1024;
96
97/// Marks a payload this crate shortened, so a truncated value is never mistaken
98/// for what the agent actually produced.
99pub const TRUNCATION_MARK: &str = "…(truncated)";
100
101/// The ceiling on an identifier: a session id, a tool-call id, a tool name.
102///
103/// Identifiers are **rejected** past this, never truncated. A shortened session
104/// id resumes nothing and a shortened tool id matches no call, so a truncated
105/// one is not a smaller version of the value, it is a wrong one. Dropping it
106/// loses correlation for that event; keeping a corrupted one loses correlation
107/// *and* lies about it.
108///
109/// Generous by three orders of magnitude: real ids are UUIDs of about 36 bytes
110/// and tool names are a dozen. Anything near this is malformed rather than
111/// merely long.
112pub const MAX_IDENTIFIER_BYTES: usize = 4 * 1024;
113
114/// The ceiling on the total bytes held in the pending-tool map.
115///
116/// Counting entries alone bounded nothing: 1024 entries of unbounded id and
117/// name could retain hundreds of megabytes. This bounds the bytes, which is
118/// what actually needed bounding.
119pub(crate) const MAX_PENDING_TOOL_BYTES: usize = 256 * 1024;
120
121/// The ceiling on how many tool calls may be tracked at once.
122///
123/// Entries are removed as results arrive, so this only bites when an agent
124/// announces calls it never completes.
125pub(crate) const MAX_PENDING_TOOLS: usize = 1024;
126
127/// Append `line` and a newline to `buf`, stopping once [`MAX_CAPTURE`] is
128/// reached. Returns whether anything was written.
129///
130/// Truncation keeps the *earliest* output, which is where a banner, a usage
131/// error, or the start of an answer lives. Later output from a runaway agent is
132/// the part worth dropping.
133pub(crate) fn append_capped(buf: &mut String, line: &str) -> bool {
134    let remaining = MAX_CAPTURE.saturating_sub(buf.len());
135    if remaining == 0 {
136        return false;
137    }
138    // `<` rather than `<=`, because the newline also has to fit.
139    if line.len() < remaining {
140        buf.push_str(line);
141        buf.push('\n');
142    } else {
143        // Cut on a character boundary; a truncated buffer must stay valid UTF-8.
144        let mut cut = remaining - 1;
145        while cut > 0 && !line.is_char_boundary(cut) {
146            cut -= 1;
147        }
148        buf.push_str(&line[..cut]);
149        buf.push('\n');
150    }
151    true
152}
153
154/// Whether an identifier is small enough to be usable.
155///
156/// The predicate deliberately returns a yes/no rather than a shortened value:
157/// see [`MAX_IDENTIFIER_BYTES`] for why truncating one is worse than losing it.
158fn usable_identifier(value: &str) -> bool {
159    value.len() <= MAX_IDENTIFIER_BYTES
160}
161
162/// Keep an identifier only if it is usable.
163fn accept_identifier(value: Option<String>) -> Option<String> {
164    value.filter(|v| usable_identifier(v))
165}
166
167/// Shorten `text` to [`MAX_EVENT_BYTES`], marking it if anything was dropped.
168fn bound_text(text: String) -> String {
169    if text.len() <= MAX_EVENT_BYTES {
170        return text;
171    }
172    let mut cut = MAX_EVENT_BYTES - TRUNCATION_MARK.len();
173    while cut > 0 && !text.is_char_boundary(cut) {
174        cut -= 1;
175    }
176    let mut out = text[..cut].to_string();
177    out.push_str(TRUNCATION_MARK);
178    out
179}
180
181/// Shorten a tool call's arguments, which are structured rather than text.
182///
183/// A truncated JSON value would no longer parse, so an oversized one is
184/// replaced wholesale by an object recording what was dropped. That keeps the
185/// value valid JSON, which is what a consumer expects of this field.
186fn bound_value(value: Value) -> Value {
187    let size = value.to_string().len();
188    if size <= MAX_EVENT_BYTES {
189        return value;
190    }
191    serde_json::json!({
192        "truncated": true,
193        "original_bytes": size,
194        "note": "arguments exceeded MAX_EVENT_BYTES and were dropped rather than \
195                 truncated, which would have produced invalid JSON",
196    })
197}
198
199/// Apply [`MAX_EVENT_BYTES`] to an event's payload.
200///
201/// Payloads only. Identifiers, the session id and tool-call ids, are left
202/// whole however long they are: they are short in practice, and shortening one
203/// would break the thing it exists for, resuming a conversation or matching a
204/// result to its call. A truncated identifier is worse than a large one.
205fn enforce_bounds(event: Event) -> Event {
206    match event {
207        Event::Text(text) => Event::Text(bound_text(text)),
208        Event::Thinking(text) => Event::Thinking(bound_text(text)),
209        // An unusable id is dropped rather than shortened, so the event still
210        // reports what the agent did while making the loss of correlation
211        // explicit instead of silently wrong.
212        Event::ToolCall { id, name, input } => Event::ToolCall {
213            id: accept_identifier(id),
214            name: bound_identifier(name),
215            input: bound_value(input),
216        },
217        Event::ToolResult { id, ok, output } => Event::ToolResult {
218            id: accept_identifier(id),
219            ok,
220            output: bound_text(output),
221        },
222        // `model` is for display, so shortening it costs nothing. The session
223        // id is not: `Started` is only emitted once a usable one exists, so it
224        // needs no filtering here.
225        Event::Started { session, model } => Event::Started {
226            session,
227            model: model.map(bound_identifier),
228        },
229        Event::RateLimit(limit) => Event::RateLimit(RateLimit {
230            status: bound_identifier(limit.status),
231            window: limit.window.map(bound_identifier),
232            resets_at: limit.resets_at,
233        }),
234    }
235}
236
237/// Shorten a short-by-nature field to [`MAX_IDENTIFIER_BYTES`].
238///
239/// For values that are descriptive rather than correlating, a tool name or a
240/// model or a quota status word, where a shortened value is still meaningful.
241fn bound_identifier(text: String) -> String {
242    if text.len() <= MAX_IDENTIFIER_BYTES {
243        return text;
244    }
245    let mut cut = MAX_IDENTIFIER_BYTES - TRUNCATION_MARK.len();
246    while cut > 0 && !text.is_char_boundary(cut) {
247        cut -= 1;
248    }
249    let mut out = text[..cut].to_string();
250    out.push_str(TRUNCATION_MARK);
251    out
252}
253
254/// Facts that are only known once the stream ends.
255#[derive(Debug, Clone, Default, PartialEq)]
256pub struct Terminal {
257    /// The native session id.
258    pub session: Option<String>,
259    /// The agent's authoritative final answer.
260    pub text: String,
261    /// Token and cost accounting.
262    pub usage: Usage,
263    /// Why the agent stopped.
264    pub stop: Stop,
265    /// The last quota signal seen.
266    pub rate_limit: Option<RateLimit>,
267    /// How many output lines could not be parsed.
268    ///
269    /// Non-zero is not automatically a fault: agents interleave banners and
270    /// warnings with their JSON. It matters when a run *also* came back empty,
271    /// which is what a vendor changing its output shape looks like from here.
272    pub unparsed: usize,
273    /// The first line that failed to parse, as evidence for the above.
274    pub first_unparsed: Option<String>,
275    /// The schema-conforming answer, where the agent reports one separately.
276    pub structured: Option<Value>,
277    /// The provider status code when the agent reported a failed turn, such as
278    /// a 404 for an unknown model.
279    pub error_status: Option<u16>,
280    /// The agent's own description of a failed turn, where it gives one apart
281    /// from the answer text. Claude puts its explanation in `result`, so this
282    /// stays `None` there; Codex reports it under `turn.failed`.
283    pub error_message: Option<String>,
284}
285
286/// Unwrap an error body an agent passed through as a JSON string.
287///
288/// Codex 0.145.0 forwards the upstream response verbatim, so `turn.failed`
289/// carries `{"type":"error","status":400,"error":{"message":"..."}}` encoded as
290/// a *string*. Showing that to a user means showing them JSON, and the status
291/// worth branching on is buried inside it. Anything that is not that shape is
292/// returned as-is.
293fn unwrap_error_body(message: &str) -> (Option<u16>, String) {
294    let Ok(body) = serde_json::from_str::<Value>(message) else {
295        return (None, message.to_string());
296    };
297    let status = body
298        .get("status")
299        .and_then(Value::as_u64)
300        .and_then(|s| u16::try_from(s).ok());
301    let inner = body
302        .get("error")
303        .and_then(|e| e.get("message"))
304        .and_then(Value::as_str)
305        .map(str::to_string);
306    (status, inner.unwrap_or_else(|| message.to_string()))
307}
308
309/// Incrementally turns one agent's output into [`Event`]s and a [`Terminal`].
310#[derive(Debug)]
311pub(crate) struct Parser {
312    agent: Agent,
313    format: Format,
314    term: Terminal,
315    /// Tool names by call id, so a result can be attributed to its call.
316    tools: HashMap<String, String>,
317    /// Bytes currently held in `tools`, kept alongside it because a map has no
318    /// cheap way to answer that.
319    tool_bytes: usize,
320    /// What the stream has shown so far.
321    seen: Seen,
322}
323
324/// Milestones a stream passes, tracked because later handling depends on them.
325///
326/// Four independent yes/no facts about position in the stream. Clippy flags the
327/// count, but packing them into bitflags would trade four self-describing names
328/// for one opaque integer, and nothing here is hot enough to want that.
329#[derive(Debug, Default)]
330#[expect(
331    clippy::struct_excessive_bools,
332    reason = "four independent stream milestones; naming each beats packing them"
333)]
334struct Seen {
335    /// A [`Event::Started`] has been emitted, so it fires only once.
336    started: bool,
337    /// A record was recognized as this agent's own shape, so the output really
338    /// is what was asked for.
339    structured: bool,
340    /// The agent's terminal record arrived, so the turn completed.
341    terminal: bool,
342    /// Token-level deltas arrived.
343    ///
344    /// Claude sends deltas *and* the completed message they build up to, so
345    /// emitting both would show every answer twice. Detected rather than
346    /// configured: the deltas always precede the completed message, so seeing
347    /// one is proof the finished copy is a duplicate.
348    deltas: bool,
349}
350
351impl Parser {
352    /// A parser for `agent` reading output in `format`.
353    #[must_use]
354    pub fn new(agent: Agent, format: Format) -> Self {
355        Self {
356            agent,
357            format,
358            term: Terminal::default(),
359            tools: HashMap::new(),
360            tool_bytes: 0,
361            seen: Seen::default(),
362        }
363    }
364
365    /// Feed one line of stdout, returning the events it produced.
366    ///
367    /// Unparseable lines yield nothing rather than failing the run: agents
368    /// interleave banners and warnings with their JSON, and a stray line is not
369    /// a reason to lose a completed turn. They are counted in
370    /// [`Terminal::unparsed`] so that a silent vendor format change is
371    /// diagnosable instead of merely producing an empty answer.
372    pub fn push(&mut self, line: &str) -> Vec<Event> {
373        let line = line.trim();
374        if line.is_empty() {
375            return Vec::new();
376        }
377        // Under a plain-text format there is nothing to parse: the whole stream
378        // is the answer.
379        if self.format == Format::Text {
380            append_capped(&mut self.term.text, line);
381            return vec![enforce_bounds(Event::Text(line.to_string()))];
382        }
383        let Ok(value) = serde_json::from_str::<Value>(line) else {
384            self.term.unparsed += 1;
385            if self.term.first_unparsed.is_none() {
386                // One short sample is enough to identify a shape change; keeping
387                // every stray line would reintroduce the unbounded growth this
388                // parser just capped.
389                let mut cut = line.len().min(512);
390                while cut > 0 && !line.is_char_boundary(cut) {
391                    cut -= 1;
392                }
393                self.term.first_unparsed = Some(line[..cut].to_string());
394            }
395            return Vec::new();
396        };
397        // A record that names a type this parser knows is evidence the stream
398        // really is the shape we asked for.
399        if let Some(ty) = value.get("type").and_then(Value::as_str)
400            && self.recognizes(ty)
401        {
402            self.seen.structured = true;
403        }
404        let mut out = match self.agent {
405            Agent::Claude => self.claude(&value),
406            Agent::Codex => self.codex(&value),
407            Agent::Copilot => self.copilot(&value),
408        };
409        // Every event leaves through here, so bounding once at the exit covers
410        // all three agents rather than each parser remembering.
411        out = out.into_iter().map(enforce_bounds).collect();
412
413        // Fire `Started` exactly once, from whichever record first revealed the
414        // id, and put it ahead of that record's own events.
415        if !self.seen.started {
416            if let Some(session) = self.term.session.clone() {
417                self.seen.started = true;
418                out.insert(
419                    0,
420                    Event::Started {
421                        session,
422                        model: model_of(&value),
423                    },
424                );
425            }
426        }
427        out
428    }
429
430    /// Whether `ty` is a record type this agent's parser understands.
431    fn recognizes(&self, ty: &str) -> bool {
432        match self.agent {
433            Agent::Claude => matches!(
434                ty,
435                "system" | "assistant" | "user" | "result" | "rate_limit_event"
436            ),
437            Agent::Codex => {
438                ty.starts_with("thread.") || ty.starts_with("turn.") || ty.starts_with("item.")
439            }
440            Agent::Copilot => {
441                ty == "result"
442                    || ty.starts_with("assistant.")
443                    || ty.starts_with("tool.")
444                    || ty.starts_with("session.")
445            }
446        }
447    }
448
449    /// Track a tool call so its result can be attributed, bounded so an agent
450    /// that announces calls it never finishes cannot grow this without limit.
451    fn remember_tool(&mut self, id: &str, name: &str) {
452        // An unusable id cannot correlate anything, so tracking it only costs
453        // memory.
454        if !usable_identifier(id) {
455            return;
456        }
457        let name = bound_identifier(name.to_string());
458        let cost = id.len() + name.len();
459        // Both budgets matter: the count bounds a flood of tiny entries, the
460        // bytes bound a few enormous ones. Counting entries alone was no bound
461        // at all while the entries themselves were unbounded.
462        if self.tools.len() >= MAX_PENDING_TOOLS
463            || self.tool_bytes.saturating_add(cost) > MAX_PENDING_TOOL_BYTES
464        {
465            return;
466        }
467        self.tool_bytes += cost;
468        if let Some(previous) = self.tools.insert(id.to_string(), name) {
469            // Replacing an entry must not double-count its predecessor.
470            self.tool_bytes = self.tool_bytes.saturating_sub(id.len() + previous.len());
471        }
472    }
473
474    /// Stop tracking a call once its result has arrived, releasing its budget.
475    fn forget_tool(&mut self, id: &str) {
476        if let Some(name) = self.tools.remove(id) {
477            self.tool_bytes = self.tool_bytes.saturating_sub(id.len() + name.len());
478        }
479    }
480
481    /// Whether any structured record has been recognized on this stream.
482    ///
483    /// A structured run that recognized nothing did not merely fail to answer;
484    /// it means the output was not the shape this parser understands.
485    pub(crate) fn saw_structured_record(&self) -> bool {
486        self.seen.structured
487    }
488
489    /// Whether the stream carried its terminal record, the one that closes a
490    /// turn and carries the answer and usage.
491    pub(crate) fn saw_terminal_record(&self) -> bool {
492        self.seen.terminal
493    }
494
495    /// Consume the parser for everything only knowable at the end.
496    #[must_use]
497    pub fn finish(mut self) -> Terminal {
498        if self.format == Format::Text {
499            self.term.text = self.term.text.trim_end().to_string();
500        }
501        self.term
502    }
503
504    /// Claude Code `--output-format json` / `stream-json`.
505    ///
506    /// Verified against claude 2.1.205: `system/init` opens with the id,
507    /// `assistant` records carry Anthropic content blocks, `rate_limit_event`
508    /// reports quota, and `result` closes with the answer and usage.
509    fn claude(&mut self, v: &Value) -> Vec<Event> {
510        let ty = v.get("type").and_then(Value::as_str).unwrap_or_default();
511        // An unusable session id must not be captured: it would be persisted as
512        // a binding that can never resume anything.
513        if let Some(id) = v.get("session_id").and_then(Value::as_str)
514            && usable_identifier(id)
515        {
516            self.term.session.get_or_insert_with(|| id.to_string());
517        }
518        match ty {
519            "rate_limit_event" => {
520                let limit = claude_rate_limit(v.get("rate_limit_info"));
521                self.term.rate_limit.clone_from(&limit);
522                limit.into_iter().map(Event::RateLimit).collect()
523            }
524            // Token-level deltas, present only with `--include-partial-messages`.
525            "stream_event" => self.claude_delta(v),
526            // Both roles carry content blocks: `assistant` holds text/thinking/
527            // tool_use, `user` carries the tool_result observations back.
528            "assistant" | "user" => self.content_blocks(v),
529            "result" => {
530                self.seen.terminal = true;
531                if let Some(text) = v.get("result").and_then(Value::as_str) {
532                    self.term.text = text.to_string();
533                }
534                // Claude returns the conforming value as its own field, so it
535                // needs no re-parsing out of the answer text.
536                if let Some(value) = v.get("structured_output") {
537                    self.term.structured = Some(value.clone());
538                }
539                self.term.usage = claude_usage(v);
540                // `subtype` says "success" even for a failed turn, so
541                // `is_error` is the field that actually decides.
542                self.term.stop = if v.get("is_error").and_then(Value::as_bool) == Some(true) {
543                    self.term.error_status = v
544                        .get("api_error_status")
545                        .and_then(Value::as_u64)
546                        .and_then(|s| u16::try_from(s).ok());
547                    Stop::Error
548                } else {
549                    stop_from(v.get("stop_reason"))
550                };
551                Vec::new()
552            }
553            _ => Vec::new(),
554        }
555    }
556
557    /// One token-level delta from Claude's `stream_event` records.
558    ///
559    /// These wrap the provider's own streaming events. Only the deltas that
560    /// carry visible text are surfaced; the block start/stop and message
561    /// envelopes describe structure this crate already expresses through the
562    /// event vocabulary.
563    fn claude_delta(&mut self, v: &Value) -> Vec<Event> {
564        let Some(event) = v.get("event") else {
565            return Vec::new();
566        };
567        if event.get("type").and_then(Value::as_str) != Some("content_block_delta") {
568            return Vec::new();
569        }
570        let Some(delta) = event.get("delta") else {
571            return Vec::new();
572        };
573        // Seeing any delta means the completed message that follows is a
574        // duplicate of what has already been streamed.
575        self.seen.deltas = true;
576
577        match delta.get("type").and_then(Value::as_str) {
578            Some("text_delta") => delta
579                .get("text")
580                .and_then(Value::as_str)
581                .filter(|text| !text.is_empty())
582                .map(|text| Event::Text(text.to_string()))
583                .into_iter()
584                .collect(),
585            Some("thinking_delta") => delta
586                .get("thinking")
587                .and_then(Value::as_str)
588                .filter(|text| !text.is_empty())
589                .map(|text| Event::Thinking(text.to_string()))
590                .into_iter()
591                .collect(),
592            // `input_json_delta` streams a tool call's arguments a fragment at a
593            // time. The completed `tool_use` block carries them whole, which is
594            // what a consumer can actually act on, so the fragments are skipped.
595            _ => Vec::new(),
596        }
597    }
598
599    /// Anthropic content blocks, shared by Claude's `assistant` and `user`
600    /// records.
601    fn content_blocks(&mut self, v: &Value) -> Vec<Event> {
602        let blocks = v
603            .get("message")
604            .and_then(|m| m.get("content"))
605            .and_then(Value::as_array);
606        let Some(blocks) = blocks else {
607            return Vec::new();
608        };
609        let mut out = Vec::new();
610        for block in blocks {
611            let ty = block
612                .get("type")
613                .and_then(Value::as_str)
614                .unwrap_or_default();
615            match ty {
616                // Skipped once deltas have streamed the same text, or the
617                // transcript would show every answer twice.
618                // Guarded on `deltas`: once tokens have streamed, the finished
619                // copy falls through to the catch-all and is dropped.
620                "text" if !self.seen.deltas => {
621                    if let Some(t) = block.get("text").and_then(Value::as_str) {
622                        out.push(Event::Text(t.to_string()));
623                    }
624                }
625                "thinking" if !self.seen.deltas => {
626                    if let Some(t) = block.get("thinking").and_then(Value::as_str) {
627                        out.push(Event::Thinking(t.to_string()));
628                    }
629                }
630                "tool_use" => {
631                    let name = block
632                        .get("name")
633                        .and_then(Value::as_str)
634                        .unwrap_or("tool")
635                        .to_string();
636                    let id = block.get("id").and_then(Value::as_str).map(str::to_string);
637                    if let Some(id) = &id {
638                        self.remember_tool(id, &name);
639                    }
640                    out.push(Event::ToolCall {
641                        id,
642                        name,
643                        input: block.get("input").cloned().unwrap_or(Value::Null),
644                    });
645                }
646                "tool_result" => out.push(Event::ToolResult {
647                    id: block
648                        .get("tool_use_id")
649                        .and_then(Value::as_str)
650                        .inspect(|id| {
651                            // The call has been answered, so stop tracking it.
652                            self.forget_tool(id);
653                        })
654                        .map(str::to_string),
655                    ok: block
656                        .get("is_error")
657                        .and_then(Value::as_bool)
658                        .map(|is_error| !is_error),
659                    output: flatten_text(block.get("content")),
660                }),
661                _ => {}
662            }
663        }
664        out
665    }
666
667    /// Codex `exec --json`.
668    ///
669    /// Verified against codex-cli 0.145.0: `thread.started` opens with
670    /// `thread_id`, items arrive as `item.started` → `item.completed` pairs, and
671    /// `turn.completed` carries usage. A tool item appears twice: once
672    /// in-progress with an empty `aggregated_output`, once finished, so the
673    /// call is emitted on first sighting and the result only once it completes.
674    fn codex(&mut self, v: &Value) -> Vec<Event> {
675        let ty = v.get("type").and_then(Value::as_str).unwrap_or_default();
676        if let Some(id) = v.get("thread_id").and_then(Value::as_str)
677            && usable_identifier(id)
678        {
679            self.term.session.get_or_insert_with(|| id.to_string());
680        }
681        match ty {
682            "turn.completed" => {
683                self.seen.terminal = true;
684                self.term.usage = codex_usage(v.get("usage"));
685                Vec::new()
686            }
687            "turn.failed" => {
688                self.seen.terminal = true;
689                self.term.stop = Stop::Error;
690                if let Some(message) = v
691                    .get("error")
692                    .and_then(|e| e.get("message"))
693                    .and_then(Value::as_str)
694                {
695                    let (status, message) = unwrap_error_body(message);
696                    self.term.error_status = status;
697                    self.term.error_message = Some(bound_text(message));
698                }
699                Vec::new()
700            }
701            "item.started" | "item.updated" | "item.completed" => {
702                let Some(item) = v.get("item") else {
703                    return Vec::new();
704                };
705                let item_ty = item.get("type").and_then(Value::as_str).unwrap_or_default();
706                let id = item.get("id").and_then(Value::as_str).map(str::to_string);
707                let done = ty == "item.completed";
708
709                // Every item is reported at least twice: in progress, then
710                // finished. Announce each one exactly once, on first sighting,
711                // and keep the id → name binding for the result.
712                let name = tool_name(item, item_ty);
713                let first = id
714                    .as_ref()
715                    .is_none_or(|id| self.tools.insert(id.clone(), name.clone()).is_none());
716
717                match item_ty {
718                    // The settled text is authoritative; a turn may contain
719                    // several messages, so the last one to complete wins.
720                    "agent_message" => {
721                        if !done {
722                            return Vec::new();
723                        }
724                        let text = item.get("text").and_then(Value::as_str).unwrap_or_default();
725                        self.term.text = text.to_string();
726                        vec![Event::Text(text.to_string())]
727                    }
728                    "reasoning" if done => item
729                        .get("text")
730                        .and_then(Value::as_str)
731                        .map(|t| Event::Thinking(t.to_string()))
732                        .into_iter()
733                        .collect(),
734                    "command_execution" | "mcp_tool_call" | "file_change" | "web_search" => {
735                        let mut out = Vec::new();
736                        if first {
737                            out.push(Event::ToolCall {
738                                id: id.clone(),
739                                name,
740                                input: codex_tool_input(item, item_ty),
741                            });
742                        }
743                        // Only the finished record carries real output: the
744                        // in-progress one has an empty string and a null code.
745                        if done {
746                            if let Some(id) = &id {
747                                self.forget_tool(id);
748                            }
749                            out.push(Event::ToolResult {
750                                id,
751                                ok: item
752                                    .get("exit_code")
753                                    .and_then(Value::as_i64)
754                                    .map(|code| code == 0),
755                                output: item
756                                    .get("aggregated_output")
757                                    .and_then(Value::as_str)
758                                    .unwrap_or_default()
759                                    .to_string(),
760                            });
761                        }
762                        out
763                    }
764                    _ => Vec::new(),
765                }
766            }
767            _ => Vec::new(),
768        }
769    }
770
771    /// Copilot `--output-format json` (JSONL).
772    ///
773    /// Verified against GitHub Copilot CLI 1.0.75: `assistant.message_delta`
774    /// streams text, `assistant.message` carries the settled answer,
775    /// `tool.execution_start` / `_complete` bracket a tool, and the final
776    /// `result` carries `sessionId` and usage.
777    fn copilot(&mut self, v: &Value) -> Vec<Event> {
778        let ty = v.get("type").and_then(Value::as_str).unwrap_or_default();
779        let data = v.get("data");
780        let field = |key: &str| -> Option<String> {
781            data.and_then(|d| d.get(key))
782                .and_then(Value::as_str)
783                .map(str::to_string)
784        };
785        match ty {
786            // The delta stream is what a live transcript renders.
787            "assistant.message_delta" => field("deltaContent")
788                .filter(|t| !t.is_empty())
789                .map(Event::Text)
790                .into_iter()
791                .collect(),
792            // The settled message is authoritative but already shown as deltas,
793            // so it updates the terminal text without re-emitting it.
794            "assistant.message" => {
795                if let Some(content) = field("content") {
796                    self.term.text = content;
797                }
798                Vec::new()
799            }
800            "assistant.reasoning" => field("content")
801                .filter(|t| !t.is_empty())
802                .map(Event::Thinking)
803                .into_iter()
804                .collect(),
805            "tool.execution_start" => {
806                let id = field("toolCallId");
807                let name = field("toolName").unwrap_or_else(|| "tool".into());
808                if let Some(id) = &id {
809                    self.remember_tool(id, &name);
810                }
811                vec![Event::ToolCall {
812                    id,
813                    name,
814                    input: data
815                        .and_then(|d| d.get("arguments"))
816                        .cloned()
817                        .unwrap_or(Value::Null),
818                }]
819            }
820            "tool.execution_complete" => vec![Event::ToolResult {
821                id: field("toolCallId").inspect(|id| {
822                    self.forget_tool(id);
823                }),
824                ok: data.and_then(|d| d.get("success")).and_then(Value::as_bool),
825                output: data
826                    .and_then(|d| d.get("result"))
827                    .and_then(|r| r.get("content"))
828                    .and_then(Value::as_str)
829                    .unwrap_or_default()
830                    .to_string(),
831            }],
832            // Copilot's terminal record is flat, not nested under `data`.
833            "result" => {
834                self.seen.terminal = true;
835                if let Some(id) = v.get("sessionId").and_then(Value::as_str)
836                    && usable_identifier(id)
837                {
838                    self.term.session = Some(id.to_string());
839                }
840                if let Some(usage) = v.get("usage") {
841                    self.term.usage.premium_requests =
842                        usage.get("premiumRequests").and_then(Value::as_u64);
843                }
844                if let Some(code) = v.get("exitCode").and_then(Value::as_i64)
845                    && code != 0
846                {
847                    self.term.stop = Stop::Error;
848                    // Copilot reports no explanation with the code, so name the
849                    // code rather than leave the failure blank.
850                    self.term.error_message = Some(format!("copilot exited with code {code}"));
851                }
852                Vec::new()
853            }
854            _ => Vec::new(),
855        }
856    }
857}
858
859/// The model named by a record, if it names one. Claude puts it at the top
860/// level; Copilot nests it under `data`.
861fn model_of(v: &Value) -> Option<String> {
862    v.get("model")
863        .or_else(|| v.get("data").and_then(|d| d.get("model")))
864        .and_then(Value::as_str)
865        .map(str::to_string)
866}
867
868/// A `stop_reason` string that is neither absent nor the normal end.
869fn stop_from(v: Option<&Value>) -> Stop {
870    match v.and_then(Value::as_str) {
871        None | Some("end_turn" | "stop" | "completed") => Stop::Completed,
872        Some(other) => Stop::Other(other.to_string()),
873    }
874}
875
876/// Claude's `rate_limit_info` object.
877fn claude_rate_limit(v: Option<&Value>) -> Option<RateLimit> {
878    let v = v?;
879    Some(RateLimit {
880        status: v.get("status").and_then(Value::as_str)?.to_string(),
881        window: v
882            .get("rateLimitType")
883            .and_then(Value::as_str)
884            .map(str::to_string),
885        resets_at: v.get("resetsAt").and_then(Value::as_i64),
886    })
887}
888
889/// Claude's terminal `usage` block plus its top-level `total_cost_usd`.
890fn claude_usage(v: &Value) -> Usage {
891    let u = v.get("usage");
892    let get = |key: &str| u.and_then(|u| u.get(key)).and_then(Value::as_u64);
893    Usage {
894        input_tokens: get("input_tokens"),
895        output_tokens: get("output_tokens"),
896        cache_read_tokens: get("cache_read_input_tokens"),
897        cache_write_tokens: get("cache_creation_input_tokens"),
898        cost_usd: v.get("total_cost_usd").and_then(Value::as_f64),
899        premium_requests: None,
900    }
901}
902
903/// Codex's `turn.completed` usage block. Codex prices nothing itself, so
904/// `cost_usd` stays absent rather than being derived from a local table.
905fn codex_usage(v: Option<&Value>) -> Usage {
906    let get = |key: &str| v.and_then(|u| u.get(key)).and_then(Value::as_u64);
907    Usage {
908        input_tokens: get("input_tokens"),
909        output_tokens: get("output_tokens"),
910        cache_read_tokens: get("cached_input_tokens"),
911        cache_write_tokens: get("cache_write_input_tokens"),
912        cost_usd: None,
913        premium_requests: None,
914    }
915}
916
917/// The display name of a Codex item: MCP and collaboration items name the tool
918/// they invoked, everything else is identified by its item type.
919fn tool_name(item: &Value, item_ty: &str) -> String {
920    item.get("tool")
921        .and_then(Value::as_str)
922        .unwrap_or(item_ty)
923        .to_string()
924}
925
926/// The arguments of a Codex tool item, in whatever shape that item uses.
927fn codex_tool_input(item: &Value, item_ty: &str) -> Value {
928    match item_ty {
929        "command_execution" => serde_json::json!({ "command": item.get("command") }),
930        "mcp_tool_call" => item.get("arguments").cloned().unwrap_or(Value::Null),
931        // `file_change` carries `changes`, `web_search` a `query`; neither has a
932        // single canonical argument field, so the item stands in for itself.
933        _ => item.clone(),
934    }
935}
936
937/// Flatten a tool result's `content` into the observation the model saw.
938///
939/// Anthropic tool results are either a bare string or an array of content
940/// blocks. Text blocks flatten to their text; any other block kind (an image,
941/// or a shape added in a future API version) is kept as its raw JSON rather
942/// than dropped, so a caller inspecting a tool result never silently loses part
943/// of it. This is lossy in presentation, never in content.
944fn flatten_text(v: Option<&Value>) -> String {
945    match v {
946        Some(Value::String(s)) => s.clone(),
947        Some(Value::Array(blocks)) => blocks
948            .iter()
949            .map(|b| match b.get("text").and_then(Value::as_str) {
950                Some(text) => text.to_string(),
951                None => b.to_string(),
952            })
953            .collect::<Vec<_>>()
954            .join("\n"),
955        Some(other) => other.to_string(),
956        None => String::new(),
957    }
958}
959
960#[cfg(test)]
961mod tests {
962    use super::*;
963
964    /// Drive a parser over `lines`, returning every event and the terminal.
965    fn run(agent: Agent, lines: &[&str]) -> (Vec<Event>, Terminal) {
966        let mut p = Parser::new(agent, Format::Stream);
967        let events = lines.iter().flat_map(|l| p.push(l)).collect();
968        (events, p.finish())
969    }
970
971    // Lines below are trimmed copies of transcripts captured from the live CLIs.
972
973    #[test]
974    fn claude_stream_yields_start_thinking_text_and_terminal_facts() {
975        let (events, term) = run(
976            Agent::Claude,
977            &[
978                r#"{"type":"system","subtype":"init","session_id":"sess-a","model":"claude-haiku-4-5"}"#,
979                r#"{"type":"assistant","session_id":"sess-a","message":{"content":[{"type":"thinking","thinking":"brief"}]}}"#,
980                r#"{"type":"assistant","session_id":"sess-a","message":{"content":[{"type":"text","text":"pong"}]}}"#,
981                r#"{"type":"result","subtype":"success","is_error":false,"result":"pong","session_id":"sess-a","total_cost_usd":0.017,"usage":{"input_tokens":10,"output_tokens":45,"cache_read_input_tokens":18764,"cache_creation_input_tokens":7322}}"#,
982            ],
983        );
984        assert_eq!(
985            events[0],
986            Event::Started {
987                session: "sess-a".into(),
988                model: Some("claude-haiku-4-5".into())
989            }
990        );
991        assert_eq!(events[1], Event::Thinking("brief".into()));
992        assert_eq!(events[2], Event::Text("pong".into()));
993        assert_eq!(term.session.as_deref(), Some("sess-a"));
994        assert_eq!(term.text, "pong");
995        assert_eq!(term.stop, Stop::Completed);
996        assert_eq!(term.usage.input_tokens, Some(10));
997        assert_eq!(term.usage.cache_read_tokens, Some(18764));
998        assert_eq!(term.usage.cache_write_tokens, Some(7322));
999        assert_eq!(term.usage.cost_usd, Some(0.017));
1000    }
1001
1002    /// Verbatim from a `--include-partial-messages` run. Claude sends both the
1003    /// deltas and the completed message they build up to, so emitting both
1004    /// would show every answer twice in a transcript.
1005    #[test]
1006    fn claude_token_deltas_stream_without_duplicating_the_finished_message() {
1007        let (events, _) = run(
1008            Agent::Claude,
1009            &[
1010                r#"{"type":"system","subtype":"init","session_id":"s"}"#,
1011                r#"{"type":"stream_event","session_id":"s","event":{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}}"#,
1012                r#"{"type":"stream_event","session_id":"s","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"po"}}}"#,
1013                r#"{"type":"stream_event","session_id":"s","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"ng"}}}"#,
1014                r#"{"type":"stream_event","session_id":"s","event":{"type":"content_block_stop","index":0}}"#,
1015                // The completed copy of the same text.
1016                r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"text","text":"pong"}]}}"#,
1017                r#"{"type":"result","subtype":"success","is_error":false,"result":"pong","session_id":"s"}"#,
1018            ],
1019        );
1020        let texts: Vec<_> = events
1021            .iter()
1022            .filter_map(|e| match e {
1023                Event::Text(t) => Some(t.as_str()),
1024                _ => None,
1025            })
1026            .collect();
1027        assert_eq!(texts, ["po", "ng"], "the finished message must not repeat");
1028    }
1029
1030    /// Thinking streams the same way, and must not double either.
1031    #[test]
1032    fn claude_thinking_deltas_stream_without_duplication() {
1033        let (events, _) = run(
1034            Agent::Claude,
1035            &[
1036                r#"{"type":"stream_event","session_id":"s","event":{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"weighing"}}}"#,
1037                r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"thinking","thinking":"weighing"}]}}"#,
1038            ],
1039        );
1040        let thoughts: Vec<_> = events
1041            .iter()
1042            .filter_map(|e| match e {
1043                Event::Thinking(t) => Some(t.as_str()),
1044                _ => None,
1045            })
1046            .collect();
1047        assert_eq!(thoughts, ["weighing"]);
1048    }
1049
1050    /// Without partial messages there are no deltas, so the completed message
1051    /// is the only source and must still be emitted.
1052    #[test]
1053    fn a_completed_message_still_streams_when_no_deltas_arrived() {
1054        let (events, _) = run(
1055            Agent::Claude,
1056            &[
1057                r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"text","text":"pong"}]}}"#,
1058            ],
1059        );
1060        assert!(events.contains(&Event::Text("pong".into())), "{events:?}");
1061    }
1062
1063    /// Tool calls are not duplicated by deltas, so they keep coming from the
1064    /// completed block even once deltas have been seen.
1065    #[test]
1066    fn tool_calls_survive_delta_suppression() {
1067        let (events, _) = run(
1068            Agent::Claude,
1069            &[
1070                r#"{"type":"stream_event","session_id":"s","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hi"}}}"#,
1071                r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"tool_use","id":"t1","name":"Bash","input":{"command":"ls"}}]}}"#,
1072            ],
1073        );
1074        assert!(
1075            events.iter().any(|e| matches!(e, Event::ToolCall { .. })),
1076            "suppression must apply to text only: {events:?}"
1077        );
1078    }
1079
1080    #[test]
1081    fn claude_started_fires_only_once() {
1082        let (events, _) = run(
1083            Agent::Claude,
1084            &[
1085                r#"{"type":"system","subtype":"init","session_id":"s"}"#,
1086                r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"text","text":"a"}]}}"#,
1087                r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"text","text":"b"}]}}"#,
1088            ],
1089        );
1090        assert_eq!(
1091            events
1092                .iter()
1093                .filter(|e| matches!(e, Event::Started { .. }))
1094                .count(),
1095            1
1096        );
1097    }
1098
1099    #[test]
1100    fn claude_pairs_tool_use_with_its_result() {
1101        let (events, _) = run(
1102            Agent::Claude,
1103            &[
1104                r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"tool_use","id":"toolu_1","name":"Bash","input":{"command":"ls"}}]}}"#,
1105                r#"{"type":"user","session_id":"s","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"a.txt"}]}}"#,
1106            ],
1107        );
1108        let call = events
1109            .iter()
1110            .find(|e| matches!(e, Event::ToolCall { .. }))
1111            .unwrap();
1112        let Event::ToolCall { id, name, input } = call else {
1113            unreachable!()
1114        };
1115        assert_eq!(id.as_deref(), Some("toolu_1"));
1116        assert_eq!(name, "Bash");
1117        assert_eq!(input["command"], "ls");
1118        assert!(events.contains(&Event::ToolResult {
1119            id: Some("toolu_1".into()),
1120            ok: None,
1121            output: "a.txt".into(),
1122        }));
1123    }
1124
1125    #[test]
1126    fn claude_reports_a_rate_limit_without_failing() {
1127        let (events, term) = run(
1128            Agent::Claude,
1129            &[
1130                r#"{"type":"rate_limit_event","session_id":"s","rate_limit_info":{"status":"allowed","resetsAt":1785260400,"rateLimitType":"five_hour"}}"#,
1131            ],
1132        );
1133        let limit = RateLimit {
1134            status: "allowed".into(),
1135            window: Some("five_hour".into()),
1136            resets_at: Some(1_785_260_400),
1137        };
1138        assert!(events.contains(&Event::RateLimit(limit.clone())));
1139        assert_eq!(term.rate_limit, Some(limit.clone()));
1140        assert!(
1141            !limit.is_blocking(),
1142            "an `allowed` heartbeat is not a block"
1143        );
1144    }
1145
1146    #[test]
1147    fn claude_error_result_sets_the_stop_reason() {
1148        let (_, term) = run(
1149            Agent::Claude,
1150            &[r#"{"type":"result","is_error":true,"result":"boom","session_id":"s"}"#],
1151        );
1152        assert_eq!(term.stop, Stop::Error);
1153    }
1154
1155    #[test]
1156    fn copilot_streams_deltas_and_takes_its_answer_from_the_settled_message() {
1157        let (events, term) = run(
1158            Agent::Copilot,
1159            &[
1160                r#"{"type":"assistant.message_delta","data":{"messageId":"m","deltaContent":"po"}}"#,
1161                r#"{"type":"assistant.message_delta","data":{"messageId":"m","deltaContent":"ng"}}"#,
1162                r#"{"type":"assistant.message","data":{"messageId":"m","model":"gpt-5-mini","content":"pong"}}"#,
1163                r#"{"type":"result","sessionId":"768c8e7d","exitCode":0,"usage":{"premiumRequests":0}}"#,
1164            ],
1165        );
1166        // The deltas stream; the settled message must not double them.
1167        let texts: Vec<_> = events
1168            .iter()
1169            .filter_map(|e| match e {
1170                Event::Text(t) => Some(t.as_str()),
1171                _ => None,
1172            })
1173            .collect();
1174        assert_eq!(texts, ["po", "ng"]);
1175        assert_eq!(term.text, "pong", "the answer is the settled message");
1176        assert_eq!(term.session.as_deref(), Some("768c8e7d"));
1177        assert_eq!(term.usage.premium_requests, Some(0));
1178    }
1179
1180    #[test]
1181    fn copilot_brackets_a_tool_call_with_its_completion() {
1182        let (events, _) = run(
1183            Agent::Copilot,
1184            &[
1185                r#"{"type":"tool.execution_start","data":{"toolCallId":"call_1","toolName":"bash","arguments":{"command":"ls"}}}"#,
1186                r#"{"type":"tool.execution_complete","data":{"toolCallId":"call_1","success":true,"result":{"content":"a.txt"}}}"#,
1187            ],
1188        );
1189        assert!(matches!(
1190            &events[0],
1191            Event::ToolCall { id, name, .. }
1192                if id.as_deref() == Some("call_1") && name == "bash"
1193        ));
1194        assert_eq!(
1195            events[1],
1196            Event::ToolResult {
1197                id: Some("call_1".into()),
1198                ok: Some(true),
1199                output: "a.txt".into()
1200            }
1201        );
1202    }
1203
1204    /// Verbatim from codex-cli 0.145.0 with an unknown model. It exits **0**
1205    /// and forwards the upstream body as a JSON *string*, so the status worth
1206    /// branching on is nested one level inside a field that is itself text.
1207    #[test]
1208    fn a_codex_failed_turn_yields_the_reason_and_the_status() {
1209        let (_, term) = run(
1210            Agent::Codex,
1211            &[
1212                r#"{"type":"thread.started","thread_id":"019fad62"}"#,
1213                r#"{"type":"turn.failed","error":{"message":"{\"type\":\"error\",\"status\":400,\"error\":{\"type\":\"invalid_request_error\",\"message\":\"The 'bogus-model-xyz' model is not supported when using Codex with a ChatGPT account.\"}}"}}"#,
1214            ],
1215        );
1216        assert_eq!(term.stop, Stop::Error);
1217        assert_eq!(term.error_status, Some(400));
1218        assert_eq!(
1219            term.error_message.as_deref(),
1220            Some(
1221                "The 'bogus-model-xyz' model is not supported when using Codex with a ChatGPT account."
1222            ),
1223            "the caller should get the sentence, not the envelope"
1224        );
1225    }
1226
1227    /// An error that is not the double-encoded shape must survive untouched
1228    /// rather than be dropped for failing to match it.
1229    #[test]
1230    fn a_plain_codex_failure_message_passes_through() {
1231        let (_, term) = run(
1232            Agent::Codex,
1233            &[
1234                r#"{"type":"turn.failed","error":{"message":"stream disconnected before completion"}}"#,
1235            ],
1236        );
1237        assert_eq!(term.error_status, None);
1238        assert_eq!(
1239            term.error_message.as_deref(),
1240            Some("stream disconnected before completion")
1241        );
1242    }
1243
1244    #[test]
1245    fn codex_reads_the_thread_id_and_the_completed_message() {
1246        let (events, term) = run(
1247            Agent::Codex,
1248            &[
1249                r#"{"type":"thread.started","thread_id":"0199-xyz"}"#,
1250                r#"{"type":"item.completed","item":{"id":"i1","type":"agent_message","text":"pong"}}"#,
1251                r#"{"type":"turn.completed","usage":{"input_tokens":12,"output_tokens":3,"cached_input_tokens":9}}"#,
1252            ],
1253        );
1254        assert_eq!(
1255            events[0],
1256            Event::Started {
1257                session: "0199-xyz".into(),
1258                model: None
1259            }
1260        );
1261        assert_eq!(term.session.as_deref(), Some("0199-xyz"));
1262        assert_eq!(term.text, "pong");
1263        assert_eq!(term.usage.input_tokens, Some(12));
1264        assert_eq!(term.usage.cache_read_tokens, Some(9));
1265    }
1266
1267    #[test]
1268    fn codex_command_execution_becomes_a_call_and_a_result() {
1269        let (events, _) = run(
1270            Agent::Codex,
1271            &[
1272                r#"{"type":"item.completed","item":{"id":"c1","type":"command_execution","command":"ls","exit_code":0,"aggregated_output":"a.txt"}}"#,
1273            ],
1274        );
1275        assert!(matches!(&events[0], Event::ToolCall { name, .. } if name == "command_execution"));
1276        assert_eq!(
1277            events[1],
1278            Event::ToolResult {
1279                id: Some("c1".into()),
1280                ok: Some(true),
1281                output: "a.txt".into()
1282            }
1283        );
1284    }
1285
1286    /// Codex reports one tool twice: in progress, then finished. The call must
1287    /// be announced once and the empty in-progress output must never surface as
1288    /// a result. Both lines are verbatim from a codex 0.145.0 transcript.
1289    #[test]
1290    fn codex_started_then_completed_yields_one_call_and_one_result() {
1291        let (events, _) = run(
1292            Agent::Codex,
1293            &[
1294                r#"{"type":"item.started","item":{"id":"item_1","type":"command_execution","command":"/bin/zsh -lc ls","aggregated_output":"","exit_code":null,"status":"in_progress"}}"#,
1295                r#"{"type":"item.completed","item":{"id":"item_1","type":"command_execution","command":"/bin/zsh -lc ls","aggregated_output":"a.txt\n","exit_code":0,"status":"completed"}}"#,
1296            ],
1297        );
1298        let calls = events
1299            .iter()
1300            .filter(|e| matches!(e, Event::ToolCall { .. }))
1301            .count();
1302        assert_eq!(calls, 1, "the same item must not be announced twice");
1303        let results: Vec<_> = events
1304            .iter()
1305            .filter_map(|e| match e {
1306                Event::ToolResult { output, .. } => Some(output.as_str()),
1307                _ => None,
1308            })
1309            .collect();
1310        assert_eq!(
1311            results,
1312            ["a.txt\n"],
1313            "the in-progress blank must not appear"
1314        );
1315    }
1316
1317    /// A turn can hold several messages; the answer is the last to settle.
1318    #[test]
1319    fn codex_last_completed_message_is_the_answer() {
1320        let (_, term) = run(
1321            Agent::Codex,
1322            &[
1323                r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"I'll list the directory."}}"#,
1324                r#"{"type":"item.completed","item":{"id":"i2","type":"agent_message","text":"DONE"}}"#,
1325            ],
1326        );
1327        assert_eq!(term.text, "DONE");
1328    }
1329
1330    /// The channel bounds how many events queue, not how large they are. With
1331    /// a 512 KiB line limit that left ~130 MiB reachable in flight.
1332    #[test]
1333    fn an_enormous_tool_result_is_bounded_and_marked() {
1334        let huge = "x".repeat(MAX_EVENT_BYTES * 4);
1335        let line = serde_json::json!({
1336            "type": "user",
1337            "session_id": "s",
1338            "message": {"content": [{
1339                "type": "tool_result", "tool_use_id": "t1", "content": huge
1340            }]}
1341        })
1342        .to_string();
1343
1344        let (events, _) = run(Agent::Claude, &[&line]);
1345        let Some(Event::ToolResult { output, id, .. }) = events
1346            .iter()
1347            .find(|e| matches!(e, Event::ToolResult { .. }))
1348            .cloned()
1349        else {
1350            panic!("expected a tool result, got {events:?}")
1351        };
1352        assert!(
1353            output.len() <= MAX_EVENT_BYTES,
1354            "kept {} bytes",
1355            output.len()
1356        );
1357        assert!(
1358            output.ends_with(TRUNCATION_MARK),
1359            "truncation must be visible"
1360        );
1361        assert_eq!(id.as_deref(), Some("t1"), "the id must survive whole");
1362    }
1363
1364    /// A usable identifier passes through whole, however awkward its length.
1365    /// Truncating one is worse than losing it: a shortened session id resumes
1366    /// nothing and a shortened tool id matches no call.
1367    #[test]
1368    fn usable_identifiers_are_never_shortened() {
1369        // Long enough to be unusual, small enough to still be an identifier.
1370        let id = "s".repeat(MAX_IDENTIFIER_BYTES);
1371        let line =
1372            serde_json::json!({"type": "system", "subtype": "init", "session_id": id}).to_string();
1373        let (events, term) = run(Agent::Claude, &[&line]);
1374
1375        let Some(Event::Started { session, .. }) = events.first().cloned() else {
1376            panic!("expected Started, got {events:?}")
1377        };
1378        assert_eq!(session.len(), id.len(), "the session id was shortened");
1379        assert_eq!(term.session.as_deref(), Some(id.as_str()));
1380    }
1381
1382    /// The hole this closes: identifiers were exempt from every bound, so a
1383    /// 512 KiB id rode through and the "16 MiB queued" figure was wrong.
1384    /// Rejecting is right where truncating is not, because a binding that
1385    /// cannot resume is worse than no binding.
1386    #[test]
1387    fn an_oversized_session_id_is_rejected_rather_than_stored() {
1388        let id = "s".repeat(MAX_IDENTIFIER_BYTES + 1);
1389        for (agent, line) in [
1390            (
1391                Agent::Claude,
1392                serde_json::json!({"type": "system", "subtype": "init", "session_id": id})
1393                    .to_string(),
1394            ),
1395            (
1396                Agent::Codex,
1397                serde_json::json!({"type": "thread.started", "thread_id": id}).to_string(),
1398            ),
1399            (
1400                Agent::Copilot,
1401                serde_json::json!({"type": "result", "sessionId": id, "exitCode": 0}).to_string(),
1402            ),
1403        ] {
1404            let (events, term) = run(agent, &[&line]);
1405            assert!(term.session.is_none(), "{agent} stored an unusable id");
1406            assert!(
1407                !events.iter().any(|e| matches!(e, Event::Started { .. })),
1408                "{agent} announced a session it cannot resume"
1409            );
1410        }
1411    }
1412
1413    /// A tool event with an unusable id is still reported: what the agent did
1414    /// is worth knowing even when it cannot be correlated. The id is dropped,
1415    /// never shortened into something that would match the wrong call.
1416    #[test]
1417    fn an_oversized_tool_id_drops_the_id_but_keeps_the_event() {
1418        let id = "t".repeat(MAX_IDENTIFIER_BYTES + 1);
1419        let line = serde_json::json!({
1420            "type": "assistant", "session_id": "s",
1421            "message": {"content": [{
1422                "type": "tool_use", "id": id, "name": "Bash", "input": {"command": "ls"}
1423            }]}
1424        })
1425        .to_string();
1426
1427        let (events, _) = run(Agent::Claude, &[&line]);
1428        let Some(Event::ToolCall { id: seen, name, .. }) = events
1429            .iter()
1430            .find(|e| matches!(e, Event::ToolCall { .. }))
1431            .cloned()
1432        else {
1433            panic!("the call itself must still be reported, got {events:?}")
1434        };
1435        assert_eq!(seen, None, "an unusable id must be dropped, not shortened");
1436        assert_eq!(name, "Bash");
1437    }
1438
1439    /// Bounding the entry count bounded nothing while the entries themselves
1440    /// were unbounded: 1024 pending calls could retain hundreds of megabytes.
1441    #[test]
1442    fn the_pending_tool_map_is_bounded_by_bytes_not_only_entries() {
1443        let mut parser = Parser::new(Agent::Claude, Format::Stream);
1444        // Ids and names just under the identifier ceiling, so the entry count
1445        // is nowhere near its limit while the bytes are.
1446        for i in 0..MAX_PENDING_TOOLS {
1447            let line = serde_json::json!({
1448                "type": "assistant", "session_id": "s",
1449                "message": {"content": [{
1450                    "type": "tool_use",
1451                    "id": format!("{i:0>width$}", width = MAX_IDENTIFIER_BYTES),
1452                    "name": "x".repeat(MAX_IDENTIFIER_BYTES),
1453                    "input": {}
1454                }]}
1455            })
1456            .to_string();
1457            parser.push(&line);
1458        }
1459        assert!(
1460            parser.tool_bytes <= MAX_PENDING_TOOL_BYTES,
1461            "pending tools grew to {} bytes",
1462            parser.tool_bytes
1463        );
1464    }
1465
1466    /// Answering a call must release its budget, or a long run of ordinary
1467    /// paired calls would exhaust it and stop correlating.
1468    #[test]
1469    fn a_completed_tool_call_releases_its_budget() {
1470        let mut parser = Parser::new(Agent::Claude, Format::Stream);
1471        let call = |id: &str| {
1472            serde_json::json!({
1473                "type": "assistant", "session_id": "s",
1474                "message": {"content": [{
1475                    "type": "tool_use", "id": id, "name": "Bash", "input": {}
1476                }]}
1477            })
1478            .to_string()
1479        };
1480        let result = |id: &str| {
1481            serde_json::json!({
1482                "type": "user", "session_id": "s",
1483                "message": {"content": [{
1484                    "type": "tool_result", "tool_use_id": id, "content": "done"
1485                }]}
1486            })
1487            .to_string()
1488        };
1489
1490        for i in 0..(MAX_PENDING_TOOLS * 4) {
1491            let id = format!("toolu_{i}");
1492            parser.push(&call(&id));
1493            parser.push(&result(&id));
1494        }
1495        assert_eq!(parser.tool_bytes, 0, "budget leaked across paired calls");
1496        assert!(parser.tools.is_empty());
1497    }
1498
1499    /// The claim the changelog makes has to survive an adversarial line: every
1500    /// field at its worst, times the channel depth, still under 20 MiB.
1501    #[test]
1502    fn a_worst_case_event_stays_within_the_stated_ceiling() {
1503        let huge = "x".repeat(MAX_LINE);
1504        let line = serde_json::json!({
1505            "type": "assistant", "session_id": huge,
1506            "message": {"content": [{
1507                "type": "tool_use", "id": huge, "name": huge, "input": {"command": huge}
1508            }]}
1509        })
1510        .to_string();
1511
1512        let (events, _) = run(Agent::Claude, &[&line]);
1513        for event in &events {
1514            let size = serde_json::to_string(event).unwrap().len();
1515            // Payload plus identifiers, with room for JSON framing.
1516            let ceiling = MAX_EVENT_BYTES + 4 * MAX_IDENTIFIER_BYTES;
1517            assert!(size <= ceiling, "an event reached {size} bytes: {event:?}");
1518        }
1519    }
1520
1521    /// Truncating JSON would produce something that no longer parses, so an
1522    /// oversized argument object is replaced rather than cut.
1523    #[test]
1524    fn oversized_tool_arguments_stay_valid_json() {
1525        let line = serde_json::json!({
1526            "type": "assistant",
1527            "session_id": "s",
1528            "message": {"content": [{
1529                "type": "tool_use", "id": "t1", "name": "Bash",
1530                "input": {"command": "y".repeat(MAX_EVENT_BYTES * 3)}
1531            }]}
1532        })
1533        .to_string();
1534
1535        let (events, _) = run(Agent::Claude, &[&line]);
1536        let Some(Event::ToolCall { input, .. }) = events
1537            .iter()
1538            .find(|e| matches!(e, Event::ToolCall { .. }))
1539            .cloned()
1540        else {
1541            panic!("expected a tool call, got {events:?}")
1542        };
1543        assert_eq!(input["truncated"], true, "got {input}");
1544        assert!(
1545            input.is_object(),
1546            "the replacement must still be valid JSON"
1547        );
1548        assert!(input.to_string().len() <= MAX_EVENT_BYTES);
1549    }
1550
1551    #[test]
1552    fn ordinary_payloads_pass_through_untouched() {
1553        let (events, _) = run(
1554            Agent::Claude,
1555            &[
1556                r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"text","text":"pong"}]}}"#,
1557            ],
1558        );
1559        assert!(events.contains(&Event::Text("pong".into())), "{events:?}");
1560    }
1561
1562    #[test]
1563    fn capture_is_bounded_and_keeps_the_earliest_output() {
1564        let mut buf = String::new();
1565        // Far more than the cap, in chunks, as a streaming agent would.
1566        for i in 0..50_000 {
1567            append_capped(&mut buf, &format!("line {i} aaaaaaaaaaaaaaaaaaaaaaaaaaaa"));
1568        }
1569        assert!(buf.len() <= MAX_CAPTURE, "grew to {}", buf.len());
1570        assert!(buf.starts_with("line 0 "), "the earliest output is kept");
1571    }
1572
1573    #[test]
1574    fn capping_never_splits_a_multibyte_character() {
1575        let mut buf = "x".repeat(MAX_CAPTURE - 3);
1576        // A 4-byte character that cannot fit in the 3 bytes remaining.
1577        assert!(append_capped(&mut buf, "🙂🙂"));
1578        assert!(buf.len() <= MAX_CAPTURE);
1579        // The invariant is simply that this is still a valid Rust string, which
1580        // would have panicked on a mid-character slice above.
1581        assert!(buf.is_char_boundary(buf.len()));
1582    }
1583
1584    #[test]
1585    fn a_full_buffer_reports_that_it_took_nothing() {
1586        let mut buf = "x".repeat(MAX_CAPTURE);
1587        assert!(!append_capped(&mut buf, "more"));
1588        assert_eq!(buf.len(), MAX_CAPTURE);
1589    }
1590
1591    /// A vendor changing its output shape looks like a clean exit with nothing
1592    /// parsed. Counting the misses turns that from a mystery into a diagnosis.
1593    #[test]
1594    fn unparseable_lines_are_counted_and_sampled() {
1595        let (_, term) = run(
1596            Agent::Claude,
1597            &[
1598                "<html>an error page, not JSON</html>",
1599                "another bad line",
1600                r#"{"type":"result","result":"ok","session_id":"s"}"#,
1601            ],
1602        );
1603        assert_eq!(term.unparsed, 2);
1604        assert_eq!(
1605            term.first_unparsed.as_deref(),
1606            Some("<html>an error page, not JSON</html>")
1607        );
1608    }
1609
1610    #[test]
1611    fn a_clean_stream_reports_no_parse_failures() {
1612        let (_, term) = run(
1613            Agent::Claude,
1614            &[r#"{"type":"result","result":"ok","session_id":"s"}"#],
1615        );
1616        assert_eq!(term.unparsed, 0);
1617        assert!(term.first_unparsed.is_none());
1618    }
1619
1620    /// Non-text content blocks are preserved as raw JSON rather than dropped, so
1621    /// a caller inspecting a tool result never silently loses part of it.
1622    #[test]
1623    fn tool_result_blocks_that_are_not_text_are_kept_not_dropped() {
1624        let (events, _) = run(
1625            Agent::Claude,
1626            &[
1627                r#"{"type":"user","session_id":"s","message":{"content":[{"type":"tool_result","tool_use_id":"t1","content":[{"type":"text","text":"seen"},{"type":"image","source":{"data":"abc"}}]}]}}"#,
1628            ],
1629        );
1630        let output = events
1631            .iter()
1632            .find_map(|e| match e {
1633                Event::ToolResult { output, .. } => Some(output),
1634                _ => None,
1635            })
1636            .unwrap_or_else(|| panic!("expected a tool result, got {events:?}"));
1637        assert!(output.contains("seen"));
1638        assert!(output.contains("image"), "the image block was dropped");
1639    }
1640
1641    #[test]
1642    fn garbage_lines_are_skipped_not_fatal() {
1643        let (events, term) = run(
1644            Agent::Claude,
1645            &[
1646                "Warning: something on stdout",
1647                "",
1648                r#"{"type":"result","result":"ok","session_id":"s"}"#,
1649            ],
1650        );
1651        assert!(events.iter().all(|e| !matches!(e, Event::Text(_))));
1652        assert_eq!(term.text, "ok");
1653    }
1654
1655    #[test]
1656    fn text_format_passes_lines_through_verbatim() {
1657        let mut p = Parser::new(Agent::Copilot, Format::Text);
1658        let events: Vec<_> = ["hello", "world"].iter().flat_map(|l| p.push(l)).collect();
1659        assert_eq!(
1660            events,
1661            [Event::Text("hello".into()), Event::Text("world".into())]
1662        );
1663        assert_eq!(p.finish().text, "hello\nworld");
1664    }
1665}