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 ApprovalRequest(crate::approval::Approval),
72 RateLimit(RateLimit),
74}
75
76pub const MAX_CAPTURE: usize = 1024 * 1024;
83
84pub const MAX_LINE: usize = 512 * 1024;
91
92pub const MAX_EVENT_BYTES: usize = 64 * 1024;
103
104pub const TRUNCATION_MARK: &str = "…(truncated)";
107
108pub const MAX_IDENTIFIER_BYTES: usize = 4 * 1024;
120
121pub(crate) const MAX_PENDING_TOOL_BYTES: usize = 256 * 1024;
127
128pub(crate) const MAX_PENDING_TOOLS: usize = 1024;
133
134pub(crate) fn append_capped(buf: &mut String, line: &str) -> bool {
141 let remaining = MAX_CAPTURE.saturating_sub(buf.len());
142 if remaining == 0 {
143 return false;
144 }
145 if line.len() < remaining {
147 buf.push_str(line);
148 buf.push('\n');
149 } else {
150 let mut cut = remaining - 1;
152 while cut > 0 && !line.is_char_boundary(cut) {
153 cut -= 1;
154 }
155 buf.push_str(&line[..cut]);
156 buf.push('\n');
157 }
158 true
159}
160
161fn usable_identifier(value: &str) -> bool {
166 value.len() <= MAX_IDENTIFIER_BYTES
167}
168
169fn accept_identifier(value: Option<String>) -> Option<String> {
171 value.filter(|v| usable_identifier(v))
172}
173
174fn bound_text(text: String) -> String {
176 if text.len() <= MAX_EVENT_BYTES {
177 return text;
178 }
179 let mut cut = MAX_EVENT_BYTES - TRUNCATION_MARK.len();
180 while cut > 0 && !text.is_char_boundary(cut) {
181 cut -= 1;
182 }
183 let mut out = text[..cut].to_string();
184 out.push_str(TRUNCATION_MARK);
185 out
186}
187
188fn bound_value(value: Value) -> Value {
194 let size = value.to_string().len();
195 if size <= MAX_EVENT_BYTES {
196 return value;
197 }
198 serde_json::json!({
199 "truncated": true,
200 "original_bytes": size,
201 "note": "arguments exceeded MAX_EVENT_BYTES and were dropped rather than \
202 truncated, which would have produced invalid JSON",
203 })
204}
205
206fn enforce_bounds(event: Event) -> Event {
213 match event {
214 Event::Text(text) => Event::Text(bound_text(text)),
215 Event::Thinking(text) => Event::Thinking(bound_text(text)),
216 Event::ToolCall { id, name, input } => Event::ToolCall {
220 id: accept_identifier(id),
221 name: bound_identifier(name),
222 input: bound_value(input),
223 },
224 Event::ToolResult { id, ok, output } => Event::ToolResult {
225 id: accept_identifier(id),
226 ok,
227 output: bound_text(output),
228 },
229 Event::Started { session, model } => Event::Started {
233 session,
234 model: model.map(bound_identifier),
235 },
236 Event::ApprovalRequest(approval) => {
237 Event::ApprovalRequest(crate::approval::Approval {
238 id: approval.id,
242 tool: bound_identifier(approval.tool),
243 input: bound_value(approval.input),
244 })
245 }
246 Event::RateLimit(limit) => Event::RateLimit(RateLimit {
247 status: bound_identifier(limit.status),
248 window: limit.window.map(bound_identifier),
249 resets_at: limit.resets_at,
250 overage_status: limit.overage_status.map(bound_identifier),
251 is_using_overage: limit.is_using_overage,
252 }),
253 }
254}
255
256fn bound_identifier(text: String) -> String {
261 if text.len() <= MAX_IDENTIFIER_BYTES {
262 return text;
263 }
264 let mut cut = MAX_IDENTIFIER_BYTES - TRUNCATION_MARK.len();
265 while cut > 0 && !text.is_char_boundary(cut) {
266 cut -= 1;
267 }
268 let mut out = text[..cut].to_string();
269 out.push_str(TRUNCATION_MARK);
270 out
271}
272
273#[derive(Debug, Clone, Default, PartialEq)]
275pub struct Terminal {
276 pub session: Option<String>,
278 pub model: Option<String>,
285 pub text: String,
287 pub usage: Usage,
289 pub stop: Stop,
291 pub rate_limit: Option<RateLimit>,
293 pub unparsed: usize,
299 pub first_unparsed: Option<String>,
301 pub structured: Option<Value>,
303 pub error_status: Option<u16>,
306 pub error_message: Option<String>,
310}
311
312fn unwrap_error_body(message: &str) -> (Option<u16>, String) {
320 let Ok(body) = serde_json::from_str::<Value>(message) else {
321 return (None, message.to_string());
322 };
323 let status = body
324 .get("status")
325 .and_then(Value::as_u64)
326 .and_then(|s| u16::try_from(s).ok());
327 let inner = body
328 .get("error")
329 .and_then(|e| e.get("message"))
330 .and_then(Value::as_str)
331 .map(str::to_string);
332 (status, inner.unwrap_or_else(|| message.to_string()))
333}
334
335#[derive(Debug)]
337pub(crate) struct Parser {
338 agent: Agent,
339 format: Format,
340 term: Terminal,
341 tools: HashMap<String, String>,
343 tool_bytes: usize,
346 seen: Seen,
348 latest_context: Option<u64>,
354}
355
356#[derive(Debug, Default)]
362#[expect(
363 clippy::struct_excessive_bools,
364 reason = "four independent stream milestones; naming each beats packing them"
365)]
366struct Seen {
367 started: bool,
369 structured: bool,
372 terminal: bool,
374 deltas: bool,
381}
382
383impl Parser {
384 #[must_use]
386 pub fn new(agent: Agent, format: Format) -> Self {
387 Self {
388 agent,
389 format,
390 term: Terminal::default(),
391 tools: HashMap::new(),
392 tool_bytes: 0,
393 seen: Seen::default(),
394 latest_context: None,
395 }
396 }
397
398 pub fn push(&mut self, line: &str) -> Vec<Event> {
406 let line = line.trim();
407 if line.is_empty() {
408 return Vec::new();
409 }
410 if self.format == Format::Text {
413 append_capped(&mut self.term.text, line);
414 return vec![enforce_bounds(Event::Text(line.to_string()))];
415 }
416 let Ok(value) = serde_json::from_str::<Value>(line) else {
417 self.term.unparsed += 1;
418 if self.term.first_unparsed.is_none() {
419 let mut cut = line.len().min(512);
423 while cut > 0 && !line.is_char_boundary(cut) {
424 cut -= 1;
425 }
426 self.term.first_unparsed = Some(line[..cut].to_string());
427 }
428 return Vec::new();
429 };
430 if let Some(ty) = value.get("type").and_then(Value::as_str)
433 && self.recognizes(ty)
434 {
435 self.seen.structured = true;
436 }
437 let mut out = match self.agent {
438 Agent::Claude => self.claude(&value),
439 Agent::Codex => self.codex(&value),
440 Agent::Copilot => self.copilot(&value),
441 };
442 out = out.into_iter().map(enforce_bounds).collect();
445
446 if !self.seen.started {
449 if let Some(session) = self.term.session.clone() {
450 self.seen.started = true;
451 let model = model_of(&value);
452 self.term.model.clone_from(&model);
453 out.insert(0, Event::Started { session, model });
454 }
455 }
456 out
457 }
458
459 pub(crate) fn saw_terminal(&self) -> bool {
466 self.seen.terminal
467 }
468
469 fn recognizes(&self, ty: &str) -> bool {
471 match self.agent {
472 Agent::Claude => matches!(
473 ty,
474 "system" | "assistant" | "user" | "result" | "rate_limit_event" | "control_request"
475 ),
476 Agent::Codex => {
477 ty.starts_with("thread.") || ty.starts_with("turn.") || ty.starts_with("item.")
478 }
479 Agent::Copilot => {
480 ty == "result"
481 || ty.starts_with("assistant.")
482 || ty.starts_with("tool.")
483 || ty.starts_with("session.")
484 }
485 }
486 }
487
488 fn remember_tool(&mut self, id: &str, name: &str) {
491 if !usable_identifier(id) {
494 return;
495 }
496 let name = bound_identifier(name.to_string());
497 let cost = id.len() + name.len();
498 if self.tools.len() >= MAX_PENDING_TOOLS
502 || self.tool_bytes.saturating_add(cost) > MAX_PENDING_TOOL_BYTES
503 {
504 return;
505 }
506 self.tool_bytes += cost;
507 if let Some(previous) = self.tools.insert(id.to_string(), name) {
508 self.tool_bytes = self.tool_bytes.saturating_sub(id.len() + previous.len());
510 }
511 }
512
513 fn forget_tool(&mut self, id: &str) {
515 if let Some(name) = self.tools.remove(id) {
516 self.tool_bytes = self.tool_bytes.saturating_sub(id.len() + name.len());
517 }
518 }
519
520 pub(crate) fn saw_structured_record(&self) -> bool {
525 self.seen.structured
526 }
527
528 pub(crate) fn saw_terminal_record(&self) -> bool {
531 self.seen.terminal
532 }
533
534 #[must_use]
536 pub fn finish(mut self) -> Terminal {
537 if self.format == Format::Text {
538 self.term.text = self.term.text.trim_end().to_string();
539 }
540 self.term
541 }
542
543 fn claude(&mut self, v: &Value) -> Vec<Event> {
549 let ty = v.get("type").and_then(Value::as_str).unwrap_or_default();
550 if let Some(id) = v.get("session_id").and_then(Value::as_str)
553 && usable_identifier(id)
554 {
555 self.term.session.get_or_insert_with(|| id.to_string());
556 }
557 match ty {
558 "rate_limit_event" => {
559 let limit = claude_rate_limit(v.get("rate_limit_info"));
560 self.term.rate_limit.clone_from(&limit);
561 limit.into_iter().map(Event::RateLimit).collect()
562 }
563 "stream_event" => self.claude_delta(v),
565 "control_request" => {
573 let Some(request) = v.get("request") else {
574 return Vec::new();
575 };
576 if request.get("subtype").and_then(Value::as_str) != Some("can_use_tool") {
577 return Vec::new();
578 }
579 let Some(id) = v.get("request_id").and_then(Value::as_str) else {
580 return Vec::new();
584 };
585 if !usable_identifier(id) {
586 return Vec::new();
587 }
588 vec![Event::ApprovalRequest(crate::approval::Approval {
589 id: id.to_string(),
590 tool: request
591 .get("tool_name")
592 .and_then(Value::as_str)
593 .unwrap_or("unknown")
594 .to_string(),
595 input: request.get("input").cloned().unwrap_or(Value::Null),
596 })]
597 }
598 "assistant" | "user" => {
599 if ty == "assistant"
610 && let Some(usage) = v.get("message").and_then(|m| m.get("usage"))
611 {
612 let part = |key: &str| usage.get(key).and_then(Value::as_u64);
613 let (input, read, write) = (
614 part("input_tokens"),
615 part("cache_read_input_tokens"),
616 part("cache_creation_input_tokens"),
617 );
618 if input.is_some() || read.is_some() || write.is_some() {
619 self.latest_context =
620 Some(input.unwrap_or(0) + read.unwrap_or(0) + write.unwrap_or(0));
621 }
622 }
623 self.content_blocks(v)
624 }
625 "result" => {
626 self.seen.terminal = true;
627 if let Some(text) = v.get("result").and_then(Value::as_str) {
628 self.term.text = text.to_string();
629 }
630 if let Some(value) = v.get("structured_output") {
633 self.term.structured = Some(value.clone());
634 }
635 self.term.usage = claude_usage(v, self.term.model.as_deref());
636 if self.latest_context.is_some() {
640 self.term.usage.context_tokens = self.latest_context;
641 }
642 self.term.stop = if v.get("is_error").and_then(Value::as_bool) == Some(true) {
645 self.term.error_status = v
646 .get("api_error_status")
647 .and_then(Value::as_u64)
648 .and_then(|s| u16::try_from(s).ok());
649 Stop::Error
650 } else {
651 stop_from(v.get("stop_reason"))
652 };
653 Vec::new()
654 }
655 _ => Vec::new(),
656 }
657 }
658
659 fn claude_delta(&mut self, v: &Value) -> Vec<Event> {
666 let Some(event) = v.get("event") else {
667 return Vec::new();
668 };
669 if event.get("type").and_then(Value::as_str) != Some("content_block_delta") {
670 return Vec::new();
671 }
672 let Some(delta) = event.get("delta") else {
673 return Vec::new();
674 };
675 self.seen.deltas = true;
678
679 match delta.get("type").and_then(Value::as_str) {
680 Some("text_delta") => delta
681 .get("text")
682 .and_then(Value::as_str)
683 .filter(|text| !text.is_empty())
684 .map(|text| Event::Text(text.to_string()))
685 .into_iter()
686 .collect(),
687 Some("thinking_delta") => delta
688 .get("thinking")
689 .and_then(Value::as_str)
690 .filter(|text| !text.is_empty())
691 .map(|text| Event::Thinking(text.to_string()))
692 .into_iter()
693 .collect(),
694 _ => Vec::new(),
698 }
699 }
700
701 fn content_blocks(&mut self, v: &Value) -> Vec<Event> {
704 let blocks = v
705 .get("message")
706 .and_then(|m| m.get("content"))
707 .and_then(Value::as_array);
708 let Some(blocks) = blocks else {
709 return Vec::new();
710 };
711 let mut out = Vec::new();
712 for block in blocks {
713 let ty = block
714 .get("type")
715 .and_then(Value::as_str)
716 .unwrap_or_default();
717 match ty {
718 "text" if !self.seen.deltas => {
723 if let Some(t) = block.get("text").and_then(Value::as_str) {
724 out.push(Event::Text(t.to_string()));
725 }
726 }
727 "thinking" if !self.seen.deltas => {
728 if let Some(t) = block.get("thinking").and_then(Value::as_str) {
729 out.push(Event::Thinking(t.to_string()));
730 }
731 }
732 "tool_use" => {
733 let name = block
734 .get("name")
735 .and_then(Value::as_str)
736 .unwrap_or("tool")
737 .to_string();
738 let id = block.get("id").and_then(Value::as_str).map(str::to_string);
739 if let Some(id) = &id {
740 self.remember_tool(id, &name);
741 }
742 out.push(Event::ToolCall {
743 id,
744 name,
745 input: block.get("input").cloned().unwrap_or(Value::Null),
746 });
747 }
748 "tool_result" => out.push(Event::ToolResult {
749 id: block
750 .get("tool_use_id")
751 .and_then(Value::as_str)
752 .inspect(|id| {
753 self.forget_tool(id);
755 })
756 .map(str::to_string),
757 ok: block
758 .get("is_error")
759 .and_then(Value::as_bool)
760 .map(|is_error| !is_error),
761 output: flatten_text(block.get("content")),
762 }),
763 _ => {}
764 }
765 }
766 out
767 }
768
769 fn codex(&mut self, v: &Value) -> Vec<Event> {
777 let ty = v.get("type").and_then(Value::as_str).unwrap_or_default();
778 if let Some(id) = v.get("thread_id").and_then(Value::as_str)
779 && usable_identifier(id)
780 {
781 self.term.session.get_or_insert_with(|| id.to_string());
782 }
783 match ty {
784 "turn.completed" => {
785 self.seen.terminal = true;
786 self.term.usage = codex_usage(v.get("usage"));
787 Vec::new()
788 }
789 "turn.failed" => {
790 self.seen.terminal = true;
791 self.term.stop = Stop::Error;
792 if let Some(message) = v
793 .get("error")
794 .and_then(|e| e.get("message"))
795 .and_then(Value::as_str)
796 {
797 let (status, message) = unwrap_error_body(message);
798 self.term.error_status = status;
799 self.term.error_message = Some(bound_text(message));
800 }
801 Vec::new()
802 }
803 "item.started" | "item.updated" | "item.completed" => {
804 let Some(item) = v.get("item") else {
805 return Vec::new();
806 };
807 let item_ty = item.get("type").and_then(Value::as_str).unwrap_or_default();
808 let id = item.get("id").and_then(Value::as_str).map(str::to_string);
809 let done = ty == "item.completed";
810
811 let name = tool_name(item, item_ty);
815 let first = id
816 .as_ref()
817 .is_none_or(|id| self.tools.insert(id.clone(), name.clone()).is_none());
818
819 match item_ty {
820 "agent_message" => {
823 if !done {
824 return Vec::new();
825 }
826 let text = item.get("text").and_then(Value::as_str).unwrap_or_default();
827 self.term.text = text.to_string();
828 vec![Event::Text(text.to_string())]
829 }
830 "reasoning" if done => item
831 .get("text")
832 .and_then(Value::as_str)
833 .map(|t| Event::Thinking(t.to_string()))
834 .into_iter()
835 .collect(),
836 "command_execution" | "mcp_tool_call" | "file_change" | "web_search" => {
837 let mut out = Vec::new();
838 if first {
839 out.push(Event::ToolCall {
840 id: id.clone(),
841 name,
842 input: codex_tool_input(item, item_ty),
843 });
844 }
845 if done {
848 if let Some(id) = &id {
849 self.forget_tool(id);
850 }
851 out.push(Event::ToolResult {
852 id,
853 ok: item
854 .get("exit_code")
855 .and_then(Value::as_i64)
856 .map(|code| code == 0),
857 output: item
858 .get("aggregated_output")
859 .and_then(Value::as_str)
860 .unwrap_or_default()
861 .to_string(),
862 });
863 }
864 out
865 }
866 _ => Vec::new(),
867 }
868 }
869 _ => Vec::new(),
870 }
871 }
872
873 fn copilot(&mut self, v: &Value) -> Vec<Event> {
880 let ty = v.get("type").and_then(Value::as_str).unwrap_or_default();
881 let data = v.get("data");
882 let field = |key: &str| -> Option<String> {
883 data.and_then(|d| d.get(key))
884 .and_then(Value::as_str)
885 .map(str::to_string)
886 };
887 match ty {
888 "assistant.message_delta" => field("deltaContent")
890 .filter(|t| !t.is_empty())
891 .map(Event::Text)
892 .into_iter()
893 .collect(),
894 "assistant.message" => {
897 if let Some(content) = field("content") {
898 self.term.text = content;
899 }
900 Vec::new()
901 }
902 "assistant.reasoning" => field("content")
903 .filter(|t| !t.is_empty())
904 .map(Event::Thinking)
905 .into_iter()
906 .collect(),
907 "tool.execution_start" => {
908 let id = field("toolCallId");
909 let name = field("toolName").unwrap_or_else(|| "tool".into());
910 if let Some(id) = &id {
911 self.remember_tool(id, &name);
912 }
913 vec![Event::ToolCall {
914 id,
915 name,
916 input: data
917 .and_then(|d| d.get("arguments"))
918 .cloned()
919 .unwrap_or(Value::Null),
920 }]
921 }
922 "tool.execution_complete" => vec![Event::ToolResult {
923 id: field("toolCallId").inspect(|id| {
924 self.forget_tool(id);
925 }),
926 ok: data.and_then(|d| d.get("success")).and_then(Value::as_bool),
927 output: data
928 .and_then(|d| d.get("result"))
929 .and_then(|r| r.get("content"))
930 .and_then(Value::as_str)
931 .unwrap_or_default()
932 .to_string(),
933 }],
934 "session.usage_checkpoint" => {
939 if let Some(data) = v.get("data") {
940 self.term.usage.ai_credits_nano =
941 data.get("totalNanoAiu").and_then(Value::as_u64);
942 if let Some(premium) = data.get("totalPremiumRequests").and_then(Value::as_u64)
943 {
944 self.term.usage.premium_requests = Some(premium);
945 }
946 }
947 Vec::new()
948 }
949 "result" => {
951 self.seen.terminal = true;
952 if let Some(id) = v.get("sessionId").and_then(Value::as_str)
953 && usable_identifier(id)
954 {
955 self.term.session = Some(id.to_string());
956 }
957 if let Some(usage) = v.get("usage") {
958 self.term.usage.premium_requests =
959 usage.get("premiumRequests").and_then(Value::as_u64);
960 self.term.usage.duration_ms =
961 usage.get("sessionDurationMs").and_then(Value::as_u64);
962 self.term.usage.api_duration_ms =
963 usage.get("totalApiDurationMs").and_then(Value::as_u64);
964 }
965 if let Some(code) = v.get("exitCode").and_then(Value::as_i64)
966 && code != 0
967 {
968 self.term.stop = Stop::Error;
969 self.term.error_message = Some(format!("copilot exited with code {code}"));
972 }
973 Vec::new()
974 }
975 _ => Vec::new(),
976 }
977 }
978}
979
980fn model_of(v: &Value) -> Option<String> {
983 v.get("model")
984 .or_else(|| v.get("data").and_then(|d| d.get("model")))
985 .and_then(Value::as_str)
986 .map(str::to_string)
987}
988
989fn stop_from(v: Option<&Value>) -> Stop {
991 match v.and_then(Value::as_str) {
992 None | Some("end_turn" | "stop" | "completed") => Stop::Completed,
993 Some(other) => Stop::Other(other.to_string()),
994 }
995}
996
997fn claude_rate_limit(v: Option<&Value>) -> Option<RateLimit> {
999 let v = v?;
1000 Some(RateLimit {
1001 status: v.get("status").and_then(Value::as_str)?.to_string(),
1002 window: v
1003 .get("rateLimitType")
1004 .and_then(Value::as_str)
1005 .map(str::to_string),
1006 resets_at: v.get("resetsAt").and_then(Value::as_i64),
1007 overage_status: v
1008 .get("overageStatus")
1009 .and_then(Value::as_str)
1010 .map(str::to_string),
1011 is_using_overage: v.get("isUsingOverage").and_then(Value::as_bool),
1012 })
1013}
1014
1015fn claude_usage(v: &Value, model: Option<&str>) -> Usage {
1017 let u = v.get("usage");
1018 let get = |key: &str| u.and_then(|u| u.get(key)).and_then(Value::as_u64);
1019 let (input, read, write) = (
1020 get("input_tokens"),
1021 get("cache_read_input_tokens"),
1022 get("cache_creation_input_tokens"),
1023 );
1024 let per_model = v
1032 .get("modelUsage")
1033 .and_then(Value::as_object)
1034 .and_then(
1035 |models| match (model.and_then(|m| models.get(m)), models.len()) {
1036 (Some(entry), _) => Some(entry),
1037 (None, 1) => models.values().next(),
1039 (None, _) => None,
1042 },
1043 );
1044 let of_model = |key: &str| per_model.and_then(|m| m.get(key)).and_then(Value::as_u64);
1045 Usage {
1046 input_tokens: input,
1047 output_tokens: get("output_tokens"),
1048 cache_read_tokens: read,
1049 cache_write_tokens: write,
1050 context_tokens: (input.is_some() || read.is_some() || write.is_some())
1054 .then(|| input.unwrap_or(0) + read.unwrap_or(0) + write.unwrap_or(0)),
1055 context_window: of_model("contextWindow"),
1056 max_output_tokens: of_model("maxOutputTokens"),
1057 reasoning_tokens: None,
1058 cost_usd: v.get("total_cost_usd").and_then(Value::as_f64),
1059 premium_requests: None,
1060 ai_credits_nano: None,
1061 duration_ms: v.get("duration_ms").and_then(Value::as_u64),
1062 api_duration_ms: v.get("duration_api_ms").and_then(Value::as_u64),
1063 }
1064}
1065
1066fn codex_usage(v: Option<&Value>) -> Usage {
1069 let get = |key: &str| v.and_then(|u| u.get(key)).and_then(Value::as_u64);
1070 let (prompt, cached) = (get("input_tokens"), get("cached_input_tokens"));
1071 Usage {
1072 input_tokens: match (prompt, cached) {
1080 (Some(prompt), Some(cached)) => Some(prompt.saturating_sub(cached)),
1081 (prompt, _) => prompt,
1082 },
1083 output_tokens: get("output_tokens"),
1084 cache_read_tokens: cached,
1085 cache_write_tokens: get("cache_write_input_tokens"),
1086 context_tokens: prompt,
1087 context_window: None,
1088 max_output_tokens: None,
1089 reasoning_tokens: get("reasoning_output_tokens"),
1090 cost_usd: None,
1091 premium_requests: None,
1092 ai_credits_nano: None,
1093 duration_ms: None,
1094 api_duration_ms: None,
1095 }
1096}
1097
1098fn tool_name(item: &Value, item_ty: &str) -> String {
1101 item.get("tool")
1102 .and_then(Value::as_str)
1103 .unwrap_or(item_ty)
1104 .to_string()
1105}
1106
1107fn codex_tool_input(item: &Value, item_ty: &str) -> Value {
1109 match item_ty {
1110 "command_execution" => serde_json::json!({ "command": item.get("command") }),
1111 "mcp_tool_call" => item.get("arguments").cloned().unwrap_or(Value::Null),
1112 _ => item.clone(),
1115 }
1116}
1117
1118fn flatten_text(v: Option<&Value>) -> String {
1126 match v {
1127 Some(Value::String(s)) => s.clone(),
1128 Some(Value::Array(blocks)) => blocks
1129 .iter()
1130 .map(|b| match b.get("text").and_then(Value::as_str) {
1131 Some(text) => text.to_string(),
1132 None => b.to_string(),
1133 })
1134 .collect::<Vec<_>>()
1135 .join("\n"),
1136 Some(other) => other.to_string(),
1137 None => String::new(),
1138 }
1139}
1140
1141#[cfg(test)]
1142mod tests {
1143 use super::*;
1144
1145 fn run(agent: Agent, lines: &[&str]) -> (Vec<Event>, Terminal) {
1147 let mut p = Parser::new(agent, Format::Stream);
1148 let events = lines.iter().flat_map(|l| p.push(l)).collect();
1149 (events, p.finish())
1150 }
1151
1152 #[test]
1155 fn claude_stream_yields_start_thinking_text_and_terminal_facts() {
1156 let (events, term) = run(
1157 Agent::Claude,
1158 &[
1159 r#"{"type":"system","subtype":"init","session_id":"sess-a","model":"claude-haiku-4-5"}"#,
1160 r#"{"type":"assistant","session_id":"sess-a","message":{"content":[{"type":"thinking","thinking":"brief"}]}}"#,
1161 r#"{"type":"assistant","session_id":"sess-a","message":{"content":[{"type":"text","text":"pong"}]}}"#,
1162 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}}"#,
1163 ],
1164 );
1165 assert_eq!(
1166 events[0],
1167 Event::Started {
1168 session: "sess-a".into(),
1169 model: Some("claude-haiku-4-5".into())
1170 }
1171 );
1172 assert_eq!(events[1], Event::Thinking("brief".into()));
1173 assert_eq!(events[2], Event::Text("pong".into()));
1174 assert_eq!(term.session.as_deref(), Some("sess-a"));
1175 assert_eq!(term.text, "pong");
1176 assert_eq!(term.stop, Stop::Completed);
1177 assert_eq!(term.usage.input_tokens, Some(10));
1178 assert_eq!(term.usage.cache_read_tokens, Some(18764));
1179 assert_eq!(term.usage.cache_write_tokens, Some(7322));
1180 assert_eq!(term.usage.cost_usd, Some(0.017));
1181 }
1182
1183 #[test]
1191 fn the_window_binds_to_the_runs_model_not_the_haiku_helper() {
1192 let (_, term) = run(
1193 Agent::Claude,
1194 &[
1195 r#"{"type":"system","subtype":"init","session_id":"sess-1m","model":"claude-sonnet-5[1m]"}"#,
1196 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}}}"#,
1197 ],
1198 );
1199 assert_eq!(term.model.as_deref(), Some("claude-sonnet-5[1m]"));
1200 assert_eq!(
1201 term.usage.context_window,
1202 Some(1_000_000),
1203 "the helper's 200k window must not shadow the real one"
1204 );
1205 assert_eq!(term.usage.max_output_tokens, Some(64_000));
1206 assert_eq!(term.usage.context_tokens, Some(2 + 27_128 + 9_825));
1208 }
1209
1210 #[test]
1216 fn context_is_the_last_requests_prompt_not_the_turns_sum() {
1217 let (_, term) = run(
1218 Agent::Claude,
1219 &[
1220 r#"{"type":"system","subtype":"init","session_id":"s","model":"claude-sonnet-5"}"#,
1221 r#"{"type":"assistant","session_id":"s","message":{"usage":{"input_tokens":4,"output_tokens":20,"cache_read_input_tokens":100000,"cache_creation_input_tokens":2000},"content":[{"type":"tool_use","id":"t1","name":"Bash","input":{"command":"ls"}}]}}"#,
1222 r#"{"type":"user","session_id":"s","message":{"content":[{"type":"tool_result","tool_use_id":"t1","content":"ok"}]}}"#,
1223 r#"{"type":"assistant","session_id":"s","message":{"usage":{"input_tokens":6,"output_tokens":40,"cache_read_input_tokens":102000,"cache_creation_input_tokens":500},"content":[{"type":"text","text":"done"}]}}"#,
1224 r#"{"type":"result","subtype":"success","is_error":false,"result":"done","session_id":"s","usage":{"input_tokens":10,"output_tokens":60,"cache_read_input_tokens":202000,"cache_creation_input_tokens":2500}}"#,
1225 ],
1226 );
1227 assert_eq!(
1228 term.usage.context_tokens,
1229 Some(6 + 102_000 + 500),
1230 "the last request's prompt is the context; the turn sum (204,510) is not"
1231 );
1232 assert_eq!(term.usage.cache_read_tokens, Some(202_000));
1234 }
1235
1236 #[test]
1239 fn the_terminal_sum_remains_the_fallback_context() {
1240 let (_, term) = run(
1241 Agent::Claude,
1242 &[
1243 r#"{"type":"system","subtype":"init","session_id":"s","model":"claude-sonnet-5"}"#,
1244 r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"text","text":"ok"}]}}"#,
1245 r#"{"type":"result","subtype":"success","is_error":false,"result":"ok","session_id":"s","usage":{"input_tokens":10,"output_tokens":5,"cache_read_input_tokens":1000,"cache_creation_input_tokens":0}}"#,
1246 ],
1247 );
1248 assert_eq!(term.usage.context_tokens, Some(10 + 1000));
1249 }
1250
1251 #[test]
1254 fn an_unmatchable_window_is_absent_not_guessed() {
1255 let (_, term) = run(
1256 Agent::Claude,
1257 &[
1258 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}}}"#,
1260 ],
1261 );
1262 assert_eq!(term.usage.context_window, None);
1263 let (_, single) = run(
1265 Agent::Claude,
1266 &[
1267 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}}}"#,
1268 ],
1269 );
1270 assert_eq!(single.usage.context_window, Some(200_000));
1271 }
1272
1273 #[test]
1274 fn claude_token_deltas_stream_without_duplicating_the_finished_message() {
1275 let (events, _) = run(
1276 Agent::Claude,
1277 &[
1278 r#"{"type":"system","subtype":"init","session_id":"s"}"#,
1279 r#"{"type":"stream_event","session_id":"s","event":{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}}"#,
1280 r#"{"type":"stream_event","session_id":"s","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"po"}}}"#,
1281 r#"{"type":"stream_event","session_id":"s","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"ng"}}}"#,
1282 r#"{"type":"stream_event","session_id":"s","event":{"type":"content_block_stop","index":0}}"#,
1283 r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"text","text":"pong"}]}}"#,
1285 r#"{"type":"result","subtype":"success","is_error":false,"result":"pong","session_id":"s"}"#,
1286 ],
1287 );
1288 let texts: Vec<_> = events
1289 .iter()
1290 .filter_map(|e| match e {
1291 Event::Text(t) => Some(t.as_str()),
1292 _ => None,
1293 })
1294 .collect();
1295 assert_eq!(texts, ["po", "ng"], "the finished message must not repeat");
1296 }
1297
1298 #[test]
1300 fn claude_thinking_deltas_stream_without_duplication() {
1301 let (events, _) = run(
1302 Agent::Claude,
1303 &[
1304 r#"{"type":"stream_event","session_id":"s","event":{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"weighing"}}}"#,
1305 r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"thinking","thinking":"weighing"}]}}"#,
1306 ],
1307 );
1308 let thoughts: Vec<_> = events
1309 .iter()
1310 .filter_map(|e| match e {
1311 Event::Thinking(t) => Some(t.as_str()),
1312 _ => None,
1313 })
1314 .collect();
1315 assert_eq!(thoughts, ["weighing"]);
1316 }
1317
1318 #[test]
1321 fn a_completed_message_still_streams_when_no_deltas_arrived() {
1322 let (events, _) = run(
1323 Agent::Claude,
1324 &[
1325 r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"text","text":"pong"}]}}"#,
1326 ],
1327 );
1328 assert!(events.contains(&Event::Text("pong".into())), "{events:?}");
1329 }
1330
1331 #[test]
1334 fn tool_calls_survive_delta_suppression() {
1335 let (events, _) = run(
1336 Agent::Claude,
1337 &[
1338 r#"{"type":"stream_event","session_id":"s","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hi"}}}"#,
1339 r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"tool_use","id":"t1","name":"Bash","input":{"command":"ls"}}]}}"#,
1340 ],
1341 );
1342 assert!(
1343 events.iter().any(|e| matches!(e, Event::ToolCall { .. })),
1344 "suppression must apply to text only: {events:?}"
1345 );
1346 }
1347
1348 #[test]
1349 fn claude_started_fires_only_once() {
1350 let (events, _) = run(
1351 Agent::Claude,
1352 &[
1353 r#"{"type":"system","subtype":"init","session_id":"s"}"#,
1354 r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"text","text":"a"}]}}"#,
1355 r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"text","text":"b"}]}}"#,
1356 ],
1357 );
1358 assert_eq!(
1359 events
1360 .iter()
1361 .filter(|e| matches!(e, Event::Started { .. }))
1362 .count(),
1363 1
1364 );
1365 }
1366
1367 #[test]
1368 fn claude_pairs_tool_use_with_its_result() {
1369 let (events, _) = run(
1370 Agent::Claude,
1371 &[
1372 r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"tool_use","id":"toolu_1","name":"Bash","input":{"command":"ls"}}]}}"#,
1373 r#"{"type":"user","session_id":"s","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"a.txt"}]}}"#,
1374 ],
1375 );
1376 let call = events
1377 .iter()
1378 .find(|e| matches!(e, Event::ToolCall { .. }))
1379 .unwrap();
1380 let Event::ToolCall { id, name, input } = call else {
1381 unreachable!()
1382 };
1383 assert_eq!(id.as_deref(), Some("toolu_1"));
1384 assert_eq!(name, "Bash");
1385 assert_eq!(input["command"], "ls");
1386 assert!(events.contains(&Event::ToolResult {
1387 id: Some("toolu_1".into()),
1388 ok: None,
1389 output: "a.txt".into(),
1390 }));
1391 }
1392
1393 #[test]
1398 fn an_approval_request_carries_the_tool_and_its_arguments() {
1399 let (events, _) = run(
1400 Agent::Claude,
1401 &[
1402 r#"{"type":"control_request","request_id":"req-7","request":{"subtype":"can_use_tool","tool_name":"Bash","input":{"command":"touch created-by-probe.txt","description":"Create an empty file"}}}"#,
1403 ],
1404 );
1405 let [Event::ApprovalRequest(approval)] = &events[..] else {
1406 panic!("expected one approval request, got {events:?}")
1407 };
1408 assert_eq!(approval.id, "req-7");
1409 assert_eq!(approval.tool, "Bash");
1410 assert_eq!(approval.input["command"], "touch created-by-probe.txt");
1411 }
1412
1413 #[test]
1417 fn an_unanswerable_approval_request_is_dropped() {
1418 for line in [
1419 r#"{"type":"control_request","request":{"subtype":"can_use_tool","tool_name":"Bash","input":{}}}"#,
1421 &format!(
1423 r#"{{"type":"control_request","request_id":"{}","request":{{"subtype":"can_use_tool","tool_name":"Bash","input":{{}}}}}}"#,
1424 "x".repeat(MAX_IDENTIFIER_BYTES + 1)
1425 ),
1426 ] {
1427 let (events, _) = run(Agent::Claude, &[line]);
1428 assert!(
1429 events.is_empty(),
1430 "an unanswerable request must not reach a consumer: {events:?}"
1431 );
1432 }
1433 }
1434
1435 #[test]
1437 fn a_control_request_that_is_not_an_approval_is_ignored() {
1438 let (events, _) = run(
1439 Agent::Claude,
1440 &[r#"{"type":"control_request","request_id":"r","request":{"subtype":"initialize"}}"#],
1441 );
1442 assert!(events.is_empty(), "{events:?}");
1443 }
1444
1445 #[test]
1446 fn claude_reports_a_rate_limit_without_failing() {
1447 let (events, term) = run(
1448 Agent::Claude,
1449 &[
1450 r#"{"type":"rate_limit_event","session_id":"s","rate_limit_info":{"status":"allowed","resetsAt":1785260400,"rateLimitType":"five_hour"}}"#,
1451 ],
1452 );
1453 let limit = RateLimit {
1454 status: "allowed".into(),
1455 window: Some("five_hour".into()),
1456 resets_at: Some(1_785_260_400),
1457 overage_status: None,
1458 is_using_overage: None,
1459 };
1460 assert!(events.contains(&Event::RateLimit(limit.clone())));
1461 assert_eq!(term.rate_limit, Some(limit.clone()));
1462 assert!(
1463 !limit.is_blocking(),
1464 "an `allowed` heartbeat is not a block"
1465 );
1466 }
1467
1468 #[test]
1469 fn claude_error_result_sets_the_stop_reason() {
1470 let (_, term) = run(
1471 Agent::Claude,
1472 &[r#"{"type":"result","is_error":true,"result":"boom","session_id":"s"}"#],
1473 );
1474 assert_eq!(term.stop, Stop::Error);
1475 }
1476
1477 #[test]
1478 fn copilot_streams_deltas_and_takes_its_answer_from_the_settled_message() {
1479 let (events, term) = run(
1480 Agent::Copilot,
1481 &[
1482 r#"{"type":"assistant.message_delta","data":{"messageId":"m","deltaContent":"po"}}"#,
1483 r#"{"type":"assistant.message_delta","data":{"messageId":"m","deltaContent":"ng"}}"#,
1484 r#"{"type":"assistant.message","data":{"messageId":"m","model":"gpt-5-mini","content":"pong"}}"#,
1485 r#"{"type":"result","sessionId":"768c8e7d","exitCode":0,"usage":{"premiumRequests":0}}"#,
1486 ],
1487 );
1488 let texts: Vec<_> = events
1490 .iter()
1491 .filter_map(|e| match e {
1492 Event::Text(t) => Some(t.as_str()),
1493 _ => None,
1494 })
1495 .collect();
1496 assert_eq!(texts, ["po", "ng"]);
1497 assert_eq!(term.text, "pong", "the answer is the settled message");
1498 assert_eq!(term.session.as_deref(), Some("768c8e7d"));
1499 assert_eq!(term.usage.premium_requests, Some(0));
1500 }
1501
1502 #[test]
1503 fn copilot_brackets_a_tool_call_with_its_completion() {
1504 let (events, _) = run(
1505 Agent::Copilot,
1506 &[
1507 r#"{"type":"tool.execution_start","data":{"toolCallId":"call_1","toolName":"bash","arguments":{"command":"ls"}}}"#,
1508 r#"{"type":"tool.execution_complete","data":{"toolCallId":"call_1","success":true,"result":{"content":"a.txt"}}}"#,
1509 ],
1510 );
1511 assert!(matches!(
1512 &events[0],
1513 Event::ToolCall { id, name, .. }
1514 if id.as_deref() == Some("call_1") && name == "bash"
1515 ));
1516 assert_eq!(
1517 events[1],
1518 Event::ToolResult {
1519 id: Some("call_1".into()),
1520 ok: Some(true),
1521 output: "a.txt".into()
1522 }
1523 );
1524 }
1525
1526 #[test]
1530 fn a_codex_failed_turn_yields_the_reason_and_the_status() {
1531 let (_, term) = run(
1532 Agent::Codex,
1533 &[
1534 r#"{"type":"thread.started","thread_id":"019fad62"}"#,
1535 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.\"}}"}}"#,
1536 ],
1537 );
1538 assert_eq!(term.stop, Stop::Error);
1539 assert_eq!(term.error_status, Some(400));
1540 assert_eq!(
1541 term.error_message.as_deref(),
1542 Some(
1543 "The 'bogus-model-xyz' model is not supported when using Codex with a ChatGPT account."
1544 ),
1545 "the caller should get the sentence, not the envelope"
1546 );
1547 }
1548
1549 #[test]
1552 fn a_plain_codex_failure_message_passes_through() {
1553 let (_, term) = run(
1554 Agent::Codex,
1555 &[
1556 r#"{"type":"turn.failed","error":{"message":"stream disconnected before completion"}}"#,
1557 ],
1558 );
1559 assert_eq!(term.error_status, None);
1560 assert_eq!(
1561 term.error_message.as_deref(),
1562 Some("stream disconnected before completion")
1563 );
1564 }
1565
1566 #[test]
1567 fn codex_reads_the_thread_id_and_the_completed_message() {
1568 let (events, term) = run(
1569 Agent::Codex,
1570 &[
1571 r#"{"type":"thread.started","thread_id":"0199-xyz"}"#,
1572 r#"{"type":"item.completed","item":{"id":"i1","type":"agent_message","text":"pong"}}"#,
1573 r#"{"type":"turn.completed","usage":{"input_tokens":12,"output_tokens":3,"cached_input_tokens":9}}"#,
1574 ],
1575 );
1576 assert_eq!(
1577 events[0],
1578 Event::Started {
1579 session: "0199-xyz".into(),
1580 model: None
1581 }
1582 );
1583 assert_eq!(term.session.as_deref(), Some("0199-xyz"));
1584 assert_eq!(term.text, "pong");
1585 assert_eq!(term.usage.input_tokens, Some(3));
1590 assert_eq!(term.usage.cache_read_tokens, Some(9));
1591 assert_eq!(term.usage.context_tokens, Some(12));
1592 }
1593
1594 #[test]
1595 fn codex_command_execution_becomes_a_call_and_a_result() {
1596 let (events, _) = run(
1597 Agent::Codex,
1598 &[
1599 r#"{"type":"item.completed","item":{"id":"c1","type":"command_execution","command":"ls","exit_code":0,"aggregated_output":"a.txt"}}"#,
1600 ],
1601 );
1602 assert!(matches!(&events[0], Event::ToolCall { name, .. } if name == "command_execution"));
1603 assert_eq!(
1604 events[1],
1605 Event::ToolResult {
1606 id: Some("c1".into()),
1607 ok: Some(true),
1608 output: "a.txt".into()
1609 }
1610 );
1611 }
1612
1613 #[test]
1617 fn codex_started_then_completed_yields_one_call_and_one_result() {
1618 let (events, _) = run(
1619 Agent::Codex,
1620 &[
1621 r#"{"type":"item.started","item":{"id":"item_1","type":"command_execution","command":"/bin/zsh -lc ls","aggregated_output":"","exit_code":null,"status":"in_progress"}}"#,
1622 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"}}"#,
1623 ],
1624 );
1625 let calls = events
1626 .iter()
1627 .filter(|e| matches!(e, Event::ToolCall { .. }))
1628 .count();
1629 assert_eq!(calls, 1, "the same item must not be announced twice");
1630 let results: Vec<_> = events
1631 .iter()
1632 .filter_map(|e| match e {
1633 Event::ToolResult { output, .. } => Some(output.as_str()),
1634 _ => None,
1635 })
1636 .collect();
1637 assert_eq!(
1638 results,
1639 ["a.txt\n"],
1640 "the in-progress blank must not appear"
1641 );
1642 }
1643
1644 #[test]
1646 fn codex_last_completed_message_is_the_answer() {
1647 let (_, term) = run(
1648 Agent::Codex,
1649 &[
1650 r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"I'll list the directory."}}"#,
1651 r#"{"type":"item.completed","item":{"id":"i2","type":"agent_message","text":"DONE"}}"#,
1652 ],
1653 );
1654 assert_eq!(term.text, "DONE");
1655 }
1656
1657 #[test]
1660 fn an_enormous_tool_result_is_bounded_and_marked() {
1661 let huge = "x".repeat(MAX_EVENT_BYTES * 4);
1662 let line = serde_json::json!({
1663 "type": "user",
1664 "session_id": "s",
1665 "message": {"content": [{
1666 "type": "tool_result", "tool_use_id": "t1", "content": huge
1667 }]}
1668 })
1669 .to_string();
1670
1671 let (events, _) = run(Agent::Claude, &[&line]);
1672 let Some(Event::ToolResult { output, id, .. }) = events
1673 .iter()
1674 .find(|e| matches!(e, Event::ToolResult { .. }))
1675 .cloned()
1676 else {
1677 panic!("expected a tool result, got {events:?}")
1678 };
1679 assert!(
1680 output.len() <= MAX_EVENT_BYTES,
1681 "kept {} bytes",
1682 output.len()
1683 );
1684 assert!(
1685 output.ends_with(TRUNCATION_MARK),
1686 "truncation must be visible"
1687 );
1688 assert_eq!(id.as_deref(), Some("t1"), "the id must survive whole");
1689 }
1690
1691 #[test]
1695 fn usable_identifiers_are_never_shortened() {
1696 let id = "s".repeat(MAX_IDENTIFIER_BYTES);
1698 let line =
1699 serde_json::json!({"type": "system", "subtype": "init", "session_id": id}).to_string();
1700 let (events, term) = run(Agent::Claude, &[&line]);
1701
1702 let Some(Event::Started { session, .. }) = events.first().cloned() else {
1703 panic!("expected Started, got {events:?}")
1704 };
1705 assert_eq!(session.len(), id.len(), "the session id was shortened");
1706 assert_eq!(term.session.as_deref(), Some(id.as_str()));
1707 }
1708
1709 #[test]
1714 fn an_oversized_session_id_is_rejected_rather_than_stored() {
1715 let id = "s".repeat(MAX_IDENTIFIER_BYTES + 1);
1716 for (agent, line) in [
1717 (
1718 Agent::Claude,
1719 serde_json::json!({"type": "system", "subtype": "init", "session_id": id})
1720 .to_string(),
1721 ),
1722 (
1723 Agent::Codex,
1724 serde_json::json!({"type": "thread.started", "thread_id": id}).to_string(),
1725 ),
1726 (
1727 Agent::Copilot,
1728 serde_json::json!({"type": "result", "sessionId": id, "exitCode": 0}).to_string(),
1729 ),
1730 ] {
1731 let (events, term) = run(agent, &[&line]);
1732 assert!(term.session.is_none(), "{agent} stored an unusable id");
1733 assert!(
1734 !events.iter().any(|e| matches!(e, Event::Started { .. })),
1735 "{agent} announced a session it cannot resume"
1736 );
1737 }
1738 }
1739
1740 #[test]
1744 fn an_oversized_tool_id_drops_the_id_but_keeps_the_event() {
1745 let id = "t".repeat(MAX_IDENTIFIER_BYTES + 1);
1746 let line = serde_json::json!({
1747 "type": "assistant", "session_id": "s",
1748 "message": {"content": [{
1749 "type": "tool_use", "id": id, "name": "Bash", "input": {"command": "ls"}
1750 }]}
1751 })
1752 .to_string();
1753
1754 let (events, _) = run(Agent::Claude, &[&line]);
1755 let Some(Event::ToolCall { id: seen, name, .. }) = events
1756 .iter()
1757 .find(|e| matches!(e, Event::ToolCall { .. }))
1758 .cloned()
1759 else {
1760 panic!("the call itself must still be reported, got {events:?}")
1761 };
1762 assert_eq!(seen, None, "an unusable id must be dropped, not shortened");
1763 assert_eq!(name, "Bash");
1764 }
1765
1766 #[test]
1769 fn the_pending_tool_map_is_bounded_by_bytes_not_only_entries() {
1770 let mut parser = Parser::new(Agent::Claude, Format::Stream);
1771 for i in 0..MAX_PENDING_TOOLS {
1774 let line = serde_json::json!({
1775 "type": "assistant", "session_id": "s",
1776 "message": {"content": [{
1777 "type": "tool_use",
1778 "id": format!("{i:0>width$}", width = MAX_IDENTIFIER_BYTES),
1779 "name": "x".repeat(MAX_IDENTIFIER_BYTES),
1780 "input": {}
1781 }]}
1782 })
1783 .to_string();
1784 parser.push(&line);
1785 }
1786 assert!(
1787 parser.tool_bytes <= MAX_PENDING_TOOL_BYTES,
1788 "pending tools grew to {} bytes",
1789 parser.tool_bytes
1790 );
1791 }
1792
1793 #[test]
1796 fn a_completed_tool_call_releases_its_budget() {
1797 let mut parser = Parser::new(Agent::Claude, Format::Stream);
1798 let call = |id: &str| {
1799 serde_json::json!({
1800 "type": "assistant", "session_id": "s",
1801 "message": {"content": [{
1802 "type": "tool_use", "id": id, "name": "Bash", "input": {}
1803 }]}
1804 })
1805 .to_string()
1806 };
1807 let result = |id: &str| {
1808 serde_json::json!({
1809 "type": "user", "session_id": "s",
1810 "message": {"content": [{
1811 "type": "tool_result", "tool_use_id": id, "content": "done"
1812 }]}
1813 })
1814 .to_string()
1815 };
1816
1817 for i in 0..(MAX_PENDING_TOOLS * 4) {
1818 let id = format!("toolu_{i}");
1819 parser.push(&call(&id));
1820 parser.push(&result(&id));
1821 }
1822 assert_eq!(parser.tool_bytes, 0, "budget leaked across paired calls");
1823 assert!(parser.tools.is_empty());
1824 }
1825
1826 #[test]
1829 fn a_worst_case_event_stays_within_the_stated_ceiling() {
1830 let huge = "x".repeat(MAX_LINE);
1831 let line = serde_json::json!({
1832 "type": "assistant", "session_id": huge,
1833 "message": {"content": [{
1834 "type": "tool_use", "id": huge, "name": huge, "input": {"command": huge}
1835 }]}
1836 })
1837 .to_string();
1838
1839 let (events, _) = run(Agent::Claude, &[&line]);
1840 for event in &events {
1841 let size = serde_json::to_string(event).unwrap().len();
1842 let ceiling = MAX_EVENT_BYTES + 4 * MAX_IDENTIFIER_BYTES;
1844 assert!(size <= ceiling, "an event reached {size} bytes: {event:?}");
1845 }
1846 }
1847
1848 #[test]
1851 fn oversized_tool_arguments_stay_valid_json() {
1852 let line = serde_json::json!({
1853 "type": "assistant",
1854 "session_id": "s",
1855 "message": {"content": [{
1856 "type": "tool_use", "id": "t1", "name": "Bash",
1857 "input": {"command": "y".repeat(MAX_EVENT_BYTES * 3)}
1858 }]}
1859 })
1860 .to_string();
1861
1862 let (events, _) = run(Agent::Claude, &[&line]);
1863 let Some(Event::ToolCall { input, .. }) = events
1864 .iter()
1865 .find(|e| matches!(e, Event::ToolCall { .. }))
1866 .cloned()
1867 else {
1868 panic!("expected a tool call, got {events:?}")
1869 };
1870 assert_eq!(input["truncated"], true, "got {input}");
1871 assert!(
1872 input.is_object(),
1873 "the replacement must still be valid JSON"
1874 );
1875 assert!(input.to_string().len() <= MAX_EVENT_BYTES);
1876 }
1877
1878 #[test]
1879 fn ordinary_payloads_pass_through_untouched() {
1880 let (events, _) = run(
1881 Agent::Claude,
1882 &[
1883 r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"text","text":"pong"}]}}"#,
1884 ],
1885 );
1886 assert!(events.contains(&Event::Text("pong".into())), "{events:?}");
1887 }
1888
1889 #[test]
1890 fn capture_is_bounded_and_keeps_the_earliest_output() {
1891 let mut buf = String::new();
1892 for i in 0..50_000 {
1894 append_capped(&mut buf, &format!("line {i} aaaaaaaaaaaaaaaaaaaaaaaaaaaa"));
1895 }
1896 assert!(buf.len() <= MAX_CAPTURE, "grew to {}", buf.len());
1897 assert!(buf.starts_with("line 0 "), "the earliest output is kept");
1898 }
1899
1900 #[test]
1901 fn capping_never_splits_a_multibyte_character() {
1902 let mut buf = "x".repeat(MAX_CAPTURE - 3);
1903 assert!(append_capped(&mut buf, "🙂🙂"));
1905 assert!(buf.len() <= MAX_CAPTURE);
1906 assert!(buf.is_char_boundary(buf.len()));
1909 }
1910
1911 #[test]
1912 fn a_full_buffer_reports_that_it_took_nothing() {
1913 let mut buf = "x".repeat(MAX_CAPTURE);
1914 assert!(!append_capped(&mut buf, "more"));
1915 assert_eq!(buf.len(), MAX_CAPTURE);
1916 }
1917
1918 #[test]
1921 fn unparseable_lines_are_counted_and_sampled() {
1922 let (_, term) = run(
1923 Agent::Claude,
1924 &[
1925 "<html>an error page, not JSON</html>",
1926 "another bad line",
1927 r#"{"type":"result","result":"ok","session_id":"s"}"#,
1928 ],
1929 );
1930 assert_eq!(term.unparsed, 2);
1931 assert_eq!(
1932 term.first_unparsed.as_deref(),
1933 Some("<html>an error page, not JSON</html>")
1934 );
1935 }
1936
1937 #[test]
1938 fn a_clean_stream_reports_no_parse_failures() {
1939 let (_, term) = run(
1940 Agent::Claude,
1941 &[r#"{"type":"result","result":"ok","session_id":"s"}"#],
1942 );
1943 assert_eq!(term.unparsed, 0);
1944 assert!(term.first_unparsed.is_none());
1945 }
1946
1947 #[test]
1950 fn tool_result_blocks_that_are_not_text_are_kept_not_dropped() {
1951 let (events, _) = run(
1952 Agent::Claude,
1953 &[
1954 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"}}]}]}}"#,
1955 ],
1956 );
1957 let output = events
1958 .iter()
1959 .find_map(|e| match e {
1960 Event::ToolResult { output, .. } => Some(output),
1961 _ => None,
1962 })
1963 .unwrap_or_else(|| panic!("expected a tool result, got {events:?}"));
1964 assert!(output.contains("seen"));
1965 assert!(output.contains("image"), "the image block was dropped");
1966 }
1967
1968 #[test]
1969 fn garbage_lines_are_skipped_not_fatal() {
1970 let (events, term) = run(
1971 Agent::Claude,
1972 &[
1973 "Warning: something on stdout",
1974 "",
1975 r#"{"type":"result","result":"ok","session_id":"s"}"#,
1976 ],
1977 );
1978 assert!(events.iter().all(|e| !matches!(e, Event::Text(_))));
1979 assert_eq!(term.text, "ok");
1980 }
1981
1982 #[test]
1983 fn text_format_passes_lines_through_verbatim() {
1984 let mut p = Parser::new(Agent::Copilot, Format::Text);
1985 let events: Vec<_> = ["hello", "world"].iter().flat_map(|l| p.push(l)).collect();
1986 assert_eq!(
1987 events,
1988 [Event::Text("hello".into()), Event::Text("world".into())]
1989 );
1990 assert_eq!(p.finish().text, "hello\nworld");
1991 }
1992}