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