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 Usage(crate::outcome::Usage),
83 ApprovalRequest(crate::approval::Approval),
90 RateLimit(RateLimit),
92}
93
94pub const MAX_CAPTURE: usize = 1024 * 1024;
101
102pub const MAX_LINE: usize = 512 * 1024;
109
110pub const MAX_EVENT_BYTES: usize = 64 * 1024;
121
122pub const TRUNCATION_MARK: &str = "…(truncated)";
125
126pub const MAX_IDENTIFIER_BYTES: usize = 4 * 1024;
138
139pub(crate) const MAX_PENDING_TOOL_BYTES: usize = 256 * 1024;
145
146pub(crate) const MAX_PENDING_TOOLS: usize = 1024;
151
152pub(crate) fn append_capped(buf: &mut String, line: &str) -> bool {
159 let remaining = MAX_CAPTURE.saturating_sub(buf.len());
160 if remaining == 0 {
161 return false;
162 }
163 if line.len() < remaining {
165 buf.push_str(line);
166 buf.push('\n');
167 } else {
168 let mut cut = remaining - 1;
170 while cut > 0 && !line.is_char_boundary(cut) {
171 cut -= 1;
172 }
173 buf.push_str(&line[..cut]);
174 buf.push('\n');
175 }
176 true
177}
178
179fn usable_identifier(value: &str) -> bool {
184 value.len() <= MAX_IDENTIFIER_BYTES
185}
186
187fn accept_identifier(value: Option<String>) -> Option<String> {
189 value.filter(|v| usable_identifier(v))
190}
191
192fn bound_text(text: String) -> String {
194 if text.len() <= MAX_EVENT_BYTES {
195 return text;
196 }
197 let mut cut = MAX_EVENT_BYTES - TRUNCATION_MARK.len();
198 while cut > 0 && !text.is_char_boundary(cut) {
199 cut -= 1;
200 }
201 let mut out = text[..cut].to_string();
202 out.push_str(TRUNCATION_MARK);
203 out
204}
205
206fn bound_value(value: Value) -> Value {
212 let size = value.to_string().len();
213 if size <= MAX_EVENT_BYTES {
214 return value;
215 }
216 serde_json::json!({
217 "truncated": true,
218 "original_bytes": size,
219 "note": "arguments exceeded MAX_EVENT_BYTES and were dropped rather than \
220 truncated, which would have produced invalid JSON",
221 })
222}
223
224fn enforce_bounds(event: Event) -> Event {
231 match event {
232 Event::Text(text) => Event::Text(bound_text(text)),
233 Event::Thinking(text) => Event::Thinking(bound_text(text)),
234 Event::ToolCall { id, name, input } => Event::ToolCall {
238 id: accept_identifier(id),
239 name: bound_identifier(name),
240 input: bound_value(input),
241 },
242 Event::ToolResult { id, ok, output } => Event::ToolResult {
243 id: accept_identifier(id),
244 ok,
245 output: bound_text(output),
246 },
247 Event::Started { session, model } => Event::Started {
251 session,
252 model: model.map(bound_identifier),
253 },
254 Event::Usage(usage) => Event::Usage(usage),
256 Event::ApprovalRequest(approval) => {
257 Event::ApprovalRequest(crate::approval::Approval {
258 id: approval.id,
262 tool: bound_identifier(approval.tool),
263 input: bound_value(approval.input),
264 })
265 }
266 Event::RateLimit(limit) => Event::RateLimit(RateLimit {
267 status: bound_identifier(limit.status),
268 window: limit.window.map(bound_identifier),
269 resets_at: limit.resets_at,
270 overage_status: limit.overage_status.map(bound_identifier),
271 is_using_overage: limit.is_using_overage,
272 }),
273 }
274}
275
276fn bound_identifier(text: String) -> String {
281 if text.len() <= MAX_IDENTIFIER_BYTES {
282 return text;
283 }
284 let mut cut = MAX_IDENTIFIER_BYTES - TRUNCATION_MARK.len();
285 while cut > 0 && !text.is_char_boundary(cut) {
286 cut -= 1;
287 }
288 let mut out = text[..cut].to_string();
289 out.push_str(TRUNCATION_MARK);
290 out
291}
292
293#[derive(Debug, Clone, Default, PartialEq)]
295pub struct Terminal {
296 pub session: Option<String>,
298 pub model: Option<String>,
305 pub text: String,
307 pub usage: Usage,
309 pub stop: Stop,
311 pub rate_limit: Option<RateLimit>,
313 pub unparsed: usize,
319 pub first_unparsed: Option<String>,
321 pub structured: Option<Value>,
323 pub error_status: Option<u16>,
326 pub error_message: Option<String>,
330}
331
332fn unwrap_error_body(message: &str) -> (Option<u16>, String) {
340 let Ok(body) = serde_json::from_str::<Value>(message) else {
341 return (None, message.to_string());
342 };
343 let status = body
344 .get("status")
345 .and_then(Value::as_u64)
346 .and_then(|s| u16::try_from(s).ok());
347 let inner = body
348 .get("error")
349 .and_then(|e| e.get("message"))
350 .and_then(Value::as_str)
351 .map(str::to_string);
352 (status, inner.unwrap_or_else(|| message.to_string()))
353}
354
355#[derive(Debug)]
357pub(crate) struct Parser {
358 agent: Agent,
359 format: Format,
360 term: Terminal,
361 tools: HashMap<String, String>,
363 tool_bytes: usize,
366 seen: Seen,
368 latest_context: Option<u64>,
374}
375
376#[derive(Debug, Default)]
382#[expect(
383 clippy::struct_excessive_bools,
384 reason = "four independent stream milestones; naming each beats packing them"
385)]
386struct Seen {
387 started: bool,
389 structured: bool,
392 terminal: bool,
394 usage_of: Option<String>,
401 deltas: bool,
408}
409
410impl Parser {
411 #[must_use]
413 pub fn new(agent: Agent, format: Format) -> Self {
414 Self {
415 agent,
416 format,
417 term: Terminal::default(),
418 tools: HashMap::new(),
419 tool_bytes: 0,
420 seen: Seen::default(),
421 latest_context: None,
422 }
423 }
424
425 pub fn push(&mut self, line: &str) -> Vec<Event> {
433 let line = line.trim();
434 if line.is_empty() {
435 return Vec::new();
436 }
437 if self.format == Format::Text {
440 append_capped(&mut self.term.text, line);
441 return vec![enforce_bounds(Event::Text(line.to_string()))];
442 }
443 let Ok(value) = serde_json::from_str::<Value>(line) else {
444 self.term.unparsed += 1;
445 if self.term.first_unparsed.is_none() {
446 let mut cut = line.len().min(512);
450 while cut > 0 && !line.is_char_boundary(cut) {
451 cut -= 1;
452 }
453 self.term.first_unparsed = Some(line[..cut].to_string());
454 }
455 return Vec::new();
456 };
457 if let Some(ty) = value.get("type").and_then(Value::as_str)
460 && self.recognizes(ty)
461 {
462 self.seen.structured = true;
463 }
464 let mut out = match self.agent {
465 Agent::Claude => self.claude(&value),
466 Agent::Codex => self.codex(&value),
467 Agent::Copilot => self.copilot(&value),
468 };
469 out = out.into_iter().map(enforce_bounds).collect();
472
473 if !self.seen.started {
476 if let Some(session) = self.term.session.clone() {
477 self.seen.started = true;
478 let model = model_of(&value);
479 self.term.model.clone_from(&model);
480 out.insert(0, Event::Started { session, model });
481 }
482 }
483 out
484 }
485
486 fn live_usage(&mut self, v: &Value) -> Vec<Event> {
496 let Some(message) = v.get("message") else {
497 return Vec::new();
498 };
499 let Some(usage) = message.get("usage") else {
500 return Vec::new();
501 };
502 let get = |key: &str| usage.get(key).and_then(Value::as_u64);
503 let (input, read, write) = (
504 get("input_tokens"),
505 get("cache_read_input_tokens"),
506 get("cache_creation_input_tokens"),
507 );
508 if input.is_none() && read.is_none() && write.is_none() {
509 return Vec::new();
510 }
511 let prompt = input.unwrap_or(0) + read.unwrap_or(0) + write.unwrap_or(0);
512 self.latest_context = Some(prompt);
526
527 let Some(id) = message.get("id").and_then(Value::as_str) else {
530 return Vec::new();
531 };
532 if self.seen.usage_of.as_deref() == Some(id) {
533 return Vec::new();
534 }
535 self.seen.usage_of = Some(id.to_string());
536 vec![Event::Usage(Usage {
537 input_tokens: input,
538 cache_read_tokens: read,
539 cache_write_tokens: write,
540 context_tokens: Some(prompt),
541 output_tokens: None,
544 ..Usage::default()
545 })]
546 }
547
548 pub(crate) fn saw_terminal(&self) -> bool {
555 self.seen.terminal
556 }
557
558 fn recognizes(&self, ty: &str) -> bool {
560 match self.agent {
561 Agent::Claude => matches!(
562 ty,
563 "system" | "assistant" | "user" | "result" | "rate_limit_event" | "control_request"
564 ),
565 Agent::Codex => {
566 ty.starts_with("thread.") || ty.starts_with("turn.") || ty.starts_with("item.")
567 }
568 Agent::Copilot => {
569 ty == "result"
570 || ty.starts_with("assistant.")
571 || ty.starts_with("tool.")
572 || ty.starts_with("session.")
573 }
574 }
575 }
576
577 fn remember_tool(&mut self, id: &str, name: &str) {
580 if !usable_identifier(id) {
583 return;
584 }
585 let name = bound_identifier(name.to_string());
586 let cost = id.len() + name.len();
587 if self.tools.len() >= MAX_PENDING_TOOLS
591 || self.tool_bytes.saturating_add(cost) > MAX_PENDING_TOOL_BYTES
592 {
593 return;
594 }
595 self.tool_bytes += cost;
596 if let Some(previous) = self.tools.insert(id.to_string(), name) {
597 self.tool_bytes = self.tool_bytes.saturating_sub(id.len() + previous.len());
599 }
600 }
601
602 fn forget_tool(&mut self, id: &str) {
604 if let Some(name) = self.tools.remove(id) {
605 self.tool_bytes = self.tool_bytes.saturating_sub(id.len() + name.len());
606 }
607 }
608
609 pub(crate) fn saw_structured_record(&self) -> bool {
614 self.seen.structured
615 }
616
617 pub(crate) fn saw_terminal_record(&self) -> bool {
620 self.seen.terminal
621 }
622
623 #[must_use]
625 pub fn finish(mut self) -> Terminal {
626 if self.format == Format::Text {
627 self.term.text = self.term.text.trim_end().to_string();
628 }
629 self.term
630 }
631
632 fn claude(&mut self, v: &Value) -> Vec<Event> {
638 let ty = v.get("type").and_then(Value::as_str).unwrap_or_default();
639 if let Some(id) = v.get("session_id").and_then(Value::as_str)
642 && usable_identifier(id)
643 {
644 self.term.session.get_or_insert_with(|| id.to_string());
645 }
646 match ty {
647 "rate_limit_event" => {
648 let limit = claude_rate_limit(v.get("rate_limit_info"));
649 self.term.rate_limit.clone_from(&limit);
650 limit.into_iter().map(Event::RateLimit).collect()
651 }
652 "stream_event" => self.claude_delta(v),
654 "control_request" => {
662 let Some(request) = v.get("request") else {
663 return Vec::new();
664 };
665 if request.get("subtype").and_then(Value::as_str) != Some("can_use_tool") {
666 return Vec::new();
667 }
668 let Some(id) = v.get("request_id").and_then(Value::as_str) else {
669 return Vec::new();
673 };
674 if !usable_identifier(id) {
675 return Vec::new();
676 }
677 vec![Event::ApprovalRequest(crate::approval::Approval {
678 id: id.to_string(),
679 tool: request
680 .get("tool_name")
681 .and_then(Value::as_str)
682 .unwrap_or("unknown")
683 .to_string(),
684 input: request.get("input").cloned().unwrap_or(Value::Null),
685 })]
686 }
687 "assistant" | "user" => self.content_blocks(v),
688 "result" => {
689 self.seen.terminal = true;
690 if let Some(text) = v.get("result").and_then(Value::as_str) {
691 self.term.text = text.to_string();
692 }
693 if let Some(value) = v.get("structured_output") {
696 self.term.structured = Some(value.clone());
697 }
698 self.term.usage = claude_usage(v, self.term.model.as_deref());
699 if self.latest_context.is_some() {
703 self.term.usage.context_tokens = self.latest_context;
704 }
705 self.term.stop = if v.get("is_error").and_then(Value::as_bool) == Some(true) {
708 self.term.error_status = v
709 .get("api_error_status")
710 .and_then(Value::as_u64)
711 .and_then(|s| u16::try_from(s).ok());
712 Stop::Error
713 } else {
714 stop_from(v.get("stop_reason"))
715 };
716 Vec::new()
717 }
718 _ => Vec::new(),
719 }
720 }
721
722 fn claude_delta(&mut self, v: &Value) -> Vec<Event> {
729 let Some(event) = v.get("event") else {
730 return Vec::new();
731 };
732 if event.get("type").and_then(Value::as_str) != Some("content_block_delta") {
733 return Vec::new();
734 }
735 let Some(delta) = event.get("delta") else {
736 return Vec::new();
737 };
738 self.seen.deltas = true;
741
742 match delta.get("type").and_then(Value::as_str) {
743 Some("text_delta") => delta
744 .get("text")
745 .and_then(Value::as_str)
746 .filter(|text| !text.is_empty())
747 .map(|text| Event::Text(text.to_string()))
748 .into_iter()
749 .collect(),
750 Some("thinking_delta") => delta
751 .get("thinking")
752 .and_then(Value::as_str)
753 .filter(|text| !text.is_empty())
754 .map(|text| Event::Thinking(text.to_string()))
755 .into_iter()
756 .collect(),
757 _ => Vec::new(),
761 }
762 }
763
764 fn content_blocks(&mut self, v: &Value) -> Vec<Event> {
767 let mut out = self.live_usage(v);
768 let blocks = v
769 .get("message")
770 .and_then(|m| m.get("content"))
771 .and_then(Value::as_array);
772 let Some(blocks) = blocks else {
773 return out;
774 };
775 for block in blocks {
776 let ty = block
777 .get("type")
778 .and_then(Value::as_str)
779 .unwrap_or_default();
780 match ty {
781 "text" if !self.seen.deltas => {
786 if let Some(t) = block.get("text").and_then(Value::as_str) {
787 out.push(Event::Text(t.to_string()));
788 }
789 }
790 "thinking" if !self.seen.deltas => {
791 if let Some(t) = block.get("thinking").and_then(Value::as_str) {
792 out.push(Event::Thinking(t.to_string()));
793 }
794 }
795 "tool_use" => {
796 let name = block
797 .get("name")
798 .and_then(Value::as_str)
799 .unwrap_or("tool")
800 .to_string();
801 let id = block.get("id").and_then(Value::as_str).map(str::to_string);
802 if let Some(id) = &id {
803 self.remember_tool(id, &name);
804 }
805 out.push(Event::ToolCall {
806 id,
807 name,
808 input: block.get("input").cloned().unwrap_or(Value::Null),
809 });
810 }
811 "tool_result" => out.push(Event::ToolResult {
812 id: block
813 .get("tool_use_id")
814 .and_then(Value::as_str)
815 .inspect(|id| {
816 self.forget_tool(id);
818 })
819 .map(str::to_string),
820 ok: block
821 .get("is_error")
822 .and_then(Value::as_bool)
823 .map(|is_error| !is_error),
824 output: flatten_text(block.get("content")),
825 }),
826 _ => {}
827 }
828 }
829 out
830 }
831
832 fn codex(&mut self, v: &Value) -> Vec<Event> {
840 let ty = v.get("type").and_then(Value::as_str).unwrap_or_default();
841 if let Some(id) = v.get("thread_id").and_then(Value::as_str)
842 && usable_identifier(id)
843 {
844 self.term.session.get_or_insert_with(|| id.to_string());
845 }
846 match ty {
847 "turn.completed" => {
848 self.seen.terminal = true;
849 self.term.usage = codex_usage(v.get("usage"));
850 Vec::new()
851 }
852 "turn.failed" => {
853 self.seen.terminal = true;
854 self.term.stop = Stop::Error;
855 if let Some(message) = v
856 .get("error")
857 .and_then(|e| e.get("message"))
858 .and_then(Value::as_str)
859 {
860 let (status, message) = unwrap_error_body(message);
861 self.term.error_status = status;
862 self.term.error_message = Some(bound_text(message));
863 }
864 Vec::new()
865 }
866 "item.started" | "item.updated" | "item.completed" => {
867 let Some(item) = v.get("item") else {
868 return Vec::new();
869 };
870 let item_ty = item.get("type").and_then(Value::as_str).unwrap_or_default();
871 let id = item.get("id").and_then(Value::as_str).map(str::to_string);
872 let done = ty == "item.completed";
873
874 let name = tool_name(item, item_ty);
878 let first = id
879 .as_ref()
880 .is_none_or(|id| self.tools.insert(id.clone(), name.clone()).is_none());
881
882 match item_ty {
883 "agent_message" => {
886 if !done {
887 return Vec::new();
888 }
889 let text = item.get("text").and_then(Value::as_str).unwrap_or_default();
890 self.term.text = text.to_string();
891 vec![Event::Text(text.to_string())]
892 }
893 "reasoning" if done => item
894 .get("text")
895 .and_then(Value::as_str)
896 .map(|t| Event::Thinking(t.to_string()))
897 .into_iter()
898 .collect(),
899 "command_execution" | "mcp_tool_call" | "file_change" | "web_search" => {
900 let mut out = Vec::new();
901 if first {
902 out.push(Event::ToolCall {
903 id: id.clone(),
904 name,
905 input: codex_tool_input(item, item_ty),
906 });
907 }
908 if done {
911 if let Some(id) = &id {
912 self.forget_tool(id);
913 }
914 out.push(Event::ToolResult {
915 id,
916 ok: item
917 .get("exit_code")
918 .and_then(Value::as_i64)
919 .map(|code| code == 0),
920 output: item
921 .get("aggregated_output")
922 .and_then(Value::as_str)
923 .unwrap_or_default()
924 .to_string(),
925 });
926 }
927 out
928 }
929 _ => Vec::new(),
930 }
931 }
932 _ => Vec::new(),
933 }
934 }
935
936 fn copilot(&mut self, v: &Value) -> Vec<Event> {
943 let ty = v.get("type").and_then(Value::as_str).unwrap_or_default();
944 let data = v.get("data");
945 let field = |key: &str| -> Option<String> {
946 data.and_then(|d| d.get(key))
947 .and_then(Value::as_str)
948 .map(str::to_string)
949 };
950 match ty {
951 "assistant.message_delta" => field("deltaContent")
953 .filter(|t| !t.is_empty())
954 .map(Event::Text)
955 .into_iter()
956 .collect(),
957 "assistant.message" => {
960 if let Some(content) = field("content") {
961 self.term.text = content;
962 }
963 Vec::new()
964 }
965 "assistant.reasoning" => field("content")
966 .filter(|t| !t.is_empty())
967 .map(Event::Thinking)
968 .into_iter()
969 .collect(),
970 "tool.execution_start" => {
971 let id = field("toolCallId");
972 let name = field("toolName").unwrap_or_else(|| "tool".into());
973 if let Some(id) = &id {
974 self.remember_tool(id, &name);
975 }
976 vec![Event::ToolCall {
977 id,
978 name,
979 input: data
980 .and_then(|d| d.get("arguments"))
981 .cloned()
982 .unwrap_or(Value::Null),
983 }]
984 }
985 "tool.execution_complete" => vec![Event::ToolResult {
986 id: field("toolCallId").inspect(|id| {
987 self.forget_tool(id);
988 }),
989 ok: data.and_then(|d| d.get("success")).and_then(Value::as_bool),
990 output: data
991 .and_then(|d| d.get("result"))
992 .and_then(|r| r.get("content"))
993 .and_then(Value::as_str)
994 .unwrap_or_default()
995 .to_string(),
996 }],
997 "session.usage_checkpoint" => {
1002 if let Some(data) = v.get("data") {
1003 self.term.usage.ai_credits_nano =
1004 data.get("totalNanoAiu").and_then(Value::as_u64);
1005 if let Some(premium) = data.get("totalPremiumRequests").and_then(Value::as_u64)
1006 {
1007 self.term.usage.premium_requests = Some(premium);
1008 }
1009 }
1010 Vec::new()
1011 }
1012 "result" => {
1014 self.seen.terminal = true;
1015 if let Some(id) = v.get("sessionId").and_then(Value::as_str)
1016 && usable_identifier(id)
1017 {
1018 self.term.session = Some(id.to_string());
1019 }
1020 if let Some(usage) = v.get("usage") {
1021 self.term.usage.premium_requests =
1022 usage.get("premiumRequests").and_then(Value::as_u64);
1023 self.term.usage.duration_ms =
1024 usage.get("sessionDurationMs").and_then(Value::as_u64);
1025 self.term.usage.api_duration_ms =
1026 usage.get("totalApiDurationMs").and_then(Value::as_u64);
1027 }
1028 if let Some(code) = v.get("exitCode").and_then(Value::as_i64)
1029 && code != 0
1030 {
1031 self.term.stop = Stop::Error;
1032 self.term.error_message = Some(format!("copilot exited with code {code}"));
1035 }
1036 Vec::new()
1037 }
1038 _ => Vec::new(),
1039 }
1040 }
1041}
1042
1043fn model_of(v: &Value) -> Option<String> {
1046 v.get("model")
1047 .or_else(|| v.get("data").and_then(|d| d.get("model")))
1048 .and_then(Value::as_str)
1049 .map(str::to_string)
1050}
1051
1052fn stop_from(v: Option<&Value>) -> Stop {
1054 match v.and_then(Value::as_str) {
1055 None | Some("end_turn" | "stop" | "completed") => Stop::Completed,
1056 Some(other) => Stop::Other(other.to_string()),
1057 }
1058}
1059
1060fn claude_rate_limit(v: Option<&Value>) -> Option<RateLimit> {
1062 let v = v?;
1063 Some(RateLimit {
1064 status: v.get("status").and_then(Value::as_str)?.to_string(),
1065 window: v
1066 .get("rateLimitType")
1067 .and_then(Value::as_str)
1068 .map(str::to_string),
1069 resets_at: v.get("resetsAt").and_then(Value::as_i64),
1070 overage_status: v
1071 .get("overageStatus")
1072 .and_then(Value::as_str)
1073 .map(str::to_string),
1074 is_using_overage: v.get("isUsingOverage").and_then(Value::as_bool),
1075 })
1076}
1077
1078fn claude_usage(v: &Value, model: Option<&str>) -> Usage {
1080 let u = v.get("usage");
1081 let get = |key: &str| u.and_then(|u| u.get(key)).and_then(Value::as_u64);
1082 let (input, read, write) = (
1083 get("input_tokens"),
1084 get("cache_read_input_tokens"),
1085 get("cache_creation_input_tokens"),
1086 );
1087 let per_model = v
1095 .get("modelUsage")
1096 .and_then(Value::as_object)
1097 .and_then(
1098 |models| match (model.and_then(|m| models.get(m)), models.len()) {
1099 (Some(entry), _) => Some(entry),
1100 (None, 1) => models.values().next(),
1102 (None, _) => None,
1105 },
1106 );
1107 let of_model = |key: &str| per_model.and_then(|m| m.get(key)).and_then(Value::as_u64);
1108 Usage {
1109 input_tokens: input,
1110 output_tokens: get("output_tokens"),
1111 cache_read_tokens: read,
1112 cache_write_tokens: write,
1113 context_tokens: (input.is_some() || read.is_some() || write.is_some())
1117 .then(|| input.unwrap_or(0) + read.unwrap_or(0) + write.unwrap_or(0)),
1118 context_window: of_model("contextWindow"),
1119 max_output_tokens: of_model("maxOutputTokens"),
1120 reasoning_tokens: None,
1121 cost_usd: v.get("total_cost_usd").and_then(Value::as_f64),
1122 premium_requests: None,
1123 ai_credits_nano: None,
1124 duration_ms: v.get("duration_ms").and_then(Value::as_u64),
1125 api_duration_ms: v.get("duration_api_ms").and_then(Value::as_u64),
1126 }
1127}
1128
1129fn codex_usage(v: Option<&Value>) -> Usage {
1132 let get = |key: &str| v.and_then(|u| u.get(key)).and_then(Value::as_u64);
1133 let (prompt, cached) = (get("input_tokens"), get("cached_input_tokens"));
1134 Usage {
1135 input_tokens: match (prompt, cached) {
1143 (Some(prompt), Some(cached)) => Some(prompt.saturating_sub(cached)),
1144 (prompt, _) => prompt,
1145 },
1146 output_tokens: get("output_tokens"),
1147 cache_read_tokens: cached,
1148 cache_write_tokens: get("cache_write_input_tokens"),
1149 context_tokens: prompt,
1150 context_window: None,
1151 max_output_tokens: None,
1152 reasoning_tokens: get("reasoning_output_tokens"),
1153 cost_usd: None,
1154 premium_requests: None,
1155 ai_credits_nano: None,
1156 duration_ms: None,
1157 api_duration_ms: None,
1158 }
1159}
1160
1161fn tool_name(item: &Value, item_ty: &str) -> String {
1164 item.get("tool")
1165 .and_then(Value::as_str)
1166 .unwrap_or(item_ty)
1167 .to_string()
1168}
1169
1170fn codex_tool_input(item: &Value, item_ty: &str) -> Value {
1172 match item_ty {
1173 "command_execution" => serde_json::json!({ "command": item.get("command") }),
1174 "mcp_tool_call" => item.get("arguments").cloned().unwrap_or(Value::Null),
1175 _ => item.clone(),
1178 }
1179}
1180
1181fn flatten_text(v: Option<&Value>) -> String {
1189 match v {
1190 Some(Value::String(s)) => s.clone(),
1191 Some(Value::Array(blocks)) => blocks
1192 .iter()
1193 .map(|b| match b.get("text").and_then(Value::as_str) {
1194 Some(text) => text.to_string(),
1195 None => b.to_string(),
1196 })
1197 .collect::<Vec<_>>()
1198 .join("\n"),
1199 Some(other) => other.to_string(),
1200 None => String::new(),
1201 }
1202}
1203
1204#[cfg(test)]
1205mod tests {
1206 use super::*;
1207
1208 fn run(agent: Agent, lines: &[&str]) -> (Vec<Event>, Terminal) {
1210 let mut p = Parser::new(agent, Format::Stream);
1211 let events = lines.iter().flat_map(|l| p.push(l)).collect();
1212 (events, p.finish())
1213 }
1214
1215 #[test]
1218 fn claude_stream_yields_start_thinking_text_and_terminal_facts() {
1219 let (events, term) = run(
1220 Agent::Claude,
1221 &[
1222 r#"{"type":"system","subtype":"init","session_id":"sess-a","model":"claude-haiku-4-5"}"#,
1223 r#"{"type":"assistant","session_id":"sess-a","message":{"content":[{"type":"thinking","thinking":"brief"}]}}"#,
1224 r#"{"type":"assistant","session_id":"sess-a","message":{"content":[{"type":"text","text":"pong"}]}}"#,
1225 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}}"#,
1226 ],
1227 );
1228 assert_eq!(
1229 events[0],
1230 Event::Started {
1231 session: "sess-a".into(),
1232 model: Some("claude-haiku-4-5".into())
1233 }
1234 );
1235 assert_eq!(events[1], Event::Thinking("brief".into()));
1236 assert_eq!(events[2], Event::Text("pong".into()));
1237 assert_eq!(term.session.as_deref(), Some("sess-a"));
1238 assert_eq!(term.text, "pong");
1239 assert_eq!(term.stop, Stop::Completed);
1240 assert_eq!(term.usage.input_tokens, Some(10));
1241 assert_eq!(term.usage.cache_read_tokens, Some(18764));
1242 assert_eq!(term.usage.cache_write_tokens, Some(7322));
1243 assert_eq!(term.usage.cost_usd, Some(0.017));
1244 }
1245
1246 #[test]
1254 fn the_window_binds_to_the_runs_model_not_the_haiku_helper() {
1255 let (_, term) = run(
1256 Agent::Claude,
1257 &[
1258 r#"{"type":"system","subtype":"init","session_id":"sess-1m","model":"claude-sonnet-5[1m]"}"#,
1259 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}}}"#,
1260 ],
1261 );
1262 assert_eq!(term.model.as_deref(), Some("claude-sonnet-5[1m]"));
1263 assert_eq!(
1264 term.usage.context_window,
1265 Some(1_000_000),
1266 "the helper's 200k window must not shadow the real one"
1267 );
1268 assert_eq!(term.usage.max_output_tokens, Some(64_000));
1269 assert_eq!(term.usage.context_tokens, Some(2 + 27_128 + 9_825));
1271 }
1272
1273 #[test]
1279 fn context_is_the_last_requests_prompt_not_the_turns_sum() {
1280 let (_, term) = run(
1281 Agent::Claude,
1282 &[
1283 r#"{"type":"system","subtype":"init","session_id":"s","model":"claude-sonnet-5"}"#,
1284 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"}}]}}"#,
1285 r#"{"type":"user","session_id":"s","message":{"content":[{"type":"tool_result","tool_use_id":"t1","content":"ok"}]}}"#,
1286 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"}]}}"#,
1287 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}}"#,
1288 ],
1289 );
1290 assert_eq!(
1291 term.usage.context_tokens,
1292 Some(6 + 102_000 + 500),
1293 "the last request's prompt is the context; the turn sum (204,510) is not"
1294 );
1295 assert_eq!(term.usage.cache_read_tokens, Some(202_000));
1297 }
1298
1299 #[test]
1302 fn the_terminal_sum_remains_the_fallback_context() {
1303 let (_, term) = run(
1304 Agent::Claude,
1305 &[
1306 r#"{"type":"system","subtype":"init","session_id":"s","model":"claude-sonnet-5"}"#,
1307 r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"text","text":"ok"}]}}"#,
1308 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}}"#,
1309 ],
1310 );
1311 assert_eq!(term.usage.context_tokens, Some(10 + 1000));
1312 }
1313
1314 #[test]
1317 fn an_unmatchable_window_is_absent_not_guessed() {
1318 let (_, term) = run(
1319 Agent::Claude,
1320 &[
1321 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}}}"#,
1323 ],
1324 );
1325 assert_eq!(term.usage.context_window, None);
1326 let (_, single) = run(
1328 Agent::Claude,
1329 &[
1330 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}}}"#,
1331 ],
1332 );
1333 assert_eq!(single.usage.context_window, Some(200_000));
1334 }
1335
1336 #[test]
1337 fn claude_token_deltas_stream_without_duplicating_the_finished_message() {
1338 let (events, _) = run(
1339 Agent::Claude,
1340 &[
1341 r#"{"type":"system","subtype":"init","session_id":"s"}"#,
1342 r#"{"type":"stream_event","session_id":"s","event":{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}}"#,
1343 r#"{"type":"stream_event","session_id":"s","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"po"}}}"#,
1344 r#"{"type":"stream_event","session_id":"s","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"ng"}}}"#,
1345 r#"{"type":"stream_event","session_id":"s","event":{"type":"content_block_stop","index":0}}"#,
1346 r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"text","text":"pong"}]}}"#,
1348 r#"{"type":"result","subtype":"success","is_error":false,"result":"pong","session_id":"s"}"#,
1349 ],
1350 );
1351 let texts: Vec<_> = events
1352 .iter()
1353 .filter_map(|e| match e {
1354 Event::Text(t) => Some(t.as_str()),
1355 _ => None,
1356 })
1357 .collect();
1358 assert_eq!(texts, ["po", "ng"], "the finished message must not repeat");
1359 }
1360
1361 #[test]
1363 fn claude_thinking_deltas_stream_without_duplication() {
1364 let (events, _) = run(
1365 Agent::Claude,
1366 &[
1367 r#"{"type":"stream_event","session_id":"s","event":{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"weighing"}}}"#,
1368 r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"thinking","thinking":"weighing"}]}}"#,
1369 ],
1370 );
1371 let thoughts: Vec<_> = events
1372 .iter()
1373 .filter_map(|e| match e {
1374 Event::Thinking(t) => Some(t.as_str()),
1375 _ => None,
1376 })
1377 .collect();
1378 assert_eq!(thoughts, ["weighing"]);
1379 }
1380
1381 #[test]
1384 fn a_completed_message_still_streams_when_no_deltas_arrived() {
1385 let (events, _) = run(
1386 Agent::Claude,
1387 &[
1388 r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"text","text":"pong"}]}}"#,
1389 ],
1390 );
1391 assert!(events.contains(&Event::Text("pong".into())), "{events:?}");
1392 }
1393
1394 #[test]
1397 fn tool_calls_survive_delta_suppression() {
1398 let (events, _) = run(
1399 Agent::Claude,
1400 &[
1401 r#"{"type":"stream_event","session_id":"s","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hi"}}}"#,
1402 r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"tool_use","id":"t1","name":"Bash","input":{"command":"ls"}}]}}"#,
1403 ],
1404 );
1405 assert!(
1406 events.iter().any(|e| matches!(e, Event::ToolCall { .. })),
1407 "suppression must apply to text only: {events:?}"
1408 );
1409 }
1410
1411 #[test]
1412 fn claude_started_fires_only_once() {
1413 let (events, _) = run(
1414 Agent::Claude,
1415 &[
1416 r#"{"type":"system","subtype":"init","session_id":"s"}"#,
1417 r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"text","text":"a"}]}}"#,
1418 r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"text","text":"b"}]}}"#,
1419 ],
1420 );
1421 assert_eq!(
1422 events
1423 .iter()
1424 .filter(|e| matches!(e, Event::Started { .. }))
1425 .count(),
1426 1
1427 );
1428 }
1429
1430 #[test]
1435 fn a_model_call_reports_its_usage_once_however_many_blocks_it_has() {
1436 let (events, _) = run(
1437 Agent::Claude,
1438 &[
1439 r#"{"type":"assistant","session_id":"s","message":{"id":"msg_a","content":[{"type":"thinking","thinking":"..."}],"usage":{"input_tokens":10,"cache_read_input_tokens":20180,"cache_creation_input_tokens":7574,"output_tokens":4}}}"#,
1440 r#"{"type":"assistant","session_id":"s","message":{"id":"msg_a","content":[{"type":"tool_use","id":"t1","name":"Bash","input":{}}],"usage":{"input_tokens":10,"cache_read_input_tokens":20180,"cache_creation_input_tokens":7574,"output_tokens":4}}}"#,
1441 r#"{"type":"assistant","session_id":"s","message":{"id":"msg_b","content":[{"type":"text","text":"done"}],"usage":{"input_tokens":8,"cache_read_input_tokens":30427,"cache_creation_input_tokens":0,"output_tokens":3}}}"#,
1442 ],
1443 );
1444 let usage: Vec<&Usage> = events
1445 .iter()
1446 .filter_map(|e| match e {
1447 Event::Usage(u) => Some(u),
1448 _ => None,
1449 })
1450 .collect();
1451 assert_eq!(usage.len(), 2, "two model calls, three records: {events:?}");
1452 assert_eq!(usage[0].input_tokens, Some(10));
1453 assert_eq!(usage[0].context_tokens, Some(10 + 20180 + 7574));
1454 assert_eq!(usage[1].context_tokens, Some(8 + 30427));
1455 }
1456
1457 #[test]
1462 fn context_is_still_tracked_when_a_record_carries_no_id() {
1463 let (events, term) = run(
1464 Agent::Claude,
1465 &[
1466 r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"text","text":"hi"}],"usage":{"input_tokens":8,"cache_read_input_tokens":30427,"cache_creation_input_tokens":0}}}"#,
1467 r#"{"type":"result","subtype":"success","is_error":false,"result":"hi","session_id":"s","usage":{"input_tokens":99,"cache_read_input_tokens":99}}"#,
1468 ],
1469 );
1470 assert!(
1471 !events.iter().any(|e| matches!(e, Event::Usage(_))),
1472 "no id means no way to deduplicate, so nothing is reported"
1473 );
1474 assert_eq!(
1475 term.usage.context_tokens,
1476 Some(8 + 30427),
1477 "the per-request figure must still outrank the terminal sum"
1478 );
1479 }
1480
1481 #[test]
1485 fn a_live_snapshot_withholds_the_output_count() {
1486 let (events, _) = run(
1487 Agent::Claude,
1488 &[
1489 r#"{"type":"assistant","session_id":"s","message":{"id":"m","content":[{"type":"text","text":"hi"}],"usage":{"input_tokens":8,"output_tokens":1}}}"#,
1490 ],
1491 );
1492 let Some(Event::Usage(usage)) = events.iter().find(|e| matches!(e, Event::Usage(_))) else {
1493 panic!("expected a usage event: {events:?}")
1494 };
1495 assert_eq!(usage.output_tokens, None, "a partial count is not reported");
1496 assert_eq!(usage.input_tokens, Some(8), "the exact figures still are");
1497 }
1498
1499 #[test]
1503 fn live_snapshots_accumulate_to_the_terminal_totals() {
1504 let calls = [
1505 (10u64, 20180u64, 7574u64),
1506 (8, 0, 30427),
1507 (8, 30427, 1859),
1508 (8, 32286, 115),
1509 ];
1510 let mut session = Usage::default();
1511 for (input, read, write) in calls {
1512 session.accumulate(&Usage {
1513 input_tokens: Some(input),
1514 cache_read_tokens: Some(read),
1515 cache_write_tokens: Some(write),
1516 context_tokens: Some(input + read + write),
1517 ..Usage::default()
1518 });
1519 }
1520 assert_eq!(session.input_tokens, Some(34));
1522 assert_eq!(
1523 session.context_tokens,
1524 Some(8 + 32286 + 115),
1525 "context takes the latest, being cumulative already"
1526 );
1527 }
1528
1529 #[test]
1530 fn claude_pairs_tool_use_with_its_result() {
1531 let (events, _) = run(
1532 Agent::Claude,
1533 &[
1534 r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"tool_use","id":"toolu_1","name":"Bash","input":{"command":"ls"}}]}}"#,
1535 r#"{"type":"user","session_id":"s","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"a.txt"}]}}"#,
1536 ],
1537 );
1538 let call = events
1539 .iter()
1540 .find(|e| matches!(e, Event::ToolCall { .. }))
1541 .unwrap();
1542 let Event::ToolCall { id, name, input } = call else {
1543 unreachable!()
1544 };
1545 assert_eq!(id.as_deref(), Some("toolu_1"));
1546 assert_eq!(name, "Bash");
1547 assert_eq!(input["command"], "ls");
1548 assert!(events.contains(&Event::ToolResult {
1549 id: Some("toolu_1".into()),
1550 ok: None,
1551 output: "a.txt".into(),
1552 }));
1553 }
1554
1555 #[test]
1560 fn an_approval_request_carries_the_tool_and_its_arguments() {
1561 let (events, _) = run(
1562 Agent::Claude,
1563 &[
1564 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"}}}"#,
1565 ],
1566 );
1567 let [Event::ApprovalRequest(approval)] = &events[..] else {
1568 panic!("expected one approval request, got {events:?}")
1569 };
1570 assert_eq!(approval.id, "req-7");
1571 assert_eq!(approval.tool, "Bash");
1572 assert_eq!(approval.input["command"], "touch created-by-probe.txt");
1573 }
1574
1575 #[test]
1579 fn an_unanswerable_approval_request_is_dropped() {
1580 for line in [
1581 r#"{"type":"control_request","request":{"subtype":"can_use_tool","tool_name":"Bash","input":{}}}"#,
1583 &format!(
1585 r#"{{"type":"control_request","request_id":"{}","request":{{"subtype":"can_use_tool","tool_name":"Bash","input":{{}}}}}}"#,
1586 "x".repeat(MAX_IDENTIFIER_BYTES + 1)
1587 ),
1588 ] {
1589 let (events, _) = run(Agent::Claude, &[line]);
1590 assert!(
1591 events.is_empty(),
1592 "an unanswerable request must not reach a consumer: {events:?}"
1593 );
1594 }
1595 }
1596
1597 #[test]
1599 fn a_control_request_that_is_not_an_approval_is_ignored() {
1600 let (events, _) = run(
1601 Agent::Claude,
1602 &[r#"{"type":"control_request","request_id":"r","request":{"subtype":"initialize"}}"#],
1603 );
1604 assert!(events.is_empty(), "{events:?}");
1605 }
1606
1607 #[test]
1608 fn claude_reports_a_rate_limit_without_failing() {
1609 let (events, term) = run(
1610 Agent::Claude,
1611 &[
1612 r#"{"type":"rate_limit_event","session_id":"s","rate_limit_info":{"status":"allowed","resetsAt":1785260400,"rateLimitType":"five_hour"}}"#,
1613 ],
1614 );
1615 let limit = RateLimit {
1616 status: "allowed".into(),
1617 window: Some("five_hour".into()),
1618 resets_at: Some(1_785_260_400),
1619 overage_status: None,
1620 is_using_overage: None,
1621 };
1622 assert!(events.contains(&Event::RateLimit(limit.clone())));
1623 assert_eq!(term.rate_limit, Some(limit.clone()));
1624 assert!(
1625 !limit.is_blocking(),
1626 "an `allowed` heartbeat is not a block"
1627 );
1628 }
1629
1630 #[test]
1631 fn claude_error_result_sets_the_stop_reason() {
1632 let (_, term) = run(
1633 Agent::Claude,
1634 &[r#"{"type":"result","is_error":true,"result":"boom","session_id":"s"}"#],
1635 );
1636 assert_eq!(term.stop, Stop::Error);
1637 }
1638
1639 #[test]
1640 fn copilot_streams_deltas_and_takes_its_answer_from_the_settled_message() {
1641 let (events, term) = run(
1642 Agent::Copilot,
1643 &[
1644 r#"{"type":"assistant.message_delta","data":{"messageId":"m","deltaContent":"po"}}"#,
1645 r#"{"type":"assistant.message_delta","data":{"messageId":"m","deltaContent":"ng"}}"#,
1646 r#"{"type":"assistant.message","data":{"messageId":"m","model":"gpt-5-mini","content":"pong"}}"#,
1647 r#"{"type":"result","sessionId":"768c8e7d","exitCode":0,"usage":{"premiumRequests":0}}"#,
1648 ],
1649 );
1650 let texts: Vec<_> = events
1652 .iter()
1653 .filter_map(|e| match e {
1654 Event::Text(t) => Some(t.as_str()),
1655 _ => None,
1656 })
1657 .collect();
1658 assert_eq!(texts, ["po", "ng"]);
1659 assert_eq!(term.text, "pong", "the answer is the settled message");
1660 assert_eq!(term.session.as_deref(), Some("768c8e7d"));
1661 assert_eq!(term.usage.premium_requests, Some(0));
1662 }
1663
1664 #[test]
1665 fn copilot_brackets_a_tool_call_with_its_completion() {
1666 let (events, _) = run(
1667 Agent::Copilot,
1668 &[
1669 r#"{"type":"tool.execution_start","data":{"toolCallId":"call_1","toolName":"bash","arguments":{"command":"ls"}}}"#,
1670 r#"{"type":"tool.execution_complete","data":{"toolCallId":"call_1","success":true,"result":{"content":"a.txt"}}}"#,
1671 ],
1672 );
1673 assert!(matches!(
1674 &events[0],
1675 Event::ToolCall { id, name, .. }
1676 if id.as_deref() == Some("call_1") && name == "bash"
1677 ));
1678 assert_eq!(
1679 events[1],
1680 Event::ToolResult {
1681 id: Some("call_1".into()),
1682 ok: Some(true),
1683 output: "a.txt".into()
1684 }
1685 );
1686 }
1687
1688 #[test]
1692 fn a_codex_failed_turn_yields_the_reason_and_the_status() {
1693 let (_, term) = run(
1694 Agent::Codex,
1695 &[
1696 r#"{"type":"thread.started","thread_id":"019fad62"}"#,
1697 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.\"}}"}}"#,
1698 ],
1699 );
1700 assert_eq!(term.stop, Stop::Error);
1701 assert_eq!(term.error_status, Some(400));
1702 assert_eq!(
1703 term.error_message.as_deref(),
1704 Some(
1705 "The 'bogus-model-xyz' model is not supported when using Codex with a ChatGPT account."
1706 ),
1707 "the caller should get the sentence, not the envelope"
1708 );
1709 }
1710
1711 #[test]
1714 fn a_plain_codex_failure_message_passes_through() {
1715 let (_, term) = run(
1716 Agent::Codex,
1717 &[
1718 r#"{"type":"turn.failed","error":{"message":"stream disconnected before completion"}}"#,
1719 ],
1720 );
1721 assert_eq!(term.error_status, None);
1722 assert_eq!(
1723 term.error_message.as_deref(),
1724 Some("stream disconnected before completion")
1725 );
1726 }
1727
1728 #[test]
1729 fn codex_reads_the_thread_id_and_the_completed_message() {
1730 let (events, term) = run(
1731 Agent::Codex,
1732 &[
1733 r#"{"type":"thread.started","thread_id":"0199-xyz"}"#,
1734 r#"{"type":"item.completed","item":{"id":"i1","type":"agent_message","text":"pong"}}"#,
1735 r#"{"type":"turn.completed","usage":{"input_tokens":12,"output_tokens":3,"cached_input_tokens":9}}"#,
1736 ],
1737 );
1738 assert_eq!(
1739 events[0],
1740 Event::Started {
1741 session: "0199-xyz".into(),
1742 model: None
1743 }
1744 );
1745 assert_eq!(term.session.as_deref(), Some("0199-xyz"));
1746 assert_eq!(term.text, "pong");
1747 assert_eq!(term.usage.input_tokens, Some(3));
1752 assert_eq!(term.usage.cache_read_tokens, Some(9));
1753 assert_eq!(term.usage.context_tokens, Some(12));
1754 }
1755
1756 #[test]
1757 fn codex_command_execution_becomes_a_call_and_a_result() {
1758 let (events, _) = run(
1759 Agent::Codex,
1760 &[
1761 r#"{"type":"item.completed","item":{"id":"c1","type":"command_execution","command":"ls","exit_code":0,"aggregated_output":"a.txt"}}"#,
1762 ],
1763 );
1764 assert!(matches!(&events[0], Event::ToolCall { name, .. } if name == "command_execution"));
1765 assert_eq!(
1766 events[1],
1767 Event::ToolResult {
1768 id: Some("c1".into()),
1769 ok: Some(true),
1770 output: "a.txt".into()
1771 }
1772 );
1773 }
1774
1775 #[test]
1779 fn codex_started_then_completed_yields_one_call_and_one_result() {
1780 let (events, _) = run(
1781 Agent::Codex,
1782 &[
1783 r#"{"type":"item.started","item":{"id":"item_1","type":"command_execution","command":"/bin/zsh -lc ls","aggregated_output":"","exit_code":null,"status":"in_progress"}}"#,
1784 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"}}"#,
1785 ],
1786 );
1787 let calls = events
1788 .iter()
1789 .filter(|e| matches!(e, Event::ToolCall { .. }))
1790 .count();
1791 assert_eq!(calls, 1, "the same item must not be announced twice");
1792 let results: Vec<_> = events
1793 .iter()
1794 .filter_map(|e| match e {
1795 Event::ToolResult { output, .. } => Some(output.as_str()),
1796 _ => None,
1797 })
1798 .collect();
1799 assert_eq!(
1800 results,
1801 ["a.txt\n"],
1802 "the in-progress blank must not appear"
1803 );
1804 }
1805
1806 #[test]
1808 fn codex_last_completed_message_is_the_answer() {
1809 let (_, term) = run(
1810 Agent::Codex,
1811 &[
1812 r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"I'll list the directory."}}"#,
1813 r#"{"type":"item.completed","item":{"id":"i2","type":"agent_message","text":"DONE"}}"#,
1814 ],
1815 );
1816 assert_eq!(term.text, "DONE");
1817 }
1818
1819 #[test]
1822 fn an_enormous_tool_result_is_bounded_and_marked() {
1823 let huge = "x".repeat(MAX_EVENT_BYTES * 4);
1824 let line = serde_json::json!({
1825 "type": "user",
1826 "session_id": "s",
1827 "message": {"content": [{
1828 "type": "tool_result", "tool_use_id": "t1", "content": huge
1829 }]}
1830 })
1831 .to_string();
1832
1833 let (events, _) = run(Agent::Claude, &[&line]);
1834 let Some(Event::ToolResult { output, id, .. }) = events
1835 .iter()
1836 .find(|e| matches!(e, Event::ToolResult { .. }))
1837 .cloned()
1838 else {
1839 panic!("expected a tool result, got {events:?}")
1840 };
1841 assert!(
1842 output.len() <= MAX_EVENT_BYTES,
1843 "kept {} bytes",
1844 output.len()
1845 );
1846 assert!(
1847 output.ends_with(TRUNCATION_MARK),
1848 "truncation must be visible"
1849 );
1850 assert_eq!(id.as_deref(), Some("t1"), "the id must survive whole");
1851 }
1852
1853 #[test]
1857 fn usable_identifiers_are_never_shortened() {
1858 let id = "s".repeat(MAX_IDENTIFIER_BYTES);
1860 let line =
1861 serde_json::json!({"type": "system", "subtype": "init", "session_id": id}).to_string();
1862 let (events, term) = run(Agent::Claude, &[&line]);
1863
1864 let Some(Event::Started { session, .. }) = events.first().cloned() else {
1865 panic!("expected Started, got {events:?}")
1866 };
1867 assert_eq!(session.len(), id.len(), "the session id was shortened");
1868 assert_eq!(term.session.as_deref(), Some(id.as_str()));
1869 }
1870
1871 #[test]
1876 fn an_oversized_session_id_is_rejected_rather_than_stored() {
1877 let id = "s".repeat(MAX_IDENTIFIER_BYTES + 1);
1878 for (agent, line) in [
1879 (
1880 Agent::Claude,
1881 serde_json::json!({"type": "system", "subtype": "init", "session_id": id})
1882 .to_string(),
1883 ),
1884 (
1885 Agent::Codex,
1886 serde_json::json!({"type": "thread.started", "thread_id": id}).to_string(),
1887 ),
1888 (
1889 Agent::Copilot,
1890 serde_json::json!({"type": "result", "sessionId": id, "exitCode": 0}).to_string(),
1891 ),
1892 ] {
1893 let (events, term) = run(agent, &[&line]);
1894 assert!(term.session.is_none(), "{agent} stored an unusable id");
1895 assert!(
1896 !events.iter().any(|e| matches!(e, Event::Started { .. })),
1897 "{agent} announced a session it cannot resume"
1898 );
1899 }
1900 }
1901
1902 #[test]
1906 fn an_oversized_tool_id_drops_the_id_but_keeps_the_event() {
1907 let id = "t".repeat(MAX_IDENTIFIER_BYTES + 1);
1908 let line = serde_json::json!({
1909 "type": "assistant", "session_id": "s",
1910 "message": {"content": [{
1911 "type": "tool_use", "id": id, "name": "Bash", "input": {"command": "ls"}
1912 }]}
1913 })
1914 .to_string();
1915
1916 let (events, _) = run(Agent::Claude, &[&line]);
1917 let Some(Event::ToolCall { id: seen, name, .. }) = events
1918 .iter()
1919 .find(|e| matches!(e, Event::ToolCall { .. }))
1920 .cloned()
1921 else {
1922 panic!("the call itself must still be reported, got {events:?}")
1923 };
1924 assert_eq!(seen, None, "an unusable id must be dropped, not shortened");
1925 assert_eq!(name, "Bash");
1926 }
1927
1928 #[test]
1931 fn the_pending_tool_map_is_bounded_by_bytes_not_only_entries() {
1932 let mut parser = Parser::new(Agent::Claude, Format::Stream);
1933 for i in 0..MAX_PENDING_TOOLS {
1936 let line = serde_json::json!({
1937 "type": "assistant", "session_id": "s",
1938 "message": {"content": [{
1939 "type": "tool_use",
1940 "id": format!("{i:0>width$}", width = MAX_IDENTIFIER_BYTES),
1941 "name": "x".repeat(MAX_IDENTIFIER_BYTES),
1942 "input": {}
1943 }]}
1944 })
1945 .to_string();
1946 parser.push(&line);
1947 }
1948 assert!(
1949 parser.tool_bytes <= MAX_PENDING_TOOL_BYTES,
1950 "pending tools grew to {} bytes",
1951 parser.tool_bytes
1952 );
1953 }
1954
1955 #[test]
1958 fn a_completed_tool_call_releases_its_budget() {
1959 let mut parser = Parser::new(Agent::Claude, Format::Stream);
1960 let call = |id: &str| {
1961 serde_json::json!({
1962 "type": "assistant", "session_id": "s",
1963 "message": {"content": [{
1964 "type": "tool_use", "id": id, "name": "Bash", "input": {}
1965 }]}
1966 })
1967 .to_string()
1968 };
1969 let result = |id: &str| {
1970 serde_json::json!({
1971 "type": "user", "session_id": "s",
1972 "message": {"content": [{
1973 "type": "tool_result", "tool_use_id": id, "content": "done"
1974 }]}
1975 })
1976 .to_string()
1977 };
1978
1979 for i in 0..(MAX_PENDING_TOOLS * 4) {
1980 let id = format!("toolu_{i}");
1981 parser.push(&call(&id));
1982 parser.push(&result(&id));
1983 }
1984 assert_eq!(parser.tool_bytes, 0, "budget leaked across paired calls");
1985 assert!(parser.tools.is_empty());
1986 }
1987
1988 #[test]
1991 fn a_worst_case_event_stays_within_the_stated_ceiling() {
1992 let huge = "x".repeat(MAX_LINE);
1993 let line = serde_json::json!({
1994 "type": "assistant", "session_id": huge,
1995 "message": {"content": [{
1996 "type": "tool_use", "id": huge, "name": huge, "input": {"command": huge}
1997 }]}
1998 })
1999 .to_string();
2000
2001 let (events, _) = run(Agent::Claude, &[&line]);
2002 for event in &events {
2003 let size = serde_json::to_string(event).unwrap().len();
2004 let ceiling = MAX_EVENT_BYTES + 4 * MAX_IDENTIFIER_BYTES;
2006 assert!(size <= ceiling, "an event reached {size} bytes: {event:?}");
2007 }
2008 }
2009
2010 #[test]
2013 fn oversized_tool_arguments_stay_valid_json() {
2014 let line = serde_json::json!({
2015 "type": "assistant",
2016 "session_id": "s",
2017 "message": {"content": [{
2018 "type": "tool_use", "id": "t1", "name": "Bash",
2019 "input": {"command": "y".repeat(MAX_EVENT_BYTES * 3)}
2020 }]}
2021 })
2022 .to_string();
2023
2024 let (events, _) = run(Agent::Claude, &[&line]);
2025 let Some(Event::ToolCall { input, .. }) = events
2026 .iter()
2027 .find(|e| matches!(e, Event::ToolCall { .. }))
2028 .cloned()
2029 else {
2030 panic!("expected a tool call, got {events:?}")
2031 };
2032 assert_eq!(input["truncated"], true, "got {input}");
2033 assert!(
2034 input.is_object(),
2035 "the replacement must still be valid JSON"
2036 );
2037 assert!(input.to_string().len() <= MAX_EVENT_BYTES);
2038 }
2039
2040 #[test]
2041 fn ordinary_payloads_pass_through_untouched() {
2042 let (events, _) = run(
2043 Agent::Claude,
2044 &[
2045 r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"text","text":"pong"}]}}"#,
2046 ],
2047 );
2048 assert!(events.contains(&Event::Text("pong".into())), "{events:?}");
2049 }
2050
2051 #[test]
2052 fn capture_is_bounded_and_keeps_the_earliest_output() {
2053 let mut buf = String::new();
2054 for i in 0..50_000 {
2056 append_capped(&mut buf, &format!("line {i} aaaaaaaaaaaaaaaaaaaaaaaaaaaa"));
2057 }
2058 assert!(buf.len() <= MAX_CAPTURE, "grew to {}", buf.len());
2059 assert!(buf.starts_with("line 0 "), "the earliest output is kept");
2060 }
2061
2062 #[test]
2063 fn capping_never_splits_a_multibyte_character() {
2064 let mut buf = "x".repeat(MAX_CAPTURE - 3);
2065 assert!(append_capped(&mut buf, "🙂🙂"));
2067 assert!(buf.len() <= MAX_CAPTURE);
2068 assert!(buf.is_char_boundary(buf.len()));
2071 }
2072
2073 #[test]
2074 fn a_full_buffer_reports_that_it_took_nothing() {
2075 let mut buf = "x".repeat(MAX_CAPTURE);
2076 assert!(!append_capped(&mut buf, "more"));
2077 assert_eq!(buf.len(), MAX_CAPTURE);
2078 }
2079
2080 #[test]
2083 fn unparseable_lines_are_counted_and_sampled() {
2084 let (_, term) = run(
2085 Agent::Claude,
2086 &[
2087 "<html>an error page, not JSON</html>",
2088 "another bad line",
2089 r#"{"type":"result","result":"ok","session_id":"s"}"#,
2090 ],
2091 );
2092 assert_eq!(term.unparsed, 2);
2093 assert_eq!(
2094 term.first_unparsed.as_deref(),
2095 Some("<html>an error page, not JSON</html>")
2096 );
2097 }
2098
2099 #[test]
2100 fn a_clean_stream_reports_no_parse_failures() {
2101 let (_, term) = run(
2102 Agent::Claude,
2103 &[r#"{"type":"result","result":"ok","session_id":"s"}"#],
2104 );
2105 assert_eq!(term.unparsed, 0);
2106 assert!(term.first_unparsed.is_none());
2107 }
2108
2109 #[test]
2112 fn tool_result_blocks_that_are_not_text_are_kept_not_dropped() {
2113 let (events, _) = run(
2114 Agent::Claude,
2115 &[
2116 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"}}]}]}}"#,
2117 ],
2118 );
2119 let output = events
2120 .iter()
2121 .find_map(|e| match e {
2122 Event::ToolResult { output, .. } => Some(output),
2123 _ => None,
2124 })
2125 .unwrap_or_else(|| panic!("expected a tool result, got {events:?}"));
2126 assert!(output.contains("seen"));
2127 assert!(output.contains("image"), "the image block was dropped");
2128 }
2129
2130 #[test]
2131 fn garbage_lines_are_skipped_not_fatal() {
2132 let (events, term) = run(
2133 Agent::Claude,
2134 &[
2135 "Warning: something on stdout",
2136 "",
2137 r#"{"type":"result","result":"ok","session_id":"s"}"#,
2138 ],
2139 );
2140 assert!(events.iter().all(|e| !matches!(e, Event::Text(_))));
2141 assert_eq!(term.text, "ok");
2142 }
2143
2144 #[test]
2145 fn text_format_passes_lines_through_verbatim() {
2146 let mut p = Parser::new(Agent::Copilot, Format::Text);
2147 let events: Vec<_> = ["hello", "world"].iter().flat_map(|l| p.push(l)).collect();
2148 assert_eq!(
2149 events,
2150 [Event::Text("hello".into()), Event::Text("world".into())]
2151 );
2152 assert_eq!(p.finish().text, "hello\nworld");
2153 }
2154}