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