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