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        }),
234    }
235}
236
237/// Shorten a short-by-nature field to [`MAX_IDENTIFIER_BYTES`].
238///
239/// For values that are descriptive rather than correlating, a tool name or a
240/// model or a quota status word, where a shortened value is still meaningful.
241fn bound_identifier(text: String) -> String {
242    if text.len() <= MAX_IDENTIFIER_BYTES {
243        return text;
244    }
245    let mut cut = MAX_IDENTIFIER_BYTES - TRUNCATION_MARK.len();
246    while cut > 0 && !text.is_char_boundary(cut) {
247        cut -= 1;
248    }
249    let mut out = text[..cut].to_string();
250    out.push_str(TRUNCATION_MARK);
251    out
252}
253
254/// Facts that are only known once the stream ends.
255#[derive(Debug, Clone, Default, PartialEq)]
256pub struct Terminal {
257    /// The native session id.
258    pub session: Option<String>,
259    /// The agent's authoritative final answer.
260    pub text: String,
261    /// Token and cost accounting.
262    pub usage: Usage,
263    /// Why the agent stopped.
264    pub stop: Stop,
265    /// The last quota signal seen.
266    pub rate_limit: Option<RateLimit>,
267    /// How many output lines could not be parsed.
268    ///
269    /// Non-zero is not automatically a fault: agents interleave banners and
270    /// warnings with their JSON. It matters when a run *also* came back empty,
271    /// which is what a vendor changing its output shape looks like from here.
272    pub unparsed: usize,
273    /// The first line that failed to parse, as evidence for the above.
274    pub first_unparsed: Option<String>,
275}
276
277/// Incrementally turns one agent's output into [`Event`]s and a [`Terminal`].
278#[derive(Debug)]
279pub(crate) struct Parser {
280    agent: Agent,
281    format: Format,
282    term: Terminal,
283    /// Tool names by call id, so a result can be attributed to its call.
284    tools: HashMap<String, String>,
285    /// Bytes currently held in `tools`, kept alongside it because a map has no
286    /// cheap way to answer that.
287    tool_bytes: usize,
288    /// True once a [`Event::Started`] has been emitted, so it fires only once.
289    started: bool,
290    /// Whether any record was recognized as this agent's own shape.
291    structured: bool,
292    /// Whether the agent's terminal record was seen.
293    terminal_seen: bool,
294}
295
296impl Parser {
297    /// A parser for `agent` reading output in `format`.
298    #[must_use]
299    pub fn new(agent: Agent, format: Format) -> Self {
300        Self {
301            agent,
302            format,
303            term: Terminal::default(),
304            tools: HashMap::new(),
305            tool_bytes: 0,
306            started: false,
307            structured: false,
308            terminal_seen: false,
309        }
310    }
311
312    /// Feed one line of stdout, returning the events it produced.
313    ///
314    /// Unparseable lines yield nothing rather than failing the run: agents
315    /// interleave banners and warnings with their JSON, and a stray line is not
316    /// a reason to lose a completed turn. They are counted in
317    /// [`Terminal::unparsed`] so that a silent vendor format change is
318    /// diagnosable instead of merely producing an empty answer.
319    pub fn push(&mut self, line: &str) -> Vec<Event> {
320        let line = line.trim();
321        if line.is_empty() {
322            return Vec::new();
323        }
324        // Under a plain-text format there is nothing to parse: the whole stream
325        // is the answer.
326        if self.format == Format::Text {
327            append_capped(&mut self.term.text, line);
328            return vec![enforce_bounds(Event::Text(line.to_string()))];
329        }
330        let Ok(value) = serde_json::from_str::<Value>(line) else {
331            self.term.unparsed += 1;
332            if self.term.first_unparsed.is_none() {
333                // One short sample is enough to identify a shape change; keeping
334                // every stray line would reintroduce the unbounded growth this
335                // parser just capped.
336                let mut cut = line.len().min(512);
337                while cut > 0 && !line.is_char_boundary(cut) {
338                    cut -= 1;
339                }
340                self.term.first_unparsed = Some(line[..cut].to_string());
341            }
342            return Vec::new();
343        };
344        // A record that names a type this parser knows is evidence the stream
345        // really is the shape we asked for.
346        if let Some(ty) = value.get("type").and_then(Value::as_str)
347            && self.recognizes(ty)
348        {
349            self.structured = true;
350        }
351        let mut out = match self.agent {
352            Agent::Claude => self.claude(&value),
353            Agent::Codex => self.codex(&value),
354            Agent::Copilot => self.copilot(&value),
355        };
356        // Every event leaves through here, so bounding once at the exit covers
357        // all three agents rather than each parser remembering.
358        out = out.into_iter().map(enforce_bounds).collect();
359
360        // Fire `Started` exactly once, from whichever record first revealed the
361        // id, and put it ahead of that record's own events.
362        if !self.started {
363            if let Some(session) = self.term.session.clone() {
364                self.started = true;
365                out.insert(
366                    0,
367                    Event::Started {
368                        session,
369                        model: model_of(&value),
370                    },
371                );
372            }
373        }
374        out
375    }
376
377    /// Whether `ty` is a record type this agent's parser understands.
378    fn recognizes(&self, ty: &str) -> bool {
379        match self.agent {
380            Agent::Claude => matches!(
381                ty,
382                "system" | "assistant" | "user" | "result" | "rate_limit_event"
383            ),
384            Agent::Codex => {
385                ty.starts_with("thread.") || ty.starts_with("turn.") || ty.starts_with("item.")
386            }
387            Agent::Copilot => {
388                ty == "result"
389                    || ty.starts_with("assistant.")
390                    || ty.starts_with("tool.")
391                    || ty.starts_with("session.")
392            }
393        }
394    }
395
396    /// Track a tool call so its result can be attributed, bounded so an agent
397    /// that announces calls it never finishes cannot grow this without limit.
398    fn remember_tool(&mut self, id: &str, name: &str) {
399        // An unusable id cannot correlate anything, so tracking it only costs
400        // memory.
401        if !usable_identifier(id) {
402            return;
403        }
404        let name = bound_identifier(name.to_string());
405        let cost = id.len() + name.len();
406        // Both budgets matter: the count bounds a flood of tiny entries, the
407        // bytes bound a few enormous ones. Counting entries alone was no bound
408        // at all while the entries themselves were unbounded.
409        if self.tools.len() >= MAX_PENDING_TOOLS
410            || self.tool_bytes.saturating_add(cost) > MAX_PENDING_TOOL_BYTES
411        {
412            return;
413        }
414        self.tool_bytes += cost;
415        if let Some(previous) = self.tools.insert(id.to_string(), name) {
416            // Replacing an entry must not double-count its predecessor.
417            self.tool_bytes = self.tool_bytes.saturating_sub(id.len() + previous.len());
418        }
419    }
420
421    /// Stop tracking a call once its result has arrived, releasing its budget.
422    fn forget_tool(&mut self, id: &str) {
423        if let Some(name) = self.tools.remove(id) {
424            self.tool_bytes = self.tool_bytes.saturating_sub(id.len() + name.len());
425        }
426    }
427
428    /// Whether any structured record has been recognized on this stream.
429    ///
430    /// A structured run that recognized nothing did not merely fail to answer;
431    /// it means the output was not the shape this parser understands.
432    pub(crate) fn saw_structured_record(&self) -> bool {
433        self.structured
434    }
435
436    /// Whether the stream carried its terminal record, the one that closes a
437    /// turn and carries the answer and usage.
438    pub(crate) fn saw_terminal_record(&self) -> bool {
439        self.terminal_seen
440    }
441
442    /// Consume the parser for everything only knowable at the end.
443    #[must_use]
444    pub fn finish(mut self) -> Terminal {
445        if self.format == Format::Text {
446            self.term.text = self.term.text.trim_end().to_string();
447        }
448        self.term
449    }
450
451    /// Claude Code `--output-format json` / `stream-json`.
452    ///
453    /// Verified against claude 2.1.205: `system/init` opens with the id,
454    /// `assistant` records carry Anthropic content blocks, `rate_limit_event`
455    /// reports quota, and `result` closes with the answer and usage.
456    fn claude(&mut self, v: &Value) -> Vec<Event> {
457        let ty = v.get("type").and_then(Value::as_str).unwrap_or_default();
458        // An unusable session id must not be captured: it would be persisted as
459        // a binding that can never resume anything.
460        if let Some(id) = v.get("session_id").and_then(Value::as_str)
461            && usable_identifier(id)
462        {
463            self.term.session.get_or_insert_with(|| id.to_string());
464        }
465        match ty {
466            "rate_limit_event" => {
467                let limit = claude_rate_limit(v.get("rate_limit_info"));
468                self.term.rate_limit.clone_from(&limit);
469                limit.into_iter().map(Event::RateLimit).collect()
470            }
471            // Both roles carry content blocks: `assistant` holds text/thinking/
472            // tool_use, `user` carries the tool_result observations back.
473            "assistant" | "user" => self.content_blocks(v),
474            "result" => {
475                self.terminal_seen = true;
476                if let Some(text) = v.get("result").and_then(Value::as_str) {
477                    self.term.text = text.to_string();
478                }
479                self.term.usage = claude_usage(v);
480                self.term.stop = if v.get("is_error").and_then(Value::as_bool) == Some(true) {
481                    Stop::Error
482                } else {
483                    stop_from(v.get("stop_reason"))
484                };
485                Vec::new()
486            }
487            _ => Vec::new(),
488        }
489    }
490
491    /// Anthropic content blocks, shared by Claude's `assistant` and `user`
492    /// records.
493    fn content_blocks(&mut self, v: &Value) -> Vec<Event> {
494        let blocks = v
495            .get("message")
496            .and_then(|m| m.get("content"))
497            .and_then(Value::as_array);
498        let Some(blocks) = blocks else {
499            return Vec::new();
500        };
501        let mut out = Vec::new();
502        for block in blocks {
503            let ty = block
504                .get("type")
505                .and_then(Value::as_str)
506                .unwrap_or_default();
507            match ty {
508                "text" => {
509                    if let Some(t) = block.get("text").and_then(Value::as_str) {
510                        out.push(Event::Text(t.to_string()));
511                    }
512                }
513                "thinking" => {
514                    if let Some(t) = block.get("thinking").and_then(Value::as_str) {
515                        out.push(Event::Thinking(t.to_string()));
516                    }
517                }
518                "tool_use" => {
519                    let name = block
520                        .get("name")
521                        .and_then(Value::as_str)
522                        .unwrap_or("tool")
523                        .to_string();
524                    let id = block.get("id").and_then(Value::as_str).map(str::to_string);
525                    if let Some(id) = &id {
526                        self.remember_tool(id, &name);
527                    }
528                    out.push(Event::ToolCall {
529                        id,
530                        name,
531                        input: block.get("input").cloned().unwrap_or(Value::Null),
532                    });
533                }
534                "tool_result" => out.push(Event::ToolResult {
535                    id: block
536                        .get("tool_use_id")
537                        .and_then(Value::as_str)
538                        .inspect(|id| {
539                            // The call has been answered, so stop tracking it.
540                            self.forget_tool(id);
541                        })
542                        .map(str::to_string),
543                    ok: block
544                        .get("is_error")
545                        .and_then(Value::as_bool)
546                        .map(|is_error| !is_error),
547                    output: flatten_text(block.get("content")),
548                }),
549                _ => {}
550            }
551        }
552        out
553    }
554
555    /// Codex `exec --json`.
556    ///
557    /// Verified against codex-cli 0.145.0: `thread.started` opens with
558    /// `thread_id`, items arrive as `item.started` → `item.completed` pairs, and
559    /// `turn.completed` carries usage. A tool item appears twice: once
560    /// in-progress with an empty `aggregated_output`, once finished, so the
561    /// call is emitted on first sighting and the result only once it completes.
562    fn codex(&mut self, v: &Value) -> Vec<Event> {
563        let ty = v.get("type").and_then(Value::as_str).unwrap_or_default();
564        if let Some(id) = v.get("thread_id").and_then(Value::as_str)
565            && usable_identifier(id)
566        {
567            self.term.session.get_or_insert_with(|| id.to_string());
568        }
569        match ty {
570            "turn.completed" => {
571                self.terminal_seen = true;
572                self.term.usage = codex_usage(v.get("usage"));
573                Vec::new()
574            }
575            "turn.failed" => {
576                self.terminal_seen = true;
577                self.term.stop = Stop::Error;
578                Vec::new()
579            }
580            "item.started" | "item.updated" | "item.completed" => {
581                let Some(item) = v.get("item") else {
582                    return Vec::new();
583                };
584                let item_ty = item.get("type").and_then(Value::as_str).unwrap_or_default();
585                let id = item.get("id").and_then(Value::as_str).map(str::to_string);
586                let done = ty == "item.completed";
587
588                // Every item is reported at least twice: in progress, then
589                // finished. Announce each one exactly once, on first sighting,
590                // and keep the id → name binding for the result.
591                let name = tool_name(item, item_ty);
592                let first = id
593                    .as_ref()
594                    .is_none_or(|id| self.tools.insert(id.clone(), name.clone()).is_none());
595
596                match item_ty {
597                    // The settled text is authoritative; a turn may contain
598                    // several messages, so the last one to complete wins.
599                    "agent_message" => {
600                        if !done {
601                            return Vec::new();
602                        }
603                        let text = item.get("text").and_then(Value::as_str).unwrap_or_default();
604                        self.term.text = text.to_string();
605                        vec![Event::Text(text.to_string())]
606                    }
607                    "reasoning" if done => item
608                        .get("text")
609                        .and_then(Value::as_str)
610                        .map(|t| Event::Thinking(t.to_string()))
611                        .into_iter()
612                        .collect(),
613                    "command_execution" | "mcp_tool_call" | "file_change" | "web_search" => {
614                        let mut out = Vec::new();
615                        if first {
616                            out.push(Event::ToolCall {
617                                id: id.clone(),
618                                name,
619                                input: codex_tool_input(item, item_ty),
620                            });
621                        }
622                        // Only the finished record carries real output: the
623                        // in-progress one has an empty string and a null code.
624                        if done {
625                            if let Some(id) = &id {
626                                self.forget_tool(id);
627                            }
628                            out.push(Event::ToolResult {
629                                id,
630                                ok: item
631                                    .get("exit_code")
632                                    .and_then(Value::as_i64)
633                                    .map(|code| code == 0),
634                                output: item
635                                    .get("aggregated_output")
636                                    .and_then(Value::as_str)
637                                    .unwrap_or_default()
638                                    .to_string(),
639                            });
640                        }
641                        out
642                    }
643                    _ => Vec::new(),
644                }
645            }
646            _ => Vec::new(),
647        }
648    }
649
650    /// Copilot `--output-format json` (JSONL).
651    ///
652    /// Verified against GitHub Copilot CLI 1.0.75: `assistant.message_delta`
653    /// streams text, `assistant.message` carries the settled answer,
654    /// `tool.execution_start` / `_complete` bracket a tool, and the final
655    /// `result` carries `sessionId` and usage.
656    fn copilot(&mut self, v: &Value) -> Vec<Event> {
657        let ty = v.get("type").and_then(Value::as_str).unwrap_or_default();
658        let data = v.get("data");
659        let field = |key: &str| -> Option<String> {
660            data.and_then(|d| d.get(key))
661                .and_then(Value::as_str)
662                .map(str::to_string)
663        };
664        match ty {
665            // The delta stream is what a live transcript renders.
666            "assistant.message_delta" => field("deltaContent")
667                .filter(|t| !t.is_empty())
668                .map(Event::Text)
669                .into_iter()
670                .collect(),
671            // The settled message is authoritative but already shown as deltas,
672            // so it updates the terminal text without re-emitting it.
673            "assistant.message" => {
674                if let Some(content) = field("content") {
675                    self.term.text = content;
676                }
677                Vec::new()
678            }
679            "assistant.reasoning" => field("content")
680                .filter(|t| !t.is_empty())
681                .map(Event::Thinking)
682                .into_iter()
683                .collect(),
684            "tool.execution_start" => {
685                let id = field("toolCallId");
686                let name = field("toolName").unwrap_or_else(|| "tool".into());
687                if let Some(id) = &id {
688                    self.remember_tool(id, &name);
689                }
690                vec![Event::ToolCall {
691                    id,
692                    name,
693                    input: data
694                        .and_then(|d| d.get("arguments"))
695                        .cloned()
696                        .unwrap_or(Value::Null),
697                }]
698            }
699            "tool.execution_complete" => vec![Event::ToolResult {
700                id: field("toolCallId").inspect(|id| {
701                    self.forget_tool(id);
702                }),
703                ok: data.and_then(|d| d.get("success")).and_then(Value::as_bool),
704                output: data
705                    .and_then(|d| d.get("result"))
706                    .and_then(|r| r.get("content"))
707                    .and_then(Value::as_str)
708                    .unwrap_or_default()
709                    .to_string(),
710            }],
711            // Copilot's terminal record is flat, not nested under `data`.
712            "result" => {
713                self.terminal_seen = true;
714                if let Some(id) = v.get("sessionId").and_then(Value::as_str)
715                    && usable_identifier(id)
716                {
717                    self.term.session = Some(id.to_string());
718                }
719                if let Some(usage) = v.get("usage") {
720                    self.term.usage.premium_requests =
721                        usage.get("premiumRequests").and_then(Value::as_u64);
722                }
723                if v.get("exitCode").and_then(Value::as_i64).unwrap_or(0) != 0 {
724                    self.term.stop = Stop::Error;
725                }
726                Vec::new()
727            }
728            _ => Vec::new(),
729        }
730    }
731}
732
733/// The model named by a record, if it names one. Claude puts it at the top
734/// level; Copilot nests it under `data`.
735fn model_of(v: &Value) -> Option<String> {
736    v.get("model")
737        .or_else(|| v.get("data").and_then(|d| d.get("model")))
738        .and_then(Value::as_str)
739        .map(str::to_string)
740}
741
742/// A `stop_reason` string that is neither absent nor the normal end.
743fn stop_from(v: Option<&Value>) -> Stop {
744    match v.and_then(Value::as_str) {
745        None | Some("end_turn" | "stop" | "completed") => Stop::Completed,
746        Some(other) => Stop::Other(other.to_string()),
747    }
748}
749
750/// Claude's `rate_limit_info` object.
751fn claude_rate_limit(v: Option<&Value>) -> Option<RateLimit> {
752    let v = v?;
753    Some(RateLimit {
754        status: v.get("status").and_then(Value::as_str)?.to_string(),
755        window: v
756            .get("rateLimitType")
757            .and_then(Value::as_str)
758            .map(str::to_string),
759        resets_at: v.get("resetsAt").and_then(Value::as_i64),
760    })
761}
762
763/// Claude's terminal `usage` block plus its top-level `total_cost_usd`.
764fn claude_usage(v: &Value) -> Usage {
765    let u = v.get("usage");
766    let get = |key: &str| u.and_then(|u| u.get(key)).and_then(Value::as_u64);
767    Usage {
768        input_tokens: get("input_tokens"),
769        output_tokens: get("output_tokens"),
770        cache_read_tokens: get("cache_read_input_tokens"),
771        cache_write_tokens: get("cache_creation_input_tokens"),
772        cost_usd: v.get("total_cost_usd").and_then(Value::as_f64),
773        premium_requests: None,
774    }
775}
776
777/// Codex's `turn.completed` usage block. Codex prices nothing itself, so
778/// `cost_usd` stays absent rather than being derived from a local table.
779fn codex_usage(v: Option<&Value>) -> Usage {
780    let get = |key: &str| v.and_then(|u| u.get(key)).and_then(Value::as_u64);
781    Usage {
782        input_tokens: get("input_tokens"),
783        output_tokens: get("output_tokens"),
784        cache_read_tokens: get("cached_input_tokens"),
785        cache_write_tokens: get("cache_write_input_tokens"),
786        cost_usd: None,
787        premium_requests: None,
788    }
789}
790
791/// The display name of a Codex item: MCP and collaboration items name the tool
792/// they invoked, everything else is identified by its item type.
793fn tool_name(item: &Value, item_ty: &str) -> String {
794    item.get("tool")
795        .and_then(Value::as_str)
796        .unwrap_or(item_ty)
797        .to_string()
798}
799
800/// The arguments of a Codex tool item, in whatever shape that item uses.
801fn codex_tool_input(item: &Value, item_ty: &str) -> Value {
802    match item_ty {
803        "command_execution" => serde_json::json!({ "command": item.get("command") }),
804        "mcp_tool_call" => item.get("arguments").cloned().unwrap_or(Value::Null),
805        // `file_change` carries `changes`, `web_search` a `query`; neither has a
806        // single canonical argument field, so the item stands in for itself.
807        _ => item.clone(),
808    }
809}
810
811/// Flatten a tool result's `content` into the observation the model saw.
812///
813/// Anthropic tool results are either a bare string or an array of content
814/// blocks. Text blocks flatten to their text; any other block kind (an image,
815/// or a shape added in a future API version) is kept as its raw JSON rather
816/// than dropped, so a caller inspecting a tool result never silently loses part
817/// of it. This is lossy in presentation, never in content.
818fn flatten_text(v: Option<&Value>) -> String {
819    match v {
820        Some(Value::String(s)) => s.clone(),
821        Some(Value::Array(blocks)) => blocks
822            .iter()
823            .map(|b| match b.get("text").and_then(Value::as_str) {
824                Some(text) => text.to_string(),
825                None => b.to_string(),
826            })
827            .collect::<Vec<_>>()
828            .join("\n"),
829        Some(other) => other.to_string(),
830        None => String::new(),
831    }
832}
833
834#[cfg(test)]
835mod tests {
836    use super::*;
837
838    /// Drive a parser over `lines`, returning every event and the terminal.
839    fn run(agent: Agent, lines: &[&str]) -> (Vec<Event>, Terminal) {
840        let mut p = Parser::new(agent, Format::Stream);
841        let events = lines.iter().flat_map(|l| p.push(l)).collect();
842        (events, p.finish())
843    }
844
845    // Lines below are trimmed copies of transcripts captured from the live CLIs.
846
847    #[test]
848    fn claude_stream_yields_start_thinking_text_and_terminal_facts() {
849        let (events, term) = run(
850            Agent::Claude,
851            &[
852                r#"{"type":"system","subtype":"init","session_id":"sess-a","model":"claude-haiku-4-5"}"#,
853                r#"{"type":"assistant","session_id":"sess-a","message":{"content":[{"type":"thinking","thinking":"brief"}]}}"#,
854                r#"{"type":"assistant","session_id":"sess-a","message":{"content":[{"type":"text","text":"pong"}]}}"#,
855                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}}"#,
856            ],
857        );
858        assert_eq!(
859            events[0],
860            Event::Started {
861                session: "sess-a".into(),
862                model: Some("claude-haiku-4-5".into())
863            }
864        );
865        assert_eq!(events[1], Event::Thinking("brief".into()));
866        assert_eq!(events[2], Event::Text("pong".into()));
867        assert_eq!(term.session.as_deref(), Some("sess-a"));
868        assert_eq!(term.text, "pong");
869        assert_eq!(term.stop, Stop::Completed);
870        assert_eq!(term.usage.input_tokens, Some(10));
871        assert_eq!(term.usage.cache_read_tokens, Some(18764));
872        assert_eq!(term.usage.cache_write_tokens, Some(7322));
873        assert_eq!(term.usage.cost_usd, Some(0.017));
874    }
875
876    #[test]
877    fn claude_started_fires_only_once() {
878        let (events, _) = run(
879            Agent::Claude,
880            &[
881                r#"{"type":"system","subtype":"init","session_id":"s"}"#,
882                r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"text","text":"a"}]}}"#,
883                r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"text","text":"b"}]}}"#,
884            ],
885        );
886        assert_eq!(
887            events
888                .iter()
889                .filter(|e| matches!(e, Event::Started { .. }))
890                .count(),
891            1
892        );
893    }
894
895    #[test]
896    fn claude_pairs_tool_use_with_its_result() {
897        let (events, _) = run(
898            Agent::Claude,
899            &[
900                r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"tool_use","id":"toolu_1","name":"Bash","input":{"command":"ls"}}]}}"#,
901                r#"{"type":"user","session_id":"s","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"a.txt"}]}}"#,
902            ],
903        );
904        let call = events
905            .iter()
906            .find(|e| matches!(e, Event::ToolCall { .. }))
907            .unwrap();
908        let Event::ToolCall { id, name, input } = call else {
909            unreachable!()
910        };
911        assert_eq!(id.as_deref(), Some("toolu_1"));
912        assert_eq!(name, "Bash");
913        assert_eq!(input["command"], "ls");
914        assert!(events.contains(&Event::ToolResult {
915            id: Some("toolu_1".into()),
916            ok: None,
917            output: "a.txt".into(),
918        }));
919    }
920
921    #[test]
922    fn claude_reports_a_rate_limit_without_failing() {
923        let (events, term) = run(
924            Agent::Claude,
925            &[
926                r#"{"type":"rate_limit_event","session_id":"s","rate_limit_info":{"status":"allowed","resetsAt":1785260400,"rateLimitType":"five_hour"}}"#,
927            ],
928        );
929        let limit = RateLimit {
930            status: "allowed".into(),
931            window: Some("five_hour".into()),
932            resets_at: Some(1_785_260_400),
933        };
934        assert!(events.contains(&Event::RateLimit(limit.clone())));
935        assert_eq!(term.rate_limit, Some(limit.clone()));
936        assert!(
937            !limit.is_blocking(),
938            "an `allowed` heartbeat is not a block"
939        );
940    }
941
942    #[test]
943    fn claude_error_result_sets_the_stop_reason() {
944        let (_, term) = run(
945            Agent::Claude,
946            &[r#"{"type":"result","is_error":true,"result":"boom","session_id":"s"}"#],
947        );
948        assert_eq!(term.stop, Stop::Error);
949    }
950
951    #[test]
952    fn copilot_streams_deltas_and_takes_its_answer_from_the_settled_message() {
953        let (events, term) = run(
954            Agent::Copilot,
955            &[
956                r#"{"type":"assistant.message_delta","data":{"messageId":"m","deltaContent":"po"}}"#,
957                r#"{"type":"assistant.message_delta","data":{"messageId":"m","deltaContent":"ng"}}"#,
958                r#"{"type":"assistant.message","data":{"messageId":"m","model":"gpt-5-mini","content":"pong"}}"#,
959                r#"{"type":"result","sessionId":"768c8e7d","exitCode":0,"usage":{"premiumRequests":0}}"#,
960            ],
961        );
962        // The deltas stream; the settled message must not double them.
963        let texts: Vec<_> = events
964            .iter()
965            .filter_map(|e| match e {
966                Event::Text(t) => Some(t.as_str()),
967                _ => None,
968            })
969            .collect();
970        assert_eq!(texts, ["po", "ng"]);
971        assert_eq!(term.text, "pong", "the answer is the settled message");
972        assert_eq!(term.session.as_deref(), Some("768c8e7d"));
973        assert_eq!(term.usage.premium_requests, Some(0));
974    }
975
976    #[test]
977    fn copilot_brackets_a_tool_call_with_its_completion() {
978        let (events, _) = run(
979            Agent::Copilot,
980            &[
981                r#"{"type":"tool.execution_start","data":{"toolCallId":"call_1","toolName":"bash","arguments":{"command":"ls"}}}"#,
982                r#"{"type":"tool.execution_complete","data":{"toolCallId":"call_1","success":true,"result":{"content":"a.txt"}}}"#,
983            ],
984        );
985        assert!(matches!(
986            &events[0],
987            Event::ToolCall { id, name, .. }
988                if id.as_deref() == Some("call_1") && name == "bash"
989        ));
990        assert_eq!(
991            events[1],
992            Event::ToolResult {
993                id: Some("call_1".into()),
994                ok: Some(true),
995                output: "a.txt".into()
996            }
997        );
998    }
999
1000    #[test]
1001    fn codex_reads_the_thread_id_and_the_completed_message() {
1002        let (events, term) = run(
1003            Agent::Codex,
1004            &[
1005                r#"{"type":"thread.started","thread_id":"0199-xyz"}"#,
1006                r#"{"type":"item.completed","item":{"id":"i1","type":"agent_message","text":"pong"}}"#,
1007                r#"{"type":"turn.completed","usage":{"input_tokens":12,"output_tokens":3,"cached_input_tokens":9}}"#,
1008            ],
1009        );
1010        assert_eq!(
1011            events[0],
1012            Event::Started {
1013                session: "0199-xyz".into(),
1014                model: None
1015            }
1016        );
1017        assert_eq!(term.session.as_deref(), Some("0199-xyz"));
1018        assert_eq!(term.text, "pong");
1019        assert_eq!(term.usage.input_tokens, Some(12));
1020        assert_eq!(term.usage.cache_read_tokens, Some(9));
1021    }
1022
1023    #[test]
1024    fn codex_command_execution_becomes_a_call_and_a_result() {
1025        let (events, _) = run(
1026            Agent::Codex,
1027            &[
1028                r#"{"type":"item.completed","item":{"id":"c1","type":"command_execution","command":"ls","exit_code":0,"aggregated_output":"a.txt"}}"#,
1029            ],
1030        );
1031        assert!(matches!(&events[0], Event::ToolCall { name, .. } if name == "command_execution"));
1032        assert_eq!(
1033            events[1],
1034            Event::ToolResult {
1035                id: Some("c1".into()),
1036                ok: Some(true),
1037                output: "a.txt".into()
1038            }
1039        );
1040    }
1041
1042    /// Codex reports one tool twice: in progress, then finished. The call must
1043    /// be announced once and the empty in-progress output must never surface as
1044    /// a result. Both lines are verbatim from a codex 0.145.0 transcript.
1045    #[test]
1046    fn codex_started_then_completed_yields_one_call_and_one_result() {
1047        let (events, _) = run(
1048            Agent::Codex,
1049            &[
1050                r#"{"type":"item.started","item":{"id":"item_1","type":"command_execution","command":"/bin/zsh -lc ls","aggregated_output":"","exit_code":null,"status":"in_progress"}}"#,
1051                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"}}"#,
1052            ],
1053        );
1054        let calls = events
1055            .iter()
1056            .filter(|e| matches!(e, Event::ToolCall { .. }))
1057            .count();
1058        assert_eq!(calls, 1, "the same item must not be announced twice");
1059        let results: Vec<_> = events
1060            .iter()
1061            .filter_map(|e| match e {
1062                Event::ToolResult { output, .. } => Some(output.as_str()),
1063                _ => None,
1064            })
1065            .collect();
1066        assert_eq!(
1067            results,
1068            ["a.txt\n"],
1069            "the in-progress blank must not appear"
1070        );
1071    }
1072
1073    /// A turn can hold several messages; the answer is the last to settle.
1074    #[test]
1075    fn codex_last_completed_message_is_the_answer() {
1076        let (_, term) = run(
1077            Agent::Codex,
1078            &[
1079                r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"I'll list the directory."}}"#,
1080                r#"{"type":"item.completed","item":{"id":"i2","type":"agent_message","text":"DONE"}}"#,
1081            ],
1082        );
1083        assert_eq!(term.text, "DONE");
1084    }
1085
1086    /// The channel bounds how many events queue, not how large they are. With
1087    /// a 512 KiB line limit that left ~130 MiB reachable in flight.
1088    #[test]
1089    fn an_enormous_tool_result_is_bounded_and_marked() {
1090        let huge = "x".repeat(MAX_EVENT_BYTES * 4);
1091        let line = serde_json::json!({
1092            "type": "user",
1093            "session_id": "s",
1094            "message": {"content": [{
1095                "type": "tool_result", "tool_use_id": "t1", "content": huge
1096            }]}
1097        })
1098        .to_string();
1099
1100        let (events, _) = run(Agent::Claude, &[&line]);
1101        let Some(Event::ToolResult { output, id, .. }) = events
1102            .iter()
1103            .find(|e| matches!(e, Event::ToolResult { .. }))
1104            .cloned()
1105        else {
1106            panic!("expected a tool result, got {events:?}")
1107        };
1108        assert!(
1109            output.len() <= MAX_EVENT_BYTES,
1110            "kept {} bytes",
1111            output.len()
1112        );
1113        assert!(
1114            output.ends_with(TRUNCATION_MARK),
1115            "truncation must be visible"
1116        );
1117        assert_eq!(id.as_deref(), Some("t1"), "the id must survive whole");
1118    }
1119
1120    /// A usable identifier passes through whole, however awkward its length.
1121    /// Truncating one is worse than losing it: a shortened session id resumes
1122    /// nothing and a shortened tool id matches no call.
1123    #[test]
1124    fn usable_identifiers_are_never_shortened() {
1125        // Long enough to be unusual, small enough to still be an identifier.
1126        let id = "s".repeat(MAX_IDENTIFIER_BYTES);
1127        let line =
1128            serde_json::json!({"type": "system", "subtype": "init", "session_id": id}).to_string();
1129        let (events, term) = run(Agent::Claude, &[&line]);
1130
1131        let Some(Event::Started { session, .. }) = events.first().cloned() else {
1132            panic!("expected Started, got {events:?}")
1133        };
1134        assert_eq!(session.len(), id.len(), "the session id was shortened");
1135        assert_eq!(term.session.as_deref(), Some(id.as_str()));
1136    }
1137
1138    /// The hole this closes: identifiers were exempt from every bound, so a
1139    /// 512 KiB id rode through and the "16 MiB queued" figure was wrong.
1140    /// Rejecting is right where truncating is not, because a binding that
1141    /// cannot resume is worse than no binding.
1142    #[test]
1143    fn an_oversized_session_id_is_rejected_rather_than_stored() {
1144        let id = "s".repeat(MAX_IDENTIFIER_BYTES + 1);
1145        for (agent, line) in [
1146            (
1147                Agent::Claude,
1148                serde_json::json!({"type": "system", "subtype": "init", "session_id": id})
1149                    .to_string(),
1150            ),
1151            (
1152                Agent::Codex,
1153                serde_json::json!({"type": "thread.started", "thread_id": id}).to_string(),
1154            ),
1155            (
1156                Agent::Copilot,
1157                serde_json::json!({"type": "result", "sessionId": id, "exitCode": 0}).to_string(),
1158            ),
1159        ] {
1160            let (events, term) = run(agent, &[&line]);
1161            assert!(term.session.is_none(), "{agent} stored an unusable id");
1162            assert!(
1163                !events.iter().any(|e| matches!(e, Event::Started { .. })),
1164                "{agent} announced a session it cannot resume"
1165            );
1166        }
1167    }
1168
1169    /// A tool event with an unusable id is still reported: what the agent did
1170    /// is worth knowing even when it cannot be correlated. The id is dropped,
1171    /// never shortened into something that would match the wrong call.
1172    #[test]
1173    fn an_oversized_tool_id_drops_the_id_but_keeps_the_event() {
1174        let id = "t".repeat(MAX_IDENTIFIER_BYTES + 1);
1175        let line = serde_json::json!({
1176            "type": "assistant", "session_id": "s",
1177            "message": {"content": [{
1178                "type": "tool_use", "id": id, "name": "Bash", "input": {"command": "ls"}
1179            }]}
1180        })
1181        .to_string();
1182
1183        let (events, _) = run(Agent::Claude, &[&line]);
1184        let Some(Event::ToolCall { id: seen, name, .. }) = events
1185            .iter()
1186            .find(|e| matches!(e, Event::ToolCall { .. }))
1187            .cloned()
1188        else {
1189            panic!("the call itself must still be reported, got {events:?}")
1190        };
1191        assert_eq!(seen, None, "an unusable id must be dropped, not shortened");
1192        assert_eq!(name, "Bash");
1193    }
1194
1195    /// Bounding the entry count bounded nothing while the entries themselves
1196    /// were unbounded: 1024 pending calls could retain hundreds of megabytes.
1197    #[test]
1198    fn the_pending_tool_map_is_bounded_by_bytes_not_only_entries() {
1199        let mut parser = Parser::new(Agent::Claude, Format::Stream);
1200        // Ids and names just under the identifier ceiling, so the entry count
1201        // is nowhere near its limit while the bytes are.
1202        for i in 0..MAX_PENDING_TOOLS {
1203            let line = serde_json::json!({
1204                "type": "assistant", "session_id": "s",
1205                "message": {"content": [{
1206                    "type": "tool_use",
1207                    "id": format!("{i:0>width$}", width = MAX_IDENTIFIER_BYTES),
1208                    "name": "x".repeat(MAX_IDENTIFIER_BYTES),
1209                    "input": {}
1210                }]}
1211            })
1212            .to_string();
1213            parser.push(&line);
1214        }
1215        assert!(
1216            parser.tool_bytes <= MAX_PENDING_TOOL_BYTES,
1217            "pending tools grew to {} bytes",
1218            parser.tool_bytes
1219        );
1220    }
1221
1222    /// Answering a call must release its budget, or a long run of ordinary
1223    /// paired calls would exhaust it and stop correlating.
1224    #[test]
1225    fn a_completed_tool_call_releases_its_budget() {
1226        let mut parser = Parser::new(Agent::Claude, Format::Stream);
1227        let call = |id: &str| {
1228            serde_json::json!({
1229                "type": "assistant", "session_id": "s",
1230                "message": {"content": [{
1231                    "type": "tool_use", "id": id, "name": "Bash", "input": {}
1232                }]}
1233            })
1234            .to_string()
1235        };
1236        let result = |id: &str| {
1237            serde_json::json!({
1238                "type": "user", "session_id": "s",
1239                "message": {"content": [{
1240                    "type": "tool_result", "tool_use_id": id, "content": "done"
1241                }]}
1242            })
1243            .to_string()
1244        };
1245
1246        for i in 0..(MAX_PENDING_TOOLS * 4) {
1247            let id = format!("toolu_{i}");
1248            parser.push(&call(&id));
1249            parser.push(&result(&id));
1250        }
1251        assert_eq!(parser.tool_bytes, 0, "budget leaked across paired calls");
1252        assert!(parser.tools.is_empty());
1253    }
1254
1255    /// The claim the changelog makes has to survive an adversarial line: every
1256    /// field at its worst, times the channel depth, still under 20 MiB.
1257    #[test]
1258    fn a_worst_case_event_stays_within_the_stated_ceiling() {
1259        let huge = "x".repeat(MAX_LINE);
1260        let line = serde_json::json!({
1261            "type": "assistant", "session_id": huge,
1262            "message": {"content": [{
1263                "type": "tool_use", "id": huge, "name": huge, "input": {"command": huge}
1264            }]}
1265        })
1266        .to_string();
1267
1268        let (events, _) = run(Agent::Claude, &[&line]);
1269        for event in &events {
1270            let size = serde_json::to_string(event).unwrap().len();
1271            // Payload plus identifiers, with room for JSON framing.
1272            let ceiling = MAX_EVENT_BYTES + 4 * MAX_IDENTIFIER_BYTES;
1273            assert!(size <= ceiling, "an event reached {size} bytes: {event:?}");
1274        }
1275    }
1276
1277    /// Truncating JSON would produce something that no longer parses, so an
1278    /// oversized argument object is replaced rather than cut.
1279    #[test]
1280    fn oversized_tool_arguments_stay_valid_json() {
1281        let line = serde_json::json!({
1282            "type": "assistant",
1283            "session_id": "s",
1284            "message": {"content": [{
1285                "type": "tool_use", "id": "t1", "name": "Bash",
1286                "input": {"command": "y".repeat(MAX_EVENT_BYTES * 3)}
1287            }]}
1288        })
1289        .to_string();
1290
1291        let (events, _) = run(Agent::Claude, &[&line]);
1292        let Some(Event::ToolCall { input, .. }) = events
1293            .iter()
1294            .find(|e| matches!(e, Event::ToolCall { .. }))
1295            .cloned()
1296        else {
1297            panic!("expected a tool call, got {events:?}")
1298        };
1299        assert_eq!(input["truncated"], true, "got {input}");
1300        assert!(
1301            input.is_object(),
1302            "the replacement must still be valid JSON"
1303        );
1304        assert!(input.to_string().len() <= MAX_EVENT_BYTES);
1305    }
1306
1307    #[test]
1308    fn ordinary_payloads_pass_through_untouched() {
1309        let (events, _) = run(
1310            Agent::Claude,
1311            &[
1312                r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"text","text":"pong"}]}}"#,
1313            ],
1314        );
1315        assert!(events.contains(&Event::Text("pong".into())), "{events:?}");
1316    }
1317
1318    #[test]
1319    fn capture_is_bounded_and_keeps_the_earliest_output() {
1320        let mut buf = String::new();
1321        // Far more than the cap, in chunks, as a streaming agent would.
1322        for i in 0..50_000 {
1323            append_capped(&mut buf, &format!("line {i} aaaaaaaaaaaaaaaaaaaaaaaaaaaa"));
1324        }
1325        assert!(buf.len() <= MAX_CAPTURE, "grew to {}", buf.len());
1326        assert!(buf.starts_with("line 0 "), "the earliest output is kept");
1327    }
1328
1329    #[test]
1330    fn capping_never_splits_a_multibyte_character() {
1331        let mut buf = "x".repeat(MAX_CAPTURE - 3);
1332        // A 4-byte character that cannot fit in the 3 bytes remaining.
1333        assert!(append_capped(&mut buf, "🙂🙂"));
1334        assert!(buf.len() <= MAX_CAPTURE);
1335        // The invariant is simply that this is still a valid Rust string, which
1336        // would have panicked on a mid-character slice above.
1337        assert!(buf.is_char_boundary(buf.len()));
1338    }
1339
1340    #[test]
1341    fn a_full_buffer_reports_that_it_took_nothing() {
1342        let mut buf = "x".repeat(MAX_CAPTURE);
1343        assert!(!append_capped(&mut buf, "more"));
1344        assert_eq!(buf.len(), MAX_CAPTURE);
1345    }
1346
1347    /// A vendor changing its output shape looks like a clean exit with nothing
1348    /// parsed. Counting the misses turns that from a mystery into a diagnosis.
1349    #[test]
1350    fn unparseable_lines_are_counted_and_sampled() {
1351        let (_, term) = run(
1352            Agent::Claude,
1353            &[
1354                "<html>an error page, not JSON</html>",
1355                "another bad line",
1356                r#"{"type":"result","result":"ok","session_id":"s"}"#,
1357            ],
1358        );
1359        assert_eq!(term.unparsed, 2);
1360        assert_eq!(
1361            term.first_unparsed.as_deref(),
1362            Some("<html>an error page, not JSON</html>")
1363        );
1364    }
1365
1366    #[test]
1367    fn a_clean_stream_reports_no_parse_failures() {
1368        let (_, term) = run(
1369            Agent::Claude,
1370            &[r#"{"type":"result","result":"ok","session_id":"s"}"#],
1371        );
1372        assert_eq!(term.unparsed, 0);
1373        assert!(term.first_unparsed.is_none());
1374    }
1375
1376    /// Non-text content blocks are preserved as raw JSON rather than dropped, so
1377    /// a caller inspecting a tool result never silently loses part of it.
1378    #[test]
1379    fn tool_result_blocks_that_are_not_text_are_kept_not_dropped() {
1380        let (events, _) = run(
1381            Agent::Claude,
1382            &[
1383                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"}}]}]}}"#,
1384            ],
1385        );
1386        let output = events
1387            .iter()
1388            .find_map(|e| match e {
1389                Event::ToolResult { output, .. } => Some(output),
1390                _ => None,
1391            })
1392            .unwrap_or_else(|| panic!("expected a tool result, got {events:?}"));
1393        assert!(output.contains("seen"));
1394        assert!(output.contains("image"), "the image block was dropped");
1395    }
1396
1397    #[test]
1398    fn garbage_lines_are_skipped_not_fatal() {
1399        let (events, term) = run(
1400            Agent::Claude,
1401            &[
1402                "Warning: something on stdout",
1403                "",
1404                r#"{"type":"result","result":"ok","session_id":"s"}"#,
1405            ],
1406        );
1407        assert!(events.iter().all(|e| !matches!(e, Event::Text(_))));
1408        assert_eq!(term.text, "ok");
1409    }
1410
1411    #[test]
1412    fn text_format_passes_lines_through_verbatim() {
1413        let mut p = Parser::new(Agent::Copilot, Format::Text);
1414        let events: Vec<_> = ["hello", "world"].iter().flat_map(|l| p.push(l)).collect();
1415        assert_eq!(
1416            events,
1417            [Event::Text("hello".into()), Event::Text("world".into())]
1418        );
1419        assert_eq!(p.finish().text, "hello\nworld");
1420    }
1421}