1use 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
32#[serde(tag = "kind", rename_all = "snake_case")]
33#[non_exhaustive]
34pub enum Event {
35 Started {
37 session: String,
39 model: Option<String>,
41 },
42 Thinking(String),
44 Text(String),
46 ToolCall {
48 id: Option<String>,
51 name: String,
53 input: Value,
55 },
56 ToolResult {
58 id: Option<String>,
60 ok: Option<bool>,
62 output: String,
64 },
65 RateLimit(RateLimit),
67}
68
69pub const MAX_CAPTURE: usize = 1024 * 1024;
76
77pub const MAX_LINE: usize = 512 * 1024;
84
85pub(crate) const MAX_PENDING_TOOLS: usize = 1024;
90
91pub(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 if line.len() < remaining {
104 buf.push_str(line);
105 buf.push('\n');
106 } else {
107 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#[derive(Debug, Clone, Default, PartialEq)]
120pub struct Terminal {
121 pub session: Option<String>,
123 pub text: String,
125 pub usage: Usage,
127 pub stop: Stop,
129 pub rate_limit: Option<RateLimit>,
131 pub unparsed: usize,
137 pub first_unparsed: Option<String>,
139}
140
141#[derive(Debug)]
143pub(crate) struct Parser {
144 agent: Agent,
145 format: Format,
146 term: Terminal,
147 tools: HashMap<String, String>,
149 started: bool,
151 structured: bool,
153 terminal_seen: bool,
155}
156
157impl Parser {
158 #[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 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 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 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 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 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 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 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 pub(crate) fn saw_structured_record(&self) -> bool {
266 self.structured
267 }
268
269 pub(crate) fn saw_terminal_record(&self) -> bool {
272 self.terminal_seen
273 }
274
275 #[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 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 "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 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 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 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 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 "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 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 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 "assistant.message_delta" => field("deltaContent")
494 .filter(|t| !t.is_empty())
495 .map(Event::Text)
496 .into_iter()
497 .collect(),
498 "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 "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
558fn 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
567fn 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
575fn 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
588fn 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
602fn 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
616fn 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
625fn 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 _ => item.clone(),
633 }
634}
635
636fn 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 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 #[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 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 #[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 #[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 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 assert!(append_capped(&mut buf, "🙂🙂"));
927 assert!(buf.len() <= MAX_CAPTURE);
928 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 #[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 #[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}