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