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 const MAX_EVENT_BYTES: usize = 64 * 1024;
96
97pub const TRUNCATION_MARK: &str = "…(truncated)";
100
101pub const MAX_IDENTIFIER_BYTES: usize = 4 * 1024;
113
114pub(crate) const MAX_PENDING_TOOL_BYTES: usize = 256 * 1024;
120
121pub(crate) const MAX_PENDING_TOOLS: usize = 1024;
126
127pub(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 if line.len() < remaining {
140 buf.push_str(line);
141 buf.push('\n');
142 } else {
143 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
154fn usable_identifier(value: &str) -> bool {
159 value.len() <= MAX_IDENTIFIER_BYTES
160}
161
162fn accept_identifier(value: Option<String>) -> Option<String> {
164 value.filter(|v| usable_identifier(v))
165}
166
167fn 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
181fn 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
199fn 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 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 Event::Started { session, model } => Event::Started {
226 session,
227 model: model.map(bound_identifier),
228 },
229 Event::RateLimit(limit) => Event::RateLimit(RateLimit {
230 status: bound_identifier(limit.status),
231 window: limit.window.map(bound_identifier),
232 resets_at: limit.resets_at,
233 overage_status: limit.overage_status.map(bound_identifier),
234 is_using_overage: limit.is_using_overage,
235 }),
236 }
237}
238
239fn bound_identifier(text: String) -> String {
244 if text.len() <= MAX_IDENTIFIER_BYTES {
245 return text;
246 }
247 let mut cut = MAX_IDENTIFIER_BYTES - TRUNCATION_MARK.len();
248 while cut > 0 && !text.is_char_boundary(cut) {
249 cut -= 1;
250 }
251 let mut out = text[..cut].to_string();
252 out.push_str(TRUNCATION_MARK);
253 out
254}
255
256#[derive(Debug, Clone, Default, PartialEq)]
258pub struct Terminal {
259 pub session: Option<String>,
261 pub text: String,
263 pub usage: Usage,
265 pub stop: Stop,
267 pub rate_limit: Option<RateLimit>,
269 pub unparsed: usize,
275 pub first_unparsed: Option<String>,
277 pub structured: Option<Value>,
279 pub error_status: Option<u16>,
282 pub error_message: Option<String>,
286}
287
288fn unwrap_error_body(message: &str) -> (Option<u16>, String) {
296 let Ok(body) = serde_json::from_str::<Value>(message) else {
297 return (None, message.to_string());
298 };
299 let status = body
300 .get("status")
301 .and_then(Value::as_u64)
302 .and_then(|s| u16::try_from(s).ok());
303 let inner = body
304 .get("error")
305 .and_then(|e| e.get("message"))
306 .and_then(Value::as_str)
307 .map(str::to_string);
308 (status, inner.unwrap_or_else(|| message.to_string()))
309}
310
311#[derive(Debug)]
313pub(crate) struct Parser {
314 agent: Agent,
315 format: Format,
316 term: Terminal,
317 tools: HashMap<String, String>,
319 tool_bytes: usize,
322 seen: Seen,
324}
325
326#[derive(Debug, Default)]
332#[expect(
333 clippy::struct_excessive_bools,
334 reason = "four independent stream milestones; naming each beats packing them"
335)]
336struct Seen {
337 started: bool,
339 structured: bool,
342 terminal: bool,
344 deltas: bool,
351}
352
353impl Parser {
354 #[must_use]
356 pub fn new(agent: Agent, format: Format) -> Self {
357 Self {
358 agent,
359 format,
360 term: Terminal::default(),
361 tools: HashMap::new(),
362 tool_bytes: 0,
363 seen: Seen::default(),
364 }
365 }
366
367 pub fn push(&mut self, line: &str) -> Vec<Event> {
375 let line = line.trim();
376 if line.is_empty() {
377 return Vec::new();
378 }
379 if self.format == Format::Text {
382 append_capped(&mut self.term.text, line);
383 return vec![enforce_bounds(Event::Text(line.to_string()))];
384 }
385 let Ok(value) = serde_json::from_str::<Value>(line) else {
386 self.term.unparsed += 1;
387 if self.term.first_unparsed.is_none() {
388 let mut cut = line.len().min(512);
392 while cut > 0 && !line.is_char_boundary(cut) {
393 cut -= 1;
394 }
395 self.term.first_unparsed = Some(line[..cut].to_string());
396 }
397 return Vec::new();
398 };
399 if let Some(ty) = value.get("type").and_then(Value::as_str)
402 && self.recognizes(ty)
403 {
404 self.seen.structured = true;
405 }
406 let mut out = match self.agent {
407 Agent::Claude => self.claude(&value),
408 Agent::Codex => self.codex(&value),
409 Agent::Copilot => self.copilot(&value),
410 };
411 out = out.into_iter().map(enforce_bounds).collect();
414
415 if !self.seen.started {
418 if let Some(session) = self.term.session.clone() {
419 self.seen.started = true;
420 out.insert(
421 0,
422 Event::Started {
423 session,
424 model: model_of(&value),
425 },
426 );
427 }
428 }
429 out
430 }
431
432 fn recognizes(&self, ty: &str) -> bool {
434 match self.agent {
435 Agent::Claude => matches!(
436 ty,
437 "system" | "assistant" | "user" | "result" | "rate_limit_event"
438 ),
439 Agent::Codex => {
440 ty.starts_with("thread.") || ty.starts_with("turn.") || ty.starts_with("item.")
441 }
442 Agent::Copilot => {
443 ty == "result"
444 || ty.starts_with("assistant.")
445 || ty.starts_with("tool.")
446 || ty.starts_with("session.")
447 }
448 }
449 }
450
451 fn remember_tool(&mut self, id: &str, name: &str) {
454 if !usable_identifier(id) {
457 return;
458 }
459 let name = bound_identifier(name.to_string());
460 let cost = id.len() + name.len();
461 if self.tools.len() >= MAX_PENDING_TOOLS
465 || self.tool_bytes.saturating_add(cost) > MAX_PENDING_TOOL_BYTES
466 {
467 return;
468 }
469 self.tool_bytes += cost;
470 if let Some(previous) = self.tools.insert(id.to_string(), name) {
471 self.tool_bytes = self.tool_bytes.saturating_sub(id.len() + previous.len());
473 }
474 }
475
476 fn forget_tool(&mut self, id: &str) {
478 if let Some(name) = self.tools.remove(id) {
479 self.tool_bytes = self.tool_bytes.saturating_sub(id.len() + name.len());
480 }
481 }
482
483 pub(crate) fn saw_structured_record(&self) -> bool {
488 self.seen.structured
489 }
490
491 pub(crate) fn saw_terminal_record(&self) -> bool {
494 self.seen.terminal
495 }
496
497 #[must_use]
499 pub fn finish(mut self) -> Terminal {
500 if self.format == Format::Text {
501 self.term.text = self.term.text.trim_end().to_string();
502 }
503 self.term
504 }
505
506 fn claude(&mut self, v: &Value) -> Vec<Event> {
512 let ty = v.get("type").and_then(Value::as_str).unwrap_or_default();
513 if let Some(id) = v.get("session_id").and_then(Value::as_str)
516 && usable_identifier(id)
517 {
518 self.term.session.get_or_insert_with(|| id.to_string());
519 }
520 match ty {
521 "rate_limit_event" => {
522 let limit = claude_rate_limit(v.get("rate_limit_info"));
523 self.term.rate_limit.clone_from(&limit);
524 limit.into_iter().map(Event::RateLimit).collect()
525 }
526 "stream_event" => self.claude_delta(v),
528 "assistant" | "user" => self.content_blocks(v),
531 "result" => {
532 self.seen.terminal = true;
533 if let Some(text) = v.get("result").and_then(Value::as_str) {
534 self.term.text = text.to_string();
535 }
536 if let Some(value) = v.get("structured_output") {
539 self.term.structured = Some(value.clone());
540 }
541 self.term.usage = claude_usage(v);
542 self.term.stop = if v.get("is_error").and_then(Value::as_bool) == Some(true) {
545 self.term.error_status = v
546 .get("api_error_status")
547 .and_then(Value::as_u64)
548 .and_then(|s| u16::try_from(s).ok());
549 Stop::Error
550 } else {
551 stop_from(v.get("stop_reason"))
552 };
553 Vec::new()
554 }
555 _ => Vec::new(),
556 }
557 }
558
559 fn claude_delta(&mut self, v: &Value) -> Vec<Event> {
566 let Some(event) = v.get("event") else {
567 return Vec::new();
568 };
569 if event.get("type").and_then(Value::as_str) != Some("content_block_delta") {
570 return Vec::new();
571 }
572 let Some(delta) = event.get("delta") else {
573 return Vec::new();
574 };
575 self.seen.deltas = true;
578
579 match delta.get("type").and_then(Value::as_str) {
580 Some("text_delta") => delta
581 .get("text")
582 .and_then(Value::as_str)
583 .filter(|text| !text.is_empty())
584 .map(|text| Event::Text(text.to_string()))
585 .into_iter()
586 .collect(),
587 Some("thinking_delta") => delta
588 .get("thinking")
589 .and_then(Value::as_str)
590 .filter(|text| !text.is_empty())
591 .map(|text| Event::Thinking(text.to_string()))
592 .into_iter()
593 .collect(),
594 _ => Vec::new(),
598 }
599 }
600
601 fn content_blocks(&mut self, v: &Value) -> Vec<Event> {
604 let blocks = v
605 .get("message")
606 .and_then(|m| m.get("content"))
607 .and_then(Value::as_array);
608 let Some(blocks) = blocks else {
609 return Vec::new();
610 };
611 let mut out = Vec::new();
612 for block in blocks {
613 let ty = block
614 .get("type")
615 .and_then(Value::as_str)
616 .unwrap_or_default();
617 match ty {
618 "text" if !self.seen.deltas => {
623 if let Some(t) = block.get("text").and_then(Value::as_str) {
624 out.push(Event::Text(t.to_string()));
625 }
626 }
627 "thinking" if !self.seen.deltas => {
628 if let Some(t) = block.get("thinking").and_then(Value::as_str) {
629 out.push(Event::Thinking(t.to_string()));
630 }
631 }
632 "tool_use" => {
633 let name = block
634 .get("name")
635 .and_then(Value::as_str)
636 .unwrap_or("tool")
637 .to_string();
638 let id = block.get("id").and_then(Value::as_str).map(str::to_string);
639 if let Some(id) = &id {
640 self.remember_tool(id, &name);
641 }
642 out.push(Event::ToolCall {
643 id,
644 name,
645 input: block.get("input").cloned().unwrap_or(Value::Null),
646 });
647 }
648 "tool_result" => out.push(Event::ToolResult {
649 id: block
650 .get("tool_use_id")
651 .and_then(Value::as_str)
652 .inspect(|id| {
653 self.forget_tool(id);
655 })
656 .map(str::to_string),
657 ok: block
658 .get("is_error")
659 .and_then(Value::as_bool)
660 .map(|is_error| !is_error),
661 output: flatten_text(block.get("content")),
662 }),
663 _ => {}
664 }
665 }
666 out
667 }
668
669 fn codex(&mut self, v: &Value) -> Vec<Event> {
677 let ty = v.get("type").and_then(Value::as_str).unwrap_or_default();
678 if let Some(id) = v.get("thread_id").and_then(Value::as_str)
679 && usable_identifier(id)
680 {
681 self.term.session.get_or_insert_with(|| id.to_string());
682 }
683 match ty {
684 "turn.completed" => {
685 self.seen.terminal = true;
686 self.term.usage = codex_usage(v.get("usage"));
687 Vec::new()
688 }
689 "turn.failed" => {
690 self.seen.terminal = true;
691 self.term.stop = Stop::Error;
692 if let Some(message) = v
693 .get("error")
694 .and_then(|e| e.get("message"))
695 .and_then(Value::as_str)
696 {
697 let (status, message) = unwrap_error_body(message);
698 self.term.error_status = status;
699 self.term.error_message = Some(bound_text(message));
700 }
701 Vec::new()
702 }
703 "item.started" | "item.updated" | "item.completed" => {
704 let Some(item) = v.get("item") else {
705 return Vec::new();
706 };
707 let item_ty = item.get("type").and_then(Value::as_str).unwrap_or_default();
708 let id = item.get("id").and_then(Value::as_str).map(str::to_string);
709 let done = ty == "item.completed";
710
711 let name = tool_name(item, item_ty);
715 let first = id
716 .as_ref()
717 .is_none_or(|id| self.tools.insert(id.clone(), name.clone()).is_none());
718
719 match item_ty {
720 "agent_message" => {
723 if !done {
724 return Vec::new();
725 }
726 let text = item.get("text").and_then(Value::as_str).unwrap_or_default();
727 self.term.text = text.to_string();
728 vec![Event::Text(text.to_string())]
729 }
730 "reasoning" if done => item
731 .get("text")
732 .and_then(Value::as_str)
733 .map(|t| Event::Thinking(t.to_string()))
734 .into_iter()
735 .collect(),
736 "command_execution" | "mcp_tool_call" | "file_change" | "web_search" => {
737 let mut out = Vec::new();
738 if first {
739 out.push(Event::ToolCall {
740 id: id.clone(),
741 name,
742 input: codex_tool_input(item, item_ty),
743 });
744 }
745 if done {
748 if let Some(id) = &id {
749 self.forget_tool(id);
750 }
751 out.push(Event::ToolResult {
752 id,
753 ok: item
754 .get("exit_code")
755 .and_then(Value::as_i64)
756 .map(|code| code == 0),
757 output: item
758 .get("aggregated_output")
759 .and_then(Value::as_str)
760 .unwrap_or_default()
761 .to_string(),
762 });
763 }
764 out
765 }
766 _ => Vec::new(),
767 }
768 }
769 _ => Vec::new(),
770 }
771 }
772
773 fn copilot(&mut self, v: &Value) -> Vec<Event> {
780 let ty = v.get("type").and_then(Value::as_str).unwrap_or_default();
781 let data = v.get("data");
782 let field = |key: &str| -> Option<String> {
783 data.and_then(|d| d.get(key))
784 .and_then(Value::as_str)
785 .map(str::to_string)
786 };
787 match ty {
788 "assistant.message_delta" => field("deltaContent")
790 .filter(|t| !t.is_empty())
791 .map(Event::Text)
792 .into_iter()
793 .collect(),
794 "assistant.message" => {
797 if let Some(content) = field("content") {
798 self.term.text = content;
799 }
800 Vec::new()
801 }
802 "assistant.reasoning" => field("content")
803 .filter(|t| !t.is_empty())
804 .map(Event::Thinking)
805 .into_iter()
806 .collect(),
807 "tool.execution_start" => {
808 let id = field("toolCallId");
809 let name = field("toolName").unwrap_or_else(|| "tool".into());
810 if let Some(id) = &id {
811 self.remember_tool(id, &name);
812 }
813 vec![Event::ToolCall {
814 id,
815 name,
816 input: data
817 .and_then(|d| d.get("arguments"))
818 .cloned()
819 .unwrap_or(Value::Null),
820 }]
821 }
822 "tool.execution_complete" => vec![Event::ToolResult {
823 id: field("toolCallId").inspect(|id| {
824 self.forget_tool(id);
825 }),
826 ok: data.and_then(|d| d.get("success")).and_then(Value::as_bool),
827 output: data
828 .and_then(|d| d.get("result"))
829 .and_then(|r| r.get("content"))
830 .and_then(Value::as_str)
831 .unwrap_or_default()
832 .to_string(),
833 }],
834 "session.usage_checkpoint" => {
839 if let Some(data) = v.get("data") {
840 self.term.usage.ai_credits_nano =
841 data.get("totalNanoAiu").and_then(Value::as_u64);
842 if let Some(premium) = data.get("totalPremiumRequests").and_then(Value::as_u64)
843 {
844 self.term.usage.premium_requests = Some(premium);
845 }
846 }
847 Vec::new()
848 }
849 "result" => {
851 self.seen.terminal = true;
852 if let Some(id) = v.get("sessionId").and_then(Value::as_str)
853 && usable_identifier(id)
854 {
855 self.term.session = Some(id.to_string());
856 }
857 if let Some(usage) = v.get("usage") {
858 self.term.usage.premium_requests =
859 usage.get("premiumRequests").and_then(Value::as_u64);
860 self.term.usage.duration_ms =
861 usage.get("sessionDurationMs").and_then(Value::as_u64);
862 self.term.usage.api_duration_ms =
863 usage.get("totalApiDurationMs").and_then(Value::as_u64);
864 }
865 if let Some(code) = v.get("exitCode").and_then(Value::as_i64)
866 && code != 0
867 {
868 self.term.stop = Stop::Error;
869 self.term.error_message = Some(format!("copilot exited with code {code}"));
872 }
873 Vec::new()
874 }
875 _ => Vec::new(),
876 }
877 }
878}
879
880fn model_of(v: &Value) -> Option<String> {
883 v.get("model")
884 .or_else(|| v.get("data").and_then(|d| d.get("model")))
885 .and_then(Value::as_str)
886 .map(str::to_string)
887}
888
889fn stop_from(v: Option<&Value>) -> Stop {
891 match v.and_then(Value::as_str) {
892 None | Some("end_turn" | "stop" | "completed") => Stop::Completed,
893 Some(other) => Stop::Other(other.to_string()),
894 }
895}
896
897fn claude_rate_limit(v: Option<&Value>) -> Option<RateLimit> {
899 let v = v?;
900 Some(RateLimit {
901 status: v.get("status").and_then(Value::as_str)?.to_string(),
902 window: v
903 .get("rateLimitType")
904 .and_then(Value::as_str)
905 .map(str::to_string),
906 resets_at: v.get("resetsAt").and_then(Value::as_i64),
907 overage_status: v
908 .get("overageStatus")
909 .and_then(Value::as_str)
910 .map(str::to_string),
911 is_using_overage: v.get("isUsingOverage").and_then(Value::as_bool),
912 })
913}
914
915fn claude_usage(v: &Value) -> Usage {
917 let u = v.get("usage");
918 let get = |key: &str| u.and_then(|u| u.get(key)).and_then(Value::as_u64);
919 let (input, read, write) = (
920 get("input_tokens"),
921 get("cache_read_input_tokens"),
922 get("cache_creation_input_tokens"),
923 );
924 let per_model = v
928 .get("modelUsage")
929 .and_then(Value::as_object)
930 .and_then(|models| models.values().next());
931 let of_model = |key: &str| per_model.and_then(|m| m.get(key)).and_then(Value::as_u64);
932 Usage {
933 input_tokens: input,
934 output_tokens: get("output_tokens"),
935 cache_read_tokens: read,
936 cache_write_tokens: write,
937 context_tokens: (input.is_some() || read.is_some() || write.is_some())
941 .then(|| input.unwrap_or(0) + read.unwrap_or(0) + write.unwrap_or(0)),
942 context_window: of_model("contextWindow"),
943 max_output_tokens: of_model("maxOutputTokens"),
944 reasoning_tokens: None,
945 cost_usd: v.get("total_cost_usd").and_then(Value::as_f64),
946 premium_requests: None,
947 ai_credits_nano: None,
948 duration_ms: v.get("duration_ms").and_then(Value::as_u64),
949 api_duration_ms: v.get("duration_api_ms").and_then(Value::as_u64),
950 }
951}
952
953fn codex_usage(v: Option<&Value>) -> Usage {
956 let get = |key: &str| v.and_then(|u| u.get(key)).and_then(Value::as_u64);
957 let (prompt, cached) = (get("input_tokens"), get("cached_input_tokens"));
958 Usage {
959 input_tokens: match (prompt, cached) {
967 (Some(prompt), Some(cached)) => Some(prompt.saturating_sub(cached)),
968 (prompt, _) => prompt,
969 },
970 output_tokens: get("output_tokens"),
971 cache_read_tokens: cached,
972 cache_write_tokens: get("cache_write_input_tokens"),
973 context_tokens: prompt,
974 context_window: None,
975 max_output_tokens: None,
976 reasoning_tokens: get("reasoning_output_tokens"),
977 cost_usd: None,
978 premium_requests: None,
979 ai_credits_nano: None,
980 duration_ms: None,
981 api_duration_ms: None,
982 }
983}
984
985fn tool_name(item: &Value, item_ty: &str) -> String {
988 item.get("tool")
989 .and_then(Value::as_str)
990 .unwrap_or(item_ty)
991 .to_string()
992}
993
994fn codex_tool_input(item: &Value, item_ty: &str) -> Value {
996 match item_ty {
997 "command_execution" => serde_json::json!({ "command": item.get("command") }),
998 "mcp_tool_call" => item.get("arguments").cloned().unwrap_or(Value::Null),
999 _ => item.clone(),
1002 }
1003}
1004
1005fn flatten_text(v: Option<&Value>) -> String {
1013 match v {
1014 Some(Value::String(s)) => s.clone(),
1015 Some(Value::Array(blocks)) => blocks
1016 .iter()
1017 .map(|b| match b.get("text").and_then(Value::as_str) {
1018 Some(text) => text.to_string(),
1019 None => b.to_string(),
1020 })
1021 .collect::<Vec<_>>()
1022 .join("\n"),
1023 Some(other) => other.to_string(),
1024 None => String::new(),
1025 }
1026}
1027
1028#[cfg(test)]
1029mod tests {
1030 use super::*;
1031
1032 fn run(agent: Agent, lines: &[&str]) -> (Vec<Event>, Terminal) {
1034 let mut p = Parser::new(agent, Format::Stream);
1035 let events = lines.iter().flat_map(|l| p.push(l)).collect();
1036 (events, p.finish())
1037 }
1038
1039 #[test]
1042 fn claude_stream_yields_start_thinking_text_and_terminal_facts() {
1043 let (events, term) = run(
1044 Agent::Claude,
1045 &[
1046 r#"{"type":"system","subtype":"init","session_id":"sess-a","model":"claude-haiku-4-5"}"#,
1047 r#"{"type":"assistant","session_id":"sess-a","message":{"content":[{"type":"thinking","thinking":"brief"}]}}"#,
1048 r#"{"type":"assistant","session_id":"sess-a","message":{"content":[{"type":"text","text":"pong"}]}}"#,
1049 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}}"#,
1050 ],
1051 );
1052 assert_eq!(
1053 events[0],
1054 Event::Started {
1055 session: "sess-a".into(),
1056 model: Some("claude-haiku-4-5".into())
1057 }
1058 );
1059 assert_eq!(events[1], Event::Thinking("brief".into()));
1060 assert_eq!(events[2], Event::Text("pong".into()));
1061 assert_eq!(term.session.as_deref(), Some("sess-a"));
1062 assert_eq!(term.text, "pong");
1063 assert_eq!(term.stop, Stop::Completed);
1064 assert_eq!(term.usage.input_tokens, Some(10));
1065 assert_eq!(term.usage.cache_read_tokens, Some(18764));
1066 assert_eq!(term.usage.cache_write_tokens, Some(7322));
1067 assert_eq!(term.usage.cost_usd, Some(0.017));
1068 }
1069
1070 #[test]
1074 fn claude_token_deltas_stream_without_duplicating_the_finished_message() {
1075 let (events, _) = run(
1076 Agent::Claude,
1077 &[
1078 r#"{"type":"system","subtype":"init","session_id":"s"}"#,
1079 r#"{"type":"stream_event","session_id":"s","event":{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}}"#,
1080 r#"{"type":"stream_event","session_id":"s","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"po"}}}"#,
1081 r#"{"type":"stream_event","session_id":"s","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"ng"}}}"#,
1082 r#"{"type":"stream_event","session_id":"s","event":{"type":"content_block_stop","index":0}}"#,
1083 r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"text","text":"pong"}]}}"#,
1085 r#"{"type":"result","subtype":"success","is_error":false,"result":"pong","session_id":"s"}"#,
1086 ],
1087 );
1088 let texts: Vec<_> = events
1089 .iter()
1090 .filter_map(|e| match e {
1091 Event::Text(t) => Some(t.as_str()),
1092 _ => None,
1093 })
1094 .collect();
1095 assert_eq!(texts, ["po", "ng"], "the finished message must not repeat");
1096 }
1097
1098 #[test]
1100 fn claude_thinking_deltas_stream_without_duplication() {
1101 let (events, _) = run(
1102 Agent::Claude,
1103 &[
1104 r#"{"type":"stream_event","session_id":"s","event":{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"weighing"}}}"#,
1105 r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"thinking","thinking":"weighing"}]}}"#,
1106 ],
1107 );
1108 let thoughts: Vec<_> = events
1109 .iter()
1110 .filter_map(|e| match e {
1111 Event::Thinking(t) => Some(t.as_str()),
1112 _ => None,
1113 })
1114 .collect();
1115 assert_eq!(thoughts, ["weighing"]);
1116 }
1117
1118 #[test]
1121 fn a_completed_message_still_streams_when_no_deltas_arrived() {
1122 let (events, _) = run(
1123 Agent::Claude,
1124 &[
1125 r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"text","text":"pong"}]}}"#,
1126 ],
1127 );
1128 assert!(events.contains(&Event::Text("pong".into())), "{events:?}");
1129 }
1130
1131 #[test]
1134 fn tool_calls_survive_delta_suppression() {
1135 let (events, _) = run(
1136 Agent::Claude,
1137 &[
1138 r#"{"type":"stream_event","session_id":"s","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hi"}}}"#,
1139 r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"tool_use","id":"t1","name":"Bash","input":{"command":"ls"}}]}}"#,
1140 ],
1141 );
1142 assert!(
1143 events.iter().any(|e| matches!(e, Event::ToolCall { .. })),
1144 "suppression must apply to text only: {events:?}"
1145 );
1146 }
1147
1148 #[test]
1149 fn claude_started_fires_only_once() {
1150 let (events, _) = run(
1151 Agent::Claude,
1152 &[
1153 r#"{"type":"system","subtype":"init","session_id":"s"}"#,
1154 r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"text","text":"a"}]}}"#,
1155 r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"text","text":"b"}]}}"#,
1156 ],
1157 );
1158 assert_eq!(
1159 events
1160 .iter()
1161 .filter(|e| matches!(e, Event::Started { .. }))
1162 .count(),
1163 1
1164 );
1165 }
1166
1167 #[test]
1168 fn claude_pairs_tool_use_with_its_result() {
1169 let (events, _) = run(
1170 Agent::Claude,
1171 &[
1172 r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"tool_use","id":"toolu_1","name":"Bash","input":{"command":"ls"}}]}}"#,
1173 r#"{"type":"user","session_id":"s","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"a.txt"}]}}"#,
1174 ],
1175 );
1176 let call = events
1177 .iter()
1178 .find(|e| matches!(e, Event::ToolCall { .. }))
1179 .unwrap();
1180 let Event::ToolCall { id, name, input } = call else {
1181 unreachable!()
1182 };
1183 assert_eq!(id.as_deref(), Some("toolu_1"));
1184 assert_eq!(name, "Bash");
1185 assert_eq!(input["command"], "ls");
1186 assert!(events.contains(&Event::ToolResult {
1187 id: Some("toolu_1".into()),
1188 ok: None,
1189 output: "a.txt".into(),
1190 }));
1191 }
1192
1193 #[test]
1194 fn claude_reports_a_rate_limit_without_failing() {
1195 let (events, term) = run(
1196 Agent::Claude,
1197 &[
1198 r#"{"type":"rate_limit_event","session_id":"s","rate_limit_info":{"status":"allowed","resetsAt":1785260400,"rateLimitType":"five_hour"}}"#,
1199 ],
1200 );
1201 let limit = RateLimit {
1202 status: "allowed".into(),
1203 window: Some("five_hour".into()),
1204 resets_at: Some(1_785_260_400),
1205 overage_status: None,
1206 is_using_overage: None,
1207 };
1208 assert!(events.contains(&Event::RateLimit(limit.clone())));
1209 assert_eq!(term.rate_limit, Some(limit.clone()));
1210 assert!(
1211 !limit.is_blocking(),
1212 "an `allowed` heartbeat is not a block"
1213 );
1214 }
1215
1216 #[test]
1217 fn claude_error_result_sets_the_stop_reason() {
1218 let (_, term) = run(
1219 Agent::Claude,
1220 &[r#"{"type":"result","is_error":true,"result":"boom","session_id":"s"}"#],
1221 );
1222 assert_eq!(term.stop, Stop::Error);
1223 }
1224
1225 #[test]
1226 fn copilot_streams_deltas_and_takes_its_answer_from_the_settled_message() {
1227 let (events, term) = run(
1228 Agent::Copilot,
1229 &[
1230 r#"{"type":"assistant.message_delta","data":{"messageId":"m","deltaContent":"po"}}"#,
1231 r#"{"type":"assistant.message_delta","data":{"messageId":"m","deltaContent":"ng"}}"#,
1232 r#"{"type":"assistant.message","data":{"messageId":"m","model":"gpt-5-mini","content":"pong"}}"#,
1233 r#"{"type":"result","sessionId":"768c8e7d","exitCode":0,"usage":{"premiumRequests":0}}"#,
1234 ],
1235 );
1236 let texts: Vec<_> = events
1238 .iter()
1239 .filter_map(|e| match e {
1240 Event::Text(t) => Some(t.as_str()),
1241 _ => None,
1242 })
1243 .collect();
1244 assert_eq!(texts, ["po", "ng"]);
1245 assert_eq!(term.text, "pong", "the answer is the settled message");
1246 assert_eq!(term.session.as_deref(), Some("768c8e7d"));
1247 assert_eq!(term.usage.premium_requests, Some(0));
1248 }
1249
1250 #[test]
1251 fn copilot_brackets_a_tool_call_with_its_completion() {
1252 let (events, _) = run(
1253 Agent::Copilot,
1254 &[
1255 r#"{"type":"tool.execution_start","data":{"toolCallId":"call_1","toolName":"bash","arguments":{"command":"ls"}}}"#,
1256 r#"{"type":"tool.execution_complete","data":{"toolCallId":"call_1","success":true,"result":{"content":"a.txt"}}}"#,
1257 ],
1258 );
1259 assert!(matches!(
1260 &events[0],
1261 Event::ToolCall { id, name, .. }
1262 if id.as_deref() == Some("call_1") && name == "bash"
1263 ));
1264 assert_eq!(
1265 events[1],
1266 Event::ToolResult {
1267 id: Some("call_1".into()),
1268 ok: Some(true),
1269 output: "a.txt".into()
1270 }
1271 );
1272 }
1273
1274 #[test]
1278 fn a_codex_failed_turn_yields_the_reason_and_the_status() {
1279 let (_, term) = run(
1280 Agent::Codex,
1281 &[
1282 r#"{"type":"thread.started","thread_id":"019fad62"}"#,
1283 r#"{"type":"turn.failed","error":{"message":"{\"type\":\"error\",\"status\":400,\"error\":{\"type\":\"invalid_request_error\",\"message\":\"The 'bogus-model-xyz' model is not supported when using Codex with a ChatGPT account.\"}}"}}"#,
1284 ],
1285 );
1286 assert_eq!(term.stop, Stop::Error);
1287 assert_eq!(term.error_status, Some(400));
1288 assert_eq!(
1289 term.error_message.as_deref(),
1290 Some(
1291 "The 'bogus-model-xyz' model is not supported when using Codex with a ChatGPT account."
1292 ),
1293 "the caller should get the sentence, not the envelope"
1294 );
1295 }
1296
1297 #[test]
1300 fn a_plain_codex_failure_message_passes_through() {
1301 let (_, term) = run(
1302 Agent::Codex,
1303 &[
1304 r#"{"type":"turn.failed","error":{"message":"stream disconnected before completion"}}"#,
1305 ],
1306 );
1307 assert_eq!(term.error_status, None);
1308 assert_eq!(
1309 term.error_message.as_deref(),
1310 Some("stream disconnected before completion")
1311 );
1312 }
1313
1314 #[test]
1315 fn codex_reads_the_thread_id_and_the_completed_message() {
1316 let (events, term) = run(
1317 Agent::Codex,
1318 &[
1319 r#"{"type":"thread.started","thread_id":"0199-xyz"}"#,
1320 r#"{"type":"item.completed","item":{"id":"i1","type":"agent_message","text":"pong"}}"#,
1321 r#"{"type":"turn.completed","usage":{"input_tokens":12,"output_tokens":3,"cached_input_tokens":9}}"#,
1322 ],
1323 );
1324 assert_eq!(
1325 events[0],
1326 Event::Started {
1327 session: "0199-xyz".into(),
1328 model: None
1329 }
1330 );
1331 assert_eq!(term.session.as_deref(), Some("0199-xyz"));
1332 assert_eq!(term.text, "pong");
1333 assert_eq!(term.usage.input_tokens, Some(3));
1338 assert_eq!(term.usage.cache_read_tokens, Some(9));
1339 assert_eq!(term.usage.context_tokens, Some(12));
1340 }
1341
1342 #[test]
1343 fn codex_command_execution_becomes_a_call_and_a_result() {
1344 let (events, _) = run(
1345 Agent::Codex,
1346 &[
1347 r#"{"type":"item.completed","item":{"id":"c1","type":"command_execution","command":"ls","exit_code":0,"aggregated_output":"a.txt"}}"#,
1348 ],
1349 );
1350 assert!(matches!(&events[0], Event::ToolCall { name, .. } if name == "command_execution"));
1351 assert_eq!(
1352 events[1],
1353 Event::ToolResult {
1354 id: Some("c1".into()),
1355 ok: Some(true),
1356 output: "a.txt".into()
1357 }
1358 );
1359 }
1360
1361 #[test]
1365 fn codex_started_then_completed_yields_one_call_and_one_result() {
1366 let (events, _) = run(
1367 Agent::Codex,
1368 &[
1369 r#"{"type":"item.started","item":{"id":"item_1","type":"command_execution","command":"/bin/zsh -lc ls","aggregated_output":"","exit_code":null,"status":"in_progress"}}"#,
1370 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"}}"#,
1371 ],
1372 );
1373 let calls = events
1374 .iter()
1375 .filter(|e| matches!(e, Event::ToolCall { .. }))
1376 .count();
1377 assert_eq!(calls, 1, "the same item must not be announced twice");
1378 let results: Vec<_> = events
1379 .iter()
1380 .filter_map(|e| match e {
1381 Event::ToolResult { output, .. } => Some(output.as_str()),
1382 _ => None,
1383 })
1384 .collect();
1385 assert_eq!(
1386 results,
1387 ["a.txt\n"],
1388 "the in-progress blank must not appear"
1389 );
1390 }
1391
1392 #[test]
1394 fn codex_last_completed_message_is_the_answer() {
1395 let (_, term) = run(
1396 Agent::Codex,
1397 &[
1398 r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"I'll list the directory."}}"#,
1399 r#"{"type":"item.completed","item":{"id":"i2","type":"agent_message","text":"DONE"}}"#,
1400 ],
1401 );
1402 assert_eq!(term.text, "DONE");
1403 }
1404
1405 #[test]
1408 fn an_enormous_tool_result_is_bounded_and_marked() {
1409 let huge = "x".repeat(MAX_EVENT_BYTES * 4);
1410 let line = serde_json::json!({
1411 "type": "user",
1412 "session_id": "s",
1413 "message": {"content": [{
1414 "type": "tool_result", "tool_use_id": "t1", "content": huge
1415 }]}
1416 })
1417 .to_string();
1418
1419 let (events, _) = run(Agent::Claude, &[&line]);
1420 let Some(Event::ToolResult { output, id, .. }) = events
1421 .iter()
1422 .find(|e| matches!(e, Event::ToolResult { .. }))
1423 .cloned()
1424 else {
1425 panic!("expected a tool result, got {events:?}")
1426 };
1427 assert!(
1428 output.len() <= MAX_EVENT_BYTES,
1429 "kept {} bytes",
1430 output.len()
1431 );
1432 assert!(
1433 output.ends_with(TRUNCATION_MARK),
1434 "truncation must be visible"
1435 );
1436 assert_eq!(id.as_deref(), Some("t1"), "the id must survive whole");
1437 }
1438
1439 #[test]
1443 fn usable_identifiers_are_never_shortened() {
1444 let id = "s".repeat(MAX_IDENTIFIER_BYTES);
1446 let line =
1447 serde_json::json!({"type": "system", "subtype": "init", "session_id": id}).to_string();
1448 let (events, term) = run(Agent::Claude, &[&line]);
1449
1450 let Some(Event::Started { session, .. }) = events.first().cloned() else {
1451 panic!("expected Started, got {events:?}")
1452 };
1453 assert_eq!(session.len(), id.len(), "the session id was shortened");
1454 assert_eq!(term.session.as_deref(), Some(id.as_str()));
1455 }
1456
1457 #[test]
1462 fn an_oversized_session_id_is_rejected_rather_than_stored() {
1463 let id = "s".repeat(MAX_IDENTIFIER_BYTES + 1);
1464 for (agent, line) in [
1465 (
1466 Agent::Claude,
1467 serde_json::json!({"type": "system", "subtype": "init", "session_id": id})
1468 .to_string(),
1469 ),
1470 (
1471 Agent::Codex,
1472 serde_json::json!({"type": "thread.started", "thread_id": id}).to_string(),
1473 ),
1474 (
1475 Agent::Copilot,
1476 serde_json::json!({"type": "result", "sessionId": id, "exitCode": 0}).to_string(),
1477 ),
1478 ] {
1479 let (events, term) = run(agent, &[&line]);
1480 assert!(term.session.is_none(), "{agent} stored an unusable id");
1481 assert!(
1482 !events.iter().any(|e| matches!(e, Event::Started { .. })),
1483 "{agent} announced a session it cannot resume"
1484 );
1485 }
1486 }
1487
1488 #[test]
1492 fn an_oversized_tool_id_drops_the_id_but_keeps_the_event() {
1493 let id = "t".repeat(MAX_IDENTIFIER_BYTES + 1);
1494 let line = serde_json::json!({
1495 "type": "assistant", "session_id": "s",
1496 "message": {"content": [{
1497 "type": "tool_use", "id": id, "name": "Bash", "input": {"command": "ls"}
1498 }]}
1499 })
1500 .to_string();
1501
1502 let (events, _) = run(Agent::Claude, &[&line]);
1503 let Some(Event::ToolCall { id: seen, name, .. }) = events
1504 .iter()
1505 .find(|e| matches!(e, Event::ToolCall { .. }))
1506 .cloned()
1507 else {
1508 panic!("the call itself must still be reported, got {events:?}")
1509 };
1510 assert_eq!(seen, None, "an unusable id must be dropped, not shortened");
1511 assert_eq!(name, "Bash");
1512 }
1513
1514 #[test]
1517 fn the_pending_tool_map_is_bounded_by_bytes_not_only_entries() {
1518 let mut parser = Parser::new(Agent::Claude, Format::Stream);
1519 for i in 0..MAX_PENDING_TOOLS {
1522 let line = serde_json::json!({
1523 "type": "assistant", "session_id": "s",
1524 "message": {"content": [{
1525 "type": "tool_use",
1526 "id": format!("{i:0>width$}", width = MAX_IDENTIFIER_BYTES),
1527 "name": "x".repeat(MAX_IDENTIFIER_BYTES),
1528 "input": {}
1529 }]}
1530 })
1531 .to_string();
1532 parser.push(&line);
1533 }
1534 assert!(
1535 parser.tool_bytes <= MAX_PENDING_TOOL_BYTES,
1536 "pending tools grew to {} bytes",
1537 parser.tool_bytes
1538 );
1539 }
1540
1541 #[test]
1544 fn a_completed_tool_call_releases_its_budget() {
1545 let mut parser = Parser::new(Agent::Claude, Format::Stream);
1546 let call = |id: &str| {
1547 serde_json::json!({
1548 "type": "assistant", "session_id": "s",
1549 "message": {"content": [{
1550 "type": "tool_use", "id": id, "name": "Bash", "input": {}
1551 }]}
1552 })
1553 .to_string()
1554 };
1555 let result = |id: &str| {
1556 serde_json::json!({
1557 "type": "user", "session_id": "s",
1558 "message": {"content": [{
1559 "type": "tool_result", "tool_use_id": id, "content": "done"
1560 }]}
1561 })
1562 .to_string()
1563 };
1564
1565 for i in 0..(MAX_PENDING_TOOLS * 4) {
1566 let id = format!("toolu_{i}");
1567 parser.push(&call(&id));
1568 parser.push(&result(&id));
1569 }
1570 assert_eq!(parser.tool_bytes, 0, "budget leaked across paired calls");
1571 assert!(parser.tools.is_empty());
1572 }
1573
1574 #[test]
1577 fn a_worst_case_event_stays_within_the_stated_ceiling() {
1578 let huge = "x".repeat(MAX_LINE);
1579 let line = serde_json::json!({
1580 "type": "assistant", "session_id": huge,
1581 "message": {"content": [{
1582 "type": "tool_use", "id": huge, "name": huge, "input": {"command": huge}
1583 }]}
1584 })
1585 .to_string();
1586
1587 let (events, _) = run(Agent::Claude, &[&line]);
1588 for event in &events {
1589 let size = serde_json::to_string(event).unwrap().len();
1590 let ceiling = MAX_EVENT_BYTES + 4 * MAX_IDENTIFIER_BYTES;
1592 assert!(size <= ceiling, "an event reached {size} bytes: {event:?}");
1593 }
1594 }
1595
1596 #[test]
1599 fn oversized_tool_arguments_stay_valid_json() {
1600 let line = serde_json::json!({
1601 "type": "assistant",
1602 "session_id": "s",
1603 "message": {"content": [{
1604 "type": "tool_use", "id": "t1", "name": "Bash",
1605 "input": {"command": "y".repeat(MAX_EVENT_BYTES * 3)}
1606 }]}
1607 })
1608 .to_string();
1609
1610 let (events, _) = run(Agent::Claude, &[&line]);
1611 let Some(Event::ToolCall { input, .. }) = events
1612 .iter()
1613 .find(|e| matches!(e, Event::ToolCall { .. }))
1614 .cloned()
1615 else {
1616 panic!("expected a tool call, got {events:?}")
1617 };
1618 assert_eq!(input["truncated"], true, "got {input}");
1619 assert!(
1620 input.is_object(),
1621 "the replacement must still be valid JSON"
1622 );
1623 assert!(input.to_string().len() <= MAX_EVENT_BYTES);
1624 }
1625
1626 #[test]
1627 fn ordinary_payloads_pass_through_untouched() {
1628 let (events, _) = run(
1629 Agent::Claude,
1630 &[
1631 r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"text","text":"pong"}]}}"#,
1632 ],
1633 );
1634 assert!(events.contains(&Event::Text("pong".into())), "{events:?}");
1635 }
1636
1637 #[test]
1638 fn capture_is_bounded_and_keeps_the_earliest_output() {
1639 let mut buf = String::new();
1640 for i in 0..50_000 {
1642 append_capped(&mut buf, &format!("line {i} aaaaaaaaaaaaaaaaaaaaaaaaaaaa"));
1643 }
1644 assert!(buf.len() <= MAX_CAPTURE, "grew to {}", buf.len());
1645 assert!(buf.starts_with("line 0 "), "the earliest output is kept");
1646 }
1647
1648 #[test]
1649 fn capping_never_splits_a_multibyte_character() {
1650 let mut buf = "x".repeat(MAX_CAPTURE - 3);
1651 assert!(append_capped(&mut buf, "🙂🙂"));
1653 assert!(buf.len() <= MAX_CAPTURE);
1654 assert!(buf.is_char_boundary(buf.len()));
1657 }
1658
1659 #[test]
1660 fn a_full_buffer_reports_that_it_took_nothing() {
1661 let mut buf = "x".repeat(MAX_CAPTURE);
1662 assert!(!append_capped(&mut buf, "more"));
1663 assert_eq!(buf.len(), MAX_CAPTURE);
1664 }
1665
1666 #[test]
1669 fn unparseable_lines_are_counted_and_sampled() {
1670 let (_, term) = run(
1671 Agent::Claude,
1672 &[
1673 "<html>an error page, not JSON</html>",
1674 "another bad line",
1675 r#"{"type":"result","result":"ok","session_id":"s"}"#,
1676 ],
1677 );
1678 assert_eq!(term.unparsed, 2);
1679 assert_eq!(
1680 term.first_unparsed.as_deref(),
1681 Some("<html>an error page, not JSON</html>")
1682 );
1683 }
1684
1685 #[test]
1686 fn a_clean_stream_reports_no_parse_failures() {
1687 let (_, term) = run(
1688 Agent::Claude,
1689 &[r#"{"type":"result","result":"ok","session_id":"s"}"#],
1690 );
1691 assert_eq!(term.unparsed, 0);
1692 assert!(term.first_unparsed.is_none());
1693 }
1694
1695 #[test]
1698 fn tool_result_blocks_that_are_not_text_are_kept_not_dropped() {
1699 let (events, _) = run(
1700 Agent::Claude,
1701 &[
1702 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"}}]}]}}"#,
1703 ],
1704 );
1705 let output = events
1706 .iter()
1707 .find_map(|e| match e {
1708 Event::ToolResult { output, .. } => Some(output),
1709 _ => None,
1710 })
1711 .unwrap_or_else(|| panic!("expected a tool result, got {events:?}"));
1712 assert!(output.contains("seen"));
1713 assert!(output.contains("image"), "the image block was dropped");
1714 }
1715
1716 #[test]
1717 fn garbage_lines_are_skipped_not_fatal() {
1718 let (events, term) = run(
1719 Agent::Claude,
1720 &[
1721 "Warning: something on stdout",
1722 "",
1723 r#"{"type":"result","result":"ok","session_id":"s"}"#,
1724 ],
1725 );
1726 assert!(events.iter().all(|e| !matches!(e, Event::Text(_))));
1727 assert_eq!(term.text, "ok");
1728 }
1729
1730 #[test]
1731 fn text_format_passes_lines_through_verbatim() {
1732 let mut p = Parser::new(Agent::Copilot, Format::Text);
1733 let events: Vec<_> = ["hello", "world"].iter().flat_map(|l| p.push(l)).collect();
1734 assert_eq!(
1735 events,
1736 [Event::Text("hello".into()), Event::Text("world".into())]
1737 );
1738 assert_eq!(p.finish().text, "hello\nworld");
1739 }
1740}