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    /// One assistant message ended inside a turn that may produce another.
47    ///
48    /// This carries no text. It lets a streaming host preserve authored
49    /// message boundaries without inventing whitespace between token deltas.
50    /// Codex app-server exposes this boundary explicitly; the other transports
51    /// do not currently report an equivalent event.
52    MessageBoundary,
53    /// The agent invoked a tool.
54    ToolCall {
55        /// Correlates with the matching [`Event::ToolResult`], when the agent
56        /// provides an id.
57        id: Option<String>,
58        /// The tool's name.
59        name: String,
60        /// Its arguments, in the agent's own shape.
61        input: Value,
62    },
63    /// A tool returned.
64    ToolResult {
65        /// Correlates with the originating [`Event::ToolCall`].
66        id: Option<String>,
67        /// Whether the tool reported success. `None` when the agent does not say.
68        ok: Option<bool>,
69        /// The observation the model saw.
70        output: String,
71    },
72    /// Token usage so far, reported while the turn is still running.
73    ///
74    /// For a live counter in a UI. Emitted once per model call, so a host can
75    /// fold each into a running total with [`crate::Usage::accumulate`], whose
76    /// per-field rules were written for exactly this.
77    ///
78    /// **`output_tokens` is deliberately absent here.** Mid-turn the agent
79    /// reports the count as it stood when the message began, which understates
80    /// the finished figure badly: a run whose per-call reports summed to 9 had
81    /// generated 497 by the end. The context figures are exact, so a counter
82    /// built on [`crate::Usage::context_tokens`] is honest and one built on
83    /// output would not be. The true output count arrives with the
84    /// [`crate::Outcome`].
85    ///
86    /// Claude reports this throughout a turn. Copilot reports once, near the
87    /// end. Codex reports nothing until the turn completes, so it never emits
88    /// this.
89    Usage(crate::outcome::Usage),
90    /// The agent is waiting for permission to make a tool call.
91    ///
92    /// Only emitted when the request asked for it via
93    /// [`crate::Request::approvals`]. **The run is blocked until
94    /// [`crate::Run::respond`] answers it**, so a consumer that ignores this
95    /// stalls until the run's timeout.
96    ApprovalRequest(crate::approval::Approval),
97    /// A quota signal. Reported, never acted on.
98    RateLimit(RateLimit),
99    /// A `/compact` started or settled.
100    ///
101    /// Only on a run carrying [`crate::Command::Compact`]. A refusal arrives
102    /// here as [`crate::Compaction::Finished`] with a reason rather than as an
103    /// error, because the command ran and answered. Claude only.
104    Compaction(crate::command::Compaction),
105    /// What the agent reports it can do, emitted once as the run starts.
106    ///
107    /// The catalogue is the agent's own, not this crate's: skills, plugins and
108    /// user commands differ per install. Claude only; nothing else publishes
109    /// one.
110    Commands(crate::command::Commands),
111}
112
113/// The ceiling on any single captured buffer.
114///
115/// An agent can stream for hours; `text`, raw stdout and stderr would otherwise
116/// grow without bound and a long run would end in an OOM rather than an answer.
117/// A megabyte is far more prose than any consumer displays, and the fields this
118/// bounds are for reading and diagnosis, never for reconstructing the stream.
119pub const MAX_CAPTURE: usize = 1024 * 1024;
120
121/// The ceiling on a single output line before it is truncated.
122///
123/// [`MAX_CAPTURE`] bounds the *total* kept, but a reader that accumulates until
124/// a newline can exhaust memory on one line that never ends. An agent emitting a
125/// huge tool result as one JSON object is the ordinary case; a broken or hostile
126/// one emitting an endless line is the case this exists for.
127pub const MAX_LINE: usize = 512 * 1024;
128
129/// The ceiling on any single event's payload.
130///
131/// [`MAX_CAPTURE`] bounds what is *kept*, and the channel bounds how many events
132/// are queued, but neither bounds how large one event is. With a 512 KiB line
133/// limit and a 256-deep channel, a stalled consumer could hold roughly 130 MiB
134/// of events. Bounding the payload brings that to about 16 MiB, which is a
135/// number worth being able to state.
136///
137/// 64 KiB is far more than a UI renders of a single tool result and is generous
138/// for a model turn.
139pub const MAX_EVENT_BYTES: usize = 64 * 1024;
140
141/// Marks a payload this crate shortened, so a truncated value is never mistaken
142/// for what the agent actually produced.
143pub const TRUNCATION_MARK: &str = "…(truncated)";
144
145/// The ceiling on an identifier: a session id, a tool-call id, a tool name.
146///
147/// Identifiers are **rejected** past this, never truncated. A shortened session
148/// id resumes nothing and a shortened tool id matches no call, so a truncated
149/// one is not a smaller version of the value, it is a wrong one. Dropping it
150/// loses correlation for that event; keeping a corrupted one loses correlation
151/// *and* lies about it.
152///
153/// Generous by three orders of magnitude: real ids are UUIDs of about 36 bytes
154/// and tool names are a dozen. Anything near this is malformed rather than
155/// merely long.
156pub const MAX_IDENTIFIER_BYTES: usize = 4 * 1024;
157
158/// The ceiling on the total bytes held in the pending-tool map.
159///
160/// Counting entries alone bounded nothing: 1024 entries of unbounded id and
161/// name could retain hundreds of megabytes. This bounds the bytes, which is
162/// what actually needed bounding.
163pub(crate) const MAX_PENDING_TOOL_BYTES: usize = 256 * 1024;
164
165/// The ceiling on how many tool calls may be tracked at once.
166///
167/// Entries are removed as results arrive, so this only bites when an agent
168/// announces calls it never completes.
169pub(crate) const MAX_PENDING_TOOLS: usize = 1024;
170
171/// Append `line` and a newline to `buf`, stopping once [`MAX_CAPTURE`] is
172/// reached. Returns whether anything was written.
173///
174/// Truncation keeps the *earliest* output, which is where a banner, a usage
175/// error, or the start of an answer lives. Later output from a runaway agent is
176/// the part worth dropping.
177pub(crate) fn append_capped(buf: &mut String, line: &str) -> bool {
178    let remaining = MAX_CAPTURE.saturating_sub(buf.len());
179    if remaining == 0 {
180        return false;
181    }
182    // `<` rather than `<=`, because the newline also has to fit.
183    if line.len() < remaining {
184        buf.push_str(line);
185        buf.push('\n');
186    } else {
187        // Cut on a character boundary; a truncated buffer must stay valid UTF-8.
188        let mut cut = remaining - 1;
189        while cut > 0 && !line.is_char_boundary(cut) {
190            cut -= 1;
191        }
192        buf.push_str(&line[..cut]);
193        buf.push('\n');
194    }
195    true
196}
197
198/// Whether an identifier is small enough to be usable.
199///
200/// The predicate deliberately returns a yes/no rather than a shortened value:
201/// see [`MAX_IDENTIFIER_BYTES`] for why truncating one is worse than losing it.
202fn usable_identifier(value: &str) -> bool {
203    value.len() <= MAX_IDENTIFIER_BYTES
204}
205
206/// Keep an identifier only if it is usable.
207fn accept_identifier(value: Option<String>) -> Option<String> {
208    value.filter(|v| usable_identifier(v))
209}
210
211/// Shorten `text` to [`MAX_EVENT_BYTES`], marking it if anything was dropped.
212fn bound_text(text: String) -> String {
213    if text.len() <= MAX_EVENT_BYTES {
214        return text;
215    }
216    let mut cut = MAX_EVENT_BYTES - TRUNCATION_MARK.len();
217    while cut > 0 && !text.is_char_boundary(cut) {
218        cut -= 1;
219    }
220    let mut out = text[..cut].to_string();
221    out.push_str(TRUNCATION_MARK);
222    out
223}
224
225/// Shorten a tool call's arguments, which are structured rather than text.
226///
227/// A truncated JSON value would no longer parse, so an oversized one is
228/// replaced wholesale by an object recording what was dropped. That keeps the
229/// value valid JSON, which is what a consumer expects of this field.
230fn bound_value(value: Value) -> Value {
231    let size = value.to_string().len();
232    if size <= MAX_EVENT_BYTES {
233        return value;
234    }
235    serde_json::json!({
236        "truncated": true,
237        "original_bytes": size,
238        "note": "arguments exceeded MAX_EVENT_BYTES and were dropped rather than \
239                 truncated, which would have produced invalid JSON",
240    })
241}
242
243/// Apply [`MAX_EVENT_BYTES`] to an event's payload.
244///
245/// Payloads only. Identifiers, the session id and tool-call ids, are left
246/// whole however long they are: they are short in practice, and shortening one
247/// would break the thing it exists for, resuming a conversation or matching a
248/// result to its call. A truncated identifier is worse than a large one.
249fn enforce_bounds(event: Event) -> Event {
250    match event {
251        Event::Text(text) => Event::Text(bound_text(text)),
252        Event::MessageBoundary => Event::MessageBoundary,
253        Event::Thinking(text) => Event::Thinking(bound_text(text)),
254        // An unusable id is dropped rather than shortened, so the event still
255        // reports what the agent did while making the loss of correlation
256        // explicit instead of silently wrong.
257        Event::ToolCall { id, name, input } => Event::ToolCall {
258            id: accept_identifier(id),
259            name: bound_identifier(name),
260            input: bound_value(input),
261        },
262        Event::ToolResult { id, ok, output } => Event::ToolResult {
263            id: accept_identifier(id),
264            ok,
265            output: bound_text(output),
266        },
267        // `model` is for display, so shortening it costs nothing. The session
268        // id is not: `Started` is only emitted once a usable one exists, so it
269        // needs no filtering here.
270        Event::Started { session, model } => Event::Started {
271            session,
272            model: model.map(bound_identifier),
273        },
274        // Numbers only, with nothing a bound could apply to.
275        Event::Usage(usage) => Event::Usage(usage),
276        Event::ApprovalRequest(approval) => {
277            Event::ApprovalRequest(crate::approval::Approval {
278                // The id has to survive intact or the answer cannot be matched
279                // to the question, so an unusable one is rejected upstream
280                // rather than shortened here.
281                id: approval.id,
282                tool: bound_identifier(approval.tool),
283                input: bound_value(approval.input),
284            })
285        }
286        Event::RateLimit(limit) => Event::RateLimit(RateLimit {
287            status: bound_identifier(limit.status),
288            window: limit.window.map(bound_identifier),
289            resets_at: limit.resets_at,
290            overage_status: limit.overage_status.map(bound_identifier),
291            is_using_overage: limit.is_using_overage,
292        }),
293        // The agent's own refusal sentence, bounded like any other prose it
294        // hands back.
295        Event::Compaction(crate::command::Compaction::Finished { ok, error }) => {
296            Event::Compaction(crate::command::Compaction::Finished {
297                ok,
298                error: error.map(bound_text),
299            })
300        }
301        Event::Compaction(phase) => Event::Compaction(phase),
302        // Command names, each short by nature and descriptive rather than
303        // correlating, so a long one shortens rather than being dropped.
304        Event::Commands(commands) => Event::Commands(crate::command::Commands {
305            all: commands.all.into_iter().map(bound_identifier).collect(),
306            skills: commands.skills.into_iter().map(bound_identifier).collect(),
307        }),
308    }
309}
310
311/// Shorten a short-by-nature field to [`MAX_IDENTIFIER_BYTES`].
312///
313/// For values that are descriptive rather than correlating, a tool name or a
314/// model or a quota status word, where a shortened value is still meaningful.
315fn bound_identifier(text: String) -> String {
316    if text.len() <= MAX_IDENTIFIER_BYTES {
317        return text;
318    }
319    let mut cut = MAX_IDENTIFIER_BYTES - TRUNCATION_MARK.len();
320    while cut > 0 && !text.is_char_boundary(cut) {
321        cut -= 1;
322    }
323    let mut out = text[..cut].to_string();
324    out.push_str(TRUNCATION_MARK);
325    out
326}
327
328/// Facts that are only known once the stream ends.
329#[derive(Debug, Clone, Default, PartialEq)]
330pub struct Terminal {
331    /// The native session id.
332    pub session: Option<String>,
333    /// The model the run actually used, as the agent named it at start.
334    ///
335    /// For Claude this is the *resolved* form, so asking for `sonnet[1m]`
336    /// records `claude-sonnet-5[1m]`. It is also the key into the terminal
337    /// record's per-model usage, which is what makes the window binding below
338    /// reliable.
339    pub model: Option<String>,
340    /// The agent's authoritative final answer.
341    pub text: String,
342    /// Token and cost accounting.
343    pub usage: Usage,
344    /// Why the agent stopped.
345    pub stop: Stop,
346    /// The last quota signal seen.
347    pub rate_limit: Option<RateLimit>,
348    /// How many output lines could not be parsed.
349    ///
350    /// Non-zero is not automatically a fault: agents interleave banners and
351    /// warnings with their JSON. It matters when a run *also* came back empty,
352    /// which is what a vendor changing its output shape looks like from here.
353    pub unparsed: usize,
354    /// The first line that failed to parse, as evidence for the above.
355    pub first_unparsed: Option<String>,
356    /// The schema-conforming answer, where the agent reports one separately.
357    pub structured: Option<Value>,
358    /// The provider status code when the agent reported a failed turn, such as
359    /// a 404 for an unknown model.
360    pub error_status: Option<u16>,
361    /// The agent's own description of a failed turn, where it gives one apart
362    /// from the answer text. Claude puts its explanation in `result`, so this
363    /// stays `None` there; Codex reports it under `turn.failed`.
364    pub error_message: Option<String>,
365}
366
367/// Unwrap an error body an agent passed through as a JSON string.
368///
369/// Codex 0.145.0 forwards the upstream response verbatim, so `turn.failed`
370/// carries `{"type":"error","status":400,"error":{"message":"..."}}` encoded as
371/// a *string*. Showing that to a user means showing them JSON, and the status
372/// worth branching on is buried inside it. Anything that is not that shape is
373/// returned as-is.
374fn unwrap_error_body(message: &str) -> (Option<u16>, String) {
375    let Ok(body) = serde_json::from_str::<Value>(message) else {
376        return (None, message.to_string());
377    };
378    let status = body
379        .get("status")
380        .and_then(Value::as_u64)
381        .and_then(|s| u16::try_from(s).ok());
382    let inner = body
383        .get("error")
384        .and_then(|e| e.get("message"))
385        .and_then(Value::as_str)
386        .map(str::to_string);
387    (status, inner.unwrap_or_else(|| message.to_string()))
388}
389
390/// Incrementally turns one agent's output into [`Event`]s and a [`Terminal`].
391#[derive(Debug)]
392pub(crate) struct Parser {
393    agent: Agent,
394    format: Format,
395    term: Terminal,
396    /// Tool names by call id, so a result can be attributed to its call.
397    tools: HashMap<String, String>,
398    /// Bytes currently held in `tools`, kept alongside it because a map has no
399    /// cheap way to answer that.
400    tool_bytes: usize,
401    /// What the stream has shown so far.
402    seen: Seen,
403    /// The prompt size of the newest API request in this turn, from the last
404    /// Claude `assistant` record's `message.usage` — the honest basis for
405    /// `Usage::context_tokens`. The terminal `result` sums usage across every
406    /// request the turn made, so a tool-heavy turn re-counts the conversation
407    /// once per round trip and the "context" can exceed the window itself.
408    latest_context: Option<u64>,
409}
410
411/// Milestones a stream passes, tracked because later handling depends on them.
412///
413/// Four independent yes/no facts about position in the stream. Clippy flags the
414/// count, but packing them into bitflags would trade four self-describing names
415/// for one opaque integer, and nothing here is hot enough to want that.
416#[derive(Debug, Default)]
417#[expect(
418    clippy::struct_excessive_bools,
419    reason = "five independent stream milestones; naming each beats packing them"
420)]
421struct Seen {
422    /// A [`Event::Started`] has been emitted, so it fires only once.
423    started: bool,
424    /// A record was recognized as this agent's own shape, so the output really
425    /// is what was asked for.
426    structured: bool,
427    /// The agent's terminal record arrived, so the turn completed.
428    terminal: bool,
429    /// The command catalogue has been reported, so it fires only once.
430    ///
431    /// A compaction re-initialises the session, so a run carrying `/compact`
432    /// sees a second `init` with the same lists.
433    catalogue: bool,
434    /// The `message.id` whose usage was last reported.
435    ///
436    /// One model call arrives as several `assistant` records, one per content
437    /// block, each repeating the same usage. Reporting all of them would make a
438    /// host accumulating the events count one call several times: a four-call
439    /// turn arrived as eight records.
440    usage_of: Option<String>,
441    /// Token-level deltas arrived.
442    ///
443    /// Claude sends deltas *and* the completed message they build up to, so
444    /// emitting both would show every answer twice. Detected rather than
445    /// configured: the deltas always precede the completed message, so seeing
446    /// one is proof the finished copy is a duplicate.
447    deltas: bool,
448}
449
450impl Parser {
451    /// A parser for `agent` reading output in `format`.
452    #[must_use]
453    pub fn new(agent: Agent, format: Format) -> Self {
454        Self {
455            agent,
456            format,
457            term: Terminal::default(),
458            tools: HashMap::new(),
459            tool_bytes: 0,
460            seen: Seen::default(),
461            latest_context: None,
462        }
463    }
464
465    /// Feed one line of stdout, returning the events it produced.
466    ///
467    /// Unparseable lines yield nothing rather than failing the run: agents
468    /// interleave banners and warnings with their JSON, and a stray line is not
469    /// a reason to lose a completed turn. They are counted in
470    /// [`Terminal::unparsed`] so that a silent vendor format change is
471    /// diagnosable instead of merely producing an empty answer.
472    pub fn push(&mut self, line: &str) -> Vec<Event> {
473        let line = line.trim();
474        if line.is_empty() {
475            return Vec::new();
476        }
477        // Under a plain-text format there is nothing to parse: the whole stream
478        // is the answer.
479        if self.format == Format::Text {
480            append_capped(&mut self.term.text, line);
481            return vec![enforce_bounds(Event::Text(line.to_string()))];
482        }
483        let Ok(value) = serde_json::from_str::<Value>(line) else {
484            self.term.unparsed += 1;
485            if self.term.first_unparsed.is_none() {
486                // One short sample is enough to identify a shape change; keeping
487                // every stray line would reintroduce the unbounded growth this
488                // parser just capped.
489                let mut cut = line.len().min(512);
490                while cut > 0 && !line.is_char_boundary(cut) {
491                    cut -= 1;
492                }
493                self.term.first_unparsed = Some(line[..cut].to_string());
494            }
495            return Vec::new();
496        };
497        // A record that names a type this parser knows is evidence the stream
498        // really is the shape we asked for.
499        if let Some(ty) = value.get("type").and_then(Value::as_str)
500            && self.recognizes(ty)
501        {
502            self.seen.structured = true;
503        }
504        let mut out = match self.agent {
505            Agent::Claude => self.claude(&value),
506            Agent::Codex => self.codex(&value),
507            Agent::Copilot => self.copilot(&value),
508        };
509        // Every event leaves through here, so bounding once at the exit covers
510        // all three agents rather than each parser remembering.
511        out = out.into_iter().map(enforce_bounds).collect();
512
513        // Fire `Started` exactly once, from whichever record first revealed the
514        // id, and put it ahead of that record's own events.
515        if !self.seen.started {
516            if let Some(session) = self.term.session.clone() {
517                self.seen.started = true;
518                let model = model_of(&value);
519                self.term.model.clone_from(&model);
520                out.insert(0, Event::Started { session, model });
521            }
522        }
523        out
524    }
525
526    /// A usage snapshot for a model call not yet reported.
527    ///
528    /// Deduplicated on `message.id`: one call arrives as several records, one
529    /// per content block, each carrying the same usage, so reporting every one
530    /// would triple-count a call in a host that accumulates them.
531    ///
532    /// Verified against claude 2.1.212: across a four-call turn the per-call
533    /// inputs summed to exactly the terminal record's totals, for input and for
534    /// both cache figures.
535    fn live_usage(&mut self, v: &Value) -> Vec<Event> {
536        let Some(message) = v.get("message") else {
537            return Vec::new();
538        };
539        let Some(usage) = message.get("usage") else {
540            return Vec::new();
541        };
542        let get = |key: &str| usage.get(key).and_then(Value::as_u64);
543        let (input, read, write) = (
544            get("input_tokens"),
545            get("cache_read_input_tokens"),
546            get("cache_creation_input_tokens"),
547        );
548        if input.is_none() && read.is_none() && write.is_none() {
549            return Vec::new();
550        }
551        let prompt = input.unwrap_or(0) + read.unwrap_or(0) + write.unwrap_or(0);
552        /*
553         * Each `assistant` record carries the usage of the one API request that
554         * produced it, and its prompt side is the conversation as the model saw
555         * it *right now*. Kept latest-wins as the turn's context figure,
556         * because the terminal record's usage sums every request in the turn: a
557         * tool-heavy turn re-reads the conversation once per round trip, and
558         * the summed "context" grows past the window itself (observed at 195%).
559         *
560         * Set before the id check on purpose. The event has to be deduplicated
561         * or a host's running total counts one call several times, but the
562         * context figure is latest-wins and harmless to set twice, so a record
563         * carrying no `message.id` still keeps it correct.
564         */
565        self.latest_context = Some(prompt);
566
567        // Without an id there is no way to tell a repeat from a new call, and
568        // over-reporting corrupts a running total, so silence is the safer miss.
569        let Some(id) = message.get("id").and_then(Value::as_str) else {
570            return Vec::new();
571        };
572        if self.seen.usage_of.as_deref() == Some(id) {
573            return Vec::new();
574        }
575        self.seen.usage_of = Some(id.to_string());
576        vec![Event::Usage(Usage {
577            input_tokens: input,
578            cache_read_tokens: read,
579            cache_write_tokens: write,
580            context_tokens: Some(prompt),
581            // Understated mid-turn: this is the count as the message began. See
582            // `Event::Usage`.
583            output_tokens: None,
584            ..Usage::default()
585        })]
586    }
587
588    /// Whether the agent's terminal record has arrived, so the turn is over.
589    ///
590    /// Needed by the runner for an approvals run: under `--input-format
591    /// stream-json` Claude keeps the session open waiting for another message,
592    /// so stdin has to be closed once the turn settles or the run only ends at
593    /// its timeout.
594    pub(crate) fn saw_terminal(&self) -> bool {
595        self.seen.terminal
596    }
597
598    /// Whether `ty` is a record type this agent's parser understands.
599    fn recognizes(&self, ty: &str) -> bool {
600        match self.agent {
601            Agent::Claude => matches!(
602                ty,
603                "system" | "assistant" | "user" | "result" | "rate_limit_event" | "control_request"
604            ),
605            Agent::Codex => {
606                ty.starts_with("thread.") || ty.starts_with("turn.") || ty.starts_with("item.")
607            }
608            Agent::Copilot => {
609                ty == "result"
610                    || ty.starts_with("assistant.")
611                    || ty.starts_with("tool.")
612                    || ty.starts_with("session.")
613            }
614        }
615    }
616
617    /// Track a tool call so its result can be attributed, bounded so an agent
618    /// that announces calls it never finishes cannot grow this without limit.
619    fn remember_tool(&mut self, id: &str, name: &str) {
620        // An unusable id cannot correlate anything, so tracking it only costs
621        // memory.
622        if !usable_identifier(id) {
623            return;
624        }
625        let name = bound_identifier(name.to_string());
626        let cost = id.len() + name.len();
627        // Both budgets matter: the count bounds a flood of tiny entries, the
628        // bytes bound a few enormous ones. Counting entries alone was no bound
629        // at all while the entries themselves were unbounded.
630        if self.tools.len() >= MAX_PENDING_TOOLS
631            || self.tool_bytes.saturating_add(cost) > MAX_PENDING_TOOL_BYTES
632        {
633            return;
634        }
635        self.tool_bytes += cost;
636        if let Some(previous) = self.tools.insert(id.to_string(), name) {
637            // Replacing an entry must not double-count its predecessor.
638            self.tool_bytes = self.tool_bytes.saturating_sub(id.len() + previous.len());
639        }
640    }
641
642    /// Stop tracking a call once its result has arrived, releasing its budget.
643    fn forget_tool(&mut self, id: &str) {
644        if let Some(name) = self.tools.remove(id) {
645            self.tool_bytes = self.tool_bytes.saturating_sub(id.len() + name.len());
646        }
647    }
648
649    /// Whether any structured record has been recognized on this stream.
650    ///
651    /// A structured run that recognized nothing did not merely fail to answer;
652    /// it means the output was not the shape this parser understands.
653    pub(crate) fn saw_structured_record(&self) -> bool {
654        self.seen.structured
655    }
656
657    /// Whether the stream carried its terminal record, the one that closes a
658    /// turn and carries the answer and usage.
659    pub(crate) fn saw_terminal_record(&self) -> bool {
660        self.seen.terminal
661    }
662
663    /// Consume the parser for everything only knowable at the end.
664    #[must_use]
665    pub fn finish(mut self) -> Terminal {
666        if self.format == Format::Text {
667            self.term.text = self.term.text.trim_end().to_string();
668        }
669        self.term
670    }
671
672    /// Claude Code `--output-format json` / `stream-json`.
673    ///
674    /// Verified against claude 2.1.212: `system/init` opens with the id,
675    /// `assistant` records carry Anthropic content blocks, `rate_limit_event`
676    /// reports quota, and `result` closes with the answer and usage.
677    fn claude(&mut self, v: &Value) -> Vec<Event> {
678        let ty = v.get("type").and_then(Value::as_str).unwrap_or_default();
679        // An unusable session id must not be captured: it would be persisted as
680        // a binding that can never resume anything.
681        if let Some(id) = v.get("session_id").and_then(Value::as_str)
682            && usable_identifier(id)
683        {
684            self.term.session.get_or_insert_with(|| id.to_string());
685        }
686        match ty {
687            "rate_limit_event" => {
688                let limit = claude_rate_limit(v.get("rate_limit_info"));
689                self.term.rate_limit.clone_from(&limit);
690                limit.into_iter().map(Event::RateLimit).collect()
691            }
692            // Token-level deltas, present only with `--include-partial-messages`.
693            "stream_event" => self.claude_delta(v),
694            // Both roles carry content blocks: `assistant` holds text/thinking/
695            // tool_use, `user` carries the tool_result observations back.
696            // The approval question, carried on Claude's control channel.
697            // Verified against claude 2.1.212:
698            //   {"type":"control_request","request_id":"...",
699            //    "request":{"subtype":"can_use_tool","tool_name":"Bash",
700            //               "input":{"command":"touch f","description":"..."}}}
701            "control_request" => {
702                let Some(request) = v.get("request") else {
703                    return Vec::new();
704                };
705                if request.get("subtype").and_then(Value::as_str) != Some("can_use_tool") {
706                    return Vec::new();
707                }
708                let Some(id) = v.get("request_id").and_then(Value::as_str) else {
709                    // Without an id the answer cannot be routed back, so the
710                    // question is unanswerable and dropping it is the only
711                    // honest option.
712                    return Vec::new();
713                };
714                if !usable_identifier(id) {
715                    return Vec::new();
716                }
717                vec![Event::ApprovalRequest(crate::approval::Approval {
718                    id: id.to_string(),
719                    tool: request
720                        .get("tool_name")
721                        .and_then(Value::as_str)
722                        .unwrap_or("unknown")
723                        .to_string(),
724                    input: request.get("input").cloned().unwrap_or(Value::Null),
725                })]
726            }
727            "assistant" | "user" => self.content_blocks(v),
728            "system" => self.claude_system(v),
729            "result" => {
730                self.seen.terminal = true;
731                if let Some(text) = v.get("result").and_then(Value::as_str) {
732                    self.term.text = text.to_string();
733                }
734                // Claude returns the conforming value as its own field, so it
735                // needs no re-parsing out of the answer text.
736                if let Some(value) = v.get("structured_output") {
737                    self.term.structured = Some(value.clone());
738                }
739                self.term.usage = claude_usage(v, self.term.model.as_deref());
740                // The per-request figure outranks the terminal sum; the sum
741                // stays only as the fallback for a turn whose assistant
742                // records carried no usage (older CLIs).
743                if self.latest_context.is_some() {
744                    self.term.usage.context_tokens = self.latest_context;
745                }
746                // `subtype` says "success" even for a failed turn, so
747                // `is_error` is the field that actually decides.
748                self.term.stop = if v.get("is_error").and_then(Value::as_bool) == Some(true) {
749                    self.term.error_status = v
750                        .get("api_error_status")
751                        .and_then(Value::as_u64)
752                        .and_then(|s| u16::try_from(s).ok());
753                    Stop::Error
754                } else {
755                    stop_from(v.get("stop_reason"))
756                };
757                Vec::new()
758            }
759            _ => Vec::new(),
760        }
761    }
762
763    /// Claude's `system` records: the catalogue at init, and compaction status.
764    ///
765    /// Verified against claude 2.1.212. A `/compact` reports as
766    /// `{"subtype":"status","status":"compacting"}` and then
767    /// `{"subtype":"status","status":null,"compact_result":"success"}`, with
768    /// `compact_error` carrying the reason on a refusal.
769    ///
770    /// `compact_boundary` marks where the summary begins and is deliberately
771    /// not surfaced: it describes the transcript's shape, which this crate does
772    /// not model, and the `status` pair already says what happened.
773    fn claude_system(&mut self, v: &Value) -> Vec<Event> {
774        match v.get("subtype").and_then(Value::as_str) {
775            Some("init") => {
776                // The second `init` of a run is the session re-initialising
777                // after a compaction, carrying the same catalogue. Emitting it
778                // again would have a host redraw a palette that did not change.
779                if self.seen.catalogue {
780                    return Vec::new();
781                }
782                let names = |key: &str| -> Vec<String> {
783                    v.get(key)
784                        .and_then(Value::as_array)
785                        .map(|entries| {
786                            entries
787                                .iter()
788                                .filter_map(Value::as_str)
789                                .map(str::to_string)
790                                .collect()
791                        })
792                        .unwrap_or_default()
793                };
794                let commands = crate::command::Commands {
795                    all: names("slash_commands"),
796                    skills: names("skills"),
797                };
798                // An init that listed nothing is an older CLI, not an agent
799                // with no commands, and saying "none" would be a claim.
800                if commands.all.is_empty() {
801                    return Vec::new();
802                }
803                self.seen.catalogue = true;
804                vec![Event::Commands(commands)]
805            }
806            Some("status") => {
807                if v.get("status").and_then(Value::as_str) == Some("compacting") {
808                    return vec![Event::Compaction(crate::command::Compaction::Started)];
809                }
810                let Some(result) = v.get("compact_result").and_then(Value::as_str) else {
811                    return Vec::new();
812                };
813                vec![Event::Compaction(crate::command::Compaction::Finished {
814                    ok: result == "success",
815                    error: v
816                        .get("compact_error")
817                        .and_then(Value::as_str)
818                        .map(str::to_string),
819                })]
820            }
821            _ => Vec::new(),
822        }
823    }
824
825    /// One token-level delta from Claude's `stream_event` records.
826    ///
827    /// These wrap the provider's own streaming events. Only the deltas that
828    /// carry visible text are surfaced; the block start/stop and message
829    /// envelopes describe structure this crate already expresses through the
830    /// event vocabulary.
831    fn claude_delta(&mut self, v: &Value) -> Vec<Event> {
832        let Some(event) = v.get("event") else {
833            return Vec::new();
834        };
835        if event.get("type").and_then(Value::as_str) != Some("content_block_delta") {
836            return Vec::new();
837        }
838        let Some(delta) = event.get("delta") else {
839            return Vec::new();
840        };
841        // Seeing any delta means the completed message that follows is a
842        // duplicate of what has already been streamed.
843        self.seen.deltas = true;
844
845        match delta.get("type").and_then(Value::as_str) {
846            Some("text_delta") => delta
847                .get("text")
848                .and_then(Value::as_str)
849                .filter(|text| !text.is_empty())
850                .map(|text| Event::Text(text.to_string()))
851                .into_iter()
852                .collect(),
853            Some("thinking_delta") => delta
854                .get("thinking")
855                .and_then(Value::as_str)
856                .filter(|text| !text.is_empty())
857                .map(|text| Event::Thinking(text.to_string()))
858                .into_iter()
859                .collect(),
860            // `input_json_delta` streams a tool call's arguments a fragment at a
861            // time. The completed `tool_use` block carries them whole, which is
862            // what a consumer can actually act on, so the fragments are skipped.
863            _ => Vec::new(),
864        }
865    }
866
867    /// Anthropic content blocks, shared by Claude's `assistant` and `user`
868    /// records.
869    fn content_blocks(&mut self, v: &Value) -> Vec<Event> {
870        let mut out = self.live_usage(v);
871        let blocks = v
872            .get("message")
873            .and_then(|m| m.get("content"))
874            .and_then(Value::as_array);
875        let Some(blocks) = blocks else {
876            return out;
877        };
878        for block in blocks {
879            let ty = block
880                .get("type")
881                .and_then(Value::as_str)
882                .unwrap_or_default();
883            match ty {
884                // Skipped once deltas have streamed the same text, or the
885                // transcript would show every answer twice.
886                // Guarded on `deltas`: once tokens have streamed, the finished
887                // copy falls through to the catch-all and is dropped.
888                "text" if !self.seen.deltas => {
889                    if let Some(t) = block.get("text").and_then(Value::as_str) {
890                        out.push(Event::Text(t.to_string()));
891                    }
892                }
893                "thinking" if !self.seen.deltas => {
894                    if let Some(t) = block.get("thinking").and_then(Value::as_str) {
895                        out.push(Event::Thinking(t.to_string()));
896                    }
897                }
898                "tool_use" => {
899                    let name = block
900                        .get("name")
901                        .and_then(Value::as_str)
902                        .unwrap_or("tool")
903                        .to_string();
904                    let id = block.get("id").and_then(Value::as_str).map(str::to_string);
905                    if let Some(id) = &id {
906                        self.remember_tool(id, &name);
907                    }
908                    out.push(Event::ToolCall {
909                        id,
910                        name,
911                        input: block.get("input").cloned().unwrap_or(Value::Null),
912                    });
913                }
914                "tool_result" => out.push(Event::ToolResult {
915                    id: block
916                        .get("tool_use_id")
917                        .and_then(Value::as_str)
918                        .inspect(|id| {
919                            // The call has been answered, so stop tracking it.
920                            self.forget_tool(id);
921                        })
922                        .map(str::to_string),
923                    ok: block
924                        .get("is_error")
925                        .and_then(Value::as_bool)
926                        .map(|is_error| !is_error),
927                    output: flatten_text(block.get("content")),
928                }),
929                _ => {}
930            }
931        }
932        out
933    }
934
935    /// Codex `exec --json`.
936    ///
937    /// Verified against codex-cli 0.145.0: `thread.started` opens with
938    /// `thread_id`, items arrive as `item.started` → `item.completed` pairs, and
939    /// `turn.completed` carries usage. A tool item appears twice: once
940    /// in-progress with an empty `aggregated_output`, once finished, so the
941    /// call is emitted on first sighting and the result only once it completes.
942    fn codex(&mut self, v: &Value) -> Vec<Event> {
943        let ty = v.get("type").and_then(Value::as_str).unwrap_or_default();
944        if let Some(id) = v.get("thread_id").and_then(Value::as_str)
945            && usable_identifier(id)
946        {
947            self.term.session.get_or_insert_with(|| id.to_string());
948        }
949        match ty {
950            "turn.completed" => {
951                self.seen.terminal = true;
952                self.term.usage = codex_usage(v.get("usage"));
953                Vec::new()
954            }
955            "turn.failed" => {
956                self.seen.terminal = true;
957                self.term.stop = Stop::Error;
958                if let Some(message) = v
959                    .get("error")
960                    .and_then(|e| e.get("message"))
961                    .and_then(Value::as_str)
962                {
963                    let (status, message) = unwrap_error_body(message);
964                    self.term.error_status = status;
965                    self.term.error_message = Some(bound_text(message));
966                }
967                Vec::new()
968            }
969            "item.started" | "item.updated" | "item.completed" => {
970                let Some(item) = v.get("item") else {
971                    return Vec::new();
972                };
973                let item_ty = item.get("type").and_then(Value::as_str).unwrap_or_default();
974                let id = item.get("id").and_then(Value::as_str).map(str::to_string);
975                let done = ty == "item.completed";
976
977                // Every item is reported at least twice: in progress, then
978                // finished. Announce each one exactly once, on first sighting,
979                // and keep the id → name binding for the result.
980                let name = tool_name(item, item_ty);
981                let first = id
982                    .as_ref()
983                    .is_none_or(|id| self.tools.insert(id.clone(), name.clone()).is_none());
984
985                match item_ty {
986                    // The settled text is authoritative; a turn may contain
987                    // several messages, so the last one to complete wins.
988                    "agent_message" => {
989                        if !done {
990                            return Vec::new();
991                        }
992                        let text = item.get("text").and_then(Value::as_str).unwrap_or_default();
993                        self.term.text = text.to_string();
994                        vec![Event::Text(text.to_string())]
995                    }
996                    "reasoning" if done => item
997                        .get("text")
998                        .and_then(Value::as_str)
999                        .map(|t| Event::Thinking(t.to_string()))
1000                        .into_iter()
1001                        .collect(),
1002                    "command_execution" | "mcp_tool_call" | "file_change" | "web_search" => {
1003                        let mut out = Vec::new();
1004                        if first {
1005                            out.push(Event::ToolCall {
1006                                id: id.clone(),
1007                                name,
1008                                input: codex_tool_input(item, item_ty),
1009                            });
1010                        }
1011                        // Only the finished record carries real output: the
1012                        // in-progress one has an empty string and a null code.
1013                        if done {
1014                            if let Some(id) = &id {
1015                                self.forget_tool(id);
1016                            }
1017                            out.push(Event::ToolResult {
1018                                id,
1019                                ok: item
1020                                    .get("exit_code")
1021                                    .and_then(Value::as_i64)
1022                                    .map(|code| code == 0),
1023                                output: item
1024                                    .get("aggregated_output")
1025                                    .and_then(Value::as_str)
1026                                    .unwrap_or_default()
1027                                    .to_string(),
1028                            });
1029                        }
1030                        out
1031                    }
1032                    _ => Vec::new(),
1033                }
1034            }
1035            _ => Vec::new(),
1036        }
1037    }
1038
1039    /// Copilot `--output-format json` (JSONL).
1040    ///
1041    /// Verified against GitHub Copilot CLI 1.0.75: `assistant.message_delta`
1042    /// streams text, `assistant.message` carries the settled answer,
1043    /// `tool.execution_start` / `_complete` bracket a tool, and the final
1044    /// `result` carries `sessionId` and usage.
1045    fn copilot(&mut self, v: &Value) -> Vec<Event> {
1046        let ty = v.get("type").and_then(Value::as_str).unwrap_or_default();
1047        let data = v.get("data");
1048        let field = |key: &str| -> Option<String> {
1049            data.and_then(|d| d.get(key))
1050                .and_then(Value::as_str)
1051                .map(str::to_string)
1052        };
1053        match ty {
1054            // The delta stream is what a live transcript renders.
1055            "assistant.message_delta" => field("deltaContent")
1056                .filter(|t| !t.is_empty())
1057                .map(Event::Text)
1058                .into_iter()
1059                .collect(),
1060            // The settled message is authoritative but already shown as deltas,
1061            // so it updates the terminal text without re-emitting it.
1062            "assistant.message" => {
1063                if let Some(content) = field("content") {
1064                    self.term.text = content;
1065                }
1066                Vec::new()
1067            }
1068            "assistant.reasoning" => field("content")
1069                .filter(|t| !t.is_empty())
1070                .map(Event::Thinking)
1071                .into_iter()
1072                .collect(),
1073            "tool.execution_start" => {
1074                let id = field("toolCallId");
1075                let name = field("toolName").unwrap_or_else(|| "tool".into());
1076                if let Some(id) = &id {
1077                    self.remember_tool(id, &name);
1078                }
1079                vec![Event::ToolCall {
1080                    id,
1081                    name,
1082                    input: data
1083                        .and_then(|d| d.get("arguments"))
1084                        .cloned()
1085                        .unwrap_or(Value::Null),
1086                }]
1087            }
1088            "tool.execution_complete" => vec![Event::ToolResult {
1089                id: field("toolCallId").inspect(|id| {
1090                    self.forget_tool(id);
1091                }),
1092                ok: data.and_then(|d| d.get("success")).and_then(Value::as_bool),
1093                output: data
1094                    .and_then(|d| d.get("result"))
1095                    .and_then(|r| r.get("content"))
1096                    .and_then(Value::as_str)
1097                    .unwrap_or_default()
1098                    .to_string(),
1099            }],
1100            // Copilot reports spend on its own event rather than only at the
1101            // end, so a long run can show a running figure. Verified against
1102            // Copilot CLI 1.0.75; the value is session-scoped and restarts each
1103            // run rather than accruing across them.
1104            "session.usage_checkpoint" => {
1105                if let Some(data) = v.get("data") {
1106                    self.term.usage.ai_credits_nano =
1107                        data.get("totalNanoAiu").and_then(Value::as_u64);
1108                    if let Some(premium) = data.get("totalPremiumRequests").and_then(Value::as_u64)
1109                    {
1110                        self.term.usage.premium_requests = Some(premium);
1111                    }
1112                }
1113                Vec::new()
1114            }
1115            // Copilot's terminal record is flat, not nested under `data`.
1116            "result" => {
1117                self.seen.terminal = true;
1118                if let Some(id) = v.get("sessionId").and_then(Value::as_str)
1119                    && usable_identifier(id)
1120                {
1121                    self.term.session = Some(id.to_string());
1122                }
1123                if let Some(usage) = v.get("usage") {
1124                    self.term.usage.premium_requests =
1125                        usage.get("premiumRequests").and_then(Value::as_u64);
1126                    self.term.usage.duration_ms =
1127                        usage.get("sessionDurationMs").and_then(Value::as_u64);
1128                    self.term.usage.api_duration_ms =
1129                        usage.get("totalApiDurationMs").and_then(Value::as_u64);
1130                }
1131                if let Some(code) = v.get("exitCode").and_then(Value::as_i64)
1132                    && code != 0
1133                {
1134                    self.term.stop = Stop::Error;
1135                    // Copilot reports no explanation with the code, so name the
1136                    // code rather than leave the failure blank.
1137                    self.term.error_message = Some(format!("copilot exited with code {code}"));
1138                }
1139                Vec::new()
1140            }
1141            _ => Vec::new(),
1142        }
1143    }
1144}
1145
1146/// The model named by a record, if it names one. Claude puts it at the top
1147/// level; Copilot nests it under `data`.
1148fn model_of(v: &Value) -> Option<String> {
1149    v.get("model")
1150        .or_else(|| v.get("data").and_then(|d| d.get("model")))
1151        .and_then(Value::as_str)
1152        .map(str::to_string)
1153}
1154
1155/// A `stop_reason` string that is neither absent nor the normal end.
1156fn stop_from(v: Option<&Value>) -> Stop {
1157    match v.and_then(Value::as_str) {
1158        None | Some("end_turn" | "stop" | "completed") => Stop::Completed,
1159        Some(other) => Stop::Other(other.to_string()),
1160    }
1161}
1162
1163/// Claude's `rate_limit_info` object.
1164fn claude_rate_limit(v: Option<&Value>) -> Option<RateLimit> {
1165    let v = v?;
1166    Some(RateLimit {
1167        status: v.get("status").and_then(Value::as_str)?.to_string(),
1168        window: v
1169            .get("rateLimitType")
1170            .and_then(Value::as_str)
1171            .map(str::to_string),
1172        resets_at: v.get("resetsAt").and_then(Value::as_i64),
1173        overage_status: v
1174            .get("overageStatus")
1175            .and_then(Value::as_str)
1176            .map(str::to_string),
1177        is_using_overage: v.get("isUsingOverage").and_then(Value::as_bool),
1178    })
1179}
1180
1181/// Claude's terminal `usage` block plus its top-level `total_cost_usd`.
1182fn claude_usage(v: &Value, model: Option<&str>) -> Usage {
1183    let u = v.get("usage");
1184    let get = |key: &str| u.and_then(|u| u.get(key)).and_then(Value::as_u64);
1185    let (input, read, write) = (
1186        get("input_tokens"),
1187        get("cache_read_input_tokens"),
1188        get("cache_creation_input_tokens"),
1189    );
1190    // The window and output ceiling are reported per model, and `modelUsage`
1191    // is not single-entry: a run on any non-Haiku model also lists a Haiku
1192    // helper, and lists it *first*. Taking the first entry bound a 1M session
1193    // to the helper's 200k window, which presented as sessions capped at 200k.
1194    // The key is the resolved model name the `init` record announced, verified
1195    // against claude 2.1.212: asking for `sonnet[1m]`, init says
1196    // `claude-sonnet-5[1m]` and that exact string keys `modelUsage`.
1197    let per_model = v
1198        .get("modelUsage")
1199        .and_then(Value::as_object)
1200        .and_then(
1201            |models| match (model.and_then(|m| models.get(m)), models.len()) {
1202                (Some(entry), _) => Some(entry),
1203                // One entry and no name to match: it can only be the run's model.
1204                (None, 1) => models.values().next(),
1205                // Several entries and no match. Guessing here is how the bug
1206                // happened, so the window is reported as unknown instead.
1207                (None, _) => None,
1208            },
1209        );
1210    let of_model = |key: &str| per_model.and_then(|m| m.get(key)).and_then(Value::as_u64);
1211    Usage {
1212        input_tokens: input,
1213        output_tokens: get("output_tokens"),
1214        cache_read_tokens: read,
1215        cache_write_tokens: write,
1216        // Claude's `input_tokens` excludes cache, so the whole prompt is the
1217        // sum. Absent unless it reported at least one of the three, so that
1218        // "did not say" never becomes a zero.
1219        context_tokens: (input.is_some() || read.is_some() || write.is_some())
1220            .then(|| input.unwrap_or(0) + read.unwrap_or(0) + write.unwrap_or(0)),
1221        context_window: of_model("contextWindow"),
1222        max_output_tokens: of_model("maxOutputTokens"),
1223        reasoning_tokens: None,
1224        cost_usd: v.get("total_cost_usd").and_then(Value::as_f64),
1225        premium_requests: None,
1226        ai_credits_nano: None,
1227        duration_ms: v.get("duration_ms").and_then(Value::as_u64),
1228        api_duration_ms: v.get("duration_api_ms").and_then(Value::as_u64),
1229    }
1230}
1231
1232/// Codex's `turn.completed` usage block. Codex prices nothing itself, so
1233/// `cost_usd` stays absent rather than being derived from a local table.
1234fn codex_usage(v: Option<&Value>) -> Usage {
1235    let get = |key: &str| v.and_then(|u| u.get(key)).and_then(Value::as_u64);
1236    let (prompt, cached) = (get("input_tokens"), get("cached_input_tokens"));
1237    Usage {
1238        // Codex counts the other way round from Claude: its `input_tokens` is
1239        // the whole prompt with the cached part inside it. Verified across two
1240        // turns of one thread, where input rose 15342 -> 30703 while cached
1241        // rose 13056 -> 28160; had cached been separate, the second turn would
1242        // have meant 30k *new* tokens for a four-word question. Subtracting
1243        // makes `input_tokens` mean the same thing on both agents, and
1244        // `context_tokens` keeps the figure Codex actually reported.
1245        input_tokens: match (prompt, cached) {
1246            (Some(prompt), Some(cached)) => Some(prompt.saturating_sub(cached)),
1247            (prompt, _) => prompt,
1248        },
1249        output_tokens: get("output_tokens"),
1250        cache_read_tokens: cached,
1251        cache_write_tokens: get("cache_write_input_tokens"),
1252        context_tokens: prompt,
1253        context_window: None,
1254        max_output_tokens: None,
1255        reasoning_tokens: get("reasoning_output_tokens"),
1256        cost_usd: None,
1257        premium_requests: None,
1258        ai_credits_nano: None,
1259        duration_ms: None,
1260        api_duration_ms: None,
1261    }
1262}
1263
1264/// The display name of a Codex item: MCP and collaboration items name the tool
1265/// they invoked, everything else is identified by its item type.
1266fn tool_name(item: &Value, item_ty: &str) -> String {
1267    item.get("tool")
1268        .and_then(Value::as_str)
1269        .unwrap_or(item_ty)
1270        .to_string()
1271}
1272
1273/// The arguments of a Codex tool item, in whatever shape that item uses.
1274fn codex_tool_input(item: &Value, item_ty: &str) -> Value {
1275    match item_ty {
1276        "command_execution" => serde_json::json!({ "command": item.get("command") }),
1277        "mcp_tool_call" => item.get("arguments").cloned().unwrap_or(Value::Null),
1278        // `file_change` carries `changes`, `web_search` a `query`; neither has a
1279        // single canonical argument field, so the item stands in for itself.
1280        _ => item.clone(),
1281    }
1282}
1283
1284/// Flatten a tool result's `content` into the observation the model saw.
1285///
1286/// Anthropic tool results are either a bare string or an array of content
1287/// blocks. Text blocks flatten to their text; any other block kind (an image,
1288/// or a shape added in a future API version) is kept as its raw JSON rather
1289/// than dropped, so a caller inspecting a tool result never silently loses part
1290/// of it. This is lossy in presentation, never in content.
1291fn flatten_text(v: Option<&Value>) -> String {
1292    match v {
1293        Some(Value::String(s)) => s.clone(),
1294        Some(Value::Array(blocks)) => blocks
1295            .iter()
1296            .map(|b| match b.get("text").and_then(Value::as_str) {
1297                Some(text) => text.to_string(),
1298                None => b.to_string(),
1299            })
1300            .collect::<Vec<_>>()
1301            .join("\n"),
1302        Some(other) => other.to_string(),
1303        None => String::new(),
1304    }
1305}
1306
1307#[cfg(test)]
1308mod tests {
1309    use super::*;
1310
1311    /// Drive a parser over `lines`, returning every event and the terminal.
1312    fn run(agent: Agent, lines: &[&str]) -> (Vec<Event>, Terminal) {
1313        let mut p = Parser::new(agent, Format::Stream);
1314        let events = lines.iter().flat_map(|l| p.push(l)).collect();
1315        (events, p.finish())
1316    }
1317
1318    // Lines below are trimmed copies of transcripts captured from the live CLIs.
1319
1320    /// The compaction lifecycle, from a real `/compact` run.
1321    ///
1322    /// Records copied from claude 2.1.212 resuming a session: the status pair,
1323    /// the second `init` the session re-initialises with, the boundary marker,
1324    /// and a terminal carrying no text because a compaction writes no answer.
1325    #[test]
1326    fn a_compaction_reports_its_phases_and_settles_cleanly() {
1327        let (events, _) = run(
1328            Agent::Claude,
1329            &[
1330                r#"{"type":"system","subtype":"init","session_id":"s","model":"claude-opus-5","slash_commands":["compact","context","code-review"],"skills":["code-review"]}"#,
1331                r#"{"type":"system","subtype":"status","status":"compacting","session_id":"s"}"#,
1332                r#"{"type":"system","subtype":"status","status":null,"compact_result":"success","session_id":"s"}"#,
1333                r#"{"type":"system","subtype":"init","session_id":"s","slash_commands":["compact","context","code-review"],"skills":["code-review"]}"#,
1334                r#"{"type":"system","subtype":"compact_boundary","session_id":"s"}"#,
1335                r#"{"type":"result","subtype":"success","is_error":false,"result":"","session_id":"s","usage":{"input_tokens":0,"output_tokens":0}}"#,
1336            ],
1337        );
1338
1339        let catalogue: Vec<_> = events
1340            .iter()
1341            .filter_map(|event| match event {
1342                Event::Commands(commands) => Some(commands),
1343                _ => None,
1344            })
1345            .collect();
1346        assert_eq!(
1347            catalogue.len(),
1348            1,
1349            "the re-init after compacting must not redraw the palette"
1350        );
1351        assert_eq!(catalogue[0].utilities(), vec!["compact", "context"]);
1352
1353        let phases: Vec<_> = events
1354            .iter()
1355            .filter_map(|event| match event {
1356                Event::Compaction(phase) => Some(phase.clone()),
1357                _ => None,
1358            })
1359            .collect();
1360        assert_eq!(
1361            phases,
1362            vec![
1363                crate::command::Compaction::Started,
1364                crate::command::Compaction::Finished {
1365                    ok: true,
1366                    error: None
1367                },
1368            ]
1369        );
1370    }
1371
1372    /// A refusal is an answer. The run completes and says why.
1373    #[test]
1374    fn a_refused_compaction_is_reported_not_raised() {
1375        let (events, _) = run(
1376            Agent::Claude,
1377            &[
1378                r#"{"type":"system","subtype":"init","session_id":"s","model":"claude-opus-5"}"#,
1379                r#"{"type":"system","subtype":"status","status":null,"compact_result":"failed","compact_error":"Not enough messages to compact.","session_id":"s"}"#,
1380                r#"{"type":"result","subtype":"success","is_error":false,"result":"","session_id":"s"}"#,
1381            ],
1382        );
1383        assert!(events.iter().any(|event| matches!(
1384            event,
1385            Event::Compaction(crate::command::Compaction::Finished { ok: false, error: Some(why) })
1386                if why == "Not enough messages to compact."
1387        )));
1388        // An init that listed nothing is an older CLI, not an agent with no
1389        // commands, so nothing is claimed about the catalogue.
1390        assert!(
1391            !events
1392                .iter()
1393                .any(|event| matches!(event, Event::Commands(_)))
1394        );
1395    }
1396
1397    #[test]
1398    fn claude_stream_yields_start_thinking_text_and_terminal_facts() {
1399        let (events, term) = run(
1400            Agent::Claude,
1401            &[
1402                r#"{"type":"system","subtype":"init","session_id":"sess-a","model":"claude-haiku-4-5"}"#,
1403                r#"{"type":"assistant","session_id":"sess-a","message":{"content":[{"type":"thinking","thinking":"brief"}]}}"#,
1404                r#"{"type":"assistant","session_id":"sess-a","message":{"content":[{"type":"text","text":"pong"}]}}"#,
1405                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}}"#,
1406            ],
1407        );
1408        assert_eq!(
1409            events[0],
1410            Event::Started {
1411                session: "sess-a".into(),
1412                model: Some("claude-haiku-4-5".into())
1413            }
1414        );
1415        assert_eq!(events[1], Event::Thinking("brief".into()));
1416        assert_eq!(events[2], Event::Text("pong".into()));
1417        assert_eq!(term.session.as_deref(), Some("sess-a"));
1418        assert_eq!(term.text, "pong");
1419        assert_eq!(term.stop, Stop::Completed);
1420        assert_eq!(term.usage.input_tokens, Some(10));
1421        assert_eq!(term.usage.cache_read_tokens, Some(18764));
1422        assert_eq!(term.usage.cache_write_tokens, Some(7322));
1423        assert_eq!(term.usage.cost_usd, Some(0.017));
1424    }
1425
1426    /// Verbatim from a `--include-partial-messages` run. Claude sends both the
1427    /// deltas and the completed message they build up to, so emitting both
1428    /// would show every answer twice in a transcript.
1429    /// Verbatim shape from claude 2.1.212: a run on any non-Haiku model lists
1430    /// a Haiku helper in `modelUsage` too, and lists it *first*. Taking the
1431    /// first entry bound a 1M session to the helper's 200k window, which
1432    /// presented to a user as "sessions are limited to 200k context".
1433    #[test]
1434    fn the_window_binds_to_the_runs_model_not_the_haiku_helper() {
1435        let (_, term) = run(
1436            Agent::Claude,
1437            &[
1438                r#"{"type":"system","subtype":"init","session_id":"sess-1m","model":"claude-sonnet-5[1m]"}"#,
1439                r#"{"type":"result","subtype":"success","is_error":false,"result":"ok","session_id":"sess-1m","total_cost_usd":0.0677,"usage":{"input_tokens":2,"output_tokens":4,"cache_read_input_tokens":27128,"cache_creation_input_tokens":9825},"modelUsage":{"claude-haiku-4-5-20251001":{"inputTokens":521,"outputTokens":12,"cacheReadInputTokens":0,"cacheCreationInputTokens":0,"costUSD":0.000581,"contextWindow":200000,"maxOutputTokens":32000},"claude-sonnet-5[1m]":{"inputTokens":2,"outputTokens":4,"cacheReadInputTokens":27128,"cacheCreationInputTokens":9825,"costUSD":0.0671544,"contextWindow":1000000,"maxOutputTokens":64000}}}"#,
1440            ],
1441        );
1442        assert_eq!(term.model.as_deref(), Some("claude-sonnet-5[1m]"));
1443        assert_eq!(
1444            term.usage.context_window,
1445            Some(1_000_000),
1446            "the helper's 200k window must not shadow the real one"
1447        );
1448        assert_eq!(term.usage.max_output_tokens, Some(64_000));
1449        // The top-level usage block already tracks the main model.
1450        assert_eq!(term.usage.context_tokens, Some(2 + 27_128 + 9_825));
1451    }
1452
1453    /// The terminal record's usage sums every API request in the turn, so a
1454    /// tool-heavy turn re-counts the conversation once per round trip — a host
1455    /// displayed 195% of a 1M window from exactly this. Each `assistant`
1456    /// record carries the usage of its own request; the last one's prompt side
1457    /// is the conversation as the model actually saw it, and it wins.
1458    #[test]
1459    fn context_is_the_last_requests_prompt_not_the_turns_sum() {
1460        let (_, term) = run(
1461            Agent::Claude,
1462            &[
1463                r#"{"type":"system","subtype":"init","session_id":"s","model":"claude-sonnet-5"}"#,
1464                r#"{"type":"assistant","session_id":"s","message":{"usage":{"input_tokens":4,"output_tokens":20,"cache_read_input_tokens":100000,"cache_creation_input_tokens":2000},"content":[{"type":"tool_use","id":"t1","name":"Bash","input":{"command":"ls"}}]}}"#,
1465                r#"{"type":"user","session_id":"s","message":{"content":[{"type":"tool_result","tool_use_id":"t1","content":"ok"}]}}"#,
1466                r#"{"type":"assistant","session_id":"s","message":{"usage":{"input_tokens":6,"output_tokens":40,"cache_read_input_tokens":102000,"cache_creation_input_tokens":500},"content":[{"type":"text","text":"done"}]}}"#,
1467                r#"{"type":"result","subtype":"success","is_error":false,"result":"done","session_id":"s","usage":{"input_tokens":10,"output_tokens":60,"cache_read_input_tokens":202000,"cache_creation_input_tokens":2500}}"#,
1468            ],
1469        );
1470        assert_eq!(
1471            term.usage.context_tokens,
1472            Some(6 + 102_000 + 500),
1473            "the last request's prompt is the context; the turn sum (204,510) is not"
1474        );
1475        // The summed figures keep their own meaning: total charged work.
1476        assert_eq!(term.usage.cache_read_tokens, Some(202_000));
1477    }
1478
1479    /// A turn whose assistant records carry no usage (older CLIs) still gets
1480    /// the terminal figure rather than nothing.
1481    #[test]
1482    fn the_terminal_sum_remains_the_fallback_context() {
1483        let (_, term) = run(
1484            Agent::Claude,
1485            &[
1486                r#"{"type":"system","subtype":"init","session_id":"s","model":"claude-sonnet-5"}"#,
1487                r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"text","text":"ok"}]}}"#,
1488                r#"{"type":"result","subtype":"success","is_error":false,"result":"ok","session_id":"s","usage":{"input_tokens":10,"output_tokens":5,"cache_read_input_tokens":1000,"cache_creation_input_tokens":0}}"#,
1489            ],
1490        );
1491        assert_eq!(term.usage.context_tokens, Some(10 + 1000));
1492    }
1493
1494    /// With several entries and no model name to match, the window is unknown
1495    /// rather than guessed. Guessing the first entry is how the bug happened.
1496    #[test]
1497    fn an_unmatchable_window_is_absent_not_guessed() {
1498        let (_, term) = run(
1499            Agent::Claude,
1500            &[
1501                // No init record, so the run's model was never announced.
1502                r#"{"type":"result","subtype":"success","is_error":false,"result":"ok","session_id":"s","usage":{"input_tokens":2,"output_tokens":4},"modelUsage":{"claude-haiku-4-5-20251001":{"contextWindow":200000},"claude-sonnet-5":{"contextWindow":1000000}}}"#,
1503            ],
1504        );
1505        assert_eq!(term.usage.context_window, None);
1506        // A single entry needs no name: it can only be the run's model.
1507        let (_, single) = run(
1508            Agent::Claude,
1509            &[
1510                r#"{"type":"result","subtype":"success","is_error":false,"result":"ok","session_id":"s","usage":{"input_tokens":2,"output_tokens":4},"modelUsage":{"claude-haiku-4-5-20251001":{"contextWindow":200000}}}"#,
1511            ],
1512        );
1513        assert_eq!(single.usage.context_window, Some(200_000));
1514    }
1515
1516    #[test]
1517    fn claude_token_deltas_stream_without_duplicating_the_finished_message() {
1518        let (events, _) = run(
1519            Agent::Claude,
1520            &[
1521                r#"{"type":"system","subtype":"init","session_id":"s"}"#,
1522                r#"{"type":"stream_event","session_id":"s","event":{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}}"#,
1523                r#"{"type":"stream_event","session_id":"s","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"po"}}}"#,
1524                r#"{"type":"stream_event","session_id":"s","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"ng"}}}"#,
1525                r#"{"type":"stream_event","session_id":"s","event":{"type":"content_block_stop","index":0}}"#,
1526                // The completed copy of the same text.
1527                r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"text","text":"pong"}]}}"#,
1528                r#"{"type":"result","subtype":"success","is_error":false,"result":"pong","session_id":"s"}"#,
1529            ],
1530        );
1531        let texts: Vec<_> = events
1532            .iter()
1533            .filter_map(|e| match e {
1534                Event::Text(t) => Some(t.as_str()),
1535                _ => None,
1536            })
1537            .collect();
1538        assert_eq!(texts, ["po", "ng"], "the finished message must not repeat");
1539    }
1540
1541    /// Thinking streams the same way, and must not double either.
1542    #[test]
1543    fn claude_thinking_deltas_stream_without_duplication() {
1544        let (events, _) = run(
1545            Agent::Claude,
1546            &[
1547                r#"{"type":"stream_event","session_id":"s","event":{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"weighing"}}}"#,
1548                r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"thinking","thinking":"weighing"}]}}"#,
1549            ],
1550        );
1551        let thoughts: Vec<_> = events
1552            .iter()
1553            .filter_map(|e| match e {
1554                Event::Thinking(t) => Some(t.as_str()),
1555                _ => None,
1556            })
1557            .collect();
1558        assert_eq!(thoughts, ["weighing"]);
1559    }
1560
1561    /// Without partial messages there are no deltas, so the completed message
1562    /// is the only source and must still be emitted.
1563    #[test]
1564    fn a_completed_message_still_streams_when_no_deltas_arrived() {
1565        let (events, _) = run(
1566            Agent::Claude,
1567            &[
1568                r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"text","text":"pong"}]}}"#,
1569            ],
1570        );
1571        assert!(events.contains(&Event::Text("pong".into())), "{events:?}");
1572    }
1573
1574    /// Tool calls are not duplicated by deltas, so they keep coming from the
1575    /// completed block even once deltas have been seen.
1576    #[test]
1577    fn tool_calls_survive_delta_suppression() {
1578        let (events, _) = run(
1579            Agent::Claude,
1580            &[
1581                r#"{"type":"stream_event","session_id":"s","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hi"}}}"#,
1582                r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"tool_use","id":"t1","name":"Bash","input":{"command":"ls"}}]}}"#,
1583            ],
1584        );
1585        assert!(
1586            events.iter().any(|e| matches!(e, Event::ToolCall { .. })),
1587            "suppression must apply to text only: {events:?}"
1588        );
1589    }
1590
1591    #[test]
1592    fn claude_started_fires_only_once() {
1593        let (events, _) = run(
1594            Agent::Claude,
1595            &[
1596                r#"{"type":"system","subtype":"init","session_id":"s"}"#,
1597                r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"text","text":"a"}]}}"#,
1598                r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"text","text":"b"}]}}"#,
1599            ],
1600        );
1601        assert_eq!(
1602            events
1603                .iter()
1604                .filter(|e| matches!(e, Event::Started { .. }))
1605                .count(),
1606            1
1607        );
1608    }
1609
1610    /// Verbatim shape from claude 2.1.212: one model call arrives as several
1611    /// `assistant` records, one per content block, each repeating the same
1612    /// usage. Reporting every one would make a host's running total count the
1613    /// call three times.
1614    #[test]
1615    fn a_model_call_reports_its_usage_once_however_many_blocks_it_has() {
1616        let (events, _) = run(
1617            Agent::Claude,
1618            &[
1619                r#"{"type":"assistant","session_id":"s","message":{"id":"msg_a","content":[{"type":"thinking","thinking":"..."}],"usage":{"input_tokens":10,"cache_read_input_tokens":20180,"cache_creation_input_tokens":7574,"output_tokens":4}}}"#,
1620                r#"{"type":"assistant","session_id":"s","message":{"id":"msg_a","content":[{"type":"tool_use","id":"t1","name":"Bash","input":{}}],"usage":{"input_tokens":10,"cache_read_input_tokens":20180,"cache_creation_input_tokens":7574,"output_tokens":4}}}"#,
1621                r#"{"type":"assistant","session_id":"s","message":{"id":"msg_b","content":[{"type":"text","text":"done"}],"usage":{"input_tokens":8,"cache_read_input_tokens":30427,"cache_creation_input_tokens":0,"output_tokens":3}}}"#,
1622            ],
1623        );
1624        let usage: Vec<&Usage> = events
1625            .iter()
1626            .filter_map(|e| match e {
1627                Event::Usage(u) => Some(u),
1628                _ => None,
1629            })
1630            .collect();
1631        assert_eq!(usage.len(), 2, "two model calls, three records: {events:?}");
1632        assert_eq!(usage[0].input_tokens, Some(10));
1633        assert_eq!(usage[0].context_tokens, Some(10 + 20180 + 7574));
1634        assert_eq!(usage[1].context_tokens, Some(8 + 30427));
1635    }
1636
1637    /// The context figure must survive a record with no `message.id`, which is
1638    /// what an older CLI emits. The event is deduplicated on that id and so
1639    /// cannot fire, but the terminal context is latest-wins and must still be
1640    /// right, so the two are deliberately gated differently.
1641    #[test]
1642    fn context_is_still_tracked_when_a_record_carries_no_id() {
1643        let (events, term) = run(
1644            Agent::Claude,
1645            &[
1646                r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"text","text":"hi"}],"usage":{"input_tokens":8,"cache_read_input_tokens":30427,"cache_creation_input_tokens":0}}}"#,
1647                r#"{"type":"result","subtype":"success","is_error":false,"result":"hi","session_id":"s","usage":{"input_tokens":99,"cache_read_input_tokens":99}}"#,
1648            ],
1649        );
1650        assert!(
1651            !events.iter().any(|e| matches!(e, Event::Usage(_))),
1652            "no id means no way to deduplicate, so nothing is reported"
1653        );
1654        assert_eq!(
1655            term.usage.context_tokens,
1656            Some(8 + 30427),
1657            "the per-request figure must still outrank the terminal sum"
1658        );
1659    }
1660
1661    /// Mid-turn the agent reports output as it stood when the message began,
1662    /// which understates the finished figure badly. Reporting it would let a
1663    /// host build a counter that is simply wrong, so it is withheld.
1664    #[test]
1665    fn a_live_snapshot_withholds_the_output_count() {
1666        let (events, _) = run(
1667            Agent::Claude,
1668            &[
1669                r#"{"type":"assistant","session_id":"s","message":{"id":"m","content":[{"type":"text","text":"hi"}],"usage":{"input_tokens":8,"output_tokens":1}}}"#,
1670            ],
1671        );
1672        let Some(Event::Usage(usage)) = events.iter().find(|e| matches!(e, Event::Usage(_))) else {
1673            panic!("expected a usage event: {events:?}")
1674        };
1675        assert_eq!(usage.output_tokens, None, "a partial count is not reported");
1676        assert_eq!(usage.input_tokens, Some(8), "the exact figures still are");
1677    }
1678
1679    /// Accumulating the live events must land on the same totals the terminal
1680    /// record reports, or a counter would drift from the final number it is
1681    /// about to be replaced by. Figures from one real four-call turn.
1682    #[test]
1683    fn live_snapshots_accumulate_to_the_terminal_totals() {
1684        let calls = [
1685            (10u64, 20180u64, 7574u64),
1686            (8, 0, 30427),
1687            (8, 30427, 1859),
1688            (8, 32286, 115),
1689        ];
1690        let mut session = Usage::default();
1691        for (input, read, write) in calls {
1692            session.accumulate(&Usage {
1693                input_tokens: Some(input),
1694                cache_read_tokens: Some(read),
1695                cache_write_tokens: Some(write),
1696                context_tokens: Some(input + read + write),
1697                ..Usage::default()
1698            });
1699        }
1700        // The terminal record for that run.
1701        assert_eq!(session.input_tokens, Some(34));
1702        assert_eq!(
1703            session.context_tokens,
1704            Some(8 + 32286 + 115),
1705            "context takes the latest, being cumulative already"
1706        );
1707    }
1708
1709    #[test]
1710    fn claude_pairs_tool_use_with_its_result() {
1711        let (events, _) = run(
1712            Agent::Claude,
1713            &[
1714                r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"tool_use","id":"toolu_1","name":"Bash","input":{"command":"ls"}}]}}"#,
1715                r#"{"type":"user","session_id":"s","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"a.txt"}]}}"#,
1716            ],
1717        );
1718        let call = events
1719            .iter()
1720            .find(|e| matches!(e, Event::ToolCall { .. }))
1721            .unwrap();
1722        let Event::ToolCall { id, name, input } = call else {
1723            unreachable!()
1724        };
1725        assert_eq!(id.as_deref(), Some("toolu_1"));
1726        assert_eq!(name, "Bash");
1727        assert_eq!(input["command"], "ls");
1728        assert!(events.contains(&Event::ToolResult {
1729            id: Some("toolu_1".into()),
1730            ok: None,
1731            output: "a.txt".into(),
1732        }));
1733    }
1734
1735    /// Verbatim from claude 2.1.212 under `--permission-prompt-tool stdio`.
1736    /// The Bash input carries the command, which is the part a user has to see
1737    /// before deciding: approving on the tool name alone approves an unseen
1738    /// command.
1739    #[test]
1740    fn an_approval_request_carries_the_tool_and_its_arguments() {
1741        let (events, _) = run(
1742            Agent::Claude,
1743            &[
1744                r#"{"type":"control_request","request_id":"req-7","request":{"subtype":"can_use_tool","tool_name":"Bash","input":{"command":"touch created-by-probe.txt","description":"Create an empty file"}}}"#,
1745            ],
1746        );
1747        let [Event::ApprovalRequest(approval)] = &events[..] else {
1748            panic!("expected one approval request, got {events:?}")
1749        };
1750        assert_eq!(approval.id, "req-7");
1751        assert_eq!(approval.tool, "Bash");
1752        assert_eq!(approval.input["command"], "touch created-by-probe.txt");
1753    }
1754
1755    /// Without an id the answer cannot be routed back, so the question is
1756    /// unanswerable. Emitting it would strand a consumer holding a request it
1757    /// can never resolve, blocking the run until timeout.
1758    #[test]
1759    fn an_unanswerable_approval_request_is_dropped() {
1760        for line in [
1761            // No request_id at all.
1762            r#"{"type":"control_request","request":{"subtype":"can_use_tool","tool_name":"Bash","input":{}}}"#,
1763            // An id too large to be usable.
1764            &format!(
1765                r#"{{"type":"control_request","request_id":"{}","request":{{"subtype":"can_use_tool","tool_name":"Bash","input":{{}}}}}}"#,
1766                "x".repeat(MAX_IDENTIFIER_BYTES + 1)
1767            ),
1768        ] {
1769            let (events, _) = run(Agent::Claude, &[line]);
1770            assert!(
1771                events.is_empty(),
1772                "an unanswerable request must not reach a consumer: {events:?}"
1773            );
1774        }
1775    }
1776
1777    /// Other control requests share the channel and are not approvals.
1778    #[test]
1779    fn a_control_request_that_is_not_an_approval_is_ignored() {
1780        let (events, _) = run(
1781            Agent::Claude,
1782            &[r#"{"type":"control_request","request_id":"r","request":{"subtype":"initialize"}}"#],
1783        );
1784        assert!(events.is_empty(), "{events:?}");
1785    }
1786
1787    #[test]
1788    fn claude_reports_a_rate_limit_without_failing() {
1789        let (events, term) = run(
1790            Agent::Claude,
1791            &[
1792                r#"{"type":"rate_limit_event","session_id":"s","rate_limit_info":{"status":"allowed","resetsAt":1785260400,"rateLimitType":"five_hour"}}"#,
1793            ],
1794        );
1795        let limit = RateLimit {
1796            status: "allowed".into(),
1797            window: Some("five_hour".into()),
1798            resets_at: Some(1_785_260_400),
1799            overage_status: None,
1800            is_using_overage: None,
1801        };
1802        assert!(events.contains(&Event::RateLimit(limit.clone())));
1803        assert_eq!(term.rate_limit, Some(limit.clone()));
1804        assert!(
1805            !limit.is_blocking(),
1806            "an `allowed` heartbeat is not a block"
1807        );
1808    }
1809
1810    #[test]
1811    fn claude_error_result_sets_the_stop_reason() {
1812        let (_, term) = run(
1813            Agent::Claude,
1814            &[r#"{"type":"result","is_error":true,"result":"boom","session_id":"s"}"#],
1815        );
1816        assert_eq!(term.stop, Stop::Error);
1817    }
1818
1819    #[test]
1820    fn copilot_streams_deltas_and_takes_its_answer_from_the_settled_message() {
1821        let (events, term) = run(
1822            Agent::Copilot,
1823            &[
1824                r#"{"type":"assistant.message_delta","data":{"messageId":"m","deltaContent":"po"}}"#,
1825                r#"{"type":"assistant.message_delta","data":{"messageId":"m","deltaContent":"ng"}}"#,
1826                r#"{"type":"assistant.message","data":{"messageId":"m","model":"gpt-5-mini","content":"pong"}}"#,
1827                r#"{"type":"result","sessionId":"768c8e7d","exitCode":0,"usage":{"premiumRequests":0}}"#,
1828            ],
1829        );
1830        // The deltas stream; the settled message must not double them.
1831        let texts: Vec<_> = events
1832            .iter()
1833            .filter_map(|e| match e {
1834                Event::Text(t) => Some(t.as_str()),
1835                _ => None,
1836            })
1837            .collect();
1838        assert_eq!(texts, ["po", "ng"]);
1839        assert_eq!(term.text, "pong", "the answer is the settled message");
1840        assert_eq!(term.session.as_deref(), Some("768c8e7d"));
1841        assert_eq!(term.usage.premium_requests, Some(0));
1842    }
1843
1844    #[test]
1845    fn copilot_brackets_a_tool_call_with_its_completion() {
1846        let (events, _) = run(
1847            Agent::Copilot,
1848            &[
1849                r#"{"type":"tool.execution_start","data":{"toolCallId":"call_1","toolName":"bash","arguments":{"command":"ls"}}}"#,
1850                r#"{"type":"tool.execution_complete","data":{"toolCallId":"call_1","success":true,"result":{"content":"a.txt"}}}"#,
1851            ],
1852        );
1853        assert!(matches!(
1854            &events[0],
1855            Event::ToolCall { id, name, .. }
1856                if id.as_deref() == Some("call_1") && name == "bash"
1857        ));
1858        assert_eq!(
1859            events[1],
1860            Event::ToolResult {
1861                id: Some("call_1".into()),
1862                ok: Some(true),
1863                output: "a.txt".into()
1864            }
1865        );
1866    }
1867
1868    /// Verbatim from codex-cli 0.145.0 with an unknown model. It exits **0**
1869    /// and forwards the upstream body as a JSON *string*, so the status worth
1870    /// branching on is nested one level inside a field that is itself text.
1871    #[test]
1872    fn a_codex_failed_turn_yields_the_reason_and_the_status() {
1873        let (_, term) = run(
1874            Agent::Codex,
1875            &[
1876                r#"{"type":"thread.started","thread_id":"019fad62"}"#,
1877                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.\"}}"}}"#,
1878            ],
1879        );
1880        assert_eq!(term.stop, Stop::Error);
1881        assert_eq!(term.error_status, Some(400));
1882        assert_eq!(
1883            term.error_message.as_deref(),
1884            Some(
1885                "The 'bogus-model-xyz' model is not supported when using Codex with a ChatGPT account."
1886            ),
1887            "the caller should get the sentence, not the envelope"
1888        );
1889    }
1890
1891    /// An error that is not the double-encoded shape must survive untouched
1892    /// rather than be dropped for failing to match it.
1893    #[test]
1894    fn a_plain_codex_failure_message_passes_through() {
1895        let (_, term) = run(
1896            Agent::Codex,
1897            &[
1898                r#"{"type":"turn.failed","error":{"message":"stream disconnected before completion"}}"#,
1899            ],
1900        );
1901        assert_eq!(term.error_status, None);
1902        assert_eq!(
1903            term.error_message.as_deref(),
1904            Some("stream disconnected before completion")
1905        );
1906    }
1907
1908    #[test]
1909    fn codex_reads_the_thread_id_and_the_completed_message() {
1910        let (events, term) = run(
1911            Agent::Codex,
1912            &[
1913                r#"{"type":"thread.started","thread_id":"0199-xyz"}"#,
1914                r#"{"type":"item.completed","item":{"id":"i1","type":"agent_message","text":"pong"}}"#,
1915                r#"{"type":"turn.completed","usage":{"input_tokens":12,"output_tokens":3,"cached_input_tokens":9}}"#,
1916            ],
1917        );
1918        assert_eq!(
1919            events[0],
1920            Event::Started {
1921                session: "0199-xyz".into(),
1922                model: None
1923            }
1924        );
1925        assert_eq!(term.session.as_deref(), Some("0199-xyz"));
1926        assert_eq!(term.text, "pong");
1927        // Codex reports the whole prompt as `input_tokens` with the cached
1928        // part inside it, so 12 total minus 9 cached is 3 tokens of new input.
1929        // `input_tokens` means the same thing here as it does on Claude, and
1930        // `context_tokens` keeps the figure Codex actually sent.
1931        assert_eq!(term.usage.input_tokens, Some(3));
1932        assert_eq!(term.usage.cache_read_tokens, Some(9));
1933        assert_eq!(term.usage.context_tokens, Some(12));
1934    }
1935
1936    #[test]
1937    fn codex_command_execution_becomes_a_call_and_a_result() {
1938        let (events, _) = run(
1939            Agent::Codex,
1940            &[
1941                r#"{"type":"item.completed","item":{"id":"c1","type":"command_execution","command":"ls","exit_code":0,"aggregated_output":"a.txt"}}"#,
1942            ],
1943        );
1944        assert!(matches!(&events[0], Event::ToolCall { name, .. } if name == "command_execution"));
1945        assert_eq!(
1946            events[1],
1947            Event::ToolResult {
1948                id: Some("c1".into()),
1949                ok: Some(true),
1950                output: "a.txt".into()
1951            }
1952        );
1953    }
1954
1955    /// Codex reports one tool twice: in progress, then finished. The call must
1956    /// be announced once and the empty in-progress output must never surface as
1957    /// a result. Both lines are verbatim from a codex 0.145.0 transcript.
1958    #[test]
1959    fn codex_started_then_completed_yields_one_call_and_one_result() {
1960        let (events, _) = run(
1961            Agent::Codex,
1962            &[
1963                r#"{"type":"item.started","item":{"id":"item_1","type":"command_execution","command":"/bin/zsh -lc ls","aggregated_output":"","exit_code":null,"status":"in_progress"}}"#,
1964                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"}}"#,
1965            ],
1966        );
1967        let calls = events
1968            .iter()
1969            .filter(|e| matches!(e, Event::ToolCall { .. }))
1970            .count();
1971        assert_eq!(calls, 1, "the same item must not be announced twice");
1972        let results: Vec<_> = events
1973            .iter()
1974            .filter_map(|e| match e {
1975                Event::ToolResult { output, .. } => Some(output.as_str()),
1976                _ => None,
1977            })
1978            .collect();
1979        assert_eq!(
1980            results,
1981            ["a.txt\n"],
1982            "the in-progress blank must not appear"
1983        );
1984    }
1985
1986    /// A turn can hold several messages; the answer is the last to settle.
1987    #[test]
1988    fn codex_last_completed_message_is_the_answer() {
1989        let (_, term) = run(
1990            Agent::Codex,
1991            &[
1992                r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"I'll list the directory."}}"#,
1993                r#"{"type":"item.completed","item":{"id":"i2","type":"agent_message","text":"DONE"}}"#,
1994            ],
1995        );
1996        assert_eq!(term.text, "DONE");
1997    }
1998
1999    /// The channel bounds how many events queue, not how large they are. With
2000    /// a 512 KiB line limit that left ~130 MiB reachable in flight.
2001    #[test]
2002    fn an_enormous_tool_result_is_bounded_and_marked() {
2003        let huge = "x".repeat(MAX_EVENT_BYTES * 4);
2004        let line = serde_json::json!({
2005            "type": "user",
2006            "session_id": "s",
2007            "message": {"content": [{
2008                "type": "tool_result", "tool_use_id": "t1", "content": huge
2009            }]}
2010        })
2011        .to_string();
2012
2013        let (events, _) = run(Agent::Claude, &[&line]);
2014        let Some(Event::ToolResult { output, id, .. }) = events
2015            .iter()
2016            .find(|e| matches!(e, Event::ToolResult { .. }))
2017            .cloned()
2018        else {
2019            panic!("expected a tool result, got {events:?}")
2020        };
2021        assert!(
2022            output.len() <= MAX_EVENT_BYTES,
2023            "kept {} bytes",
2024            output.len()
2025        );
2026        assert!(
2027            output.ends_with(TRUNCATION_MARK),
2028            "truncation must be visible"
2029        );
2030        assert_eq!(id.as_deref(), Some("t1"), "the id must survive whole");
2031    }
2032
2033    /// A usable identifier passes through whole, however awkward its length.
2034    /// Truncating one is worse than losing it: a shortened session id resumes
2035    /// nothing and a shortened tool id matches no call.
2036    #[test]
2037    fn usable_identifiers_are_never_shortened() {
2038        // Long enough to be unusual, small enough to still be an identifier.
2039        let id = "s".repeat(MAX_IDENTIFIER_BYTES);
2040        let line =
2041            serde_json::json!({"type": "system", "subtype": "init", "session_id": id}).to_string();
2042        let (events, term) = run(Agent::Claude, &[&line]);
2043
2044        let Some(Event::Started { session, .. }) = events.first().cloned() else {
2045            panic!("expected Started, got {events:?}")
2046        };
2047        assert_eq!(session.len(), id.len(), "the session id was shortened");
2048        assert_eq!(term.session.as_deref(), Some(id.as_str()));
2049    }
2050
2051    /// The hole this closes: identifiers were exempt from every bound, so a
2052    /// 512 KiB id rode through and the "16 MiB queued" figure was wrong.
2053    /// Rejecting is right where truncating is not, because a binding that
2054    /// cannot resume is worse than no binding.
2055    #[test]
2056    fn an_oversized_session_id_is_rejected_rather_than_stored() {
2057        let id = "s".repeat(MAX_IDENTIFIER_BYTES + 1);
2058        for (agent, line) in [
2059            (
2060                Agent::Claude,
2061                serde_json::json!({"type": "system", "subtype": "init", "session_id": id})
2062                    .to_string(),
2063            ),
2064            (
2065                Agent::Codex,
2066                serde_json::json!({"type": "thread.started", "thread_id": id}).to_string(),
2067            ),
2068            (
2069                Agent::Copilot,
2070                serde_json::json!({"type": "result", "sessionId": id, "exitCode": 0}).to_string(),
2071            ),
2072        ] {
2073            let (events, term) = run(agent, &[&line]);
2074            assert!(term.session.is_none(), "{agent} stored an unusable id");
2075            assert!(
2076                !events.iter().any(|e| matches!(e, Event::Started { .. })),
2077                "{agent} announced a session it cannot resume"
2078            );
2079        }
2080    }
2081
2082    /// A tool event with an unusable id is still reported: what the agent did
2083    /// is worth knowing even when it cannot be correlated. The id is dropped,
2084    /// never shortened into something that would match the wrong call.
2085    #[test]
2086    fn an_oversized_tool_id_drops_the_id_but_keeps_the_event() {
2087        let id = "t".repeat(MAX_IDENTIFIER_BYTES + 1);
2088        let line = serde_json::json!({
2089            "type": "assistant", "session_id": "s",
2090            "message": {"content": [{
2091                "type": "tool_use", "id": id, "name": "Bash", "input": {"command": "ls"}
2092            }]}
2093        })
2094        .to_string();
2095
2096        let (events, _) = run(Agent::Claude, &[&line]);
2097        let Some(Event::ToolCall { id: seen, name, .. }) = events
2098            .iter()
2099            .find(|e| matches!(e, Event::ToolCall { .. }))
2100            .cloned()
2101        else {
2102            panic!("the call itself must still be reported, got {events:?}")
2103        };
2104        assert_eq!(seen, None, "an unusable id must be dropped, not shortened");
2105        assert_eq!(name, "Bash");
2106    }
2107
2108    /// Bounding the entry count bounded nothing while the entries themselves
2109    /// were unbounded: 1024 pending calls could retain hundreds of megabytes.
2110    #[test]
2111    fn the_pending_tool_map_is_bounded_by_bytes_not_only_entries() {
2112        let mut parser = Parser::new(Agent::Claude, Format::Stream);
2113        // Ids and names just under the identifier ceiling, so the entry count
2114        // is nowhere near its limit while the bytes are.
2115        for i in 0..MAX_PENDING_TOOLS {
2116            let line = serde_json::json!({
2117                "type": "assistant", "session_id": "s",
2118                "message": {"content": [{
2119                    "type": "tool_use",
2120                    "id": format!("{i:0>width$}", width = MAX_IDENTIFIER_BYTES),
2121                    "name": "x".repeat(MAX_IDENTIFIER_BYTES),
2122                    "input": {}
2123                }]}
2124            })
2125            .to_string();
2126            parser.push(&line);
2127        }
2128        assert!(
2129            parser.tool_bytes <= MAX_PENDING_TOOL_BYTES,
2130            "pending tools grew to {} bytes",
2131            parser.tool_bytes
2132        );
2133    }
2134
2135    /// Answering a call must release its budget, or a long run of ordinary
2136    /// paired calls would exhaust it and stop correlating.
2137    #[test]
2138    fn a_completed_tool_call_releases_its_budget() {
2139        let mut parser = Parser::new(Agent::Claude, Format::Stream);
2140        let call = |id: &str| {
2141            serde_json::json!({
2142                "type": "assistant", "session_id": "s",
2143                "message": {"content": [{
2144                    "type": "tool_use", "id": id, "name": "Bash", "input": {}
2145                }]}
2146            })
2147            .to_string()
2148        };
2149        let result = |id: &str| {
2150            serde_json::json!({
2151                "type": "user", "session_id": "s",
2152                "message": {"content": [{
2153                    "type": "tool_result", "tool_use_id": id, "content": "done"
2154                }]}
2155            })
2156            .to_string()
2157        };
2158
2159        for i in 0..(MAX_PENDING_TOOLS * 4) {
2160            let id = format!("toolu_{i}");
2161            parser.push(&call(&id));
2162            parser.push(&result(&id));
2163        }
2164        assert_eq!(parser.tool_bytes, 0, "budget leaked across paired calls");
2165        assert!(parser.tools.is_empty());
2166    }
2167
2168    /// The claim the changelog makes has to survive an adversarial line: every
2169    /// field at its worst, times the channel depth, still under 20 MiB.
2170    #[test]
2171    fn a_worst_case_event_stays_within_the_stated_ceiling() {
2172        let huge = "x".repeat(MAX_LINE);
2173        let line = serde_json::json!({
2174            "type": "assistant", "session_id": huge,
2175            "message": {"content": [{
2176                "type": "tool_use", "id": huge, "name": huge, "input": {"command": huge}
2177            }]}
2178        })
2179        .to_string();
2180
2181        let (events, _) = run(Agent::Claude, &[&line]);
2182        for event in &events {
2183            let size = serde_json::to_string(event).unwrap().len();
2184            // Payload plus identifiers, with room for JSON framing.
2185            let ceiling = MAX_EVENT_BYTES + 4 * MAX_IDENTIFIER_BYTES;
2186            assert!(size <= ceiling, "an event reached {size} bytes: {event:?}");
2187        }
2188    }
2189
2190    /// Truncating JSON would produce something that no longer parses, so an
2191    /// oversized argument object is replaced rather than cut.
2192    #[test]
2193    fn oversized_tool_arguments_stay_valid_json() {
2194        let line = serde_json::json!({
2195            "type": "assistant",
2196            "session_id": "s",
2197            "message": {"content": [{
2198                "type": "tool_use", "id": "t1", "name": "Bash",
2199                "input": {"command": "y".repeat(MAX_EVENT_BYTES * 3)}
2200            }]}
2201        })
2202        .to_string();
2203
2204        let (events, _) = run(Agent::Claude, &[&line]);
2205        let Some(Event::ToolCall { input, .. }) = events
2206            .iter()
2207            .find(|e| matches!(e, Event::ToolCall { .. }))
2208            .cloned()
2209        else {
2210            panic!("expected a tool call, got {events:?}")
2211        };
2212        assert_eq!(input["truncated"], true, "got {input}");
2213        assert!(
2214            input.is_object(),
2215            "the replacement must still be valid JSON"
2216        );
2217        assert!(input.to_string().len() <= MAX_EVENT_BYTES);
2218    }
2219
2220    #[test]
2221    fn ordinary_payloads_pass_through_untouched() {
2222        let (events, _) = run(
2223            Agent::Claude,
2224            &[
2225                r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"text","text":"pong"}]}}"#,
2226            ],
2227        );
2228        assert!(events.contains(&Event::Text("pong".into())), "{events:?}");
2229    }
2230
2231    #[test]
2232    fn capture_is_bounded_and_keeps_the_earliest_output() {
2233        let mut buf = String::new();
2234        // Far more than the cap, in chunks, as a streaming agent would.
2235        for i in 0..50_000 {
2236            append_capped(&mut buf, &format!("line {i} aaaaaaaaaaaaaaaaaaaaaaaaaaaa"));
2237        }
2238        assert!(buf.len() <= MAX_CAPTURE, "grew to {}", buf.len());
2239        assert!(buf.starts_with("line 0 "), "the earliest output is kept");
2240    }
2241
2242    #[test]
2243    fn capping_never_splits_a_multibyte_character() {
2244        let mut buf = "x".repeat(MAX_CAPTURE - 3);
2245        // A 4-byte character that cannot fit in the 3 bytes remaining.
2246        assert!(append_capped(&mut buf, "🙂🙂"));
2247        assert!(buf.len() <= MAX_CAPTURE);
2248        // The invariant is simply that this is still a valid Rust string, which
2249        // would have panicked on a mid-character slice above.
2250        assert!(buf.is_char_boundary(buf.len()));
2251    }
2252
2253    #[test]
2254    fn a_full_buffer_reports_that_it_took_nothing() {
2255        let mut buf = "x".repeat(MAX_CAPTURE);
2256        assert!(!append_capped(&mut buf, "more"));
2257        assert_eq!(buf.len(), MAX_CAPTURE);
2258    }
2259
2260    /// A vendor changing its output shape looks like a clean exit with nothing
2261    /// parsed. Counting the misses turns that from a mystery into a diagnosis.
2262    #[test]
2263    fn unparseable_lines_are_counted_and_sampled() {
2264        let (_, term) = run(
2265            Agent::Claude,
2266            &[
2267                "<html>an error page, not JSON</html>",
2268                "another bad line",
2269                r#"{"type":"result","result":"ok","session_id":"s"}"#,
2270            ],
2271        );
2272        assert_eq!(term.unparsed, 2);
2273        assert_eq!(
2274            term.first_unparsed.as_deref(),
2275            Some("<html>an error page, not JSON</html>")
2276        );
2277    }
2278
2279    #[test]
2280    fn a_clean_stream_reports_no_parse_failures() {
2281        let (_, term) = run(
2282            Agent::Claude,
2283            &[r#"{"type":"result","result":"ok","session_id":"s"}"#],
2284        );
2285        assert_eq!(term.unparsed, 0);
2286        assert!(term.first_unparsed.is_none());
2287    }
2288
2289    /// Non-text content blocks are preserved as raw JSON rather than dropped, so
2290    /// a caller inspecting a tool result never silently loses part of it.
2291    #[test]
2292    fn tool_result_blocks_that_are_not_text_are_kept_not_dropped() {
2293        let (events, _) = run(
2294            Agent::Claude,
2295            &[
2296                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"}}]}]}}"#,
2297            ],
2298        );
2299        let output = events
2300            .iter()
2301            .find_map(|e| match e {
2302                Event::ToolResult { output, .. } => Some(output),
2303                _ => None,
2304            })
2305            .unwrap_or_else(|| panic!("expected a tool result, got {events:?}"));
2306        assert!(output.contains("seen"));
2307        assert!(output.contains("image"), "the image block was dropped");
2308    }
2309
2310    #[test]
2311    fn garbage_lines_are_skipped_not_fatal() {
2312        let (events, term) = run(
2313            Agent::Claude,
2314            &[
2315                "Warning: something on stdout",
2316                "",
2317                r#"{"type":"result","result":"ok","session_id":"s"}"#,
2318            ],
2319        );
2320        assert!(events.iter().all(|e| !matches!(e, Event::Text(_))));
2321        assert_eq!(term.text, "ok");
2322    }
2323
2324    #[test]
2325    fn text_format_passes_lines_through_verbatim() {
2326        let mut p = Parser::new(Agent::Copilot, Format::Text);
2327        let events: Vec<_> = ["hello", "world"].iter().flat_map(|l| p.push(l)).collect();
2328        assert_eq!(
2329            events,
2330            [Event::Text("hello".into()), Event::Text("world".into())]
2331        );
2332        assert_eq!(p.finish().text, "hello\nworld");
2333    }
2334}