Skip to main content

agent_abstraction/
event.rs

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