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 MessageBoundary,
53 ToolCall {
55 id: Option<String>,
58 name: String,
60 input: Value,
62 },
63 ToolResult {
65 id: Option<String>,
67 ok: Option<bool>,
69 output: String,
71 },
72 Usage(crate::outcome::Usage),
90 ApprovalRequest(crate::approval::Approval),
97 RateLimit(RateLimit),
99 Compaction(crate::command::Compaction),
105 Commands(crate::command::Commands),
111}
112
113pub const MAX_CAPTURE: usize = 1024 * 1024;
120
121pub const MAX_LINE: usize = 512 * 1024;
128
129pub const MAX_EVENT_BYTES: usize = 64 * 1024;
140
141pub const TRUNCATION_MARK: &str = "…(truncated)";
144
145pub const MAX_IDENTIFIER_BYTES: usize = 4 * 1024;
157
158pub(crate) const MAX_PENDING_TOOL_BYTES: usize = 256 * 1024;
164
165pub(crate) const MAX_PENDING_TOOLS: usize = 1024;
170
171pub(crate) fn append_capped(buf: &mut String, line: &str) -> bool {
178 let remaining = MAX_CAPTURE.saturating_sub(buf.len());
179 if remaining == 0 {
180 return false;
181 }
182 if line.len() < remaining {
184 buf.push_str(line);
185 buf.push('\n');
186 } else {
187 let mut cut = remaining - 1;
189 while cut > 0 && !line.is_char_boundary(cut) {
190 cut -= 1;
191 }
192 buf.push_str(&line[..cut]);
193 buf.push('\n');
194 }
195 true
196}
197
198fn usable_identifier(value: &str) -> bool {
203 value.len() <= MAX_IDENTIFIER_BYTES
204}
205
206fn accept_identifier(value: Option<String>) -> Option<String> {
208 value.filter(|v| usable_identifier(v))
209}
210
211fn bound_text(text: String) -> String {
213 if text.len() <= MAX_EVENT_BYTES {
214 return text;
215 }
216 let mut cut = MAX_EVENT_BYTES - TRUNCATION_MARK.len();
217 while cut > 0 && !text.is_char_boundary(cut) {
218 cut -= 1;
219 }
220 let mut out = text[..cut].to_string();
221 out.push_str(TRUNCATION_MARK);
222 out
223}
224
225fn bound_value(value: Value) -> Value {
231 let size = value.to_string().len();
232 if size <= MAX_EVENT_BYTES {
233 return value;
234 }
235 serde_json::json!({
236 "truncated": true,
237 "original_bytes": size,
238 "note": "arguments exceeded MAX_EVENT_BYTES and were dropped rather than \
239 truncated, which would have produced invalid JSON",
240 })
241}
242
243fn enforce_bounds(event: Event) -> Event {
250 match event {
251 Event::Text(text) => Event::Text(bound_text(text)),
252 Event::MessageBoundary => Event::MessageBoundary,
253 Event::Thinking(text) => Event::Thinking(bound_text(text)),
254 Event::ToolCall { id, name, input } => Event::ToolCall {
258 id: accept_identifier(id),
259 name: bound_identifier(name),
260 input: bound_value(input),
261 },
262 Event::ToolResult { id, ok, output } => Event::ToolResult {
263 id: accept_identifier(id),
264 ok,
265 output: bound_text(output),
266 },
267 Event::Started { session, model } => Event::Started {
271 session,
272 model: model.map(bound_identifier),
273 },
274 Event::Usage(usage) => Event::Usage(usage),
276 Event::ApprovalRequest(approval) => {
277 Event::ApprovalRequest(crate::approval::Approval {
278 id: approval.id,
282 tool: bound_identifier(approval.tool),
283 input: bound_value(approval.input),
284 })
285 }
286 Event::RateLimit(limit) => Event::RateLimit(RateLimit {
287 status: bound_identifier(limit.status),
288 window: limit.window.map(bound_identifier),
289 resets_at: limit.resets_at,
290 overage_status: limit.overage_status.map(bound_identifier),
291 is_using_overage: limit.is_using_overage,
292 }),
293 Event::Compaction(crate::command::Compaction::Finished { ok, error }) => {
296 Event::Compaction(crate::command::Compaction::Finished {
297 ok,
298 error: error.map(bound_text),
299 })
300 }
301 Event::Compaction(phase) => Event::Compaction(phase),
302 Event::Commands(commands) => Event::Commands(crate::command::Commands {
305 all: commands.all.into_iter().map(bound_identifier).collect(),
306 skills: commands.skills.into_iter().map(bound_identifier).collect(),
307 }),
308 }
309}
310
311fn bound_identifier(text: String) -> String {
316 if text.len() <= MAX_IDENTIFIER_BYTES {
317 return text;
318 }
319 let mut cut = MAX_IDENTIFIER_BYTES - TRUNCATION_MARK.len();
320 while cut > 0 && !text.is_char_boundary(cut) {
321 cut -= 1;
322 }
323 let mut out = text[..cut].to_string();
324 out.push_str(TRUNCATION_MARK);
325 out
326}
327
328#[derive(Debug, Clone, Default, PartialEq)]
330pub struct Terminal {
331 pub session: Option<String>,
333 pub model: Option<String>,
340 pub text: String,
342 pub usage: Usage,
344 pub stop: Stop,
346 pub rate_limit: Option<RateLimit>,
348 pub unparsed: usize,
354 pub first_unparsed: Option<String>,
356 pub structured: Option<Value>,
358 pub error_status: Option<u16>,
361 pub error_message: Option<String>,
365}
366
367fn unwrap_error_body(message: &str) -> (Option<u16>, String) {
375 let Ok(body) = serde_json::from_str::<Value>(message) else {
376 return (None, message.to_string());
377 };
378 let status = body
379 .get("status")
380 .and_then(Value::as_u64)
381 .and_then(|s| u16::try_from(s).ok());
382 let inner = body
383 .get("error")
384 .and_then(|e| e.get("message"))
385 .and_then(Value::as_str)
386 .map(str::to_string);
387 (status, inner.unwrap_or_else(|| message.to_string()))
388}
389
390#[derive(Debug)]
392pub(crate) struct Parser {
393 agent: Agent,
394 format: Format,
395 term: Terminal,
396 tools: HashMap<String, String>,
398 tool_bytes: usize,
401 seen: Seen,
403 latest_context: Option<u64>,
409}
410
411#[derive(Debug, Default)]
417#[expect(
418 clippy::struct_excessive_bools,
419 reason = "five independent stream milestones; naming each beats packing them"
420)]
421struct Seen {
422 started: bool,
424 structured: bool,
427 terminal: bool,
429 catalogue: bool,
434 usage_of: Option<String>,
441 deltas: bool,
448}
449
450impl Parser {
451 #[must_use]
453 pub fn new(agent: Agent, format: Format) -> Self {
454 Self {
455 agent,
456 format,
457 term: Terminal::default(),
458 tools: HashMap::new(),
459 tool_bytes: 0,
460 seen: Seen::default(),
461 latest_context: None,
462 }
463 }
464
465 pub fn push(&mut self, line: &str) -> Vec<Event> {
473 let line = line.trim();
474 if line.is_empty() {
475 return Vec::new();
476 }
477 if self.format == Format::Text {
480 append_capped(&mut self.term.text, line);
481 return vec![enforce_bounds(Event::Text(line.to_string()))];
482 }
483 let Ok(value) = serde_json::from_str::<Value>(line) else {
484 self.term.unparsed += 1;
485 if self.term.first_unparsed.is_none() {
486 let mut cut = line.len().min(512);
490 while cut > 0 && !line.is_char_boundary(cut) {
491 cut -= 1;
492 }
493 self.term.first_unparsed = Some(line[..cut].to_string());
494 }
495 return Vec::new();
496 };
497 if let Some(ty) = value.get("type").and_then(Value::as_str)
500 && self.recognizes(ty)
501 {
502 self.seen.structured = true;
503 }
504 let mut out = match self.agent {
505 Agent::Claude => self.claude(&value),
506 Agent::Codex => self.codex(&value),
507 Agent::Copilot => self.copilot(&value),
508 };
509 out = out.into_iter().map(enforce_bounds).collect();
512
513 if !self.seen.started {
516 if let Some(session) = self.term.session.clone() {
517 self.seen.started = true;
518 let model = model_of(&value);
519 self.term.model.clone_from(&model);
520 out.insert(0, Event::Started { session, model });
521 }
522 }
523 out
524 }
525
526 fn live_usage(&mut self, v: &Value) -> Vec<Event> {
536 let Some(message) = v.get("message") else {
537 return Vec::new();
538 };
539 let Some(usage) = message.get("usage") else {
540 return Vec::new();
541 };
542 let get = |key: &str| usage.get(key).and_then(Value::as_u64);
543 let (input, read, write) = (
544 get("input_tokens"),
545 get("cache_read_input_tokens"),
546 get("cache_creation_input_tokens"),
547 );
548 if input.is_none() && read.is_none() && write.is_none() {
549 return Vec::new();
550 }
551 let prompt = input.unwrap_or(0) + read.unwrap_or(0) + write.unwrap_or(0);
552 self.latest_context = Some(prompt);
566
567 let Some(id) = message.get("id").and_then(Value::as_str) else {
570 return Vec::new();
571 };
572 if self.seen.usage_of.as_deref() == Some(id) {
573 return Vec::new();
574 }
575 self.seen.usage_of = Some(id.to_string());
576 vec![Event::Usage(Usage {
577 input_tokens: input,
578 cache_read_tokens: read,
579 cache_write_tokens: write,
580 context_tokens: Some(prompt),
581 output_tokens: None,
584 ..Usage::default()
585 })]
586 }
587
588 pub(crate) fn saw_terminal(&self) -> bool {
595 self.seen.terminal
596 }
597
598 fn recognizes(&self, ty: &str) -> bool {
600 match self.agent {
601 Agent::Claude => matches!(
602 ty,
603 "system" | "assistant" | "user" | "result" | "rate_limit_event" | "control_request"
604 ),
605 Agent::Codex => {
606 ty.starts_with("thread.") || ty.starts_with("turn.") || ty.starts_with("item.")
607 }
608 Agent::Copilot => {
609 ty == "result"
610 || ty.starts_with("assistant.")
611 || ty.starts_with("tool.")
612 || ty.starts_with("session.")
613 }
614 }
615 }
616
617 fn remember_tool(&mut self, id: &str, name: &str) {
620 if !usable_identifier(id) {
623 return;
624 }
625 let name = bound_identifier(name.to_string());
626 let cost = id.len() + name.len();
627 if self.tools.len() >= MAX_PENDING_TOOLS
631 || self.tool_bytes.saturating_add(cost) > MAX_PENDING_TOOL_BYTES
632 {
633 return;
634 }
635 self.tool_bytes += cost;
636 if let Some(previous) = self.tools.insert(id.to_string(), name) {
637 self.tool_bytes = self.tool_bytes.saturating_sub(id.len() + previous.len());
639 }
640 }
641
642 fn forget_tool(&mut self, id: &str) {
644 if let Some(name) = self.tools.remove(id) {
645 self.tool_bytes = self.tool_bytes.saturating_sub(id.len() + name.len());
646 }
647 }
648
649 pub(crate) fn saw_structured_record(&self) -> bool {
654 self.seen.structured
655 }
656
657 pub(crate) fn saw_terminal_record(&self) -> bool {
660 self.seen.terminal
661 }
662
663 #[must_use]
665 pub fn finish(mut self) -> Terminal {
666 if self.format == Format::Text {
667 self.term.text = self.term.text.trim_end().to_string();
668 }
669 self.term
670 }
671
672 fn claude(&mut self, v: &Value) -> Vec<Event> {
678 let ty = v.get("type").and_then(Value::as_str).unwrap_or_default();
679 if let Some(id) = v.get("session_id").and_then(Value::as_str)
682 && usable_identifier(id)
683 {
684 self.term.session.get_or_insert_with(|| id.to_string());
685 }
686 match ty {
687 "rate_limit_event" => {
688 let limit = claude_rate_limit(v.get("rate_limit_info"));
689 self.term.rate_limit.clone_from(&limit);
690 limit.into_iter().map(Event::RateLimit).collect()
691 }
692 "stream_event" => self.claude_delta(v),
694 "control_request" => {
702 let Some(request) = v.get("request") else {
703 return Vec::new();
704 };
705 if request.get("subtype").and_then(Value::as_str) != Some("can_use_tool") {
706 return Vec::new();
707 }
708 let Some(id) = v.get("request_id").and_then(Value::as_str) else {
709 return Vec::new();
713 };
714 if !usable_identifier(id) {
715 return Vec::new();
716 }
717 vec![Event::ApprovalRequest(crate::approval::Approval {
718 id: id.to_string(),
719 tool: request
720 .get("tool_name")
721 .and_then(Value::as_str)
722 .unwrap_or("unknown")
723 .to_string(),
724 input: request.get("input").cloned().unwrap_or(Value::Null),
725 })]
726 }
727 "assistant" | "user" => self.content_blocks(v),
728 "system" => self.claude_system(v),
729 "result" => {
730 self.seen.terminal = true;
731 if let Some(text) = v.get("result").and_then(Value::as_str) {
732 self.term.text = text.to_string();
733 }
734 if let Some(value) = v.get("structured_output") {
737 self.term.structured = Some(value.clone());
738 }
739 self.term.usage = claude_usage(v, self.term.model.as_deref());
740 if self.latest_context.is_some() {
744 self.term.usage.context_tokens = self.latest_context;
745 }
746 self.term.stop = if v.get("is_error").and_then(Value::as_bool) == Some(true) {
749 self.term.error_status = v
750 .get("api_error_status")
751 .and_then(Value::as_u64)
752 .and_then(|s| u16::try_from(s).ok());
753 Stop::Error
754 } else {
755 stop_from(v.get("stop_reason"))
756 };
757 Vec::new()
758 }
759 _ => Vec::new(),
760 }
761 }
762
763 fn claude_system(&mut self, v: &Value) -> Vec<Event> {
774 match v.get("subtype").and_then(Value::as_str) {
775 Some("init") => {
776 if self.seen.catalogue {
780 return Vec::new();
781 }
782 let names = |key: &str| -> Vec<String> {
783 v.get(key)
784 .and_then(Value::as_array)
785 .map(|entries| {
786 entries
787 .iter()
788 .filter_map(Value::as_str)
789 .map(str::to_string)
790 .collect()
791 })
792 .unwrap_or_default()
793 };
794 let commands = crate::command::Commands {
795 all: names("slash_commands"),
796 skills: names("skills"),
797 };
798 if commands.all.is_empty() {
801 return Vec::new();
802 }
803 self.seen.catalogue = true;
804 vec![Event::Commands(commands)]
805 }
806 Some("status") => {
807 if v.get("status").and_then(Value::as_str) == Some("compacting") {
808 return vec![Event::Compaction(crate::command::Compaction::Started)];
809 }
810 let Some(result) = v.get("compact_result").and_then(Value::as_str) else {
811 return Vec::new();
812 };
813 vec![Event::Compaction(crate::command::Compaction::Finished {
814 ok: result == "success",
815 error: v
816 .get("compact_error")
817 .and_then(Value::as_str)
818 .map(str::to_string),
819 })]
820 }
821 _ => Vec::new(),
822 }
823 }
824
825 fn claude_delta(&mut self, v: &Value) -> Vec<Event> {
832 let Some(event) = v.get("event") else {
833 return Vec::new();
834 };
835 if event.get("type").and_then(Value::as_str) != Some("content_block_delta") {
836 return Vec::new();
837 }
838 let Some(delta) = event.get("delta") else {
839 return Vec::new();
840 };
841 self.seen.deltas = true;
844
845 match delta.get("type").and_then(Value::as_str) {
846 Some("text_delta") => delta
847 .get("text")
848 .and_then(Value::as_str)
849 .filter(|text| !text.is_empty())
850 .map(|text| Event::Text(text.to_string()))
851 .into_iter()
852 .collect(),
853 Some("thinking_delta") => delta
854 .get("thinking")
855 .and_then(Value::as_str)
856 .filter(|text| !text.is_empty())
857 .map(|text| Event::Thinking(text.to_string()))
858 .into_iter()
859 .collect(),
860 _ => Vec::new(),
864 }
865 }
866
867 fn content_blocks(&mut self, v: &Value) -> Vec<Event> {
870 let mut out = self.live_usage(v);
871 let blocks = v
872 .get("message")
873 .and_then(|m| m.get("content"))
874 .and_then(Value::as_array);
875 let Some(blocks) = blocks else {
876 return out;
877 };
878 for block in blocks {
879 let ty = block
880 .get("type")
881 .and_then(Value::as_str)
882 .unwrap_or_default();
883 match ty {
884 "text" if !self.seen.deltas => {
889 if let Some(t) = block.get("text").and_then(Value::as_str) {
890 out.push(Event::Text(t.to_string()));
891 }
892 }
893 "thinking" if !self.seen.deltas => {
894 if let Some(t) = block.get("thinking").and_then(Value::as_str) {
895 out.push(Event::Thinking(t.to_string()));
896 }
897 }
898 "tool_use" => {
899 let name = block
900 .get("name")
901 .and_then(Value::as_str)
902 .unwrap_or("tool")
903 .to_string();
904 let id = block.get("id").and_then(Value::as_str).map(str::to_string);
905 if let Some(id) = &id {
906 self.remember_tool(id, &name);
907 }
908 out.push(Event::ToolCall {
909 id,
910 name,
911 input: block.get("input").cloned().unwrap_or(Value::Null),
912 });
913 }
914 "tool_result" => out.push(Event::ToolResult {
915 id: block
916 .get("tool_use_id")
917 .and_then(Value::as_str)
918 .inspect(|id| {
919 self.forget_tool(id);
921 })
922 .map(str::to_string),
923 ok: block
924 .get("is_error")
925 .and_then(Value::as_bool)
926 .map(|is_error| !is_error),
927 output: flatten_text(block.get("content")),
928 }),
929 _ => {}
930 }
931 }
932 out
933 }
934
935 fn codex(&mut self, v: &Value) -> Vec<Event> {
943 let ty = v.get("type").and_then(Value::as_str).unwrap_or_default();
944 if let Some(id) = v.get("thread_id").and_then(Value::as_str)
945 && usable_identifier(id)
946 {
947 self.term.session.get_or_insert_with(|| id.to_string());
948 }
949 match ty {
950 "turn.completed" => {
951 self.seen.terminal = true;
952 self.term.usage = codex_usage(v.get("usage"));
953 Vec::new()
954 }
955 "turn.failed" => {
956 self.seen.terminal = true;
957 self.term.stop = Stop::Error;
958 if let Some(message) = v
959 .get("error")
960 .and_then(|e| e.get("message"))
961 .and_then(Value::as_str)
962 {
963 let (status, message) = unwrap_error_body(message);
964 self.term.error_status = status;
965 self.term.error_message = Some(bound_text(message));
966 }
967 Vec::new()
968 }
969 "item.started" | "item.updated" | "item.completed" => {
970 let Some(item) = v.get("item") else {
971 return Vec::new();
972 };
973 let item_ty = item.get("type").and_then(Value::as_str).unwrap_or_default();
974 let id = item.get("id").and_then(Value::as_str).map(str::to_string);
975 let done = ty == "item.completed";
976
977 let name = tool_name(item, item_ty);
981 let first = id
982 .as_ref()
983 .is_none_or(|id| self.tools.insert(id.clone(), name.clone()).is_none());
984
985 match item_ty {
986 "agent_message" => {
989 if !done {
990 return Vec::new();
991 }
992 let text = item.get("text").and_then(Value::as_str).unwrap_or_default();
993 self.term.text = text.to_string();
994 vec![Event::Text(text.to_string())]
995 }
996 "reasoning" if done => item
997 .get("text")
998 .and_then(Value::as_str)
999 .map(|t| Event::Thinking(t.to_string()))
1000 .into_iter()
1001 .collect(),
1002 "command_execution" | "mcp_tool_call" | "file_change" | "web_search" => {
1003 let mut out = Vec::new();
1004 if first {
1005 out.push(Event::ToolCall {
1006 id: id.clone(),
1007 name,
1008 input: codex_tool_input(item, item_ty),
1009 });
1010 }
1011 if done {
1014 if let Some(id) = &id {
1015 self.forget_tool(id);
1016 }
1017 out.push(Event::ToolResult {
1018 id,
1019 ok: item
1020 .get("exit_code")
1021 .and_then(Value::as_i64)
1022 .map(|code| code == 0),
1023 output: item
1024 .get("aggregated_output")
1025 .and_then(Value::as_str)
1026 .unwrap_or_default()
1027 .to_string(),
1028 });
1029 }
1030 out
1031 }
1032 _ => Vec::new(),
1033 }
1034 }
1035 _ => Vec::new(),
1036 }
1037 }
1038
1039 fn copilot(&mut self, v: &Value) -> Vec<Event> {
1046 let ty = v.get("type").and_then(Value::as_str).unwrap_or_default();
1047 let data = v.get("data");
1048 let field = |key: &str| -> Option<String> {
1049 data.and_then(|d| d.get(key))
1050 .and_then(Value::as_str)
1051 .map(str::to_string)
1052 };
1053 match ty {
1054 "assistant.message_delta" => field("deltaContent")
1056 .filter(|t| !t.is_empty())
1057 .map(Event::Text)
1058 .into_iter()
1059 .collect(),
1060 "assistant.message" => {
1063 if let Some(content) = field("content") {
1064 self.term.text = content;
1065 }
1066 Vec::new()
1067 }
1068 "assistant.reasoning" => field("content")
1069 .filter(|t| !t.is_empty())
1070 .map(Event::Thinking)
1071 .into_iter()
1072 .collect(),
1073 "tool.execution_start" => {
1074 let id = field("toolCallId");
1075 let name = field("toolName").unwrap_or_else(|| "tool".into());
1076 if let Some(id) = &id {
1077 self.remember_tool(id, &name);
1078 }
1079 vec![Event::ToolCall {
1080 id,
1081 name,
1082 input: data
1083 .and_then(|d| d.get("arguments"))
1084 .cloned()
1085 .unwrap_or(Value::Null),
1086 }]
1087 }
1088 "tool.execution_complete" => vec![Event::ToolResult {
1089 id: field("toolCallId").inspect(|id| {
1090 self.forget_tool(id);
1091 }),
1092 ok: data.and_then(|d| d.get("success")).and_then(Value::as_bool),
1093 output: data
1094 .and_then(|d| d.get("result"))
1095 .and_then(|r| r.get("content"))
1096 .and_then(Value::as_str)
1097 .unwrap_or_default()
1098 .to_string(),
1099 }],
1100 "session.usage_checkpoint" => {
1105 if let Some(data) = v.get("data") {
1106 self.term.usage.ai_credits_nano =
1107 data.get("totalNanoAiu").and_then(Value::as_u64);
1108 if let Some(premium) = data.get("totalPremiumRequests").and_then(Value::as_u64)
1109 {
1110 self.term.usage.premium_requests = Some(premium);
1111 }
1112 }
1113 Vec::new()
1114 }
1115 "result" => {
1117 self.seen.terminal = true;
1118 if let Some(id) = v.get("sessionId").and_then(Value::as_str)
1119 && usable_identifier(id)
1120 {
1121 self.term.session = Some(id.to_string());
1122 }
1123 if let Some(usage) = v.get("usage") {
1124 self.term.usage.premium_requests =
1125 usage.get("premiumRequests").and_then(Value::as_u64);
1126 self.term.usage.duration_ms =
1127 usage.get("sessionDurationMs").and_then(Value::as_u64);
1128 self.term.usage.api_duration_ms =
1129 usage.get("totalApiDurationMs").and_then(Value::as_u64);
1130 }
1131 if let Some(code) = v.get("exitCode").and_then(Value::as_i64)
1132 && code != 0
1133 {
1134 self.term.stop = Stop::Error;
1135 self.term.error_message = Some(format!("copilot exited with code {code}"));
1138 }
1139 Vec::new()
1140 }
1141 _ => Vec::new(),
1142 }
1143 }
1144}
1145
1146fn model_of(v: &Value) -> Option<String> {
1149 v.get("model")
1150 .or_else(|| v.get("data").and_then(|d| d.get("model")))
1151 .and_then(Value::as_str)
1152 .map(str::to_string)
1153}
1154
1155fn stop_from(v: Option<&Value>) -> Stop {
1157 match v.and_then(Value::as_str) {
1158 None | Some("end_turn" | "stop" | "completed") => Stop::Completed,
1159 Some(other) => Stop::Other(other.to_string()),
1160 }
1161}
1162
1163fn claude_rate_limit(v: Option<&Value>) -> Option<RateLimit> {
1165 let v = v?;
1166 Some(RateLimit {
1167 status: v.get("status").and_then(Value::as_str)?.to_string(),
1168 window: v
1169 .get("rateLimitType")
1170 .and_then(Value::as_str)
1171 .map(str::to_string),
1172 resets_at: v.get("resetsAt").and_then(Value::as_i64),
1173 overage_status: v
1174 .get("overageStatus")
1175 .and_then(Value::as_str)
1176 .map(str::to_string),
1177 is_using_overage: v.get("isUsingOverage").and_then(Value::as_bool),
1178 })
1179}
1180
1181fn claude_usage(v: &Value, model: Option<&str>) -> Usage {
1183 let u = v.get("usage");
1184 let get = |key: &str| u.and_then(|u| u.get(key)).and_then(Value::as_u64);
1185 let (input, read, write) = (
1186 get("input_tokens"),
1187 get("cache_read_input_tokens"),
1188 get("cache_creation_input_tokens"),
1189 );
1190 let per_model = v
1198 .get("modelUsage")
1199 .and_then(Value::as_object)
1200 .and_then(
1201 |models| match (model.and_then(|m| models.get(m)), models.len()) {
1202 (Some(entry), _) => Some(entry),
1203 (None, 1) => models.values().next(),
1205 (None, _) => None,
1208 },
1209 );
1210 let of_model = |key: &str| per_model.and_then(|m| m.get(key)).and_then(Value::as_u64);
1211 Usage {
1212 input_tokens: input,
1213 output_tokens: get("output_tokens"),
1214 cache_read_tokens: read,
1215 cache_write_tokens: write,
1216 context_tokens: (input.is_some() || read.is_some() || write.is_some())
1220 .then(|| input.unwrap_or(0) + read.unwrap_or(0) + write.unwrap_or(0)),
1221 context_window: of_model("contextWindow"),
1222 max_output_tokens: of_model("maxOutputTokens"),
1223 reasoning_tokens: None,
1224 cost_usd: v.get("total_cost_usd").and_then(Value::as_f64),
1225 premium_requests: None,
1226 ai_credits_nano: None,
1227 duration_ms: v.get("duration_ms").and_then(Value::as_u64),
1228 api_duration_ms: v.get("duration_api_ms").and_then(Value::as_u64),
1229 }
1230}
1231
1232fn codex_usage(v: Option<&Value>) -> Usage {
1235 let get = |key: &str| v.and_then(|u| u.get(key)).and_then(Value::as_u64);
1236 let (prompt, cached) = (get("input_tokens"), get("cached_input_tokens"));
1237 Usage {
1238 input_tokens: match (prompt, cached) {
1246 (Some(prompt), Some(cached)) => Some(prompt.saturating_sub(cached)),
1247 (prompt, _) => prompt,
1248 },
1249 output_tokens: get("output_tokens"),
1250 cache_read_tokens: cached,
1251 cache_write_tokens: get("cache_write_input_tokens"),
1252 context_tokens: prompt,
1253 context_window: None,
1254 max_output_tokens: None,
1255 reasoning_tokens: get("reasoning_output_tokens"),
1256 cost_usd: None,
1257 premium_requests: None,
1258 ai_credits_nano: None,
1259 duration_ms: None,
1260 api_duration_ms: None,
1261 }
1262}
1263
1264fn tool_name(item: &Value, item_ty: &str) -> String {
1267 item.get("tool")
1268 .and_then(Value::as_str)
1269 .unwrap_or(item_ty)
1270 .to_string()
1271}
1272
1273fn codex_tool_input(item: &Value, item_ty: &str) -> Value {
1275 match item_ty {
1276 "command_execution" => serde_json::json!({ "command": item.get("command") }),
1277 "mcp_tool_call" => item.get("arguments").cloned().unwrap_or(Value::Null),
1278 _ => item.clone(),
1281 }
1282}
1283
1284fn flatten_text(v: Option<&Value>) -> String {
1292 match v {
1293 Some(Value::String(s)) => s.clone(),
1294 Some(Value::Array(blocks)) => blocks
1295 .iter()
1296 .map(|b| match b.get("text").and_then(Value::as_str) {
1297 Some(text) => text.to_string(),
1298 None => b.to_string(),
1299 })
1300 .collect::<Vec<_>>()
1301 .join("\n"),
1302 Some(other) => other.to_string(),
1303 None => String::new(),
1304 }
1305}
1306
1307#[cfg(test)]
1308mod tests {
1309 use super::*;
1310
1311 fn run(agent: Agent, lines: &[&str]) -> (Vec<Event>, Terminal) {
1313 let mut p = Parser::new(agent, Format::Stream);
1314 let events = lines.iter().flat_map(|l| p.push(l)).collect();
1315 (events, p.finish())
1316 }
1317
1318 #[test]
1326 fn a_compaction_reports_its_phases_and_settles_cleanly() {
1327 let (events, _) = run(
1328 Agent::Claude,
1329 &[
1330 r#"{"type":"system","subtype":"init","session_id":"s","model":"claude-opus-5","slash_commands":["compact","context","code-review"],"skills":["code-review"]}"#,
1331 r#"{"type":"system","subtype":"status","status":"compacting","session_id":"s"}"#,
1332 r#"{"type":"system","subtype":"status","status":null,"compact_result":"success","session_id":"s"}"#,
1333 r#"{"type":"system","subtype":"init","session_id":"s","slash_commands":["compact","context","code-review"],"skills":["code-review"]}"#,
1334 r#"{"type":"system","subtype":"compact_boundary","session_id":"s"}"#,
1335 r#"{"type":"result","subtype":"success","is_error":false,"result":"","session_id":"s","usage":{"input_tokens":0,"output_tokens":0}}"#,
1336 ],
1337 );
1338
1339 let catalogue: Vec<_> = events
1340 .iter()
1341 .filter_map(|event| match event {
1342 Event::Commands(commands) => Some(commands),
1343 _ => None,
1344 })
1345 .collect();
1346 assert_eq!(
1347 catalogue.len(),
1348 1,
1349 "the re-init after compacting must not redraw the palette"
1350 );
1351 assert_eq!(catalogue[0].utilities(), vec!["compact", "context"]);
1352
1353 let phases: Vec<_> = events
1354 .iter()
1355 .filter_map(|event| match event {
1356 Event::Compaction(phase) => Some(phase.clone()),
1357 _ => None,
1358 })
1359 .collect();
1360 assert_eq!(
1361 phases,
1362 vec![
1363 crate::command::Compaction::Started,
1364 crate::command::Compaction::Finished {
1365 ok: true,
1366 error: None
1367 },
1368 ]
1369 );
1370 }
1371
1372 #[test]
1374 fn a_refused_compaction_is_reported_not_raised() {
1375 let (events, _) = run(
1376 Agent::Claude,
1377 &[
1378 r#"{"type":"system","subtype":"init","session_id":"s","model":"claude-opus-5"}"#,
1379 r#"{"type":"system","subtype":"status","status":null,"compact_result":"failed","compact_error":"Not enough messages to compact.","session_id":"s"}"#,
1380 r#"{"type":"result","subtype":"success","is_error":false,"result":"","session_id":"s"}"#,
1381 ],
1382 );
1383 assert!(events.iter().any(|event| matches!(
1384 event,
1385 Event::Compaction(crate::command::Compaction::Finished { ok: false, error: Some(why) })
1386 if why == "Not enough messages to compact."
1387 )));
1388 assert!(
1391 !events
1392 .iter()
1393 .any(|event| matches!(event, Event::Commands(_)))
1394 );
1395 }
1396
1397 #[test]
1398 fn claude_stream_yields_start_thinking_text_and_terminal_facts() {
1399 let (events, term) = run(
1400 Agent::Claude,
1401 &[
1402 r#"{"type":"system","subtype":"init","session_id":"sess-a","model":"claude-haiku-4-5"}"#,
1403 r#"{"type":"assistant","session_id":"sess-a","message":{"content":[{"type":"thinking","thinking":"brief"}]}}"#,
1404 r#"{"type":"assistant","session_id":"sess-a","message":{"content":[{"type":"text","text":"pong"}]}}"#,
1405 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}}"#,
1406 ],
1407 );
1408 assert_eq!(
1409 events[0],
1410 Event::Started {
1411 session: "sess-a".into(),
1412 model: Some("claude-haiku-4-5".into())
1413 }
1414 );
1415 assert_eq!(events[1], Event::Thinking("brief".into()));
1416 assert_eq!(events[2], Event::Text("pong".into()));
1417 assert_eq!(term.session.as_deref(), Some("sess-a"));
1418 assert_eq!(term.text, "pong");
1419 assert_eq!(term.stop, Stop::Completed);
1420 assert_eq!(term.usage.input_tokens, Some(10));
1421 assert_eq!(term.usage.cache_read_tokens, Some(18764));
1422 assert_eq!(term.usage.cache_write_tokens, Some(7322));
1423 assert_eq!(term.usage.cost_usd, Some(0.017));
1424 }
1425
1426 #[test]
1434 fn the_window_binds_to_the_runs_model_not_the_haiku_helper() {
1435 let (_, term) = run(
1436 Agent::Claude,
1437 &[
1438 r#"{"type":"system","subtype":"init","session_id":"sess-1m","model":"claude-sonnet-5[1m]"}"#,
1439 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}}}"#,
1440 ],
1441 );
1442 assert_eq!(term.model.as_deref(), Some("claude-sonnet-5[1m]"));
1443 assert_eq!(
1444 term.usage.context_window,
1445 Some(1_000_000),
1446 "the helper's 200k window must not shadow the real one"
1447 );
1448 assert_eq!(term.usage.max_output_tokens, Some(64_000));
1449 assert_eq!(term.usage.context_tokens, Some(2 + 27_128 + 9_825));
1451 }
1452
1453 #[test]
1459 fn context_is_the_last_requests_prompt_not_the_turns_sum() {
1460 let (_, term) = run(
1461 Agent::Claude,
1462 &[
1463 r#"{"type":"system","subtype":"init","session_id":"s","model":"claude-sonnet-5"}"#,
1464 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"}}]}}"#,
1465 r#"{"type":"user","session_id":"s","message":{"content":[{"type":"tool_result","tool_use_id":"t1","content":"ok"}]}}"#,
1466 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"}]}}"#,
1467 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}}"#,
1468 ],
1469 );
1470 assert_eq!(
1471 term.usage.context_tokens,
1472 Some(6 + 102_000 + 500),
1473 "the last request's prompt is the context; the turn sum (204,510) is not"
1474 );
1475 assert_eq!(term.usage.cache_read_tokens, Some(202_000));
1477 }
1478
1479 #[test]
1482 fn the_terminal_sum_remains_the_fallback_context() {
1483 let (_, term) = run(
1484 Agent::Claude,
1485 &[
1486 r#"{"type":"system","subtype":"init","session_id":"s","model":"claude-sonnet-5"}"#,
1487 r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"text","text":"ok"}]}}"#,
1488 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}}"#,
1489 ],
1490 );
1491 assert_eq!(term.usage.context_tokens, Some(10 + 1000));
1492 }
1493
1494 #[test]
1497 fn an_unmatchable_window_is_absent_not_guessed() {
1498 let (_, term) = run(
1499 Agent::Claude,
1500 &[
1501 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}}}"#,
1503 ],
1504 );
1505 assert_eq!(term.usage.context_window, None);
1506 let (_, single) = run(
1508 Agent::Claude,
1509 &[
1510 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}}}"#,
1511 ],
1512 );
1513 assert_eq!(single.usage.context_window, Some(200_000));
1514 }
1515
1516 #[test]
1517 fn claude_token_deltas_stream_without_duplicating_the_finished_message() {
1518 let (events, _) = run(
1519 Agent::Claude,
1520 &[
1521 r#"{"type":"system","subtype":"init","session_id":"s"}"#,
1522 r#"{"type":"stream_event","session_id":"s","event":{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}}"#,
1523 r#"{"type":"stream_event","session_id":"s","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"po"}}}"#,
1524 r#"{"type":"stream_event","session_id":"s","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"ng"}}}"#,
1525 r#"{"type":"stream_event","session_id":"s","event":{"type":"content_block_stop","index":0}}"#,
1526 r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"text","text":"pong"}]}}"#,
1528 r#"{"type":"result","subtype":"success","is_error":false,"result":"pong","session_id":"s"}"#,
1529 ],
1530 );
1531 let texts: Vec<_> = events
1532 .iter()
1533 .filter_map(|e| match e {
1534 Event::Text(t) => Some(t.as_str()),
1535 _ => None,
1536 })
1537 .collect();
1538 assert_eq!(texts, ["po", "ng"], "the finished message must not repeat");
1539 }
1540
1541 #[test]
1543 fn claude_thinking_deltas_stream_without_duplication() {
1544 let (events, _) = run(
1545 Agent::Claude,
1546 &[
1547 r#"{"type":"stream_event","session_id":"s","event":{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"weighing"}}}"#,
1548 r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"thinking","thinking":"weighing"}]}}"#,
1549 ],
1550 );
1551 let thoughts: Vec<_> = events
1552 .iter()
1553 .filter_map(|e| match e {
1554 Event::Thinking(t) => Some(t.as_str()),
1555 _ => None,
1556 })
1557 .collect();
1558 assert_eq!(thoughts, ["weighing"]);
1559 }
1560
1561 #[test]
1564 fn a_completed_message_still_streams_when_no_deltas_arrived() {
1565 let (events, _) = run(
1566 Agent::Claude,
1567 &[
1568 r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"text","text":"pong"}]}}"#,
1569 ],
1570 );
1571 assert!(events.contains(&Event::Text("pong".into())), "{events:?}");
1572 }
1573
1574 #[test]
1577 fn tool_calls_survive_delta_suppression() {
1578 let (events, _) = run(
1579 Agent::Claude,
1580 &[
1581 r#"{"type":"stream_event","session_id":"s","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hi"}}}"#,
1582 r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"tool_use","id":"t1","name":"Bash","input":{"command":"ls"}}]}}"#,
1583 ],
1584 );
1585 assert!(
1586 events.iter().any(|e| matches!(e, Event::ToolCall { .. })),
1587 "suppression must apply to text only: {events:?}"
1588 );
1589 }
1590
1591 #[test]
1592 fn claude_started_fires_only_once() {
1593 let (events, _) = run(
1594 Agent::Claude,
1595 &[
1596 r#"{"type":"system","subtype":"init","session_id":"s"}"#,
1597 r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"text","text":"a"}]}}"#,
1598 r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"text","text":"b"}]}}"#,
1599 ],
1600 );
1601 assert_eq!(
1602 events
1603 .iter()
1604 .filter(|e| matches!(e, Event::Started { .. }))
1605 .count(),
1606 1
1607 );
1608 }
1609
1610 #[test]
1615 fn a_model_call_reports_its_usage_once_however_many_blocks_it_has() {
1616 let (events, _) = run(
1617 Agent::Claude,
1618 &[
1619 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}}}"#,
1620 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}}}"#,
1621 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}}}"#,
1622 ],
1623 );
1624 let usage: Vec<&Usage> = events
1625 .iter()
1626 .filter_map(|e| match e {
1627 Event::Usage(u) => Some(u),
1628 _ => None,
1629 })
1630 .collect();
1631 assert_eq!(usage.len(), 2, "two model calls, three records: {events:?}");
1632 assert_eq!(usage[0].input_tokens, Some(10));
1633 assert_eq!(usage[0].context_tokens, Some(10 + 20180 + 7574));
1634 assert_eq!(usage[1].context_tokens, Some(8 + 30427));
1635 }
1636
1637 #[test]
1642 fn context_is_still_tracked_when_a_record_carries_no_id() {
1643 let (events, term) = run(
1644 Agent::Claude,
1645 &[
1646 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}}}"#,
1647 r#"{"type":"result","subtype":"success","is_error":false,"result":"hi","session_id":"s","usage":{"input_tokens":99,"cache_read_input_tokens":99}}"#,
1648 ],
1649 );
1650 assert!(
1651 !events.iter().any(|e| matches!(e, Event::Usage(_))),
1652 "no id means no way to deduplicate, so nothing is reported"
1653 );
1654 assert_eq!(
1655 term.usage.context_tokens,
1656 Some(8 + 30427),
1657 "the per-request figure must still outrank the terminal sum"
1658 );
1659 }
1660
1661 #[test]
1665 fn a_live_snapshot_withholds_the_output_count() {
1666 let (events, _) = run(
1667 Agent::Claude,
1668 &[
1669 r#"{"type":"assistant","session_id":"s","message":{"id":"m","content":[{"type":"text","text":"hi"}],"usage":{"input_tokens":8,"output_tokens":1}}}"#,
1670 ],
1671 );
1672 let Some(Event::Usage(usage)) = events.iter().find(|e| matches!(e, Event::Usage(_))) else {
1673 panic!("expected a usage event: {events:?}")
1674 };
1675 assert_eq!(usage.output_tokens, None, "a partial count is not reported");
1676 assert_eq!(usage.input_tokens, Some(8), "the exact figures still are");
1677 }
1678
1679 #[test]
1683 fn live_snapshots_accumulate_to_the_terminal_totals() {
1684 let calls = [
1685 (10u64, 20180u64, 7574u64),
1686 (8, 0, 30427),
1687 (8, 30427, 1859),
1688 (8, 32286, 115),
1689 ];
1690 let mut session = Usage::default();
1691 for (input, read, write) in calls {
1692 session.accumulate(&Usage {
1693 input_tokens: Some(input),
1694 cache_read_tokens: Some(read),
1695 cache_write_tokens: Some(write),
1696 context_tokens: Some(input + read + write),
1697 ..Usage::default()
1698 });
1699 }
1700 assert_eq!(session.input_tokens, Some(34));
1702 assert_eq!(
1703 session.context_tokens,
1704 Some(8 + 32286 + 115),
1705 "context takes the latest, being cumulative already"
1706 );
1707 }
1708
1709 #[test]
1710 fn claude_pairs_tool_use_with_its_result() {
1711 let (events, _) = run(
1712 Agent::Claude,
1713 &[
1714 r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"tool_use","id":"toolu_1","name":"Bash","input":{"command":"ls"}}]}}"#,
1715 r#"{"type":"user","session_id":"s","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"a.txt"}]}}"#,
1716 ],
1717 );
1718 let call = events
1719 .iter()
1720 .find(|e| matches!(e, Event::ToolCall { .. }))
1721 .unwrap();
1722 let Event::ToolCall { id, name, input } = call else {
1723 unreachable!()
1724 };
1725 assert_eq!(id.as_deref(), Some("toolu_1"));
1726 assert_eq!(name, "Bash");
1727 assert_eq!(input["command"], "ls");
1728 assert!(events.contains(&Event::ToolResult {
1729 id: Some("toolu_1".into()),
1730 ok: None,
1731 output: "a.txt".into(),
1732 }));
1733 }
1734
1735 #[test]
1740 fn an_approval_request_carries_the_tool_and_its_arguments() {
1741 let (events, _) = run(
1742 Agent::Claude,
1743 &[
1744 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"}}}"#,
1745 ],
1746 );
1747 let [Event::ApprovalRequest(approval)] = &events[..] else {
1748 panic!("expected one approval request, got {events:?}")
1749 };
1750 assert_eq!(approval.id, "req-7");
1751 assert_eq!(approval.tool, "Bash");
1752 assert_eq!(approval.input["command"], "touch created-by-probe.txt");
1753 }
1754
1755 #[test]
1759 fn an_unanswerable_approval_request_is_dropped() {
1760 for line in [
1761 r#"{"type":"control_request","request":{"subtype":"can_use_tool","tool_name":"Bash","input":{}}}"#,
1763 &format!(
1765 r#"{{"type":"control_request","request_id":"{}","request":{{"subtype":"can_use_tool","tool_name":"Bash","input":{{}}}}}}"#,
1766 "x".repeat(MAX_IDENTIFIER_BYTES + 1)
1767 ),
1768 ] {
1769 let (events, _) = run(Agent::Claude, &[line]);
1770 assert!(
1771 events.is_empty(),
1772 "an unanswerable request must not reach a consumer: {events:?}"
1773 );
1774 }
1775 }
1776
1777 #[test]
1779 fn a_control_request_that_is_not_an_approval_is_ignored() {
1780 let (events, _) = run(
1781 Agent::Claude,
1782 &[r#"{"type":"control_request","request_id":"r","request":{"subtype":"initialize"}}"#],
1783 );
1784 assert!(events.is_empty(), "{events:?}");
1785 }
1786
1787 #[test]
1788 fn claude_reports_a_rate_limit_without_failing() {
1789 let (events, term) = run(
1790 Agent::Claude,
1791 &[
1792 r#"{"type":"rate_limit_event","session_id":"s","rate_limit_info":{"status":"allowed","resetsAt":1785260400,"rateLimitType":"five_hour"}}"#,
1793 ],
1794 );
1795 let limit = RateLimit {
1796 status: "allowed".into(),
1797 window: Some("five_hour".into()),
1798 resets_at: Some(1_785_260_400),
1799 overage_status: None,
1800 is_using_overage: None,
1801 };
1802 assert!(events.contains(&Event::RateLimit(limit.clone())));
1803 assert_eq!(term.rate_limit, Some(limit.clone()));
1804 assert!(
1805 !limit.is_blocking(),
1806 "an `allowed` heartbeat is not a block"
1807 );
1808 }
1809
1810 #[test]
1811 fn claude_error_result_sets_the_stop_reason() {
1812 let (_, term) = run(
1813 Agent::Claude,
1814 &[r#"{"type":"result","is_error":true,"result":"boom","session_id":"s"}"#],
1815 );
1816 assert_eq!(term.stop, Stop::Error);
1817 }
1818
1819 #[test]
1820 fn copilot_streams_deltas_and_takes_its_answer_from_the_settled_message() {
1821 let (events, term) = run(
1822 Agent::Copilot,
1823 &[
1824 r#"{"type":"assistant.message_delta","data":{"messageId":"m","deltaContent":"po"}}"#,
1825 r#"{"type":"assistant.message_delta","data":{"messageId":"m","deltaContent":"ng"}}"#,
1826 r#"{"type":"assistant.message","data":{"messageId":"m","model":"gpt-5-mini","content":"pong"}}"#,
1827 r#"{"type":"result","sessionId":"768c8e7d","exitCode":0,"usage":{"premiumRequests":0}}"#,
1828 ],
1829 );
1830 let texts: Vec<_> = events
1832 .iter()
1833 .filter_map(|e| match e {
1834 Event::Text(t) => Some(t.as_str()),
1835 _ => None,
1836 })
1837 .collect();
1838 assert_eq!(texts, ["po", "ng"]);
1839 assert_eq!(term.text, "pong", "the answer is the settled message");
1840 assert_eq!(term.session.as_deref(), Some("768c8e7d"));
1841 assert_eq!(term.usage.premium_requests, Some(0));
1842 }
1843
1844 #[test]
1845 fn copilot_brackets_a_tool_call_with_its_completion() {
1846 let (events, _) = run(
1847 Agent::Copilot,
1848 &[
1849 r#"{"type":"tool.execution_start","data":{"toolCallId":"call_1","toolName":"bash","arguments":{"command":"ls"}}}"#,
1850 r#"{"type":"tool.execution_complete","data":{"toolCallId":"call_1","success":true,"result":{"content":"a.txt"}}}"#,
1851 ],
1852 );
1853 assert!(matches!(
1854 &events[0],
1855 Event::ToolCall { id, name, .. }
1856 if id.as_deref() == Some("call_1") && name == "bash"
1857 ));
1858 assert_eq!(
1859 events[1],
1860 Event::ToolResult {
1861 id: Some("call_1".into()),
1862 ok: Some(true),
1863 output: "a.txt".into()
1864 }
1865 );
1866 }
1867
1868 #[test]
1872 fn a_codex_failed_turn_yields_the_reason_and_the_status() {
1873 let (_, term) = run(
1874 Agent::Codex,
1875 &[
1876 r#"{"type":"thread.started","thread_id":"019fad62"}"#,
1877 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.\"}}"}}"#,
1878 ],
1879 );
1880 assert_eq!(term.stop, Stop::Error);
1881 assert_eq!(term.error_status, Some(400));
1882 assert_eq!(
1883 term.error_message.as_deref(),
1884 Some(
1885 "The 'bogus-model-xyz' model is not supported when using Codex with a ChatGPT account."
1886 ),
1887 "the caller should get the sentence, not the envelope"
1888 );
1889 }
1890
1891 #[test]
1894 fn a_plain_codex_failure_message_passes_through() {
1895 let (_, term) = run(
1896 Agent::Codex,
1897 &[
1898 r#"{"type":"turn.failed","error":{"message":"stream disconnected before completion"}}"#,
1899 ],
1900 );
1901 assert_eq!(term.error_status, None);
1902 assert_eq!(
1903 term.error_message.as_deref(),
1904 Some("stream disconnected before completion")
1905 );
1906 }
1907
1908 #[test]
1909 fn codex_reads_the_thread_id_and_the_completed_message() {
1910 let (events, term) = run(
1911 Agent::Codex,
1912 &[
1913 r#"{"type":"thread.started","thread_id":"0199-xyz"}"#,
1914 r#"{"type":"item.completed","item":{"id":"i1","type":"agent_message","text":"pong"}}"#,
1915 r#"{"type":"turn.completed","usage":{"input_tokens":12,"output_tokens":3,"cached_input_tokens":9}}"#,
1916 ],
1917 );
1918 assert_eq!(
1919 events[0],
1920 Event::Started {
1921 session: "0199-xyz".into(),
1922 model: None
1923 }
1924 );
1925 assert_eq!(term.session.as_deref(), Some("0199-xyz"));
1926 assert_eq!(term.text, "pong");
1927 assert_eq!(term.usage.input_tokens, Some(3));
1932 assert_eq!(term.usage.cache_read_tokens, Some(9));
1933 assert_eq!(term.usage.context_tokens, Some(12));
1934 }
1935
1936 #[test]
1937 fn codex_command_execution_becomes_a_call_and_a_result() {
1938 let (events, _) = run(
1939 Agent::Codex,
1940 &[
1941 r#"{"type":"item.completed","item":{"id":"c1","type":"command_execution","command":"ls","exit_code":0,"aggregated_output":"a.txt"}}"#,
1942 ],
1943 );
1944 assert!(matches!(&events[0], Event::ToolCall { name, .. } if name == "command_execution"));
1945 assert_eq!(
1946 events[1],
1947 Event::ToolResult {
1948 id: Some("c1".into()),
1949 ok: Some(true),
1950 output: "a.txt".into()
1951 }
1952 );
1953 }
1954
1955 #[test]
1959 fn codex_started_then_completed_yields_one_call_and_one_result() {
1960 let (events, _) = run(
1961 Agent::Codex,
1962 &[
1963 r#"{"type":"item.started","item":{"id":"item_1","type":"command_execution","command":"/bin/zsh -lc ls","aggregated_output":"","exit_code":null,"status":"in_progress"}}"#,
1964 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"}}"#,
1965 ],
1966 );
1967 let calls = events
1968 .iter()
1969 .filter(|e| matches!(e, Event::ToolCall { .. }))
1970 .count();
1971 assert_eq!(calls, 1, "the same item must not be announced twice");
1972 let results: Vec<_> = events
1973 .iter()
1974 .filter_map(|e| match e {
1975 Event::ToolResult { output, .. } => Some(output.as_str()),
1976 _ => None,
1977 })
1978 .collect();
1979 assert_eq!(
1980 results,
1981 ["a.txt\n"],
1982 "the in-progress blank must not appear"
1983 );
1984 }
1985
1986 #[test]
1988 fn codex_last_completed_message_is_the_answer() {
1989 let (_, term) = run(
1990 Agent::Codex,
1991 &[
1992 r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"I'll list the directory."}}"#,
1993 r#"{"type":"item.completed","item":{"id":"i2","type":"agent_message","text":"DONE"}}"#,
1994 ],
1995 );
1996 assert_eq!(term.text, "DONE");
1997 }
1998
1999 #[test]
2002 fn an_enormous_tool_result_is_bounded_and_marked() {
2003 let huge = "x".repeat(MAX_EVENT_BYTES * 4);
2004 let line = serde_json::json!({
2005 "type": "user",
2006 "session_id": "s",
2007 "message": {"content": [{
2008 "type": "tool_result", "tool_use_id": "t1", "content": huge
2009 }]}
2010 })
2011 .to_string();
2012
2013 let (events, _) = run(Agent::Claude, &[&line]);
2014 let Some(Event::ToolResult { output, id, .. }) = events
2015 .iter()
2016 .find(|e| matches!(e, Event::ToolResult { .. }))
2017 .cloned()
2018 else {
2019 panic!("expected a tool result, got {events:?}")
2020 };
2021 assert!(
2022 output.len() <= MAX_EVENT_BYTES,
2023 "kept {} bytes",
2024 output.len()
2025 );
2026 assert!(
2027 output.ends_with(TRUNCATION_MARK),
2028 "truncation must be visible"
2029 );
2030 assert_eq!(id.as_deref(), Some("t1"), "the id must survive whole");
2031 }
2032
2033 #[test]
2037 fn usable_identifiers_are_never_shortened() {
2038 let id = "s".repeat(MAX_IDENTIFIER_BYTES);
2040 let line =
2041 serde_json::json!({"type": "system", "subtype": "init", "session_id": id}).to_string();
2042 let (events, term) = run(Agent::Claude, &[&line]);
2043
2044 let Some(Event::Started { session, .. }) = events.first().cloned() else {
2045 panic!("expected Started, got {events:?}")
2046 };
2047 assert_eq!(session.len(), id.len(), "the session id was shortened");
2048 assert_eq!(term.session.as_deref(), Some(id.as_str()));
2049 }
2050
2051 #[test]
2056 fn an_oversized_session_id_is_rejected_rather_than_stored() {
2057 let id = "s".repeat(MAX_IDENTIFIER_BYTES + 1);
2058 for (agent, line) in [
2059 (
2060 Agent::Claude,
2061 serde_json::json!({"type": "system", "subtype": "init", "session_id": id})
2062 .to_string(),
2063 ),
2064 (
2065 Agent::Codex,
2066 serde_json::json!({"type": "thread.started", "thread_id": id}).to_string(),
2067 ),
2068 (
2069 Agent::Copilot,
2070 serde_json::json!({"type": "result", "sessionId": id, "exitCode": 0}).to_string(),
2071 ),
2072 ] {
2073 let (events, term) = run(agent, &[&line]);
2074 assert!(term.session.is_none(), "{agent} stored an unusable id");
2075 assert!(
2076 !events.iter().any(|e| matches!(e, Event::Started { .. })),
2077 "{agent} announced a session it cannot resume"
2078 );
2079 }
2080 }
2081
2082 #[test]
2086 fn an_oversized_tool_id_drops_the_id_but_keeps_the_event() {
2087 let id = "t".repeat(MAX_IDENTIFIER_BYTES + 1);
2088 let line = serde_json::json!({
2089 "type": "assistant", "session_id": "s",
2090 "message": {"content": [{
2091 "type": "tool_use", "id": id, "name": "Bash", "input": {"command": "ls"}
2092 }]}
2093 })
2094 .to_string();
2095
2096 let (events, _) = run(Agent::Claude, &[&line]);
2097 let Some(Event::ToolCall { id: seen, name, .. }) = events
2098 .iter()
2099 .find(|e| matches!(e, Event::ToolCall { .. }))
2100 .cloned()
2101 else {
2102 panic!("the call itself must still be reported, got {events:?}")
2103 };
2104 assert_eq!(seen, None, "an unusable id must be dropped, not shortened");
2105 assert_eq!(name, "Bash");
2106 }
2107
2108 #[test]
2111 fn the_pending_tool_map_is_bounded_by_bytes_not_only_entries() {
2112 let mut parser = Parser::new(Agent::Claude, Format::Stream);
2113 for i in 0..MAX_PENDING_TOOLS {
2116 let line = serde_json::json!({
2117 "type": "assistant", "session_id": "s",
2118 "message": {"content": [{
2119 "type": "tool_use",
2120 "id": format!("{i:0>width$}", width = MAX_IDENTIFIER_BYTES),
2121 "name": "x".repeat(MAX_IDENTIFIER_BYTES),
2122 "input": {}
2123 }]}
2124 })
2125 .to_string();
2126 parser.push(&line);
2127 }
2128 assert!(
2129 parser.tool_bytes <= MAX_PENDING_TOOL_BYTES,
2130 "pending tools grew to {} bytes",
2131 parser.tool_bytes
2132 );
2133 }
2134
2135 #[test]
2138 fn a_completed_tool_call_releases_its_budget() {
2139 let mut parser = Parser::new(Agent::Claude, Format::Stream);
2140 let call = |id: &str| {
2141 serde_json::json!({
2142 "type": "assistant", "session_id": "s",
2143 "message": {"content": [{
2144 "type": "tool_use", "id": id, "name": "Bash", "input": {}
2145 }]}
2146 })
2147 .to_string()
2148 };
2149 let result = |id: &str| {
2150 serde_json::json!({
2151 "type": "user", "session_id": "s",
2152 "message": {"content": [{
2153 "type": "tool_result", "tool_use_id": id, "content": "done"
2154 }]}
2155 })
2156 .to_string()
2157 };
2158
2159 for i in 0..(MAX_PENDING_TOOLS * 4) {
2160 let id = format!("toolu_{i}");
2161 parser.push(&call(&id));
2162 parser.push(&result(&id));
2163 }
2164 assert_eq!(parser.tool_bytes, 0, "budget leaked across paired calls");
2165 assert!(parser.tools.is_empty());
2166 }
2167
2168 #[test]
2171 fn a_worst_case_event_stays_within_the_stated_ceiling() {
2172 let huge = "x".repeat(MAX_LINE);
2173 let line = serde_json::json!({
2174 "type": "assistant", "session_id": huge,
2175 "message": {"content": [{
2176 "type": "tool_use", "id": huge, "name": huge, "input": {"command": huge}
2177 }]}
2178 })
2179 .to_string();
2180
2181 let (events, _) = run(Agent::Claude, &[&line]);
2182 for event in &events {
2183 let size = serde_json::to_string(event).unwrap().len();
2184 let ceiling = MAX_EVENT_BYTES + 4 * MAX_IDENTIFIER_BYTES;
2186 assert!(size <= ceiling, "an event reached {size} bytes: {event:?}");
2187 }
2188 }
2189
2190 #[test]
2193 fn oversized_tool_arguments_stay_valid_json() {
2194 let line = serde_json::json!({
2195 "type": "assistant",
2196 "session_id": "s",
2197 "message": {"content": [{
2198 "type": "tool_use", "id": "t1", "name": "Bash",
2199 "input": {"command": "y".repeat(MAX_EVENT_BYTES * 3)}
2200 }]}
2201 })
2202 .to_string();
2203
2204 let (events, _) = run(Agent::Claude, &[&line]);
2205 let Some(Event::ToolCall { input, .. }) = events
2206 .iter()
2207 .find(|e| matches!(e, Event::ToolCall { .. }))
2208 .cloned()
2209 else {
2210 panic!("expected a tool call, got {events:?}")
2211 };
2212 assert_eq!(input["truncated"], true, "got {input}");
2213 assert!(
2214 input.is_object(),
2215 "the replacement must still be valid JSON"
2216 );
2217 assert!(input.to_string().len() <= MAX_EVENT_BYTES);
2218 }
2219
2220 #[test]
2221 fn ordinary_payloads_pass_through_untouched() {
2222 let (events, _) = run(
2223 Agent::Claude,
2224 &[
2225 r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"text","text":"pong"}]}}"#,
2226 ],
2227 );
2228 assert!(events.contains(&Event::Text("pong".into())), "{events:?}");
2229 }
2230
2231 #[test]
2232 fn capture_is_bounded_and_keeps_the_earliest_output() {
2233 let mut buf = String::new();
2234 for i in 0..50_000 {
2236 append_capped(&mut buf, &format!("line {i} aaaaaaaaaaaaaaaaaaaaaaaaaaaa"));
2237 }
2238 assert!(buf.len() <= MAX_CAPTURE, "grew to {}", buf.len());
2239 assert!(buf.starts_with("line 0 "), "the earliest output is kept");
2240 }
2241
2242 #[test]
2243 fn capping_never_splits_a_multibyte_character() {
2244 let mut buf = "x".repeat(MAX_CAPTURE - 3);
2245 assert!(append_capped(&mut buf, "🙂🙂"));
2247 assert!(buf.len() <= MAX_CAPTURE);
2248 assert!(buf.is_char_boundary(buf.len()));
2251 }
2252
2253 #[test]
2254 fn a_full_buffer_reports_that_it_took_nothing() {
2255 let mut buf = "x".repeat(MAX_CAPTURE);
2256 assert!(!append_capped(&mut buf, "more"));
2257 assert_eq!(buf.len(), MAX_CAPTURE);
2258 }
2259
2260 #[test]
2263 fn unparseable_lines_are_counted_and_sampled() {
2264 let (_, term) = run(
2265 Agent::Claude,
2266 &[
2267 "<html>an error page, not JSON</html>",
2268 "another bad line",
2269 r#"{"type":"result","result":"ok","session_id":"s"}"#,
2270 ],
2271 );
2272 assert_eq!(term.unparsed, 2);
2273 assert_eq!(
2274 term.first_unparsed.as_deref(),
2275 Some("<html>an error page, not JSON</html>")
2276 );
2277 }
2278
2279 #[test]
2280 fn a_clean_stream_reports_no_parse_failures() {
2281 let (_, term) = run(
2282 Agent::Claude,
2283 &[r#"{"type":"result","result":"ok","session_id":"s"}"#],
2284 );
2285 assert_eq!(term.unparsed, 0);
2286 assert!(term.first_unparsed.is_none());
2287 }
2288
2289 #[test]
2292 fn tool_result_blocks_that_are_not_text_are_kept_not_dropped() {
2293 let (events, _) = run(
2294 Agent::Claude,
2295 &[
2296 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"}}]}]}}"#,
2297 ],
2298 );
2299 let output = events
2300 .iter()
2301 .find_map(|e| match e {
2302 Event::ToolResult { output, .. } => Some(output),
2303 _ => None,
2304 })
2305 .unwrap_or_else(|| panic!("expected a tool result, got {events:?}"));
2306 assert!(output.contains("seen"));
2307 assert!(output.contains("image"), "the image block was dropped");
2308 }
2309
2310 #[test]
2311 fn garbage_lines_are_skipped_not_fatal() {
2312 let (events, term) = run(
2313 Agent::Claude,
2314 &[
2315 "Warning: something on stdout",
2316 "",
2317 r#"{"type":"result","result":"ok","session_id":"s"}"#,
2318 ],
2319 );
2320 assert!(events.iter().all(|e| !matches!(e, Event::Text(_))));
2321 assert_eq!(term.text, "ok");
2322 }
2323
2324 #[test]
2325 fn text_format_passes_lines_through_verbatim() {
2326 let mut p = Parser::new(Agent::Copilot, Format::Text);
2327 let events: Vec<_> = ["hello", "world"].iter().flat_map(|l| p.push(l)).collect();
2328 assert_eq!(
2329 events,
2330 [Event::Text("hello".into()), Event::Text("world".into())]
2331 );
2332 assert_eq!(p.finish().text, "hello\nworld");
2333 }
2334}