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 how many tool calls may be tracked at once.
86///
87/// Entries are removed as results arrive, so this only bites when an agent
88/// announces calls it never completes.
89pub(crate) const MAX_PENDING_TOOLS: usize = 1024;
90
91/// Append `line` and a newline to `buf`, stopping once [`MAX_CAPTURE`] is
92/// reached. Returns whether anything was written.
93///
94/// Truncation keeps the *earliest* output, which is where a banner, a usage
95/// error, or the start of an answer lives. Later output from a runaway agent is
96/// the part worth dropping.
97pub(crate) fn append_capped(buf: &mut String, line: &str) -> bool {
98    let remaining = MAX_CAPTURE.saturating_sub(buf.len());
99    if remaining == 0 {
100        return false;
101    }
102    // `<` rather than `<=`, because the newline also has to fit.
103    if line.len() < remaining {
104        buf.push_str(line);
105        buf.push('\n');
106    } else {
107        // Cut on a character boundary; a truncated buffer must stay valid UTF-8.
108        let mut cut = remaining - 1;
109        while cut > 0 && !line.is_char_boundary(cut) {
110            cut -= 1;
111        }
112        buf.push_str(&line[..cut]);
113        buf.push('\n');
114    }
115    true
116}
117
118/// Facts that are only known once the stream ends.
119#[derive(Debug, Clone, Default, PartialEq)]
120pub struct Terminal {
121    /// The native session id.
122    pub session: Option<String>,
123    /// The agent's authoritative final answer.
124    pub text: String,
125    /// Token and cost accounting.
126    pub usage: Usage,
127    /// Why the agent stopped.
128    pub stop: Stop,
129    /// The last quota signal seen.
130    pub rate_limit: Option<RateLimit>,
131    /// How many output lines could not be parsed.
132    ///
133    /// Non-zero is not automatically a fault: agents interleave banners and
134    /// warnings with their JSON. It matters when a run *also* came back empty,
135    /// which is what a vendor changing its output shape looks like from here.
136    pub unparsed: usize,
137    /// The first line that failed to parse, as evidence for the above.
138    pub first_unparsed: Option<String>,
139}
140
141/// Incrementally turns one agent's output into [`Event`]s and a [`Terminal`].
142#[derive(Debug)]
143pub(crate) struct Parser {
144    agent: Agent,
145    format: Format,
146    term: Terminal,
147    /// Tool names by call id, so a result can be attributed to its call.
148    tools: HashMap<String, String>,
149    /// True once a [`Event::Started`] has been emitted, so it fires only once.
150    started: bool,
151    /// Whether any record was recognized as this agent's own shape.
152    structured: bool,
153    /// Whether the agent's terminal record was seen.
154    terminal_seen: bool,
155}
156
157impl Parser {
158    /// A parser for `agent` reading output in `format`.
159    #[must_use]
160    pub fn new(agent: Agent, format: Format) -> Self {
161        Self {
162            agent,
163            format,
164            term: Terminal::default(),
165            tools: HashMap::new(),
166            started: false,
167            structured: false,
168            terminal_seen: false,
169        }
170    }
171
172    /// Feed one line of stdout, returning the events it produced.
173    ///
174    /// Unparseable lines yield nothing rather than failing the run: agents
175    /// interleave banners and warnings with their JSON, and a stray line is not
176    /// a reason to lose a completed turn. They are counted in
177    /// [`Terminal::unparsed`] so that a silent vendor format change is
178    /// diagnosable instead of merely producing an empty answer.
179    pub fn push(&mut self, line: &str) -> Vec<Event> {
180        let line = line.trim();
181        if line.is_empty() {
182            return Vec::new();
183        }
184        // Under a plain-text format there is nothing to parse: the whole stream
185        // is the answer.
186        if self.format == Format::Text {
187            append_capped(&mut self.term.text, line);
188            return vec![Event::Text(line.to_string())];
189        }
190        let Ok(value) = serde_json::from_str::<Value>(line) else {
191            self.term.unparsed += 1;
192            if self.term.first_unparsed.is_none() {
193                // One short sample is enough to identify a shape change; keeping
194                // every stray line would reintroduce the unbounded growth this
195                // parser just capped.
196                let mut cut = line.len().min(512);
197                while cut > 0 && !line.is_char_boundary(cut) {
198                    cut -= 1;
199                }
200                self.term.first_unparsed = Some(line[..cut].to_string());
201            }
202            return Vec::new();
203        };
204        // A record that names a type this parser knows is evidence the stream
205        // really is the shape we asked for.
206        if let Some(ty) = value.get("type").and_then(Value::as_str)
207            && self.recognizes(ty)
208        {
209            self.structured = true;
210        }
211        let mut out = match self.agent {
212            Agent::Claude => self.claude(&value),
213            Agent::Codex => self.codex(&value),
214            Agent::Copilot => self.copilot(&value),
215        };
216        // Fire `Started` exactly once, from whichever record first revealed the
217        // id, and put it ahead of that record's own events.
218        if !self.started {
219            if let Some(session) = self.term.session.clone() {
220                self.started = true;
221                out.insert(
222                    0,
223                    Event::Started {
224                        session,
225                        model: model_of(&value),
226                    },
227                );
228            }
229        }
230        out
231    }
232
233    /// Whether `ty` is a record type this agent's parser understands.
234    fn recognizes(&self, ty: &str) -> bool {
235        match self.agent {
236            Agent::Claude => matches!(
237                ty,
238                "system" | "assistant" | "user" | "result" | "rate_limit_event"
239            ),
240            Agent::Codex => {
241                ty.starts_with("thread.") || ty.starts_with("turn.") || ty.starts_with("item.")
242            }
243            Agent::Copilot => {
244                ty == "result"
245                    || ty.starts_with("assistant.")
246                    || ty.starts_with("tool.")
247                    || ty.starts_with("session.")
248            }
249        }
250    }
251
252    /// Track a tool call so its result can be attributed, bounded so an agent
253    /// that announces calls it never finishes cannot grow this without limit.
254    fn remember_tool(&mut self, id: &str, name: &str) {
255        if self.tools.len() >= MAX_PENDING_TOOLS {
256            return;
257        }
258        self.tools.insert(id.to_string(), name.to_string());
259    }
260
261    /// Whether any structured record has been recognized on this stream.
262    ///
263    /// A structured run that recognized nothing did not merely fail to answer;
264    /// it means the output was not the shape this parser understands.
265    pub(crate) fn saw_structured_record(&self) -> bool {
266        self.structured
267    }
268
269    /// Whether the stream carried its terminal record, the one that closes a
270    /// turn and carries the answer and usage.
271    pub(crate) fn saw_terminal_record(&self) -> bool {
272        self.terminal_seen
273    }
274
275    /// Consume the parser for everything only knowable at the end.
276    #[must_use]
277    pub fn finish(mut self) -> Terminal {
278        if self.format == Format::Text {
279            self.term.text = self.term.text.trim_end().to_string();
280        }
281        self.term
282    }
283
284    /// Claude Code `--output-format json` / `stream-json`.
285    ///
286    /// Verified against claude 2.1.205: `system/init` opens with the id,
287    /// `assistant` records carry Anthropic content blocks, `rate_limit_event`
288    /// reports quota, and `result` closes with the answer and usage.
289    fn claude(&mut self, v: &Value) -> Vec<Event> {
290        let ty = v.get("type").and_then(Value::as_str).unwrap_or_default();
291        if let Some(id) = v.get("session_id").and_then(Value::as_str) {
292            self.term.session.get_or_insert_with(|| id.to_string());
293        }
294        match ty {
295            "rate_limit_event" => {
296                let limit = claude_rate_limit(v.get("rate_limit_info"));
297                self.term.rate_limit.clone_from(&limit);
298                limit.into_iter().map(Event::RateLimit).collect()
299            }
300            // Both roles carry content blocks: `assistant` holds text/thinking/
301            // tool_use, `user` carries the tool_result observations back.
302            "assistant" | "user" => self.content_blocks(v),
303            "result" => {
304                self.terminal_seen = true;
305                if let Some(text) = v.get("result").and_then(Value::as_str) {
306                    self.term.text = text.to_string();
307                }
308                self.term.usage = claude_usage(v);
309                self.term.stop = if v.get("is_error").and_then(Value::as_bool) == Some(true) {
310                    Stop::Error
311                } else {
312                    stop_from(v.get("stop_reason"))
313                };
314                Vec::new()
315            }
316            _ => Vec::new(),
317        }
318    }
319
320    /// Anthropic content blocks, shared by Claude's `assistant` and `user`
321    /// records.
322    fn content_blocks(&mut self, v: &Value) -> Vec<Event> {
323        let blocks = v
324            .get("message")
325            .and_then(|m| m.get("content"))
326            .and_then(Value::as_array);
327        let Some(blocks) = blocks else {
328            return Vec::new();
329        };
330        let mut out = Vec::new();
331        for block in blocks {
332            let ty = block
333                .get("type")
334                .and_then(Value::as_str)
335                .unwrap_or_default();
336            match ty {
337                "text" => {
338                    if let Some(t) = block.get("text").and_then(Value::as_str) {
339                        out.push(Event::Text(t.to_string()));
340                    }
341                }
342                "thinking" => {
343                    if let Some(t) = block.get("thinking").and_then(Value::as_str) {
344                        out.push(Event::Thinking(t.to_string()));
345                    }
346                }
347                "tool_use" => {
348                    let name = block
349                        .get("name")
350                        .and_then(Value::as_str)
351                        .unwrap_or("tool")
352                        .to_string();
353                    let id = block.get("id").and_then(Value::as_str).map(str::to_string);
354                    if let Some(id) = &id {
355                        self.remember_tool(id, &name);
356                    }
357                    out.push(Event::ToolCall {
358                        id,
359                        name,
360                        input: block.get("input").cloned().unwrap_or(Value::Null),
361                    });
362                }
363                "tool_result" => out.push(Event::ToolResult {
364                    id: block
365                        .get("tool_use_id")
366                        .and_then(Value::as_str)
367                        .inspect(|id| {
368                            // The call has been answered, so stop tracking it.
369                            self.tools.remove(*id);
370                        })
371                        .map(str::to_string),
372                    ok: block
373                        .get("is_error")
374                        .and_then(Value::as_bool)
375                        .map(|is_error| !is_error),
376                    output: flatten_text(block.get("content")),
377                }),
378                _ => {}
379            }
380        }
381        out
382    }
383
384    /// Codex `exec --json`.
385    ///
386    /// Verified against codex-cli 0.145.0: `thread.started` opens with
387    /// `thread_id`, items arrive as `item.started` → `item.completed` pairs, and
388    /// `turn.completed` carries usage. A tool item appears twice: once
389    /// in-progress with an empty `aggregated_output`, once finished, so the
390    /// call is emitted on first sighting and the result only once it completes.
391    fn codex(&mut self, v: &Value) -> Vec<Event> {
392        let ty = v.get("type").and_then(Value::as_str).unwrap_or_default();
393        if let Some(id) = v.get("thread_id").and_then(Value::as_str) {
394            self.term.session.get_or_insert_with(|| id.to_string());
395        }
396        match ty {
397            "turn.completed" => {
398                self.terminal_seen = true;
399                self.term.usage = codex_usage(v.get("usage"));
400                Vec::new()
401            }
402            "turn.failed" => {
403                self.terminal_seen = true;
404                self.term.stop = Stop::Error;
405                Vec::new()
406            }
407            "item.started" | "item.updated" | "item.completed" => {
408                let Some(item) = v.get("item") else {
409                    return Vec::new();
410                };
411                let item_ty = item.get("type").and_then(Value::as_str).unwrap_or_default();
412                let id = item.get("id").and_then(Value::as_str).map(str::to_string);
413                let done = ty == "item.completed";
414
415                // Every item is reported at least twice: in progress, then
416                // finished. Announce each one exactly once, on first sighting,
417                // and keep the id → name binding for the result.
418                let name = tool_name(item, item_ty);
419                let first = id
420                    .as_ref()
421                    .is_none_or(|id| self.tools.insert(id.clone(), name.clone()).is_none());
422
423                match item_ty {
424                    // The settled text is authoritative; a turn may contain
425                    // several messages, so the last one to complete wins.
426                    "agent_message" => {
427                        if !done {
428                            return Vec::new();
429                        }
430                        let text = item.get("text").and_then(Value::as_str).unwrap_or_default();
431                        self.term.text = text.to_string();
432                        vec![Event::Text(text.to_string())]
433                    }
434                    "reasoning" if done => item
435                        .get("text")
436                        .and_then(Value::as_str)
437                        .map(|t| Event::Thinking(t.to_string()))
438                        .into_iter()
439                        .collect(),
440                    "command_execution" | "mcp_tool_call" | "file_change" | "web_search" => {
441                        let mut out = Vec::new();
442                        if first {
443                            out.push(Event::ToolCall {
444                                id: id.clone(),
445                                name,
446                                input: codex_tool_input(item, item_ty),
447                            });
448                        }
449                        // Only the finished record carries real output: the
450                        // in-progress one has an empty string and a null code.
451                        if done {
452                            if let Some(id) = &id {
453                                self.tools.remove(id);
454                            }
455                            out.push(Event::ToolResult {
456                                id,
457                                ok: item
458                                    .get("exit_code")
459                                    .and_then(Value::as_i64)
460                                    .map(|code| code == 0),
461                                output: item
462                                    .get("aggregated_output")
463                                    .and_then(Value::as_str)
464                                    .unwrap_or_default()
465                                    .to_string(),
466                            });
467                        }
468                        out
469                    }
470                    _ => Vec::new(),
471                }
472            }
473            _ => Vec::new(),
474        }
475    }
476
477    /// Copilot `--output-format json` (JSONL).
478    ///
479    /// Verified against GitHub Copilot CLI 1.0.75: `assistant.message_delta`
480    /// streams text, `assistant.message` carries the settled answer,
481    /// `tool.execution_start` / `_complete` bracket a tool, and the final
482    /// `result` carries `sessionId` and usage.
483    fn copilot(&mut self, v: &Value) -> Vec<Event> {
484        let ty = v.get("type").and_then(Value::as_str).unwrap_or_default();
485        let data = v.get("data");
486        let field = |key: &str| -> Option<String> {
487            data.and_then(|d| d.get(key))
488                .and_then(Value::as_str)
489                .map(str::to_string)
490        };
491        match ty {
492            // The delta stream is what a live transcript renders.
493            "assistant.message_delta" => field("deltaContent")
494                .filter(|t| !t.is_empty())
495                .map(Event::Text)
496                .into_iter()
497                .collect(),
498            // The settled message is authoritative but already shown as deltas,
499            // so it updates the terminal text without re-emitting it.
500            "assistant.message" => {
501                if let Some(content) = field("content") {
502                    self.term.text = content;
503                }
504                Vec::new()
505            }
506            "assistant.reasoning" => field("content")
507                .filter(|t| !t.is_empty())
508                .map(Event::Thinking)
509                .into_iter()
510                .collect(),
511            "tool.execution_start" => {
512                let id = field("toolCallId");
513                let name = field("toolName").unwrap_or_else(|| "tool".into());
514                if let Some(id) = &id {
515                    self.remember_tool(id, &name);
516                }
517                vec![Event::ToolCall {
518                    id,
519                    name,
520                    input: data
521                        .and_then(|d| d.get("arguments"))
522                        .cloned()
523                        .unwrap_or(Value::Null),
524                }]
525            }
526            "tool.execution_complete" => vec![Event::ToolResult {
527                id: field("toolCallId").inspect(|id| {
528                    self.tools.remove(id);
529                }),
530                ok: data.and_then(|d| d.get("success")).and_then(Value::as_bool),
531                output: data
532                    .and_then(|d| d.get("result"))
533                    .and_then(|r| r.get("content"))
534                    .and_then(Value::as_str)
535                    .unwrap_or_default()
536                    .to_string(),
537            }],
538            // Copilot's terminal record is flat, not nested under `data`.
539            "result" => {
540                self.terminal_seen = true;
541                if let Some(id) = v.get("sessionId").and_then(Value::as_str) {
542                    self.term.session = Some(id.to_string());
543                }
544                if let Some(usage) = v.get("usage") {
545                    self.term.usage.premium_requests =
546                        usage.get("premiumRequests").and_then(Value::as_u64);
547                }
548                if v.get("exitCode").and_then(Value::as_i64).unwrap_or(0) != 0 {
549                    self.term.stop = Stop::Error;
550                }
551                Vec::new()
552            }
553            _ => Vec::new(),
554        }
555    }
556}
557
558/// The model named by a record, if it names one. Claude puts it at the top
559/// level; Copilot nests it under `data`.
560fn model_of(v: &Value) -> Option<String> {
561    v.get("model")
562        .or_else(|| v.get("data").and_then(|d| d.get("model")))
563        .and_then(Value::as_str)
564        .map(str::to_string)
565}
566
567/// A `stop_reason` string that is neither absent nor the normal end.
568fn stop_from(v: Option<&Value>) -> Stop {
569    match v.and_then(Value::as_str) {
570        None | Some("end_turn" | "stop" | "completed") => Stop::Completed,
571        Some(other) => Stop::Other(other.to_string()),
572    }
573}
574
575/// Claude's `rate_limit_info` object.
576fn claude_rate_limit(v: Option<&Value>) -> Option<RateLimit> {
577    let v = v?;
578    Some(RateLimit {
579        status: v.get("status").and_then(Value::as_str)?.to_string(),
580        window: v
581            .get("rateLimitType")
582            .and_then(Value::as_str)
583            .map(str::to_string),
584        resets_at: v.get("resetsAt").and_then(Value::as_i64),
585    })
586}
587
588/// Claude's terminal `usage` block plus its top-level `total_cost_usd`.
589fn claude_usage(v: &Value) -> Usage {
590    let u = v.get("usage");
591    let get = |key: &str| u.and_then(|u| u.get(key)).and_then(Value::as_u64);
592    Usage {
593        input_tokens: get("input_tokens"),
594        output_tokens: get("output_tokens"),
595        cache_read_tokens: get("cache_read_input_tokens"),
596        cache_write_tokens: get("cache_creation_input_tokens"),
597        cost_usd: v.get("total_cost_usd").and_then(Value::as_f64),
598        premium_requests: None,
599    }
600}
601
602/// Codex's `turn.completed` usage block. Codex prices nothing itself, so
603/// `cost_usd` stays absent rather than being derived from a local table.
604fn codex_usage(v: Option<&Value>) -> Usage {
605    let get = |key: &str| v.and_then(|u| u.get(key)).and_then(Value::as_u64);
606    Usage {
607        input_tokens: get("input_tokens"),
608        output_tokens: get("output_tokens"),
609        cache_read_tokens: get("cached_input_tokens"),
610        cache_write_tokens: get("cache_write_input_tokens"),
611        cost_usd: None,
612        premium_requests: None,
613    }
614}
615
616/// The display name of a Codex item: MCP and collaboration items name the tool
617/// they invoked, everything else is identified by its item type.
618fn tool_name(item: &Value, item_ty: &str) -> String {
619    item.get("tool")
620        .and_then(Value::as_str)
621        .unwrap_or(item_ty)
622        .to_string()
623}
624
625/// The arguments of a Codex tool item, in whatever shape that item uses.
626fn codex_tool_input(item: &Value, item_ty: &str) -> Value {
627    match item_ty {
628        "command_execution" => serde_json::json!({ "command": item.get("command") }),
629        "mcp_tool_call" => item.get("arguments").cloned().unwrap_or(Value::Null),
630        // `file_change` carries `changes`, `web_search` a `query`; neither has a
631        // single canonical argument field, so the item stands in for itself.
632        _ => item.clone(),
633    }
634}
635
636/// Flatten a tool result's `content` into the observation the model saw.
637///
638/// Anthropic tool results are either a bare string or an array of content
639/// blocks. Text blocks flatten to their text; any other block kind (an image,
640/// or a shape added in a future API version) is kept as its raw JSON rather
641/// than dropped, so a caller inspecting a tool result never silently loses part
642/// of it. This is lossy in presentation, never in content.
643fn flatten_text(v: Option<&Value>) -> String {
644    match v {
645        Some(Value::String(s)) => s.clone(),
646        Some(Value::Array(blocks)) => blocks
647            .iter()
648            .map(|b| match b.get("text").and_then(Value::as_str) {
649                Some(text) => text.to_string(),
650                None => b.to_string(),
651            })
652            .collect::<Vec<_>>()
653            .join("\n"),
654        Some(other) => other.to_string(),
655        None => String::new(),
656    }
657}
658
659#[cfg(test)]
660mod tests {
661    use super::*;
662
663    /// Drive a parser over `lines`, returning every event and the terminal.
664    fn run(agent: Agent, lines: &[&str]) -> (Vec<Event>, Terminal) {
665        let mut p = Parser::new(agent, Format::Stream);
666        let events = lines.iter().flat_map(|l| p.push(l)).collect();
667        (events, p.finish())
668    }
669
670    // Lines below are trimmed copies of transcripts captured from the live CLIs.
671
672    #[test]
673    fn claude_stream_yields_start_thinking_text_and_terminal_facts() {
674        let (events, term) = run(
675            Agent::Claude,
676            &[
677                r#"{"type":"system","subtype":"init","session_id":"sess-a","model":"claude-haiku-4-5"}"#,
678                r#"{"type":"assistant","session_id":"sess-a","message":{"content":[{"type":"thinking","thinking":"brief"}]}}"#,
679                r#"{"type":"assistant","session_id":"sess-a","message":{"content":[{"type":"text","text":"pong"}]}}"#,
680                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}}"#,
681            ],
682        );
683        assert_eq!(
684            events[0],
685            Event::Started {
686                session: "sess-a".into(),
687                model: Some("claude-haiku-4-5".into())
688            }
689        );
690        assert_eq!(events[1], Event::Thinking("brief".into()));
691        assert_eq!(events[2], Event::Text("pong".into()));
692        assert_eq!(term.session.as_deref(), Some("sess-a"));
693        assert_eq!(term.text, "pong");
694        assert_eq!(term.stop, Stop::Completed);
695        assert_eq!(term.usage.input_tokens, Some(10));
696        assert_eq!(term.usage.cache_read_tokens, Some(18764));
697        assert_eq!(term.usage.cache_write_tokens, Some(7322));
698        assert_eq!(term.usage.cost_usd, Some(0.017));
699    }
700
701    #[test]
702    fn claude_started_fires_only_once() {
703        let (events, _) = run(
704            Agent::Claude,
705            &[
706                r#"{"type":"system","subtype":"init","session_id":"s"}"#,
707                r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"text","text":"a"}]}}"#,
708                r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"text","text":"b"}]}}"#,
709            ],
710        );
711        assert_eq!(
712            events
713                .iter()
714                .filter(|e| matches!(e, Event::Started { .. }))
715                .count(),
716            1
717        );
718    }
719
720    #[test]
721    fn claude_pairs_tool_use_with_its_result() {
722        let (events, _) = run(
723            Agent::Claude,
724            &[
725                r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"tool_use","id":"toolu_1","name":"Bash","input":{"command":"ls"}}]}}"#,
726                r#"{"type":"user","session_id":"s","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"a.txt"}]}}"#,
727            ],
728        );
729        let call = events
730            .iter()
731            .find(|e| matches!(e, Event::ToolCall { .. }))
732            .unwrap();
733        let Event::ToolCall { id, name, input } = call else {
734            unreachable!()
735        };
736        assert_eq!(id.as_deref(), Some("toolu_1"));
737        assert_eq!(name, "Bash");
738        assert_eq!(input["command"], "ls");
739        assert!(events.contains(&Event::ToolResult {
740            id: Some("toolu_1".into()),
741            ok: None,
742            output: "a.txt".into(),
743        }));
744    }
745
746    #[test]
747    fn claude_reports_a_rate_limit_without_failing() {
748        let (events, term) = run(
749            Agent::Claude,
750            &[
751                r#"{"type":"rate_limit_event","session_id":"s","rate_limit_info":{"status":"allowed","resetsAt":1785260400,"rateLimitType":"five_hour"}}"#,
752            ],
753        );
754        let limit = RateLimit {
755            status: "allowed".into(),
756            window: Some("five_hour".into()),
757            resets_at: Some(1_785_260_400),
758        };
759        assert!(events.contains(&Event::RateLimit(limit.clone())));
760        assert_eq!(term.rate_limit, Some(limit.clone()));
761        assert!(
762            !limit.is_blocking(),
763            "an `allowed` heartbeat is not a block"
764        );
765    }
766
767    #[test]
768    fn claude_error_result_sets_the_stop_reason() {
769        let (_, term) = run(
770            Agent::Claude,
771            &[r#"{"type":"result","is_error":true,"result":"boom","session_id":"s"}"#],
772        );
773        assert_eq!(term.stop, Stop::Error);
774    }
775
776    #[test]
777    fn copilot_streams_deltas_and_takes_its_answer_from_the_settled_message() {
778        let (events, term) = run(
779            Agent::Copilot,
780            &[
781                r#"{"type":"assistant.message_delta","data":{"messageId":"m","deltaContent":"po"}}"#,
782                r#"{"type":"assistant.message_delta","data":{"messageId":"m","deltaContent":"ng"}}"#,
783                r#"{"type":"assistant.message","data":{"messageId":"m","model":"gpt-5-mini","content":"pong"}}"#,
784                r#"{"type":"result","sessionId":"768c8e7d","exitCode":0,"usage":{"premiumRequests":0}}"#,
785            ],
786        );
787        // The deltas stream; the settled message must not double them.
788        let texts: Vec<_> = events
789            .iter()
790            .filter_map(|e| match e {
791                Event::Text(t) => Some(t.as_str()),
792                _ => None,
793            })
794            .collect();
795        assert_eq!(texts, ["po", "ng"]);
796        assert_eq!(term.text, "pong", "the answer is the settled message");
797        assert_eq!(term.session.as_deref(), Some("768c8e7d"));
798        assert_eq!(term.usage.premium_requests, Some(0));
799    }
800
801    #[test]
802    fn copilot_brackets_a_tool_call_with_its_completion() {
803        let (events, _) = run(
804            Agent::Copilot,
805            &[
806                r#"{"type":"tool.execution_start","data":{"toolCallId":"call_1","toolName":"bash","arguments":{"command":"ls"}}}"#,
807                r#"{"type":"tool.execution_complete","data":{"toolCallId":"call_1","success":true,"result":{"content":"a.txt"}}}"#,
808            ],
809        );
810        assert!(matches!(
811            &events[0],
812            Event::ToolCall { id, name, .. }
813                if id.as_deref() == Some("call_1") && name == "bash"
814        ));
815        assert_eq!(
816            events[1],
817            Event::ToolResult {
818                id: Some("call_1".into()),
819                ok: Some(true),
820                output: "a.txt".into()
821            }
822        );
823    }
824
825    #[test]
826    fn codex_reads_the_thread_id_and_the_completed_message() {
827        let (events, term) = run(
828            Agent::Codex,
829            &[
830                r#"{"type":"thread.started","thread_id":"0199-xyz"}"#,
831                r#"{"type":"item.completed","item":{"id":"i1","type":"agent_message","text":"pong"}}"#,
832                r#"{"type":"turn.completed","usage":{"input_tokens":12,"output_tokens":3,"cached_input_tokens":9}}"#,
833            ],
834        );
835        assert_eq!(
836            events[0],
837            Event::Started {
838                session: "0199-xyz".into(),
839                model: None
840            }
841        );
842        assert_eq!(term.session.as_deref(), Some("0199-xyz"));
843        assert_eq!(term.text, "pong");
844        assert_eq!(term.usage.input_tokens, Some(12));
845        assert_eq!(term.usage.cache_read_tokens, Some(9));
846    }
847
848    #[test]
849    fn codex_command_execution_becomes_a_call_and_a_result() {
850        let (events, _) = run(
851            Agent::Codex,
852            &[
853                r#"{"type":"item.completed","item":{"id":"c1","type":"command_execution","command":"ls","exit_code":0,"aggregated_output":"a.txt"}}"#,
854            ],
855        );
856        assert!(matches!(&events[0], Event::ToolCall { name, .. } if name == "command_execution"));
857        assert_eq!(
858            events[1],
859            Event::ToolResult {
860                id: Some("c1".into()),
861                ok: Some(true),
862                output: "a.txt".into()
863            }
864        );
865    }
866
867    /// Codex reports one tool twice: in progress, then finished. The call must
868    /// be announced once and the empty in-progress output must never surface as
869    /// a result. Both lines are verbatim from a codex 0.145.0 transcript.
870    #[test]
871    fn codex_started_then_completed_yields_one_call_and_one_result() {
872        let (events, _) = run(
873            Agent::Codex,
874            &[
875                r#"{"type":"item.started","item":{"id":"item_1","type":"command_execution","command":"/bin/zsh -lc ls","aggregated_output":"","exit_code":null,"status":"in_progress"}}"#,
876                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"}}"#,
877            ],
878        );
879        let calls = events
880            .iter()
881            .filter(|e| matches!(e, Event::ToolCall { .. }))
882            .count();
883        assert_eq!(calls, 1, "the same item must not be announced twice");
884        let results: Vec<_> = events
885            .iter()
886            .filter_map(|e| match e {
887                Event::ToolResult { output, .. } => Some(output.as_str()),
888                _ => None,
889            })
890            .collect();
891        assert_eq!(
892            results,
893            ["a.txt\n"],
894            "the in-progress blank must not appear"
895        );
896    }
897
898    /// A turn can hold several messages; the answer is the last to settle.
899    #[test]
900    fn codex_last_completed_message_is_the_answer() {
901        let (_, term) = run(
902            Agent::Codex,
903            &[
904                r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"I'll list the directory."}}"#,
905                r#"{"type":"item.completed","item":{"id":"i2","type":"agent_message","text":"DONE"}}"#,
906            ],
907        );
908        assert_eq!(term.text, "DONE");
909    }
910
911    #[test]
912    fn capture_is_bounded_and_keeps_the_earliest_output() {
913        let mut buf = String::new();
914        // Far more than the cap, in chunks, as a streaming agent would.
915        for i in 0..50_000 {
916            append_capped(&mut buf, &format!("line {i} aaaaaaaaaaaaaaaaaaaaaaaaaaaa"));
917        }
918        assert!(buf.len() <= MAX_CAPTURE, "grew to {}", buf.len());
919        assert!(buf.starts_with("line 0 "), "the earliest output is kept");
920    }
921
922    #[test]
923    fn capping_never_splits_a_multibyte_character() {
924        let mut buf = "x".repeat(MAX_CAPTURE - 3);
925        // A 4-byte character that cannot fit in the 3 bytes remaining.
926        assert!(append_capped(&mut buf, "🙂🙂"));
927        assert!(buf.len() <= MAX_CAPTURE);
928        // The invariant is simply that this is still a valid Rust string, which
929        // would have panicked on a mid-character slice above.
930        assert!(buf.is_char_boundary(buf.len()));
931    }
932
933    #[test]
934    fn a_full_buffer_reports_that_it_took_nothing() {
935        let mut buf = "x".repeat(MAX_CAPTURE);
936        assert!(!append_capped(&mut buf, "more"));
937        assert_eq!(buf.len(), MAX_CAPTURE);
938    }
939
940    /// A vendor changing its output shape looks like a clean exit with nothing
941    /// parsed. Counting the misses turns that from a mystery into a diagnosis.
942    #[test]
943    fn unparseable_lines_are_counted_and_sampled() {
944        let (_, term) = run(
945            Agent::Claude,
946            &[
947                "<html>an error page, not JSON</html>",
948                "another bad line",
949                r#"{"type":"result","result":"ok","session_id":"s"}"#,
950            ],
951        );
952        assert_eq!(term.unparsed, 2);
953        assert_eq!(
954            term.first_unparsed.as_deref(),
955            Some("<html>an error page, not JSON</html>")
956        );
957    }
958
959    #[test]
960    fn a_clean_stream_reports_no_parse_failures() {
961        let (_, term) = run(
962            Agent::Claude,
963            &[r#"{"type":"result","result":"ok","session_id":"s"}"#],
964        );
965        assert_eq!(term.unparsed, 0);
966        assert!(term.first_unparsed.is_none());
967    }
968
969    /// Non-text content blocks are preserved as raw JSON rather than dropped, so
970    /// a caller inspecting a tool result never silently loses part of it.
971    #[test]
972    fn tool_result_blocks_that_are_not_text_are_kept_not_dropped() {
973        let (events, _) = run(
974            Agent::Claude,
975            &[
976                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"}}]}]}}"#,
977            ],
978        );
979        let output = events
980            .iter()
981            .find_map(|e| match e {
982                Event::ToolResult { output, .. } => Some(output),
983                _ => None,
984            })
985            .unwrap_or_else(|| panic!("expected a tool result, got {events:?}"));
986        assert!(output.contains("seen"));
987        assert!(output.contains("image"), "the image block was dropped");
988    }
989
990    #[test]
991    fn garbage_lines_are_skipped_not_fatal() {
992        let (events, term) = run(
993            Agent::Claude,
994            &[
995                "Warning: something on stdout",
996                "",
997                r#"{"type":"result","result":"ok","session_id":"s"}"#,
998            ],
999        );
1000        assert!(events.iter().all(|e| !matches!(e, Event::Text(_))));
1001        assert_eq!(term.text, "ok");
1002    }
1003
1004    #[test]
1005    fn text_format_passes_lines_through_verbatim() {
1006        let mut p = Parser::new(Agent::Copilot, Format::Text);
1007        let events: Vec<_> = ["hello", "world"].iter().flat_map(|l| p.push(l)).collect();
1008        assert_eq!(
1009            events,
1010            [Event::Text("hello".into()), Event::Text("world".into())]
1011        );
1012        assert_eq!(p.finish().text, "hello\nworld");
1013    }
1014}