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 model: Option<String>,
268 pub text: String,
270 pub usage: Usage,
272 pub stop: Stop,
274 pub rate_limit: Option<RateLimit>,
276 pub unparsed: usize,
282 pub first_unparsed: Option<String>,
284 pub structured: Option<Value>,
286 pub error_status: Option<u16>,
289 pub error_message: Option<String>,
293}
294
295fn unwrap_error_body(message: &str) -> (Option<u16>, String) {
303 let Ok(body) = serde_json::from_str::<Value>(message) else {
304 return (None, message.to_string());
305 };
306 let status = body
307 .get("status")
308 .and_then(Value::as_u64)
309 .and_then(|s| u16::try_from(s).ok());
310 let inner = body
311 .get("error")
312 .and_then(|e| e.get("message"))
313 .and_then(Value::as_str)
314 .map(str::to_string);
315 (status, inner.unwrap_or_else(|| message.to_string()))
316}
317
318#[derive(Debug)]
320pub(crate) struct Parser {
321 agent: Agent,
322 format: Format,
323 term: Terminal,
324 tools: HashMap<String, String>,
326 tool_bytes: usize,
329 seen: Seen,
331}
332
333#[derive(Debug, Default)]
339#[expect(
340 clippy::struct_excessive_bools,
341 reason = "four independent stream milestones; naming each beats packing them"
342)]
343struct Seen {
344 started: bool,
346 structured: bool,
349 terminal: bool,
351 deltas: bool,
358}
359
360impl Parser {
361 #[must_use]
363 pub fn new(agent: Agent, format: Format) -> Self {
364 Self {
365 agent,
366 format,
367 term: Terminal::default(),
368 tools: HashMap::new(),
369 tool_bytes: 0,
370 seen: Seen::default(),
371 }
372 }
373
374 pub fn push(&mut self, line: &str) -> Vec<Event> {
382 let line = line.trim();
383 if line.is_empty() {
384 return Vec::new();
385 }
386 if self.format == Format::Text {
389 append_capped(&mut self.term.text, line);
390 return vec![enforce_bounds(Event::Text(line.to_string()))];
391 }
392 let Ok(value) = serde_json::from_str::<Value>(line) else {
393 self.term.unparsed += 1;
394 if self.term.first_unparsed.is_none() {
395 let mut cut = line.len().min(512);
399 while cut > 0 && !line.is_char_boundary(cut) {
400 cut -= 1;
401 }
402 self.term.first_unparsed = Some(line[..cut].to_string());
403 }
404 return Vec::new();
405 };
406 if let Some(ty) = value.get("type").and_then(Value::as_str)
409 && self.recognizes(ty)
410 {
411 self.seen.structured = true;
412 }
413 let mut out = match self.agent {
414 Agent::Claude => self.claude(&value),
415 Agent::Codex => self.codex(&value),
416 Agent::Copilot => self.copilot(&value),
417 };
418 out = out.into_iter().map(enforce_bounds).collect();
421
422 if !self.seen.started {
425 if let Some(session) = self.term.session.clone() {
426 self.seen.started = true;
427 let model = model_of(&value);
428 self.term.model.clone_from(&model);
429 out.insert(0, Event::Started { session, model });
430 }
431 }
432 out
433 }
434
435 fn recognizes(&self, ty: &str) -> bool {
437 match self.agent {
438 Agent::Claude => matches!(
439 ty,
440 "system" | "assistant" | "user" | "result" | "rate_limit_event"
441 ),
442 Agent::Codex => {
443 ty.starts_with("thread.") || ty.starts_with("turn.") || ty.starts_with("item.")
444 }
445 Agent::Copilot => {
446 ty == "result"
447 || ty.starts_with("assistant.")
448 || ty.starts_with("tool.")
449 || ty.starts_with("session.")
450 }
451 }
452 }
453
454 fn remember_tool(&mut self, id: &str, name: &str) {
457 if !usable_identifier(id) {
460 return;
461 }
462 let name = bound_identifier(name.to_string());
463 let cost = id.len() + name.len();
464 if self.tools.len() >= MAX_PENDING_TOOLS
468 || self.tool_bytes.saturating_add(cost) > MAX_PENDING_TOOL_BYTES
469 {
470 return;
471 }
472 self.tool_bytes += cost;
473 if let Some(previous) = self.tools.insert(id.to_string(), name) {
474 self.tool_bytes = self.tool_bytes.saturating_sub(id.len() + previous.len());
476 }
477 }
478
479 fn forget_tool(&mut self, id: &str) {
481 if let Some(name) = self.tools.remove(id) {
482 self.tool_bytes = self.tool_bytes.saturating_sub(id.len() + name.len());
483 }
484 }
485
486 pub(crate) fn saw_structured_record(&self) -> bool {
491 self.seen.structured
492 }
493
494 pub(crate) fn saw_terminal_record(&self) -> bool {
497 self.seen.terminal
498 }
499
500 #[must_use]
502 pub fn finish(mut self) -> Terminal {
503 if self.format == Format::Text {
504 self.term.text = self.term.text.trim_end().to_string();
505 }
506 self.term
507 }
508
509 fn claude(&mut self, v: &Value) -> Vec<Event> {
515 let ty = v.get("type").and_then(Value::as_str).unwrap_or_default();
516 if let Some(id) = v.get("session_id").and_then(Value::as_str)
519 && usable_identifier(id)
520 {
521 self.term.session.get_or_insert_with(|| id.to_string());
522 }
523 match ty {
524 "rate_limit_event" => {
525 let limit = claude_rate_limit(v.get("rate_limit_info"));
526 self.term.rate_limit.clone_from(&limit);
527 limit.into_iter().map(Event::RateLimit).collect()
528 }
529 "stream_event" => self.claude_delta(v),
531 "assistant" | "user" => self.content_blocks(v),
534 "result" => {
535 self.seen.terminal = true;
536 if let Some(text) = v.get("result").and_then(Value::as_str) {
537 self.term.text = text.to_string();
538 }
539 if let Some(value) = v.get("structured_output") {
542 self.term.structured = Some(value.clone());
543 }
544 self.term.usage = claude_usage(v, self.term.model.as_deref());
545 self.term.stop = if v.get("is_error").and_then(Value::as_bool) == Some(true) {
548 self.term.error_status = v
549 .get("api_error_status")
550 .and_then(Value::as_u64)
551 .and_then(|s| u16::try_from(s).ok());
552 Stop::Error
553 } else {
554 stop_from(v.get("stop_reason"))
555 };
556 Vec::new()
557 }
558 _ => Vec::new(),
559 }
560 }
561
562 fn claude_delta(&mut self, v: &Value) -> Vec<Event> {
569 let Some(event) = v.get("event") else {
570 return Vec::new();
571 };
572 if event.get("type").and_then(Value::as_str) != Some("content_block_delta") {
573 return Vec::new();
574 }
575 let Some(delta) = event.get("delta") else {
576 return Vec::new();
577 };
578 self.seen.deltas = true;
581
582 match delta.get("type").and_then(Value::as_str) {
583 Some("text_delta") => delta
584 .get("text")
585 .and_then(Value::as_str)
586 .filter(|text| !text.is_empty())
587 .map(|text| Event::Text(text.to_string()))
588 .into_iter()
589 .collect(),
590 Some("thinking_delta") => delta
591 .get("thinking")
592 .and_then(Value::as_str)
593 .filter(|text| !text.is_empty())
594 .map(|text| Event::Thinking(text.to_string()))
595 .into_iter()
596 .collect(),
597 _ => Vec::new(),
601 }
602 }
603
604 fn content_blocks(&mut self, v: &Value) -> Vec<Event> {
607 let blocks = v
608 .get("message")
609 .and_then(|m| m.get("content"))
610 .and_then(Value::as_array);
611 let Some(blocks) = blocks else {
612 return Vec::new();
613 };
614 let mut out = Vec::new();
615 for block in blocks {
616 let ty = block
617 .get("type")
618 .and_then(Value::as_str)
619 .unwrap_or_default();
620 match ty {
621 "text" if !self.seen.deltas => {
626 if let Some(t) = block.get("text").and_then(Value::as_str) {
627 out.push(Event::Text(t.to_string()));
628 }
629 }
630 "thinking" if !self.seen.deltas => {
631 if let Some(t) = block.get("thinking").and_then(Value::as_str) {
632 out.push(Event::Thinking(t.to_string()));
633 }
634 }
635 "tool_use" => {
636 let name = block
637 .get("name")
638 .and_then(Value::as_str)
639 .unwrap_or("tool")
640 .to_string();
641 let id = block.get("id").and_then(Value::as_str).map(str::to_string);
642 if let Some(id) = &id {
643 self.remember_tool(id, &name);
644 }
645 out.push(Event::ToolCall {
646 id,
647 name,
648 input: block.get("input").cloned().unwrap_or(Value::Null),
649 });
650 }
651 "tool_result" => out.push(Event::ToolResult {
652 id: block
653 .get("tool_use_id")
654 .and_then(Value::as_str)
655 .inspect(|id| {
656 self.forget_tool(id);
658 })
659 .map(str::to_string),
660 ok: block
661 .get("is_error")
662 .and_then(Value::as_bool)
663 .map(|is_error| !is_error),
664 output: flatten_text(block.get("content")),
665 }),
666 _ => {}
667 }
668 }
669 out
670 }
671
672 fn codex(&mut self, v: &Value) -> Vec<Event> {
680 let ty = v.get("type").and_then(Value::as_str).unwrap_or_default();
681 if let Some(id) = v.get("thread_id").and_then(Value::as_str)
682 && usable_identifier(id)
683 {
684 self.term.session.get_or_insert_with(|| id.to_string());
685 }
686 match ty {
687 "turn.completed" => {
688 self.seen.terminal = true;
689 self.term.usage = codex_usage(v.get("usage"));
690 Vec::new()
691 }
692 "turn.failed" => {
693 self.seen.terminal = true;
694 self.term.stop = Stop::Error;
695 if let Some(message) = v
696 .get("error")
697 .and_then(|e| e.get("message"))
698 .and_then(Value::as_str)
699 {
700 let (status, message) = unwrap_error_body(message);
701 self.term.error_status = status;
702 self.term.error_message = Some(bound_text(message));
703 }
704 Vec::new()
705 }
706 "item.started" | "item.updated" | "item.completed" => {
707 let Some(item) = v.get("item") else {
708 return Vec::new();
709 };
710 let item_ty = item.get("type").and_then(Value::as_str).unwrap_or_default();
711 let id = item.get("id").and_then(Value::as_str).map(str::to_string);
712 let done = ty == "item.completed";
713
714 let name = tool_name(item, item_ty);
718 let first = id
719 .as_ref()
720 .is_none_or(|id| self.tools.insert(id.clone(), name.clone()).is_none());
721
722 match item_ty {
723 "agent_message" => {
726 if !done {
727 return Vec::new();
728 }
729 let text = item.get("text").and_then(Value::as_str).unwrap_or_default();
730 self.term.text = text.to_string();
731 vec![Event::Text(text.to_string())]
732 }
733 "reasoning" if done => item
734 .get("text")
735 .and_then(Value::as_str)
736 .map(|t| Event::Thinking(t.to_string()))
737 .into_iter()
738 .collect(),
739 "command_execution" | "mcp_tool_call" | "file_change" | "web_search" => {
740 let mut out = Vec::new();
741 if first {
742 out.push(Event::ToolCall {
743 id: id.clone(),
744 name,
745 input: codex_tool_input(item, item_ty),
746 });
747 }
748 if done {
751 if let Some(id) = &id {
752 self.forget_tool(id);
753 }
754 out.push(Event::ToolResult {
755 id,
756 ok: item
757 .get("exit_code")
758 .and_then(Value::as_i64)
759 .map(|code| code == 0),
760 output: item
761 .get("aggregated_output")
762 .and_then(Value::as_str)
763 .unwrap_or_default()
764 .to_string(),
765 });
766 }
767 out
768 }
769 _ => Vec::new(),
770 }
771 }
772 _ => Vec::new(),
773 }
774 }
775
776 fn copilot(&mut self, v: &Value) -> Vec<Event> {
783 let ty = v.get("type").and_then(Value::as_str).unwrap_or_default();
784 let data = v.get("data");
785 let field = |key: &str| -> Option<String> {
786 data.and_then(|d| d.get(key))
787 .and_then(Value::as_str)
788 .map(str::to_string)
789 };
790 match ty {
791 "assistant.message_delta" => field("deltaContent")
793 .filter(|t| !t.is_empty())
794 .map(Event::Text)
795 .into_iter()
796 .collect(),
797 "assistant.message" => {
800 if let Some(content) = field("content") {
801 self.term.text = content;
802 }
803 Vec::new()
804 }
805 "assistant.reasoning" => field("content")
806 .filter(|t| !t.is_empty())
807 .map(Event::Thinking)
808 .into_iter()
809 .collect(),
810 "tool.execution_start" => {
811 let id = field("toolCallId");
812 let name = field("toolName").unwrap_or_else(|| "tool".into());
813 if let Some(id) = &id {
814 self.remember_tool(id, &name);
815 }
816 vec![Event::ToolCall {
817 id,
818 name,
819 input: data
820 .and_then(|d| d.get("arguments"))
821 .cloned()
822 .unwrap_or(Value::Null),
823 }]
824 }
825 "tool.execution_complete" => vec![Event::ToolResult {
826 id: field("toolCallId").inspect(|id| {
827 self.forget_tool(id);
828 }),
829 ok: data.and_then(|d| d.get("success")).and_then(Value::as_bool),
830 output: data
831 .and_then(|d| d.get("result"))
832 .and_then(|r| r.get("content"))
833 .and_then(Value::as_str)
834 .unwrap_or_default()
835 .to_string(),
836 }],
837 "session.usage_checkpoint" => {
842 if let Some(data) = v.get("data") {
843 self.term.usage.ai_credits_nano =
844 data.get("totalNanoAiu").and_then(Value::as_u64);
845 if let Some(premium) = data.get("totalPremiumRequests").and_then(Value::as_u64)
846 {
847 self.term.usage.premium_requests = Some(premium);
848 }
849 }
850 Vec::new()
851 }
852 "result" => {
854 self.seen.terminal = true;
855 if let Some(id) = v.get("sessionId").and_then(Value::as_str)
856 && usable_identifier(id)
857 {
858 self.term.session = Some(id.to_string());
859 }
860 if let Some(usage) = v.get("usage") {
861 self.term.usage.premium_requests =
862 usage.get("premiumRequests").and_then(Value::as_u64);
863 self.term.usage.duration_ms =
864 usage.get("sessionDurationMs").and_then(Value::as_u64);
865 self.term.usage.api_duration_ms =
866 usage.get("totalApiDurationMs").and_then(Value::as_u64);
867 }
868 if let Some(code) = v.get("exitCode").and_then(Value::as_i64)
869 && code != 0
870 {
871 self.term.stop = Stop::Error;
872 self.term.error_message = Some(format!("copilot exited with code {code}"));
875 }
876 Vec::new()
877 }
878 _ => Vec::new(),
879 }
880 }
881}
882
883fn model_of(v: &Value) -> Option<String> {
886 v.get("model")
887 .or_else(|| v.get("data").and_then(|d| d.get("model")))
888 .and_then(Value::as_str)
889 .map(str::to_string)
890}
891
892fn stop_from(v: Option<&Value>) -> Stop {
894 match v.and_then(Value::as_str) {
895 None | Some("end_turn" | "stop" | "completed") => Stop::Completed,
896 Some(other) => Stop::Other(other.to_string()),
897 }
898}
899
900fn claude_rate_limit(v: Option<&Value>) -> Option<RateLimit> {
902 let v = v?;
903 Some(RateLimit {
904 status: v.get("status").and_then(Value::as_str)?.to_string(),
905 window: v
906 .get("rateLimitType")
907 .and_then(Value::as_str)
908 .map(str::to_string),
909 resets_at: v.get("resetsAt").and_then(Value::as_i64),
910 overage_status: v
911 .get("overageStatus")
912 .and_then(Value::as_str)
913 .map(str::to_string),
914 is_using_overage: v.get("isUsingOverage").and_then(Value::as_bool),
915 })
916}
917
918fn claude_usage(v: &Value, model: Option<&str>) -> Usage {
920 let u = v.get("usage");
921 let get = |key: &str| u.and_then(|u| u.get(key)).and_then(Value::as_u64);
922 let (input, read, write) = (
923 get("input_tokens"),
924 get("cache_read_input_tokens"),
925 get("cache_creation_input_tokens"),
926 );
927 let per_model = v
935 .get("modelUsage")
936 .and_then(Value::as_object)
937 .and_then(
938 |models| match (model.and_then(|m| models.get(m)), models.len()) {
939 (Some(entry), _) => Some(entry),
940 (None, 1) => models.values().next(),
942 (None, _) => None,
945 },
946 );
947 let of_model = |key: &str| per_model.and_then(|m| m.get(key)).and_then(Value::as_u64);
948 Usage {
949 input_tokens: input,
950 output_tokens: get("output_tokens"),
951 cache_read_tokens: read,
952 cache_write_tokens: write,
953 context_tokens: (input.is_some() || read.is_some() || write.is_some())
957 .then(|| input.unwrap_or(0) + read.unwrap_or(0) + write.unwrap_or(0)),
958 context_window: of_model("contextWindow"),
959 max_output_tokens: of_model("maxOutputTokens"),
960 reasoning_tokens: None,
961 cost_usd: v.get("total_cost_usd").and_then(Value::as_f64),
962 premium_requests: None,
963 ai_credits_nano: None,
964 duration_ms: v.get("duration_ms").and_then(Value::as_u64),
965 api_duration_ms: v.get("duration_api_ms").and_then(Value::as_u64),
966 }
967}
968
969fn codex_usage(v: Option<&Value>) -> Usage {
972 let get = |key: &str| v.and_then(|u| u.get(key)).and_then(Value::as_u64);
973 let (prompt, cached) = (get("input_tokens"), get("cached_input_tokens"));
974 Usage {
975 input_tokens: match (prompt, cached) {
983 (Some(prompt), Some(cached)) => Some(prompt.saturating_sub(cached)),
984 (prompt, _) => prompt,
985 },
986 output_tokens: get("output_tokens"),
987 cache_read_tokens: cached,
988 cache_write_tokens: get("cache_write_input_tokens"),
989 context_tokens: prompt,
990 context_window: None,
991 max_output_tokens: None,
992 reasoning_tokens: get("reasoning_output_tokens"),
993 cost_usd: None,
994 premium_requests: None,
995 ai_credits_nano: None,
996 duration_ms: None,
997 api_duration_ms: None,
998 }
999}
1000
1001fn tool_name(item: &Value, item_ty: &str) -> String {
1004 item.get("tool")
1005 .and_then(Value::as_str)
1006 .unwrap_or(item_ty)
1007 .to_string()
1008}
1009
1010fn codex_tool_input(item: &Value, item_ty: &str) -> Value {
1012 match item_ty {
1013 "command_execution" => serde_json::json!({ "command": item.get("command") }),
1014 "mcp_tool_call" => item.get("arguments").cloned().unwrap_or(Value::Null),
1015 _ => item.clone(),
1018 }
1019}
1020
1021fn flatten_text(v: Option<&Value>) -> String {
1029 match v {
1030 Some(Value::String(s)) => s.clone(),
1031 Some(Value::Array(blocks)) => blocks
1032 .iter()
1033 .map(|b| match b.get("text").and_then(Value::as_str) {
1034 Some(text) => text.to_string(),
1035 None => b.to_string(),
1036 })
1037 .collect::<Vec<_>>()
1038 .join("\n"),
1039 Some(other) => other.to_string(),
1040 None => String::new(),
1041 }
1042}
1043
1044#[cfg(test)]
1045mod tests {
1046 use super::*;
1047
1048 fn run(agent: Agent, lines: &[&str]) -> (Vec<Event>, Terminal) {
1050 let mut p = Parser::new(agent, Format::Stream);
1051 let events = lines.iter().flat_map(|l| p.push(l)).collect();
1052 (events, p.finish())
1053 }
1054
1055 #[test]
1058 fn claude_stream_yields_start_thinking_text_and_terminal_facts() {
1059 let (events, term) = run(
1060 Agent::Claude,
1061 &[
1062 r#"{"type":"system","subtype":"init","session_id":"sess-a","model":"claude-haiku-4-5"}"#,
1063 r#"{"type":"assistant","session_id":"sess-a","message":{"content":[{"type":"thinking","thinking":"brief"}]}}"#,
1064 r#"{"type":"assistant","session_id":"sess-a","message":{"content":[{"type":"text","text":"pong"}]}}"#,
1065 r#"{"type":"result","subtype":"success","is_error":false,"result":"pong","session_id":"sess-a","total_cost_usd":0.017,"usage":{"input_tokens":10,"output_tokens":45,"cache_read_input_tokens":18764,"cache_creation_input_tokens":7322}}"#,
1066 ],
1067 );
1068 assert_eq!(
1069 events[0],
1070 Event::Started {
1071 session: "sess-a".into(),
1072 model: Some("claude-haiku-4-5".into())
1073 }
1074 );
1075 assert_eq!(events[1], Event::Thinking("brief".into()));
1076 assert_eq!(events[2], Event::Text("pong".into()));
1077 assert_eq!(term.session.as_deref(), Some("sess-a"));
1078 assert_eq!(term.text, "pong");
1079 assert_eq!(term.stop, Stop::Completed);
1080 assert_eq!(term.usage.input_tokens, Some(10));
1081 assert_eq!(term.usage.cache_read_tokens, Some(18764));
1082 assert_eq!(term.usage.cache_write_tokens, Some(7322));
1083 assert_eq!(term.usage.cost_usd, Some(0.017));
1084 }
1085
1086 #[test]
1094 fn the_window_binds_to_the_runs_model_not_the_haiku_helper() {
1095 let (_, term) = run(
1096 Agent::Claude,
1097 &[
1098 r#"{"type":"system","subtype":"init","session_id":"sess-1m","model":"claude-sonnet-5[1m]"}"#,
1099 r#"{"type":"result","subtype":"success","is_error":false,"result":"ok","session_id":"sess-1m","total_cost_usd":0.0677,"usage":{"input_tokens":2,"output_tokens":4,"cache_read_input_tokens":27128,"cache_creation_input_tokens":9825},"modelUsage":{"claude-haiku-4-5-20251001":{"inputTokens":521,"outputTokens":12,"cacheReadInputTokens":0,"cacheCreationInputTokens":0,"costUSD":0.000581,"contextWindow":200000,"maxOutputTokens":32000},"claude-sonnet-5[1m]":{"inputTokens":2,"outputTokens":4,"cacheReadInputTokens":27128,"cacheCreationInputTokens":9825,"costUSD":0.0671544,"contextWindow":1000000,"maxOutputTokens":64000}}}"#,
1100 ],
1101 );
1102 assert_eq!(term.model.as_deref(), Some("claude-sonnet-5[1m]"));
1103 assert_eq!(
1104 term.usage.context_window,
1105 Some(1_000_000),
1106 "the helper's 200k window must not shadow the real one"
1107 );
1108 assert_eq!(term.usage.max_output_tokens, Some(64_000));
1109 assert_eq!(term.usage.context_tokens, Some(2 + 27_128 + 9_825));
1111 }
1112
1113 #[test]
1116 fn an_unmatchable_window_is_absent_not_guessed() {
1117 let (_, term) = run(
1118 Agent::Claude,
1119 &[
1120 r#"{"type":"result","subtype":"success","is_error":false,"result":"ok","session_id":"s","usage":{"input_tokens":2,"output_tokens":4},"modelUsage":{"claude-haiku-4-5-20251001":{"contextWindow":200000},"claude-sonnet-5":{"contextWindow":1000000}}}"#,
1122 ],
1123 );
1124 assert_eq!(term.usage.context_window, None);
1125 let (_, single) = run(
1127 Agent::Claude,
1128 &[
1129 r#"{"type":"result","subtype":"success","is_error":false,"result":"ok","session_id":"s","usage":{"input_tokens":2,"output_tokens":4},"modelUsage":{"claude-haiku-4-5-20251001":{"contextWindow":200000}}}"#,
1130 ],
1131 );
1132 assert_eq!(single.usage.context_window, Some(200_000));
1133 }
1134
1135 #[test]
1136 fn claude_token_deltas_stream_without_duplicating_the_finished_message() {
1137 let (events, _) = run(
1138 Agent::Claude,
1139 &[
1140 r#"{"type":"system","subtype":"init","session_id":"s"}"#,
1141 r#"{"type":"stream_event","session_id":"s","event":{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}}"#,
1142 r#"{"type":"stream_event","session_id":"s","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"po"}}}"#,
1143 r#"{"type":"stream_event","session_id":"s","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"ng"}}}"#,
1144 r#"{"type":"stream_event","session_id":"s","event":{"type":"content_block_stop","index":0}}"#,
1145 r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"text","text":"pong"}]}}"#,
1147 r#"{"type":"result","subtype":"success","is_error":false,"result":"pong","session_id":"s"}"#,
1148 ],
1149 );
1150 let texts: Vec<_> = events
1151 .iter()
1152 .filter_map(|e| match e {
1153 Event::Text(t) => Some(t.as_str()),
1154 _ => None,
1155 })
1156 .collect();
1157 assert_eq!(texts, ["po", "ng"], "the finished message must not repeat");
1158 }
1159
1160 #[test]
1162 fn claude_thinking_deltas_stream_without_duplication() {
1163 let (events, _) = run(
1164 Agent::Claude,
1165 &[
1166 r#"{"type":"stream_event","session_id":"s","event":{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"weighing"}}}"#,
1167 r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"thinking","thinking":"weighing"}]}}"#,
1168 ],
1169 );
1170 let thoughts: Vec<_> = events
1171 .iter()
1172 .filter_map(|e| match e {
1173 Event::Thinking(t) => Some(t.as_str()),
1174 _ => None,
1175 })
1176 .collect();
1177 assert_eq!(thoughts, ["weighing"]);
1178 }
1179
1180 #[test]
1183 fn a_completed_message_still_streams_when_no_deltas_arrived() {
1184 let (events, _) = run(
1185 Agent::Claude,
1186 &[
1187 r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"text","text":"pong"}]}}"#,
1188 ],
1189 );
1190 assert!(events.contains(&Event::Text("pong".into())), "{events:?}");
1191 }
1192
1193 #[test]
1196 fn tool_calls_survive_delta_suppression() {
1197 let (events, _) = run(
1198 Agent::Claude,
1199 &[
1200 r#"{"type":"stream_event","session_id":"s","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hi"}}}"#,
1201 r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"tool_use","id":"t1","name":"Bash","input":{"command":"ls"}}]}}"#,
1202 ],
1203 );
1204 assert!(
1205 events.iter().any(|e| matches!(e, Event::ToolCall { .. })),
1206 "suppression must apply to text only: {events:?}"
1207 );
1208 }
1209
1210 #[test]
1211 fn claude_started_fires_only_once() {
1212 let (events, _) = run(
1213 Agent::Claude,
1214 &[
1215 r#"{"type":"system","subtype":"init","session_id":"s"}"#,
1216 r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"text","text":"a"}]}}"#,
1217 r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"text","text":"b"}]}}"#,
1218 ],
1219 );
1220 assert_eq!(
1221 events
1222 .iter()
1223 .filter(|e| matches!(e, Event::Started { .. }))
1224 .count(),
1225 1
1226 );
1227 }
1228
1229 #[test]
1230 fn claude_pairs_tool_use_with_its_result() {
1231 let (events, _) = run(
1232 Agent::Claude,
1233 &[
1234 r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"tool_use","id":"toolu_1","name":"Bash","input":{"command":"ls"}}]}}"#,
1235 r#"{"type":"user","session_id":"s","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"a.txt"}]}}"#,
1236 ],
1237 );
1238 let call = events
1239 .iter()
1240 .find(|e| matches!(e, Event::ToolCall { .. }))
1241 .unwrap();
1242 let Event::ToolCall { id, name, input } = call else {
1243 unreachable!()
1244 };
1245 assert_eq!(id.as_deref(), Some("toolu_1"));
1246 assert_eq!(name, "Bash");
1247 assert_eq!(input["command"], "ls");
1248 assert!(events.contains(&Event::ToolResult {
1249 id: Some("toolu_1".into()),
1250 ok: None,
1251 output: "a.txt".into(),
1252 }));
1253 }
1254
1255 #[test]
1256 fn claude_reports_a_rate_limit_without_failing() {
1257 let (events, term) = run(
1258 Agent::Claude,
1259 &[
1260 r#"{"type":"rate_limit_event","session_id":"s","rate_limit_info":{"status":"allowed","resetsAt":1785260400,"rateLimitType":"five_hour"}}"#,
1261 ],
1262 );
1263 let limit = RateLimit {
1264 status: "allowed".into(),
1265 window: Some("five_hour".into()),
1266 resets_at: Some(1_785_260_400),
1267 overage_status: None,
1268 is_using_overage: None,
1269 };
1270 assert!(events.contains(&Event::RateLimit(limit.clone())));
1271 assert_eq!(term.rate_limit, Some(limit.clone()));
1272 assert!(
1273 !limit.is_blocking(),
1274 "an `allowed` heartbeat is not a block"
1275 );
1276 }
1277
1278 #[test]
1279 fn claude_error_result_sets_the_stop_reason() {
1280 let (_, term) = run(
1281 Agent::Claude,
1282 &[r#"{"type":"result","is_error":true,"result":"boom","session_id":"s"}"#],
1283 );
1284 assert_eq!(term.stop, Stop::Error);
1285 }
1286
1287 #[test]
1288 fn copilot_streams_deltas_and_takes_its_answer_from_the_settled_message() {
1289 let (events, term) = run(
1290 Agent::Copilot,
1291 &[
1292 r#"{"type":"assistant.message_delta","data":{"messageId":"m","deltaContent":"po"}}"#,
1293 r#"{"type":"assistant.message_delta","data":{"messageId":"m","deltaContent":"ng"}}"#,
1294 r#"{"type":"assistant.message","data":{"messageId":"m","model":"gpt-5-mini","content":"pong"}}"#,
1295 r#"{"type":"result","sessionId":"768c8e7d","exitCode":0,"usage":{"premiumRequests":0}}"#,
1296 ],
1297 );
1298 let texts: Vec<_> = events
1300 .iter()
1301 .filter_map(|e| match e {
1302 Event::Text(t) => Some(t.as_str()),
1303 _ => None,
1304 })
1305 .collect();
1306 assert_eq!(texts, ["po", "ng"]);
1307 assert_eq!(term.text, "pong", "the answer is the settled message");
1308 assert_eq!(term.session.as_deref(), Some("768c8e7d"));
1309 assert_eq!(term.usage.premium_requests, Some(0));
1310 }
1311
1312 #[test]
1313 fn copilot_brackets_a_tool_call_with_its_completion() {
1314 let (events, _) = run(
1315 Agent::Copilot,
1316 &[
1317 r#"{"type":"tool.execution_start","data":{"toolCallId":"call_1","toolName":"bash","arguments":{"command":"ls"}}}"#,
1318 r#"{"type":"tool.execution_complete","data":{"toolCallId":"call_1","success":true,"result":{"content":"a.txt"}}}"#,
1319 ],
1320 );
1321 assert!(matches!(
1322 &events[0],
1323 Event::ToolCall { id, name, .. }
1324 if id.as_deref() == Some("call_1") && name == "bash"
1325 ));
1326 assert_eq!(
1327 events[1],
1328 Event::ToolResult {
1329 id: Some("call_1".into()),
1330 ok: Some(true),
1331 output: "a.txt".into()
1332 }
1333 );
1334 }
1335
1336 #[test]
1340 fn a_codex_failed_turn_yields_the_reason_and_the_status() {
1341 let (_, term) = run(
1342 Agent::Codex,
1343 &[
1344 r#"{"type":"thread.started","thread_id":"019fad62"}"#,
1345 r#"{"type":"turn.failed","error":{"message":"{\"type\":\"error\",\"status\":400,\"error\":{\"type\":\"invalid_request_error\",\"message\":\"The 'bogus-model-xyz' model is not supported when using Codex with a ChatGPT account.\"}}"}}"#,
1346 ],
1347 );
1348 assert_eq!(term.stop, Stop::Error);
1349 assert_eq!(term.error_status, Some(400));
1350 assert_eq!(
1351 term.error_message.as_deref(),
1352 Some(
1353 "The 'bogus-model-xyz' model is not supported when using Codex with a ChatGPT account."
1354 ),
1355 "the caller should get the sentence, not the envelope"
1356 );
1357 }
1358
1359 #[test]
1362 fn a_plain_codex_failure_message_passes_through() {
1363 let (_, term) = run(
1364 Agent::Codex,
1365 &[
1366 r#"{"type":"turn.failed","error":{"message":"stream disconnected before completion"}}"#,
1367 ],
1368 );
1369 assert_eq!(term.error_status, None);
1370 assert_eq!(
1371 term.error_message.as_deref(),
1372 Some("stream disconnected before completion")
1373 );
1374 }
1375
1376 #[test]
1377 fn codex_reads_the_thread_id_and_the_completed_message() {
1378 let (events, term) = run(
1379 Agent::Codex,
1380 &[
1381 r#"{"type":"thread.started","thread_id":"0199-xyz"}"#,
1382 r#"{"type":"item.completed","item":{"id":"i1","type":"agent_message","text":"pong"}}"#,
1383 r#"{"type":"turn.completed","usage":{"input_tokens":12,"output_tokens":3,"cached_input_tokens":9}}"#,
1384 ],
1385 );
1386 assert_eq!(
1387 events[0],
1388 Event::Started {
1389 session: "0199-xyz".into(),
1390 model: None
1391 }
1392 );
1393 assert_eq!(term.session.as_deref(), Some("0199-xyz"));
1394 assert_eq!(term.text, "pong");
1395 assert_eq!(term.usage.input_tokens, Some(3));
1400 assert_eq!(term.usage.cache_read_tokens, Some(9));
1401 assert_eq!(term.usage.context_tokens, Some(12));
1402 }
1403
1404 #[test]
1405 fn codex_command_execution_becomes_a_call_and_a_result() {
1406 let (events, _) = run(
1407 Agent::Codex,
1408 &[
1409 r#"{"type":"item.completed","item":{"id":"c1","type":"command_execution","command":"ls","exit_code":0,"aggregated_output":"a.txt"}}"#,
1410 ],
1411 );
1412 assert!(matches!(&events[0], Event::ToolCall { name, .. } if name == "command_execution"));
1413 assert_eq!(
1414 events[1],
1415 Event::ToolResult {
1416 id: Some("c1".into()),
1417 ok: Some(true),
1418 output: "a.txt".into()
1419 }
1420 );
1421 }
1422
1423 #[test]
1427 fn codex_started_then_completed_yields_one_call_and_one_result() {
1428 let (events, _) = run(
1429 Agent::Codex,
1430 &[
1431 r#"{"type":"item.started","item":{"id":"item_1","type":"command_execution","command":"/bin/zsh -lc ls","aggregated_output":"","exit_code":null,"status":"in_progress"}}"#,
1432 r#"{"type":"item.completed","item":{"id":"item_1","type":"command_execution","command":"/bin/zsh -lc ls","aggregated_output":"a.txt\n","exit_code":0,"status":"completed"}}"#,
1433 ],
1434 );
1435 let calls = events
1436 .iter()
1437 .filter(|e| matches!(e, Event::ToolCall { .. }))
1438 .count();
1439 assert_eq!(calls, 1, "the same item must not be announced twice");
1440 let results: Vec<_> = events
1441 .iter()
1442 .filter_map(|e| match e {
1443 Event::ToolResult { output, .. } => Some(output.as_str()),
1444 _ => None,
1445 })
1446 .collect();
1447 assert_eq!(
1448 results,
1449 ["a.txt\n"],
1450 "the in-progress blank must not appear"
1451 );
1452 }
1453
1454 #[test]
1456 fn codex_last_completed_message_is_the_answer() {
1457 let (_, term) = run(
1458 Agent::Codex,
1459 &[
1460 r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"I'll list the directory."}}"#,
1461 r#"{"type":"item.completed","item":{"id":"i2","type":"agent_message","text":"DONE"}}"#,
1462 ],
1463 );
1464 assert_eq!(term.text, "DONE");
1465 }
1466
1467 #[test]
1470 fn an_enormous_tool_result_is_bounded_and_marked() {
1471 let huge = "x".repeat(MAX_EVENT_BYTES * 4);
1472 let line = serde_json::json!({
1473 "type": "user",
1474 "session_id": "s",
1475 "message": {"content": [{
1476 "type": "tool_result", "tool_use_id": "t1", "content": huge
1477 }]}
1478 })
1479 .to_string();
1480
1481 let (events, _) = run(Agent::Claude, &[&line]);
1482 let Some(Event::ToolResult { output, id, .. }) = events
1483 .iter()
1484 .find(|e| matches!(e, Event::ToolResult { .. }))
1485 .cloned()
1486 else {
1487 panic!("expected a tool result, got {events:?}")
1488 };
1489 assert!(
1490 output.len() <= MAX_EVENT_BYTES,
1491 "kept {} bytes",
1492 output.len()
1493 );
1494 assert!(
1495 output.ends_with(TRUNCATION_MARK),
1496 "truncation must be visible"
1497 );
1498 assert_eq!(id.as_deref(), Some("t1"), "the id must survive whole");
1499 }
1500
1501 #[test]
1505 fn usable_identifiers_are_never_shortened() {
1506 let id = "s".repeat(MAX_IDENTIFIER_BYTES);
1508 let line =
1509 serde_json::json!({"type": "system", "subtype": "init", "session_id": id}).to_string();
1510 let (events, term) = run(Agent::Claude, &[&line]);
1511
1512 let Some(Event::Started { session, .. }) = events.first().cloned() else {
1513 panic!("expected Started, got {events:?}")
1514 };
1515 assert_eq!(session.len(), id.len(), "the session id was shortened");
1516 assert_eq!(term.session.as_deref(), Some(id.as_str()));
1517 }
1518
1519 #[test]
1524 fn an_oversized_session_id_is_rejected_rather_than_stored() {
1525 let id = "s".repeat(MAX_IDENTIFIER_BYTES + 1);
1526 for (agent, line) in [
1527 (
1528 Agent::Claude,
1529 serde_json::json!({"type": "system", "subtype": "init", "session_id": id})
1530 .to_string(),
1531 ),
1532 (
1533 Agent::Codex,
1534 serde_json::json!({"type": "thread.started", "thread_id": id}).to_string(),
1535 ),
1536 (
1537 Agent::Copilot,
1538 serde_json::json!({"type": "result", "sessionId": id, "exitCode": 0}).to_string(),
1539 ),
1540 ] {
1541 let (events, term) = run(agent, &[&line]);
1542 assert!(term.session.is_none(), "{agent} stored an unusable id");
1543 assert!(
1544 !events.iter().any(|e| matches!(e, Event::Started { .. })),
1545 "{agent} announced a session it cannot resume"
1546 );
1547 }
1548 }
1549
1550 #[test]
1554 fn an_oversized_tool_id_drops_the_id_but_keeps_the_event() {
1555 let id = "t".repeat(MAX_IDENTIFIER_BYTES + 1);
1556 let line = serde_json::json!({
1557 "type": "assistant", "session_id": "s",
1558 "message": {"content": [{
1559 "type": "tool_use", "id": id, "name": "Bash", "input": {"command": "ls"}
1560 }]}
1561 })
1562 .to_string();
1563
1564 let (events, _) = run(Agent::Claude, &[&line]);
1565 let Some(Event::ToolCall { id: seen, name, .. }) = events
1566 .iter()
1567 .find(|e| matches!(e, Event::ToolCall { .. }))
1568 .cloned()
1569 else {
1570 panic!("the call itself must still be reported, got {events:?}")
1571 };
1572 assert_eq!(seen, None, "an unusable id must be dropped, not shortened");
1573 assert_eq!(name, "Bash");
1574 }
1575
1576 #[test]
1579 fn the_pending_tool_map_is_bounded_by_bytes_not_only_entries() {
1580 let mut parser = Parser::new(Agent::Claude, Format::Stream);
1581 for i in 0..MAX_PENDING_TOOLS {
1584 let line = serde_json::json!({
1585 "type": "assistant", "session_id": "s",
1586 "message": {"content": [{
1587 "type": "tool_use",
1588 "id": format!("{i:0>width$}", width = MAX_IDENTIFIER_BYTES),
1589 "name": "x".repeat(MAX_IDENTIFIER_BYTES),
1590 "input": {}
1591 }]}
1592 })
1593 .to_string();
1594 parser.push(&line);
1595 }
1596 assert!(
1597 parser.tool_bytes <= MAX_PENDING_TOOL_BYTES,
1598 "pending tools grew to {} bytes",
1599 parser.tool_bytes
1600 );
1601 }
1602
1603 #[test]
1606 fn a_completed_tool_call_releases_its_budget() {
1607 let mut parser = Parser::new(Agent::Claude, Format::Stream);
1608 let call = |id: &str| {
1609 serde_json::json!({
1610 "type": "assistant", "session_id": "s",
1611 "message": {"content": [{
1612 "type": "tool_use", "id": id, "name": "Bash", "input": {}
1613 }]}
1614 })
1615 .to_string()
1616 };
1617 let result = |id: &str| {
1618 serde_json::json!({
1619 "type": "user", "session_id": "s",
1620 "message": {"content": [{
1621 "type": "tool_result", "tool_use_id": id, "content": "done"
1622 }]}
1623 })
1624 .to_string()
1625 };
1626
1627 for i in 0..(MAX_PENDING_TOOLS * 4) {
1628 let id = format!("toolu_{i}");
1629 parser.push(&call(&id));
1630 parser.push(&result(&id));
1631 }
1632 assert_eq!(parser.tool_bytes, 0, "budget leaked across paired calls");
1633 assert!(parser.tools.is_empty());
1634 }
1635
1636 #[test]
1639 fn a_worst_case_event_stays_within_the_stated_ceiling() {
1640 let huge = "x".repeat(MAX_LINE);
1641 let line = serde_json::json!({
1642 "type": "assistant", "session_id": huge,
1643 "message": {"content": [{
1644 "type": "tool_use", "id": huge, "name": huge, "input": {"command": huge}
1645 }]}
1646 })
1647 .to_string();
1648
1649 let (events, _) = run(Agent::Claude, &[&line]);
1650 for event in &events {
1651 let size = serde_json::to_string(event).unwrap().len();
1652 let ceiling = MAX_EVENT_BYTES + 4 * MAX_IDENTIFIER_BYTES;
1654 assert!(size <= ceiling, "an event reached {size} bytes: {event:?}");
1655 }
1656 }
1657
1658 #[test]
1661 fn oversized_tool_arguments_stay_valid_json() {
1662 let line = serde_json::json!({
1663 "type": "assistant",
1664 "session_id": "s",
1665 "message": {"content": [{
1666 "type": "tool_use", "id": "t1", "name": "Bash",
1667 "input": {"command": "y".repeat(MAX_EVENT_BYTES * 3)}
1668 }]}
1669 })
1670 .to_string();
1671
1672 let (events, _) = run(Agent::Claude, &[&line]);
1673 let Some(Event::ToolCall { input, .. }) = events
1674 .iter()
1675 .find(|e| matches!(e, Event::ToolCall { .. }))
1676 .cloned()
1677 else {
1678 panic!("expected a tool call, got {events:?}")
1679 };
1680 assert_eq!(input["truncated"], true, "got {input}");
1681 assert!(
1682 input.is_object(),
1683 "the replacement must still be valid JSON"
1684 );
1685 assert!(input.to_string().len() <= MAX_EVENT_BYTES);
1686 }
1687
1688 #[test]
1689 fn ordinary_payloads_pass_through_untouched() {
1690 let (events, _) = run(
1691 Agent::Claude,
1692 &[
1693 r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"text","text":"pong"}]}}"#,
1694 ],
1695 );
1696 assert!(events.contains(&Event::Text("pong".into())), "{events:?}");
1697 }
1698
1699 #[test]
1700 fn capture_is_bounded_and_keeps_the_earliest_output() {
1701 let mut buf = String::new();
1702 for i in 0..50_000 {
1704 append_capped(&mut buf, &format!("line {i} aaaaaaaaaaaaaaaaaaaaaaaaaaaa"));
1705 }
1706 assert!(buf.len() <= MAX_CAPTURE, "grew to {}", buf.len());
1707 assert!(buf.starts_with("line 0 "), "the earliest output is kept");
1708 }
1709
1710 #[test]
1711 fn capping_never_splits_a_multibyte_character() {
1712 let mut buf = "x".repeat(MAX_CAPTURE - 3);
1713 assert!(append_capped(&mut buf, "🙂🙂"));
1715 assert!(buf.len() <= MAX_CAPTURE);
1716 assert!(buf.is_char_boundary(buf.len()));
1719 }
1720
1721 #[test]
1722 fn a_full_buffer_reports_that_it_took_nothing() {
1723 let mut buf = "x".repeat(MAX_CAPTURE);
1724 assert!(!append_capped(&mut buf, "more"));
1725 assert_eq!(buf.len(), MAX_CAPTURE);
1726 }
1727
1728 #[test]
1731 fn unparseable_lines_are_counted_and_sampled() {
1732 let (_, term) = run(
1733 Agent::Claude,
1734 &[
1735 "<html>an error page, not JSON</html>",
1736 "another bad line",
1737 r#"{"type":"result","result":"ok","session_id":"s"}"#,
1738 ],
1739 );
1740 assert_eq!(term.unparsed, 2);
1741 assert_eq!(
1742 term.first_unparsed.as_deref(),
1743 Some("<html>an error page, not JSON</html>")
1744 );
1745 }
1746
1747 #[test]
1748 fn a_clean_stream_reports_no_parse_failures() {
1749 let (_, term) = run(
1750 Agent::Claude,
1751 &[r#"{"type":"result","result":"ok","session_id":"s"}"#],
1752 );
1753 assert_eq!(term.unparsed, 0);
1754 assert!(term.first_unparsed.is_none());
1755 }
1756
1757 #[test]
1760 fn tool_result_blocks_that_are_not_text_are_kept_not_dropped() {
1761 let (events, _) = run(
1762 Agent::Claude,
1763 &[
1764 r#"{"type":"user","session_id":"s","message":{"content":[{"type":"tool_result","tool_use_id":"t1","content":[{"type":"text","text":"seen"},{"type":"image","source":{"data":"abc"}}]}]}}"#,
1765 ],
1766 );
1767 let output = events
1768 .iter()
1769 .find_map(|e| match e {
1770 Event::ToolResult { output, .. } => Some(output),
1771 _ => None,
1772 })
1773 .unwrap_or_else(|| panic!("expected a tool result, got {events:?}"));
1774 assert!(output.contains("seen"));
1775 assert!(output.contains("image"), "the image block was dropped");
1776 }
1777
1778 #[test]
1779 fn garbage_lines_are_skipped_not_fatal() {
1780 let (events, term) = run(
1781 Agent::Claude,
1782 &[
1783 "Warning: something on stdout",
1784 "",
1785 r#"{"type":"result","result":"ok","session_id":"s"}"#,
1786 ],
1787 );
1788 assert!(events.iter().all(|e| !matches!(e, Event::Text(_))));
1789 assert_eq!(term.text, "ok");
1790 }
1791
1792 #[test]
1793 fn text_format_passes_lines_through_verbatim() {
1794 let mut p = Parser::new(Agent::Copilot, Format::Text);
1795 let events: Vec<_> = ["hello", "world"].iter().flat_map(|l| p.push(l)).collect();
1796 assert_eq!(
1797 events,
1798 [Event::Text("hello".into()), Event::Text("world".into())]
1799 );
1800 assert_eq!(p.finish().text, "hello\nworld");
1801 }
1802}