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