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 Compaction(crate::command::Compaction),
98 Commands(crate::command::Commands),
104}
105
106pub const MAX_CAPTURE: usize = 1024 * 1024;
113
114pub const MAX_LINE: usize = 512 * 1024;
121
122pub const MAX_EVENT_BYTES: usize = 64 * 1024;
133
134pub const TRUNCATION_MARK: &str = "…(truncated)";
137
138pub const MAX_IDENTIFIER_BYTES: usize = 4 * 1024;
150
151pub(crate) const MAX_PENDING_TOOL_BYTES: usize = 256 * 1024;
157
158pub(crate) const MAX_PENDING_TOOLS: usize = 1024;
163
164pub(crate) fn append_capped(buf: &mut String, line: &str) -> bool {
171 let remaining = MAX_CAPTURE.saturating_sub(buf.len());
172 if remaining == 0 {
173 return false;
174 }
175 if line.len() < remaining {
177 buf.push_str(line);
178 buf.push('\n');
179 } else {
180 let mut cut = remaining - 1;
182 while cut > 0 && !line.is_char_boundary(cut) {
183 cut -= 1;
184 }
185 buf.push_str(&line[..cut]);
186 buf.push('\n');
187 }
188 true
189}
190
191fn usable_identifier(value: &str) -> bool {
196 value.len() <= MAX_IDENTIFIER_BYTES
197}
198
199fn accept_identifier(value: Option<String>) -> Option<String> {
201 value.filter(|v| usable_identifier(v))
202}
203
204fn bound_text(text: String) -> String {
206 if text.len() <= MAX_EVENT_BYTES {
207 return text;
208 }
209 let mut cut = MAX_EVENT_BYTES - TRUNCATION_MARK.len();
210 while cut > 0 && !text.is_char_boundary(cut) {
211 cut -= 1;
212 }
213 let mut out = text[..cut].to_string();
214 out.push_str(TRUNCATION_MARK);
215 out
216}
217
218fn bound_value(value: Value) -> Value {
224 let size = value.to_string().len();
225 if size <= MAX_EVENT_BYTES {
226 return value;
227 }
228 serde_json::json!({
229 "truncated": true,
230 "original_bytes": size,
231 "note": "arguments exceeded MAX_EVENT_BYTES and were dropped rather than \
232 truncated, which would have produced invalid JSON",
233 })
234}
235
236fn enforce_bounds(event: Event) -> Event {
243 match event {
244 Event::Text(text) => Event::Text(bound_text(text)),
245 Event::Thinking(text) => Event::Thinking(bound_text(text)),
246 Event::ToolCall { id, name, input } => Event::ToolCall {
250 id: accept_identifier(id),
251 name: bound_identifier(name),
252 input: bound_value(input),
253 },
254 Event::ToolResult { id, ok, output } => Event::ToolResult {
255 id: accept_identifier(id),
256 ok,
257 output: bound_text(output),
258 },
259 Event::Started { session, model } => Event::Started {
263 session,
264 model: model.map(bound_identifier),
265 },
266 Event::Usage(usage) => Event::Usage(usage),
268 Event::ApprovalRequest(approval) => {
269 Event::ApprovalRequest(crate::approval::Approval {
270 id: approval.id,
274 tool: bound_identifier(approval.tool),
275 input: bound_value(approval.input),
276 })
277 }
278 Event::RateLimit(limit) => Event::RateLimit(RateLimit {
279 status: bound_identifier(limit.status),
280 window: limit.window.map(bound_identifier),
281 resets_at: limit.resets_at,
282 overage_status: limit.overage_status.map(bound_identifier),
283 is_using_overage: limit.is_using_overage,
284 }),
285 Event::Compaction(crate::command::Compaction::Finished { ok, error }) => {
288 Event::Compaction(crate::command::Compaction::Finished {
289 ok,
290 error: error.map(bound_text),
291 })
292 }
293 Event::Compaction(phase) => Event::Compaction(phase),
294 Event::Commands(commands) => Event::Commands(crate::command::Commands {
297 all: commands.all.into_iter().map(bound_identifier).collect(),
298 skills: commands.skills.into_iter().map(bound_identifier).collect(),
299 }),
300 }
301}
302
303fn bound_identifier(text: String) -> String {
308 if text.len() <= MAX_IDENTIFIER_BYTES {
309 return text;
310 }
311 let mut cut = MAX_IDENTIFIER_BYTES - TRUNCATION_MARK.len();
312 while cut > 0 && !text.is_char_boundary(cut) {
313 cut -= 1;
314 }
315 let mut out = text[..cut].to_string();
316 out.push_str(TRUNCATION_MARK);
317 out
318}
319
320#[derive(Debug, Clone, Default, PartialEq)]
322pub struct Terminal {
323 pub session: Option<String>,
325 pub model: Option<String>,
332 pub text: String,
334 pub usage: Usage,
336 pub stop: Stop,
338 pub rate_limit: Option<RateLimit>,
340 pub unparsed: usize,
346 pub first_unparsed: Option<String>,
348 pub structured: Option<Value>,
350 pub error_status: Option<u16>,
353 pub error_message: Option<String>,
357}
358
359fn unwrap_error_body(message: &str) -> (Option<u16>, String) {
367 let Ok(body) = serde_json::from_str::<Value>(message) else {
368 return (None, message.to_string());
369 };
370 let status = body
371 .get("status")
372 .and_then(Value::as_u64)
373 .and_then(|s| u16::try_from(s).ok());
374 let inner = body
375 .get("error")
376 .and_then(|e| e.get("message"))
377 .and_then(Value::as_str)
378 .map(str::to_string);
379 (status, inner.unwrap_or_else(|| message.to_string()))
380}
381
382#[derive(Debug)]
384pub(crate) struct Parser {
385 agent: Agent,
386 format: Format,
387 term: Terminal,
388 tools: HashMap<String, String>,
390 tool_bytes: usize,
393 seen: Seen,
395 latest_context: Option<u64>,
401}
402
403#[derive(Debug, Default)]
409#[expect(
410 clippy::struct_excessive_bools,
411 reason = "five independent stream milestones; naming each beats packing them"
412)]
413struct Seen {
414 started: bool,
416 structured: bool,
419 terminal: bool,
421 catalogue: bool,
426 usage_of: Option<String>,
433 deltas: bool,
440}
441
442impl Parser {
443 #[must_use]
445 pub fn new(agent: Agent, format: Format) -> Self {
446 Self {
447 agent,
448 format,
449 term: Terminal::default(),
450 tools: HashMap::new(),
451 tool_bytes: 0,
452 seen: Seen::default(),
453 latest_context: None,
454 }
455 }
456
457 pub fn push(&mut self, line: &str) -> Vec<Event> {
465 let line = line.trim();
466 if line.is_empty() {
467 return Vec::new();
468 }
469 if self.format == Format::Text {
472 append_capped(&mut self.term.text, line);
473 return vec![enforce_bounds(Event::Text(line.to_string()))];
474 }
475 let Ok(value) = serde_json::from_str::<Value>(line) else {
476 self.term.unparsed += 1;
477 if self.term.first_unparsed.is_none() {
478 let mut cut = line.len().min(512);
482 while cut > 0 && !line.is_char_boundary(cut) {
483 cut -= 1;
484 }
485 self.term.first_unparsed = Some(line[..cut].to_string());
486 }
487 return Vec::new();
488 };
489 if let Some(ty) = value.get("type").and_then(Value::as_str)
492 && self.recognizes(ty)
493 {
494 self.seen.structured = true;
495 }
496 let mut out = match self.agent {
497 Agent::Claude => self.claude(&value),
498 Agent::Codex => self.codex(&value),
499 Agent::Copilot => self.copilot(&value),
500 };
501 out = out.into_iter().map(enforce_bounds).collect();
504
505 if !self.seen.started {
508 if let Some(session) = self.term.session.clone() {
509 self.seen.started = true;
510 let model = model_of(&value);
511 self.term.model.clone_from(&model);
512 out.insert(0, Event::Started { session, model });
513 }
514 }
515 out
516 }
517
518 fn live_usage(&mut self, v: &Value) -> Vec<Event> {
528 let Some(message) = v.get("message") else {
529 return Vec::new();
530 };
531 let Some(usage) = message.get("usage") else {
532 return Vec::new();
533 };
534 let get = |key: &str| usage.get(key).and_then(Value::as_u64);
535 let (input, read, write) = (
536 get("input_tokens"),
537 get("cache_read_input_tokens"),
538 get("cache_creation_input_tokens"),
539 );
540 if input.is_none() && read.is_none() && write.is_none() {
541 return Vec::new();
542 }
543 let prompt = input.unwrap_or(0) + read.unwrap_or(0) + write.unwrap_or(0);
544 self.latest_context = Some(prompt);
558
559 let Some(id) = message.get("id").and_then(Value::as_str) else {
562 return Vec::new();
563 };
564 if self.seen.usage_of.as_deref() == Some(id) {
565 return Vec::new();
566 }
567 self.seen.usage_of = Some(id.to_string());
568 vec![Event::Usage(Usage {
569 input_tokens: input,
570 cache_read_tokens: read,
571 cache_write_tokens: write,
572 context_tokens: Some(prompt),
573 output_tokens: None,
576 ..Usage::default()
577 })]
578 }
579
580 pub(crate) fn saw_terminal(&self) -> bool {
587 self.seen.terminal
588 }
589
590 fn recognizes(&self, ty: &str) -> bool {
592 match self.agent {
593 Agent::Claude => matches!(
594 ty,
595 "system" | "assistant" | "user" | "result" | "rate_limit_event" | "control_request"
596 ),
597 Agent::Codex => {
598 ty.starts_with("thread.") || ty.starts_with("turn.") || ty.starts_with("item.")
599 }
600 Agent::Copilot => {
601 ty == "result"
602 || ty.starts_with("assistant.")
603 || ty.starts_with("tool.")
604 || ty.starts_with("session.")
605 }
606 }
607 }
608
609 fn remember_tool(&mut self, id: &str, name: &str) {
612 if !usable_identifier(id) {
615 return;
616 }
617 let name = bound_identifier(name.to_string());
618 let cost = id.len() + name.len();
619 if self.tools.len() >= MAX_PENDING_TOOLS
623 || self.tool_bytes.saturating_add(cost) > MAX_PENDING_TOOL_BYTES
624 {
625 return;
626 }
627 self.tool_bytes += cost;
628 if let Some(previous) = self.tools.insert(id.to_string(), name) {
629 self.tool_bytes = self.tool_bytes.saturating_sub(id.len() + previous.len());
631 }
632 }
633
634 fn forget_tool(&mut self, id: &str) {
636 if let Some(name) = self.tools.remove(id) {
637 self.tool_bytes = self.tool_bytes.saturating_sub(id.len() + name.len());
638 }
639 }
640
641 pub(crate) fn saw_structured_record(&self) -> bool {
646 self.seen.structured
647 }
648
649 pub(crate) fn saw_terminal_record(&self) -> bool {
652 self.seen.terminal
653 }
654
655 #[must_use]
657 pub fn finish(mut self) -> Terminal {
658 if self.format == Format::Text {
659 self.term.text = self.term.text.trim_end().to_string();
660 }
661 self.term
662 }
663
664 fn claude(&mut self, v: &Value) -> Vec<Event> {
670 let ty = v.get("type").and_then(Value::as_str).unwrap_or_default();
671 if let Some(id) = v.get("session_id").and_then(Value::as_str)
674 && usable_identifier(id)
675 {
676 self.term.session.get_or_insert_with(|| id.to_string());
677 }
678 match ty {
679 "rate_limit_event" => {
680 let limit = claude_rate_limit(v.get("rate_limit_info"));
681 self.term.rate_limit.clone_from(&limit);
682 limit.into_iter().map(Event::RateLimit).collect()
683 }
684 "stream_event" => self.claude_delta(v),
686 "control_request" => {
694 let Some(request) = v.get("request") else {
695 return Vec::new();
696 };
697 if request.get("subtype").and_then(Value::as_str) != Some("can_use_tool") {
698 return Vec::new();
699 }
700 let Some(id) = v.get("request_id").and_then(Value::as_str) else {
701 return Vec::new();
705 };
706 if !usable_identifier(id) {
707 return Vec::new();
708 }
709 vec![Event::ApprovalRequest(crate::approval::Approval {
710 id: id.to_string(),
711 tool: request
712 .get("tool_name")
713 .and_then(Value::as_str)
714 .unwrap_or("unknown")
715 .to_string(),
716 input: request.get("input").cloned().unwrap_or(Value::Null),
717 })]
718 }
719 "assistant" | "user" => self.content_blocks(v),
720 "system" => self.claude_system(v),
721 "result" => {
722 self.seen.terminal = true;
723 if let Some(text) = v.get("result").and_then(Value::as_str) {
724 self.term.text = text.to_string();
725 }
726 if let Some(value) = v.get("structured_output") {
729 self.term.structured = Some(value.clone());
730 }
731 self.term.usage = claude_usage(v, self.term.model.as_deref());
732 if self.latest_context.is_some() {
736 self.term.usage.context_tokens = self.latest_context;
737 }
738 self.term.stop = if v.get("is_error").and_then(Value::as_bool) == Some(true) {
741 self.term.error_status = v
742 .get("api_error_status")
743 .and_then(Value::as_u64)
744 .and_then(|s| u16::try_from(s).ok());
745 Stop::Error
746 } else {
747 stop_from(v.get("stop_reason"))
748 };
749 Vec::new()
750 }
751 _ => Vec::new(),
752 }
753 }
754
755 fn claude_system(&mut self, v: &Value) -> Vec<Event> {
766 match v.get("subtype").and_then(Value::as_str) {
767 Some("init") => {
768 if self.seen.catalogue {
772 return Vec::new();
773 }
774 let names = |key: &str| -> Vec<String> {
775 v.get(key)
776 .and_then(Value::as_array)
777 .map(|entries| {
778 entries
779 .iter()
780 .filter_map(Value::as_str)
781 .map(str::to_string)
782 .collect()
783 })
784 .unwrap_or_default()
785 };
786 let commands = crate::command::Commands {
787 all: names("slash_commands"),
788 skills: names("skills"),
789 };
790 if commands.all.is_empty() {
793 return Vec::new();
794 }
795 self.seen.catalogue = true;
796 vec![Event::Commands(commands)]
797 }
798 Some("status") => {
799 if v.get("status").and_then(Value::as_str) == Some("compacting") {
800 return vec![Event::Compaction(crate::command::Compaction::Started)];
801 }
802 let Some(result) = v.get("compact_result").and_then(Value::as_str) else {
803 return Vec::new();
804 };
805 vec![Event::Compaction(crate::command::Compaction::Finished {
806 ok: result == "success",
807 error: v
808 .get("compact_error")
809 .and_then(Value::as_str)
810 .map(str::to_string),
811 })]
812 }
813 _ => Vec::new(),
814 }
815 }
816
817 fn claude_delta(&mut self, v: &Value) -> Vec<Event> {
824 let Some(event) = v.get("event") else {
825 return Vec::new();
826 };
827 if event.get("type").and_then(Value::as_str) != Some("content_block_delta") {
828 return Vec::new();
829 }
830 let Some(delta) = event.get("delta") else {
831 return Vec::new();
832 };
833 self.seen.deltas = true;
836
837 match delta.get("type").and_then(Value::as_str) {
838 Some("text_delta") => delta
839 .get("text")
840 .and_then(Value::as_str)
841 .filter(|text| !text.is_empty())
842 .map(|text| Event::Text(text.to_string()))
843 .into_iter()
844 .collect(),
845 Some("thinking_delta") => delta
846 .get("thinking")
847 .and_then(Value::as_str)
848 .filter(|text| !text.is_empty())
849 .map(|text| Event::Thinking(text.to_string()))
850 .into_iter()
851 .collect(),
852 _ => Vec::new(),
856 }
857 }
858
859 fn content_blocks(&mut self, v: &Value) -> Vec<Event> {
862 let mut out = self.live_usage(v);
863 let blocks = v
864 .get("message")
865 .and_then(|m| m.get("content"))
866 .and_then(Value::as_array);
867 let Some(blocks) = blocks else {
868 return out;
869 };
870 for block in blocks {
871 let ty = block
872 .get("type")
873 .and_then(Value::as_str)
874 .unwrap_or_default();
875 match ty {
876 "text" if !self.seen.deltas => {
881 if let Some(t) = block.get("text").and_then(Value::as_str) {
882 out.push(Event::Text(t.to_string()));
883 }
884 }
885 "thinking" if !self.seen.deltas => {
886 if let Some(t) = block.get("thinking").and_then(Value::as_str) {
887 out.push(Event::Thinking(t.to_string()));
888 }
889 }
890 "tool_use" => {
891 let name = block
892 .get("name")
893 .and_then(Value::as_str)
894 .unwrap_or("tool")
895 .to_string();
896 let id = block.get("id").and_then(Value::as_str).map(str::to_string);
897 if let Some(id) = &id {
898 self.remember_tool(id, &name);
899 }
900 out.push(Event::ToolCall {
901 id,
902 name,
903 input: block.get("input").cloned().unwrap_or(Value::Null),
904 });
905 }
906 "tool_result" => out.push(Event::ToolResult {
907 id: block
908 .get("tool_use_id")
909 .and_then(Value::as_str)
910 .inspect(|id| {
911 self.forget_tool(id);
913 })
914 .map(str::to_string),
915 ok: block
916 .get("is_error")
917 .and_then(Value::as_bool)
918 .map(|is_error| !is_error),
919 output: flatten_text(block.get("content")),
920 }),
921 _ => {}
922 }
923 }
924 out
925 }
926
927 fn codex(&mut self, v: &Value) -> Vec<Event> {
935 let ty = v.get("type").and_then(Value::as_str).unwrap_or_default();
936 if let Some(id) = v.get("thread_id").and_then(Value::as_str)
937 && usable_identifier(id)
938 {
939 self.term.session.get_or_insert_with(|| id.to_string());
940 }
941 match ty {
942 "turn.completed" => {
943 self.seen.terminal = true;
944 self.term.usage = codex_usage(v.get("usage"));
945 Vec::new()
946 }
947 "turn.failed" => {
948 self.seen.terminal = true;
949 self.term.stop = Stop::Error;
950 if let Some(message) = v
951 .get("error")
952 .and_then(|e| e.get("message"))
953 .and_then(Value::as_str)
954 {
955 let (status, message) = unwrap_error_body(message);
956 self.term.error_status = status;
957 self.term.error_message = Some(bound_text(message));
958 }
959 Vec::new()
960 }
961 "item.started" | "item.updated" | "item.completed" => {
962 let Some(item) = v.get("item") else {
963 return Vec::new();
964 };
965 let item_ty = item.get("type").and_then(Value::as_str).unwrap_or_default();
966 let id = item.get("id").and_then(Value::as_str).map(str::to_string);
967 let done = ty == "item.completed";
968
969 let name = tool_name(item, item_ty);
973 let first = id
974 .as_ref()
975 .is_none_or(|id| self.tools.insert(id.clone(), name.clone()).is_none());
976
977 match item_ty {
978 "agent_message" => {
981 if !done {
982 return Vec::new();
983 }
984 let text = item.get("text").and_then(Value::as_str).unwrap_or_default();
985 self.term.text = text.to_string();
986 vec![Event::Text(text.to_string())]
987 }
988 "reasoning" if done => item
989 .get("text")
990 .and_then(Value::as_str)
991 .map(|t| Event::Thinking(t.to_string()))
992 .into_iter()
993 .collect(),
994 "command_execution" | "mcp_tool_call" | "file_change" | "web_search" => {
995 let mut out = Vec::new();
996 if first {
997 out.push(Event::ToolCall {
998 id: id.clone(),
999 name,
1000 input: codex_tool_input(item, item_ty),
1001 });
1002 }
1003 if done {
1006 if let Some(id) = &id {
1007 self.forget_tool(id);
1008 }
1009 out.push(Event::ToolResult {
1010 id,
1011 ok: item
1012 .get("exit_code")
1013 .and_then(Value::as_i64)
1014 .map(|code| code == 0),
1015 output: item
1016 .get("aggregated_output")
1017 .and_then(Value::as_str)
1018 .unwrap_or_default()
1019 .to_string(),
1020 });
1021 }
1022 out
1023 }
1024 _ => Vec::new(),
1025 }
1026 }
1027 _ => Vec::new(),
1028 }
1029 }
1030
1031 fn copilot(&mut self, v: &Value) -> Vec<Event> {
1038 let ty = v.get("type").and_then(Value::as_str).unwrap_or_default();
1039 let data = v.get("data");
1040 let field = |key: &str| -> Option<String> {
1041 data.and_then(|d| d.get(key))
1042 .and_then(Value::as_str)
1043 .map(str::to_string)
1044 };
1045 match ty {
1046 "assistant.message_delta" => field("deltaContent")
1048 .filter(|t| !t.is_empty())
1049 .map(Event::Text)
1050 .into_iter()
1051 .collect(),
1052 "assistant.message" => {
1055 if let Some(content) = field("content") {
1056 self.term.text = content;
1057 }
1058 Vec::new()
1059 }
1060 "assistant.reasoning" => field("content")
1061 .filter(|t| !t.is_empty())
1062 .map(Event::Thinking)
1063 .into_iter()
1064 .collect(),
1065 "tool.execution_start" => {
1066 let id = field("toolCallId");
1067 let name = field("toolName").unwrap_or_else(|| "tool".into());
1068 if let Some(id) = &id {
1069 self.remember_tool(id, &name);
1070 }
1071 vec![Event::ToolCall {
1072 id,
1073 name,
1074 input: data
1075 .and_then(|d| d.get("arguments"))
1076 .cloned()
1077 .unwrap_or(Value::Null),
1078 }]
1079 }
1080 "tool.execution_complete" => vec![Event::ToolResult {
1081 id: field("toolCallId").inspect(|id| {
1082 self.forget_tool(id);
1083 }),
1084 ok: data.and_then(|d| d.get("success")).and_then(Value::as_bool),
1085 output: data
1086 .and_then(|d| d.get("result"))
1087 .and_then(|r| r.get("content"))
1088 .and_then(Value::as_str)
1089 .unwrap_or_default()
1090 .to_string(),
1091 }],
1092 "session.usage_checkpoint" => {
1097 if let Some(data) = v.get("data") {
1098 self.term.usage.ai_credits_nano =
1099 data.get("totalNanoAiu").and_then(Value::as_u64);
1100 if let Some(premium) = data.get("totalPremiumRequests").and_then(Value::as_u64)
1101 {
1102 self.term.usage.premium_requests = Some(premium);
1103 }
1104 }
1105 Vec::new()
1106 }
1107 "result" => {
1109 self.seen.terminal = true;
1110 if let Some(id) = v.get("sessionId").and_then(Value::as_str)
1111 && usable_identifier(id)
1112 {
1113 self.term.session = Some(id.to_string());
1114 }
1115 if let Some(usage) = v.get("usage") {
1116 self.term.usage.premium_requests =
1117 usage.get("premiumRequests").and_then(Value::as_u64);
1118 self.term.usage.duration_ms =
1119 usage.get("sessionDurationMs").and_then(Value::as_u64);
1120 self.term.usage.api_duration_ms =
1121 usage.get("totalApiDurationMs").and_then(Value::as_u64);
1122 }
1123 if let Some(code) = v.get("exitCode").and_then(Value::as_i64)
1124 && code != 0
1125 {
1126 self.term.stop = Stop::Error;
1127 self.term.error_message = Some(format!("copilot exited with code {code}"));
1130 }
1131 Vec::new()
1132 }
1133 _ => Vec::new(),
1134 }
1135 }
1136}
1137
1138fn model_of(v: &Value) -> Option<String> {
1141 v.get("model")
1142 .or_else(|| v.get("data").and_then(|d| d.get("model")))
1143 .and_then(Value::as_str)
1144 .map(str::to_string)
1145}
1146
1147fn stop_from(v: Option<&Value>) -> Stop {
1149 match v.and_then(Value::as_str) {
1150 None | Some("end_turn" | "stop" | "completed") => Stop::Completed,
1151 Some(other) => Stop::Other(other.to_string()),
1152 }
1153}
1154
1155fn claude_rate_limit(v: Option<&Value>) -> Option<RateLimit> {
1157 let v = v?;
1158 Some(RateLimit {
1159 status: v.get("status").and_then(Value::as_str)?.to_string(),
1160 window: v
1161 .get("rateLimitType")
1162 .and_then(Value::as_str)
1163 .map(str::to_string),
1164 resets_at: v.get("resetsAt").and_then(Value::as_i64),
1165 overage_status: v
1166 .get("overageStatus")
1167 .and_then(Value::as_str)
1168 .map(str::to_string),
1169 is_using_overage: v.get("isUsingOverage").and_then(Value::as_bool),
1170 })
1171}
1172
1173fn claude_usage(v: &Value, model: Option<&str>) -> Usage {
1175 let u = v.get("usage");
1176 let get = |key: &str| u.and_then(|u| u.get(key)).and_then(Value::as_u64);
1177 let (input, read, write) = (
1178 get("input_tokens"),
1179 get("cache_read_input_tokens"),
1180 get("cache_creation_input_tokens"),
1181 );
1182 let per_model = v
1190 .get("modelUsage")
1191 .and_then(Value::as_object)
1192 .and_then(
1193 |models| match (model.and_then(|m| models.get(m)), models.len()) {
1194 (Some(entry), _) => Some(entry),
1195 (None, 1) => models.values().next(),
1197 (None, _) => None,
1200 },
1201 );
1202 let of_model = |key: &str| per_model.and_then(|m| m.get(key)).and_then(Value::as_u64);
1203 Usage {
1204 input_tokens: input,
1205 output_tokens: get("output_tokens"),
1206 cache_read_tokens: read,
1207 cache_write_tokens: write,
1208 context_tokens: (input.is_some() || read.is_some() || write.is_some())
1212 .then(|| input.unwrap_or(0) + read.unwrap_or(0) + write.unwrap_or(0)),
1213 context_window: of_model("contextWindow"),
1214 max_output_tokens: of_model("maxOutputTokens"),
1215 reasoning_tokens: None,
1216 cost_usd: v.get("total_cost_usd").and_then(Value::as_f64),
1217 premium_requests: None,
1218 ai_credits_nano: None,
1219 duration_ms: v.get("duration_ms").and_then(Value::as_u64),
1220 api_duration_ms: v.get("duration_api_ms").and_then(Value::as_u64),
1221 }
1222}
1223
1224fn codex_usage(v: Option<&Value>) -> Usage {
1227 let get = |key: &str| v.and_then(|u| u.get(key)).and_then(Value::as_u64);
1228 let (prompt, cached) = (get("input_tokens"), get("cached_input_tokens"));
1229 Usage {
1230 input_tokens: match (prompt, cached) {
1238 (Some(prompt), Some(cached)) => Some(prompt.saturating_sub(cached)),
1239 (prompt, _) => prompt,
1240 },
1241 output_tokens: get("output_tokens"),
1242 cache_read_tokens: cached,
1243 cache_write_tokens: get("cache_write_input_tokens"),
1244 context_tokens: prompt,
1245 context_window: None,
1246 max_output_tokens: None,
1247 reasoning_tokens: get("reasoning_output_tokens"),
1248 cost_usd: None,
1249 premium_requests: None,
1250 ai_credits_nano: None,
1251 duration_ms: None,
1252 api_duration_ms: None,
1253 }
1254}
1255
1256fn tool_name(item: &Value, item_ty: &str) -> String {
1259 item.get("tool")
1260 .and_then(Value::as_str)
1261 .unwrap_or(item_ty)
1262 .to_string()
1263}
1264
1265fn codex_tool_input(item: &Value, item_ty: &str) -> Value {
1267 match item_ty {
1268 "command_execution" => serde_json::json!({ "command": item.get("command") }),
1269 "mcp_tool_call" => item.get("arguments").cloned().unwrap_or(Value::Null),
1270 _ => item.clone(),
1273 }
1274}
1275
1276fn flatten_text(v: Option<&Value>) -> String {
1284 match v {
1285 Some(Value::String(s)) => s.clone(),
1286 Some(Value::Array(blocks)) => blocks
1287 .iter()
1288 .map(|b| match b.get("text").and_then(Value::as_str) {
1289 Some(text) => text.to_string(),
1290 None => b.to_string(),
1291 })
1292 .collect::<Vec<_>>()
1293 .join("\n"),
1294 Some(other) => other.to_string(),
1295 None => String::new(),
1296 }
1297}
1298
1299#[cfg(test)]
1300mod tests {
1301 use super::*;
1302
1303 fn run(agent: Agent, lines: &[&str]) -> (Vec<Event>, Terminal) {
1305 let mut p = Parser::new(agent, Format::Stream);
1306 let events = lines.iter().flat_map(|l| p.push(l)).collect();
1307 (events, p.finish())
1308 }
1309
1310 #[test]
1318 fn a_compaction_reports_its_phases_and_settles_cleanly() {
1319 let (events, _) = run(
1320 Agent::Claude,
1321 &[
1322 r#"{"type":"system","subtype":"init","session_id":"s","model":"claude-opus-5","slash_commands":["compact","context","code-review"],"skills":["code-review"]}"#,
1323 r#"{"type":"system","subtype":"status","status":"compacting","session_id":"s"}"#,
1324 r#"{"type":"system","subtype":"status","status":null,"compact_result":"success","session_id":"s"}"#,
1325 r#"{"type":"system","subtype":"init","session_id":"s","slash_commands":["compact","context","code-review"],"skills":["code-review"]}"#,
1326 r#"{"type":"system","subtype":"compact_boundary","session_id":"s"}"#,
1327 r#"{"type":"result","subtype":"success","is_error":false,"result":"","session_id":"s","usage":{"input_tokens":0,"output_tokens":0}}"#,
1328 ],
1329 );
1330
1331 let catalogue: Vec<_> = events
1332 .iter()
1333 .filter_map(|event| match event {
1334 Event::Commands(commands) => Some(commands),
1335 _ => None,
1336 })
1337 .collect();
1338 assert_eq!(
1339 catalogue.len(),
1340 1,
1341 "the re-init after compacting must not redraw the palette"
1342 );
1343 assert_eq!(catalogue[0].utilities(), vec!["compact", "context"]);
1344
1345 let phases: Vec<_> = events
1346 .iter()
1347 .filter_map(|event| match event {
1348 Event::Compaction(phase) => Some(phase.clone()),
1349 _ => None,
1350 })
1351 .collect();
1352 assert_eq!(
1353 phases,
1354 vec![
1355 crate::command::Compaction::Started,
1356 crate::command::Compaction::Finished {
1357 ok: true,
1358 error: None
1359 },
1360 ]
1361 );
1362 }
1363
1364 #[test]
1366 fn a_refused_compaction_is_reported_not_raised() {
1367 let (events, _) = run(
1368 Agent::Claude,
1369 &[
1370 r#"{"type":"system","subtype":"init","session_id":"s","model":"claude-opus-5"}"#,
1371 r#"{"type":"system","subtype":"status","status":null,"compact_result":"failed","compact_error":"Not enough messages to compact.","session_id":"s"}"#,
1372 r#"{"type":"result","subtype":"success","is_error":false,"result":"","session_id":"s"}"#,
1373 ],
1374 );
1375 assert!(events.iter().any(|event| matches!(
1376 event,
1377 Event::Compaction(crate::command::Compaction::Finished { ok: false, error: Some(why) })
1378 if why == "Not enough messages to compact."
1379 )));
1380 assert!(
1383 !events
1384 .iter()
1385 .any(|event| matches!(event, Event::Commands(_)))
1386 );
1387 }
1388
1389 #[test]
1390 fn claude_stream_yields_start_thinking_text_and_terminal_facts() {
1391 let (events, term) = run(
1392 Agent::Claude,
1393 &[
1394 r#"{"type":"system","subtype":"init","session_id":"sess-a","model":"claude-haiku-4-5"}"#,
1395 r#"{"type":"assistant","session_id":"sess-a","message":{"content":[{"type":"thinking","thinking":"brief"}]}}"#,
1396 r#"{"type":"assistant","session_id":"sess-a","message":{"content":[{"type":"text","text":"pong"}]}}"#,
1397 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}}"#,
1398 ],
1399 );
1400 assert_eq!(
1401 events[0],
1402 Event::Started {
1403 session: "sess-a".into(),
1404 model: Some("claude-haiku-4-5".into())
1405 }
1406 );
1407 assert_eq!(events[1], Event::Thinking("brief".into()));
1408 assert_eq!(events[2], Event::Text("pong".into()));
1409 assert_eq!(term.session.as_deref(), Some("sess-a"));
1410 assert_eq!(term.text, "pong");
1411 assert_eq!(term.stop, Stop::Completed);
1412 assert_eq!(term.usage.input_tokens, Some(10));
1413 assert_eq!(term.usage.cache_read_tokens, Some(18764));
1414 assert_eq!(term.usage.cache_write_tokens, Some(7322));
1415 assert_eq!(term.usage.cost_usd, Some(0.017));
1416 }
1417
1418 #[test]
1426 fn the_window_binds_to_the_runs_model_not_the_haiku_helper() {
1427 let (_, term) = run(
1428 Agent::Claude,
1429 &[
1430 r#"{"type":"system","subtype":"init","session_id":"sess-1m","model":"claude-sonnet-5[1m]"}"#,
1431 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}}}"#,
1432 ],
1433 );
1434 assert_eq!(term.model.as_deref(), Some("claude-sonnet-5[1m]"));
1435 assert_eq!(
1436 term.usage.context_window,
1437 Some(1_000_000),
1438 "the helper's 200k window must not shadow the real one"
1439 );
1440 assert_eq!(term.usage.max_output_tokens, Some(64_000));
1441 assert_eq!(term.usage.context_tokens, Some(2 + 27_128 + 9_825));
1443 }
1444
1445 #[test]
1451 fn context_is_the_last_requests_prompt_not_the_turns_sum() {
1452 let (_, term) = run(
1453 Agent::Claude,
1454 &[
1455 r#"{"type":"system","subtype":"init","session_id":"s","model":"claude-sonnet-5"}"#,
1456 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"}}]}}"#,
1457 r#"{"type":"user","session_id":"s","message":{"content":[{"type":"tool_result","tool_use_id":"t1","content":"ok"}]}}"#,
1458 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"}]}}"#,
1459 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}}"#,
1460 ],
1461 );
1462 assert_eq!(
1463 term.usage.context_tokens,
1464 Some(6 + 102_000 + 500),
1465 "the last request's prompt is the context; the turn sum (204,510) is not"
1466 );
1467 assert_eq!(term.usage.cache_read_tokens, Some(202_000));
1469 }
1470
1471 #[test]
1474 fn the_terminal_sum_remains_the_fallback_context() {
1475 let (_, term) = run(
1476 Agent::Claude,
1477 &[
1478 r#"{"type":"system","subtype":"init","session_id":"s","model":"claude-sonnet-5"}"#,
1479 r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"text","text":"ok"}]}}"#,
1480 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}}"#,
1481 ],
1482 );
1483 assert_eq!(term.usage.context_tokens, Some(10 + 1000));
1484 }
1485
1486 #[test]
1489 fn an_unmatchable_window_is_absent_not_guessed() {
1490 let (_, term) = run(
1491 Agent::Claude,
1492 &[
1493 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}}}"#,
1495 ],
1496 );
1497 assert_eq!(term.usage.context_window, None);
1498 let (_, single) = run(
1500 Agent::Claude,
1501 &[
1502 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}}}"#,
1503 ],
1504 );
1505 assert_eq!(single.usage.context_window, Some(200_000));
1506 }
1507
1508 #[test]
1509 fn claude_token_deltas_stream_without_duplicating_the_finished_message() {
1510 let (events, _) = run(
1511 Agent::Claude,
1512 &[
1513 r#"{"type":"system","subtype":"init","session_id":"s"}"#,
1514 r#"{"type":"stream_event","session_id":"s","event":{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}}"#,
1515 r#"{"type":"stream_event","session_id":"s","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"po"}}}"#,
1516 r#"{"type":"stream_event","session_id":"s","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"ng"}}}"#,
1517 r#"{"type":"stream_event","session_id":"s","event":{"type":"content_block_stop","index":0}}"#,
1518 r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"text","text":"pong"}]}}"#,
1520 r#"{"type":"result","subtype":"success","is_error":false,"result":"pong","session_id":"s"}"#,
1521 ],
1522 );
1523 let texts: Vec<_> = events
1524 .iter()
1525 .filter_map(|e| match e {
1526 Event::Text(t) => Some(t.as_str()),
1527 _ => None,
1528 })
1529 .collect();
1530 assert_eq!(texts, ["po", "ng"], "the finished message must not repeat");
1531 }
1532
1533 #[test]
1535 fn claude_thinking_deltas_stream_without_duplication() {
1536 let (events, _) = run(
1537 Agent::Claude,
1538 &[
1539 r#"{"type":"stream_event","session_id":"s","event":{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"weighing"}}}"#,
1540 r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"thinking","thinking":"weighing"}]}}"#,
1541 ],
1542 );
1543 let thoughts: Vec<_> = events
1544 .iter()
1545 .filter_map(|e| match e {
1546 Event::Thinking(t) => Some(t.as_str()),
1547 _ => None,
1548 })
1549 .collect();
1550 assert_eq!(thoughts, ["weighing"]);
1551 }
1552
1553 #[test]
1556 fn a_completed_message_still_streams_when_no_deltas_arrived() {
1557 let (events, _) = run(
1558 Agent::Claude,
1559 &[
1560 r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"text","text":"pong"}]}}"#,
1561 ],
1562 );
1563 assert!(events.contains(&Event::Text("pong".into())), "{events:?}");
1564 }
1565
1566 #[test]
1569 fn tool_calls_survive_delta_suppression() {
1570 let (events, _) = run(
1571 Agent::Claude,
1572 &[
1573 r#"{"type":"stream_event","session_id":"s","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hi"}}}"#,
1574 r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"tool_use","id":"t1","name":"Bash","input":{"command":"ls"}}]}}"#,
1575 ],
1576 );
1577 assert!(
1578 events.iter().any(|e| matches!(e, Event::ToolCall { .. })),
1579 "suppression must apply to text only: {events:?}"
1580 );
1581 }
1582
1583 #[test]
1584 fn claude_started_fires_only_once() {
1585 let (events, _) = run(
1586 Agent::Claude,
1587 &[
1588 r#"{"type":"system","subtype":"init","session_id":"s"}"#,
1589 r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"text","text":"a"}]}}"#,
1590 r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"text","text":"b"}]}}"#,
1591 ],
1592 );
1593 assert_eq!(
1594 events
1595 .iter()
1596 .filter(|e| matches!(e, Event::Started { .. }))
1597 .count(),
1598 1
1599 );
1600 }
1601
1602 #[test]
1607 fn a_model_call_reports_its_usage_once_however_many_blocks_it_has() {
1608 let (events, _) = run(
1609 Agent::Claude,
1610 &[
1611 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}}}"#,
1612 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}}}"#,
1613 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}}}"#,
1614 ],
1615 );
1616 let usage: Vec<&Usage> = events
1617 .iter()
1618 .filter_map(|e| match e {
1619 Event::Usage(u) => Some(u),
1620 _ => None,
1621 })
1622 .collect();
1623 assert_eq!(usage.len(), 2, "two model calls, three records: {events:?}");
1624 assert_eq!(usage[0].input_tokens, Some(10));
1625 assert_eq!(usage[0].context_tokens, Some(10 + 20180 + 7574));
1626 assert_eq!(usage[1].context_tokens, Some(8 + 30427));
1627 }
1628
1629 #[test]
1634 fn context_is_still_tracked_when_a_record_carries_no_id() {
1635 let (events, term) = run(
1636 Agent::Claude,
1637 &[
1638 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}}}"#,
1639 r#"{"type":"result","subtype":"success","is_error":false,"result":"hi","session_id":"s","usage":{"input_tokens":99,"cache_read_input_tokens":99}}"#,
1640 ],
1641 );
1642 assert!(
1643 !events.iter().any(|e| matches!(e, Event::Usage(_))),
1644 "no id means no way to deduplicate, so nothing is reported"
1645 );
1646 assert_eq!(
1647 term.usage.context_tokens,
1648 Some(8 + 30427),
1649 "the per-request figure must still outrank the terminal sum"
1650 );
1651 }
1652
1653 #[test]
1657 fn a_live_snapshot_withholds_the_output_count() {
1658 let (events, _) = run(
1659 Agent::Claude,
1660 &[
1661 r#"{"type":"assistant","session_id":"s","message":{"id":"m","content":[{"type":"text","text":"hi"}],"usage":{"input_tokens":8,"output_tokens":1}}}"#,
1662 ],
1663 );
1664 let Some(Event::Usage(usage)) = events.iter().find(|e| matches!(e, Event::Usage(_))) else {
1665 panic!("expected a usage event: {events:?}")
1666 };
1667 assert_eq!(usage.output_tokens, None, "a partial count is not reported");
1668 assert_eq!(usage.input_tokens, Some(8), "the exact figures still are");
1669 }
1670
1671 #[test]
1675 fn live_snapshots_accumulate_to_the_terminal_totals() {
1676 let calls = [
1677 (10u64, 20180u64, 7574u64),
1678 (8, 0, 30427),
1679 (8, 30427, 1859),
1680 (8, 32286, 115),
1681 ];
1682 let mut session = Usage::default();
1683 for (input, read, write) in calls {
1684 session.accumulate(&Usage {
1685 input_tokens: Some(input),
1686 cache_read_tokens: Some(read),
1687 cache_write_tokens: Some(write),
1688 context_tokens: Some(input + read + write),
1689 ..Usage::default()
1690 });
1691 }
1692 assert_eq!(session.input_tokens, Some(34));
1694 assert_eq!(
1695 session.context_tokens,
1696 Some(8 + 32286 + 115),
1697 "context takes the latest, being cumulative already"
1698 );
1699 }
1700
1701 #[test]
1702 fn claude_pairs_tool_use_with_its_result() {
1703 let (events, _) = run(
1704 Agent::Claude,
1705 &[
1706 r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"tool_use","id":"toolu_1","name":"Bash","input":{"command":"ls"}}]}}"#,
1707 r#"{"type":"user","session_id":"s","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"a.txt"}]}}"#,
1708 ],
1709 );
1710 let call = events
1711 .iter()
1712 .find(|e| matches!(e, Event::ToolCall { .. }))
1713 .unwrap();
1714 let Event::ToolCall { id, name, input } = call else {
1715 unreachable!()
1716 };
1717 assert_eq!(id.as_deref(), Some("toolu_1"));
1718 assert_eq!(name, "Bash");
1719 assert_eq!(input["command"], "ls");
1720 assert!(events.contains(&Event::ToolResult {
1721 id: Some("toolu_1".into()),
1722 ok: None,
1723 output: "a.txt".into(),
1724 }));
1725 }
1726
1727 #[test]
1732 fn an_approval_request_carries_the_tool_and_its_arguments() {
1733 let (events, _) = run(
1734 Agent::Claude,
1735 &[
1736 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"}}}"#,
1737 ],
1738 );
1739 let [Event::ApprovalRequest(approval)] = &events[..] else {
1740 panic!("expected one approval request, got {events:?}")
1741 };
1742 assert_eq!(approval.id, "req-7");
1743 assert_eq!(approval.tool, "Bash");
1744 assert_eq!(approval.input["command"], "touch created-by-probe.txt");
1745 }
1746
1747 #[test]
1751 fn an_unanswerable_approval_request_is_dropped() {
1752 for line in [
1753 r#"{"type":"control_request","request":{"subtype":"can_use_tool","tool_name":"Bash","input":{}}}"#,
1755 &format!(
1757 r#"{{"type":"control_request","request_id":"{}","request":{{"subtype":"can_use_tool","tool_name":"Bash","input":{{}}}}}}"#,
1758 "x".repeat(MAX_IDENTIFIER_BYTES + 1)
1759 ),
1760 ] {
1761 let (events, _) = run(Agent::Claude, &[line]);
1762 assert!(
1763 events.is_empty(),
1764 "an unanswerable request must not reach a consumer: {events:?}"
1765 );
1766 }
1767 }
1768
1769 #[test]
1771 fn a_control_request_that_is_not_an_approval_is_ignored() {
1772 let (events, _) = run(
1773 Agent::Claude,
1774 &[r#"{"type":"control_request","request_id":"r","request":{"subtype":"initialize"}}"#],
1775 );
1776 assert!(events.is_empty(), "{events:?}");
1777 }
1778
1779 #[test]
1780 fn claude_reports_a_rate_limit_without_failing() {
1781 let (events, term) = run(
1782 Agent::Claude,
1783 &[
1784 r#"{"type":"rate_limit_event","session_id":"s","rate_limit_info":{"status":"allowed","resetsAt":1785260400,"rateLimitType":"five_hour"}}"#,
1785 ],
1786 );
1787 let limit = RateLimit {
1788 status: "allowed".into(),
1789 window: Some("five_hour".into()),
1790 resets_at: Some(1_785_260_400),
1791 overage_status: None,
1792 is_using_overage: None,
1793 };
1794 assert!(events.contains(&Event::RateLimit(limit.clone())));
1795 assert_eq!(term.rate_limit, Some(limit.clone()));
1796 assert!(
1797 !limit.is_blocking(),
1798 "an `allowed` heartbeat is not a block"
1799 );
1800 }
1801
1802 #[test]
1803 fn claude_error_result_sets_the_stop_reason() {
1804 let (_, term) = run(
1805 Agent::Claude,
1806 &[r#"{"type":"result","is_error":true,"result":"boom","session_id":"s"}"#],
1807 );
1808 assert_eq!(term.stop, Stop::Error);
1809 }
1810
1811 #[test]
1812 fn copilot_streams_deltas_and_takes_its_answer_from_the_settled_message() {
1813 let (events, term) = run(
1814 Agent::Copilot,
1815 &[
1816 r#"{"type":"assistant.message_delta","data":{"messageId":"m","deltaContent":"po"}}"#,
1817 r#"{"type":"assistant.message_delta","data":{"messageId":"m","deltaContent":"ng"}}"#,
1818 r#"{"type":"assistant.message","data":{"messageId":"m","model":"gpt-5-mini","content":"pong"}}"#,
1819 r#"{"type":"result","sessionId":"768c8e7d","exitCode":0,"usage":{"premiumRequests":0}}"#,
1820 ],
1821 );
1822 let texts: Vec<_> = events
1824 .iter()
1825 .filter_map(|e| match e {
1826 Event::Text(t) => Some(t.as_str()),
1827 _ => None,
1828 })
1829 .collect();
1830 assert_eq!(texts, ["po", "ng"]);
1831 assert_eq!(term.text, "pong", "the answer is the settled message");
1832 assert_eq!(term.session.as_deref(), Some("768c8e7d"));
1833 assert_eq!(term.usage.premium_requests, Some(0));
1834 }
1835
1836 #[test]
1837 fn copilot_brackets_a_tool_call_with_its_completion() {
1838 let (events, _) = run(
1839 Agent::Copilot,
1840 &[
1841 r#"{"type":"tool.execution_start","data":{"toolCallId":"call_1","toolName":"bash","arguments":{"command":"ls"}}}"#,
1842 r#"{"type":"tool.execution_complete","data":{"toolCallId":"call_1","success":true,"result":{"content":"a.txt"}}}"#,
1843 ],
1844 );
1845 assert!(matches!(
1846 &events[0],
1847 Event::ToolCall { id, name, .. }
1848 if id.as_deref() == Some("call_1") && name == "bash"
1849 ));
1850 assert_eq!(
1851 events[1],
1852 Event::ToolResult {
1853 id: Some("call_1".into()),
1854 ok: Some(true),
1855 output: "a.txt".into()
1856 }
1857 );
1858 }
1859
1860 #[test]
1864 fn a_codex_failed_turn_yields_the_reason_and_the_status() {
1865 let (_, term) = run(
1866 Agent::Codex,
1867 &[
1868 r#"{"type":"thread.started","thread_id":"019fad62"}"#,
1869 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.\"}}"}}"#,
1870 ],
1871 );
1872 assert_eq!(term.stop, Stop::Error);
1873 assert_eq!(term.error_status, Some(400));
1874 assert_eq!(
1875 term.error_message.as_deref(),
1876 Some(
1877 "The 'bogus-model-xyz' model is not supported when using Codex with a ChatGPT account."
1878 ),
1879 "the caller should get the sentence, not the envelope"
1880 );
1881 }
1882
1883 #[test]
1886 fn a_plain_codex_failure_message_passes_through() {
1887 let (_, term) = run(
1888 Agent::Codex,
1889 &[
1890 r#"{"type":"turn.failed","error":{"message":"stream disconnected before completion"}}"#,
1891 ],
1892 );
1893 assert_eq!(term.error_status, None);
1894 assert_eq!(
1895 term.error_message.as_deref(),
1896 Some("stream disconnected before completion")
1897 );
1898 }
1899
1900 #[test]
1901 fn codex_reads_the_thread_id_and_the_completed_message() {
1902 let (events, term) = run(
1903 Agent::Codex,
1904 &[
1905 r#"{"type":"thread.started","thread_id":"0199-xyz"}"#,
1906 r#"{"type":"item.completed","item":{"id":"i1","type":"agent_message","text":"pong"}}"#,
1907 r#"{"type":"turn.completed","usage":{"input_tokens":12,"output_tokens":3,"cached_input_tokens":9}}"#,
1908 ],
1909 );
1910 assert_eq!(
1911 events[0],
1912 Event::Started {
1913 session: "0199-xyz".into(),
1914 model: None
1915 }
1916 );
1917 assert_eq!(term.session.as_deref(), Some("0199-xyz"));
1918 assert_eq!(term.text, "pong");
1919 assert_eq!(term.usage.input_tokens, Some(3));
1924 assert_eq!(term.usage.cache_read_tokens, Some(9));
1925 assert_eq!(term.usage.context_tokens, Some(12));
1926 }
1927
1928 #[test]
1929 fn codex_command_execution_becomes_a_call_and_a_result() {
1930 let (events, _) = run(
1931 Agent::Codex,
1932 &[
1933 r#"{"type":"item.completed","item":{"id":"c1","type":"command_execution","command":"ls","exit_code":0,"aggregated_output":"a.txt"}}"#,
1934 ],
1935 );
1936 assert!(matches!(&events[0], Event::ToolCall { name, .. } if name == "command_execution"));
1937 assert_eq!(
1938 events[1],
1939 Event::ToolResult {
1940 id: Some("c1".into()),
1941 ok: Some(true),
1942 output: "a.txt".into()
1943 }
1944 );
1945 }
1946
1947 #[test]
1951 fn codex_started_then_completed_yields_one_call_and_one_result() {
1952 let (events, _) = run(
1953 Agent::Codex,
1954 &[
1955 r#"{"type":"item.started","item":{"id":"item_1","type":"command_execution","command":"/bin/zsh -lc ls","aggregated_output":"","exit_code":null,"status":"in_progress"}}"#,
1956 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"}}"#,
1957 ],
1958 );
1959 let calls = events
1960 .iter()
1961 .filter(|e| matches!(e, Event::ToolCall { .. }))
1962 .count();
1963 assert_eq!(calls, 1, "the same item must not be announced twice");
1964 let results: Vec<_> = events
1965 .iter()
1966 .filter_map(|e| match e {
1967 Event::ToolResult { output, .. } => Some(output.as_str()),
1968 _ => None,
1969 })
1970 .collect();
1971 assert_eq!(
1972 results,
1973 ["a.txt\n"],
1974 "the in-progress blank must not appear"
1975 );
1976 }
1977
1978 #[test]
1980 fn codex_last_completed_message_is_the_answer() {
1981 let (_, term) = run(
1982 Agent::Codex,
1983 &[
1984 r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"I'll list the directory."}}"#,
1985 r#"{"type":"item.completed","item":{"id":"i2","type":"agent_message","text":"DONE"}}"#,
1986 ],
1987 );
1988 assert_eq!(term.text, "DONE");
1989 }
1990
1991 #[test]
1994 fn an_enormous_tool_result_is_bounded_and_marked() {
1995 let huge = "x".repeat(MAX_EVENT_BYTES * 4);
1996 let line = serde_json::json!({
1997 "type": "user",
1998 "session_id": "s",
1999 "message": {"content": [{
2000 "type": "tool_result", "tool_use_id": "t1", "content": huge
2001 }]}
2002 })
2003 .to_string();
2004
2005 let (events, _) = run(Agent::Claude, &[&line]);
2006 let Some(Event::ToolResult { output, id, .. }) = events
2007 .iter()
2008 .find(|e| matches!(e, Event::ToolResult { .. }))
2009 .cloned()
2010 else {
2011 panic!("expected a tool result, got {events:?}")
2012 };
2013 assert!(
2014 output.len() <= MAX_EVENT_BYTES,
2015 "kept {} bytes",
2016 output.len()
2017 );
2018 assert!(
2019 output.ends_with(TRUNCATION_MARK),
2020 "truncation must be visible"
2021 );
2022 assert_eq!(id.as_deref(), Some("t1"), "the id must survive whole");
2023 }
2024
2025 #[test]
2029 fn usable_identifiers_are_never_shortened() {
2030 let id = "s".repeat(MAX_IDENTIFIER_BYTES);
2032 let line =
2033 serde_json::json!({"type": "system", "subtype": "init", "session_id": id}).to_string();
2034 let (events, term) = run(Agent::Claude, &[&line]);
2035
2036 let Some(Event::Started { session, .. }) = events.first().cloned() else {
2037 panic!("expected Started, got {events:?}")
2038 };
2039 assert_eq!(session.len(), id.len(), "the session id was shortened");
2040 assert_eq!(term.session.as_deref(), Some(id.as_str()));
2041 }
2042
2043 #[test]
2048 fn an_oversized_session_id_is_rejected_rather_than_stored() {
2049 let id = "s".repeat(MAX_IDENTIFIER_BYTES + 1);
2050 for (agent, line) in [
2051 (
2052 Agent::Claude,
2053 serde_json::json!({"type": "system", "subtype": "init", "session_id": id})
2054 .to_string(),
2055 ),
2056 (
2057 Agent::Codex,
2058 serde_json::json!({"type": "thread.started", "thread_id": id}).to_string(),
2059 ),
2060 (
2061 Agent::Copilot,
2062 serde_json::json!({"type": "result", "sessionId": id, "exitCode": 0}).to_string(),
2063 ),
2064 ] {
2065 let (events, term) = run(agent, &[&line]);
2066 assert!(term.session.is_none(), "{agent} stored an unusable id");
2067 assert!(
2068 !events.iter().any(|e| matches!(e, Event::Started { .. })),
2069 "{agent} announced a session it cannot resume"
2070 );
2071 }
2072 }
2073
2074 #[test]
2078 fn an_oversized_tool_id_drops_the_id_but_keeps_the_event() {
2079 let id = "t".repeat(MAX_IDENTIFIER_BYTES + 1);
2080 let line = serde_json::json!({
2081 "type": "assistant", "session_id": "s",
2082 "message": {"content": [{
2083 "type": "tool_use", "id": id, "name": "Bash", "input": {"command": "ls"}
2084 }]}
2085 })
2086 .to_string();
2087
2088 let (events, _) = run(Agent::Claude, &[&line]);
2089 let Some(Event::ToolCall { id: seen, name, .. }) = events
2090 .iter()
2091 .find(|e| matches!(e, Event::ToolCall { .. }))
2092 .cloned()
2093 else {
2094 panic!("the call itself must still be reported, got {events:?}")
2095 };
2096 assert_eq!(seen, None, "an unusable id must be dropped, not shortened");
2097 assert_eq!(name, "Bash");
2098 }
2099
2100 #[test]
2103 fn the_pending_tool_map_is_bounded_by_bytes_not_only_entries() {
2104 let mut parser = Parser::new(Agent::Claude, Format::Stream);
2105 for i in 0..MAX_PENDING_TOOLS {
2108 let line = serde_json::json!({
2109 "type": "assistant", "session_id": "s",
2110 "message": {"content": [{
2111 "type": "tool_use",
2112 "id": format!("{i:0>width$}", width = MAX_IDENTIFIER_BYTES),
2113 "name": "x".repeat(MAX_IDENTIFIER_BYTES),
2114 "input": {}
2115 }]}
2116 })
2117 .to_string();
2118 parser.push(&line);
2119 }
2120 assert!(
2121 parser.tool_bytes <= MAX_PENDING_TOOL_BYTES,
2122 "pending tools grew to {} bytes",
2123 parser.tool_bytes
2124 );
2125 }
2126
2127 #[test]
2130 fn a_completed_tool_call_releases_its_budget() {
2131 let mut parser = Parser::new(Agent::Claude, Format::Stream);
2132 let call = |id: &str| {
2133 serde_json::json!({
2134 "type": "assistant", "session_id": "s",
2135 "message": {"content": [{
2136 "type": "tool_use", "id": id, "name": "Bash", "input": {}
2137 }]}
2138 })
2139 .to_string()
2140 };
2141 let result = |id: &str| {
2142 serde_json::json!({
2143 "type": "user", "session_id": "s",
2144 "message": {"content": [{
2145 "type": "tool_result", "tool_use_id": id, "content": "done"
2146 }]}
2147 })
2148 .to_string()
2149 };
2150
2151 for i in 0..(MAX_PENDING_TOOLS * 4) {
2152 let id = format!("toolu_{i}");
2153 parser.push(&call(&id));
2154 parser.push(&result(&id));
2155 }
2156 assert_eq!(parser.tool_bytes, 0, "budget leaked across paired calls");
2157 assert!(parser.tools.is_empty());
2158 }
2159
2160 #[test]
2163 fn a_worst_case_event_stays_within_the_stated_ceiling() {
2164 let huge = "x".repeat(MAX_LINE);
2165 let line = serde_json::json!({
2166 "type": "assistant", "session_id": huge,
2167 "message": {"content": [{
2168 "type": "tool_use", "id": huge, "name": huge, "input": {"command": huge}
2169 }]}
2170 })
2171 .to_string();
2172
2173 let (events, _) = run(Agent::Claude, &[&line]);
2174 for event in &events {
2175 let size = serde_json::to_string(event).unwrap().len();
2176 let ceiling = MAX_EVENT_BYTES + 4 * MAX_IDENTIFIER_BYTES;
2178 assert!(size <= ceiling, "an event reached {size} bytes: {event:?}");
2179 }
2180 }
2181
2182 #[test]
2185 fn oversized_tool_arguments_stay_valid_json() {
2186 let line = serde_json::json!({
2187 "type": "assistant",
2188 "session_id": "s",
2189 "message": {"content": [{
2190 "type": "tool_use", "id": "t1", "name": "Bash",
2191 "input": {"command": "y".repeat(MAX_EVENT_BYTES * 3)}
2192 }]}
2193 })
2194 .to_string();
2195
2196 let (events, _) = run(Agent::Claude, &[&line]);
2197 let Some(Event::ToolCall { input, .. }) = events
2198 .iter()
2199 .find(|e| matches!(e, Event::ToolCall { .. }))
2200 .cloned()
2201 else {
2202 panic!("expected a tool call, got {events:?}")
2203 };
2204 assert_eq!(input["truncated"], true, "got {input}");
2205 assert!(
2206 input.is_object(),
2207 "the replacement must still be valid JSON"
2208 );
2209 assert!(input.to_string().len() <= MAX_EVENT_BYTES);
2210 }
2211
2212 #[test]
2213 fn ordinary_payloads_pass_through_untouched() {
2214 let (events, _) = run(
2215 Agent::Claude,
2216 &[
2217 r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"text","text":"pong"}]}}"#,
2218 ],
2219 );
2220 assert!(events.contains(&Event::Text("pong".into())), "{events:?}");
2221 }
2222
2223 #[test]
2224 fn capture_is_bounded_and_keeps_the_earliest_output() {
2225 let mut buf = String::new();
2226 for i in 0..50_000 {
2228 append_capped(&mut buf, &format!("line {i} aaaaaaaaaaaaaaaaaaaaaaaaaaaa"));
2229 }
2230 assert!(buf.len() <= MAX_CAPTURE, "grew to {}", buf.len());
2231 assert!(buf.starts_with("line 0 "), "the earliest output is kept");
2232 }
2233
2234 #[test]
2235 fn capping_never_splits_a_multibyte_character() {
2236 let mut buf = "x".repeat(MAX_CAPTURE - 3);
2237 assert!(append_capped(&mut buf, "🙂🙂"));
2239 assert!(buf.len() <= MAX_CAPTURE);
2240 assert!(buf.is_char_boundary(buf.len()));
2243 }
2244
2245 #[test]
2246 fn a_full_buffer_reports_that_it_took_nothing() {
2247 let mut buf = "x".repeat(MAX_CAPTURE);
2248 assert!(!append_capped(&mut buf, "more"));
2249 assert_eq!(buf.len(), MAX_CAPTURE);
2250 }
2251
2252 #[test]
2255 fn unparseable_lines_are_counted_and_sampled() {
2256 let (_, term) = run(
2257 Agent::Claude,
2258 &[
2259 "<html>an error page, not JSON</html>",
2260 "another bad line",
2261 r#"{"type":"result","result":"ok","session_id":"s"}"#,
2262 ],
2263 );
2264 assert_eq!(term.unparsed, 2);
2265 assert_eq!(
2266 term.first_unparsed.as_deref(),
2267 Some("<html>an error page, not JSON</html>")
2268 );
2269 }
2270
2271 #[test]
2272 fn a_clean_stream_reports_no_parse_failures() {
2273 let (_, term) = run(
2274 Agent::Claude,
2275 &[r#"{"type":"result","result":"ok","session_id":"s"}"#],
2276 );
2277 assert_eq!(term.unparsed, 0);
2278 assert!(term.first_unparsed.is_none());
2279 }
2280
2281 #[test]
2284 fn tool_result_blocks_that_are_not_text_are_kept_not_dropped() {
2285 let (events, _) = run(
2286 Agent::Claude,
2287 &[
2288 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"}}]}]}}"#,
2289 ],
2290 );
2291 let output = events
2292 .iter()
2293 .find_map(|e| match e {
2294 Event::ToolResult { output, .. } => Some(output),
2295 _ => None,
2296 })
2297 .unwrap_or_else(|| panic!("expected a tool result, got {events:?}"));
2298 assert!(output.contains("seen"));
2299 assert!(output.contains("image"), "the image block was dropped");
2300 }
2301
2302 #[test]
2303 fn garbage_lines_are_skipped_not_fatal() {
2304 let (events, term) = run(
2305 Agent::Claude,
2306 &[
2307 "Warning: something on stdout",
2308 "",
2309 r#"{"type":"result","result":"ok","session_id":"s"}"#,
2310 ],
2311 );
2312 assert!(events.iter().all(|e| !matches!(e, Event::Text(_))));
2313 assert_eq!(term.text, "ok");
2314 }
2315
2316 #[test]
2317 fn text_format_passes_lines_through_verbatim() {
2318 let mut p = Parser::new(Agent::Copilot, Format::Text);
2319 let events: Vec<_> = ["hello", "world"].iter().flat_map(|l| p.push(l)).collect();
2320 assert_eq!(
2321 events,
2322 [Event::Text("hello".into()), Event::Text("world".into())]
2323 );
2324 assert_eq!(p.finish().text, "hello\nworld");
2325 }
2326}