1use std::collections::HashMap;
22
23use serde::{Deserialize, Serialize};
24use serde_json::Value;
25
26use crate::agent::{Agent, Format};
27use crate::outcome::{RateLimit, Stop, Usage};
28
29#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
32#[serde(tag = "kind", rename_all = "snake_case")]
33#[non_exhaustive]
34pub enum Event {
35 Started {
37 session: String,
39 model: Option<String>,
41 },
42 Thinking(String),
44 Text(String),
46 ToolCall {
48 id: Option<String>,
51 name: String,
53 input: Value,
55 },
56 ToolResult {
58 id: Option<String>,
60 ok: Option<bool>,
62 output: String,
64 },
65 RateLimit(RateLimit),
67}
68
69pub const MAX_CAPTURE: usize = 1024 * 1024;
76
77pub const MAX_LINE: usize = 512 * 1024;
84
85pub const MAX_EVENT_BYTES: usize = 64 * 1024;
96
97pub const TRUNCATION_MARK: &str = "…(truncated)";
100
101pub const MAX_IDENTIFIER_BYTES: usize = 4 * 1024;
113
114pub(crate) const MAX_PENDING_TOOL_BYTES: usize = 256 * 1024;
120
121pub(crate) const MAX_PENDING_TOOLS: usize = 1024;
126
127pub(crate) fn append_capped(buf: &mut String, line: &str) -> bool {
134 let remaining = MAX_CAPTURE.saturating_sub(buf.len());
135 if remaining == 0 {
136 return false;
137 }
138 if line.len() < remaining {
140 buf.push_str(line);
141 buf.push('\n');
142 } else {
143 let mut cut = remaining - 1;
145 while cut > 0 && !line.is_char_boundary(cut) {
146 cut -= 1;
147 }
148 buf.push_str(&line[..cut]);
149 buf.push('\n');
150 }
151 true
152}
153
154fn usable_identifier(value: &str) -> bool {
159 value.len() <= MAX_IDENTIFIER_BYTES
160}
161
162fn accept_identifier(value: Option<String>) -> Option<String> {
164 value.filter(|v| usable_identifier(v))
165}
166
167fn bound_text(text: String) -> String {
169 if text.len() <= MAX_EVENT_BYTES {
170 return text;
171 }
172 let mut cut = MAX_EVENT_BYTES - TRUNCATION_MARK.len();
173 while cut > 0 && !text.is_char_boundary(cut) {
174 cut -= 1;
175 }
176 let mut out = text[..cut].to_string();
177 out.push_str(TRUNCATION_MARK);
178 out
179}
180
181fn bound_value(value: Value) -> Value {
187 let size = value.to_string().len();
188 if size <= MAX_EVENT_BYTES {
189 return value;
190 }
191 serde_json::json!({
192 "truncated": true,
193 "original_bytes": size,
194 "note": "arguments exceeded MAX_EVENT_BYTES and were dropped rather than \
195 truncated, which would have produced invalid JSON",
196 })
197}
198
199fn enforce_bounds(event: Event) -> Event {
206 match event {
207 Event::Text(text) => Event::Text(bound_text(text)),
208 Event::Thinking(text) => Event::Thinking(bound_text(text)),
209 Event::ToolCall { id, name, input } => Event::ToolCall {
213 id: accept_identifier(id),
214 name: bound_identifier(name),
215 input: bound_value(input),
216 },
217 Event::ToolResult { id, ok, output } => Event::ToolResult {
218 id: accept_identifier(id),
219 ok,
220 output: bound_text(output),
221 },
222 Event::Started { session, model } => Event::Started {
226 session,
227 model: model.map(bound_identifier),
228 },
229 Event::RateLimit(limit) => Event::RateLimit(RateLimit {
230 status: bound_identifier(limit.status),
231 window: limit.window.map(bound_identifier),
232 resets_at: limit.resets_at,
233 }),
234 }
235}
236
237fn bound_identifier(text: String) -> String {
242 if text.len() <= MAX_IDENTIFIER_BYTES {
243 return text;
244 }
245 let mut cut = MAX_IDENTIFIER_BYTES - TRUNCATION_MARK.len();
246 while cut > 0 && !text.is_char_boundary(cut) {
247 cut -= 1;
248 }
249 let mut out = text[..cut].to_string();
250 out.push_str(TRUNCATION_MARK);
251 out
252}
253
254#[derive(Debug, Clone, Default, PartialEq)]
256pub struct Terminal {
257 pub session: Option<String>,
259 pub text: String,
261 pub usage: Usage,
263 pub stop: Stop,
265 pub rate_limit: Option<RateLimit>,
267 pub unparsed: usize,
273 pub first_unparsed: Option<String>,
275}
276
277#[derive(Debug)]
279pub(crate) struct Parser {
280 agent: Agent,
281 format: Format,
282 term: Terminal,
283 tools: HashMap<String, String>,
285 tool_bytes: usize,
288 started: bool,
290 structured: bool,
292 terminal_seen: bool,
294}
295
296impl Parser {
297 #[must_use]
299 pub fn new(agent: Agent, format: Format) -> Self {
300 Self {
301 agent,
302 format,
303 term: Terminal::default(),
304 tools: HashMap::new(),
305 tool_bytes: 0,
306 started: false,
307 structured: false,
308 terminal_seen: false,
309 }
310 }
311
312 pub fn push(&mut self, line: &str) -> Vec<Event> {
320 let line = line.trim();
321 if line.is_empty() {
322 return Vec::new();
323 }
324 if self.format == Format::Text {
327 append_capped(&mut self.term.text, line);
328 return vec![enforce_bounds(Event::Text(line.to_string()))];
329 }
330 let Ok(value) = serde_json::from_str::<Value>(line) else {
331 self.term.unparsed += 1;
332 if self.term.first_unparsed.is_none() {
333 let mut cut = line.len().min(512);
337 while cut > 0 && !line.is_char_boundary(cut) {
338 cut -= 1;
339 }
340 self.term.first_unparsed = Some(line[..cut].to_string());
341 }
342 return Vec::new();
343 };
344 if let Some(ty) = value.get("type").and_then(Value::as_str)
347 && self.recognizes(ty)
348 {
349 self.structured = true;
350 }
351 let mut out = match self.agent {
352 Agent::Claude => self.claude(&value),
353 Agent::Codex => self.codex(&value),
354 Agent::Copilot => self.copilot(&value),
355 };
356 out = out.into_iter().map(enforce_bounds).collect();
359
360 if !self.started {
363 if let Some(session) = self.term.session.clone() {
364 self.started = true;
365 out.insert(
366 0,
367 Event::Started {
368 session,
369 model: model_of(&value),
370 },
371 );
372 }
373 }
374 out
375 }
376
377 fn recognizes(&self, ty: &str) -> bool {
379 match self.agent {
380 Agent::Claude => matches!(
381 ty,
382 "system" | "assistant" | "user" | "result" | "rate_limit_event"
383 ),
384 Agent::Codex => {
385 ty.starts_with("thread.") || ty.starts_with("turn.") || ty.starts_with("item.")
386 }
387 Agent::Copilot => {
388 ty == "result"
389 || ty.starts_with("assistant.")
390 || ty.starts_with("tool.")
391 || ty.starts_with("session.")
392 }
393 }
394 }
395
396 fn remember_tool(&mut self, id: &str, name: &str) {
399 if !usable_identifier(id) {
402 return;
403 }
404 let name = bound_identifier(name.to_string());
405 let cost = id.len() + name.len();
406 if self.tools.len() >= MAX_PENDING_TOOLS
410 || self.tool_bytes.saturating_add(cost) > MAX_PENDING_TOOL_BYTES
411 {
412 return;
413 }
414 self.tool_bytes += cost;
415 if let Some(previous) = self.tools.insert(id.to_string(), name) {
416 self.tool_bytes = self.tool_bytes.saturating_sub(id.len() + previous.len());
418 }
419 }
420
421 fn forget_tool(&mut self, id: &str) {
423 if let Some(name) = self.tools.remove(id) {
424 self.tool_bytes = self.tool_bytes.saturating_sub(id.len() + name.len());
425 }
426 }
427
428 pub(crate) fn saw_structured_record(&self) -> bool {
433 self.structured
434 }
435
436 pub(crate) fn saw_terminal_record(&self) -> bool {
439 self.terminal_seen
440 }
441
442 #[must_use]
444 pub fn finish(mut self) -> Terminal {
445 if self.format == Format::Text {
446 self.term.text = self.term.text.trim_end().to_string();
447 }
448 self.term
449 }
450
451 fn claude(&mut self, v: &Value) -> Vec<Event> {
457 let ty = v.get("type").and_then(Value::as_str).unwrap_or_default();
458 if let Some(id) = v.get("session_id").and_then(Value::as_str)
461 && usable_identifier(id)
462 {
463 self.term.session.get_or_insert_with(|| id.to_string());
464 }
465 match ty {
466 "rate_limit_event" => {
467 let limit = claude_rate_limit(v.get("rate_limit_info"));
468 self.term.rate_limit.clone_from(&limit);
469 limit.into_iter().map(Event::RateLimit).collect()
470 }
471 "assistant" | "user" => self.content_blocks(v),
474 "result" => {
475 self.terminal_seen = true;
476 if let Some(text) = v.get("result").and_then(Value::as_str) {
477 self.term.text = text.to_string();
478 }
479 self.term.usage = claude_usage(v);
480 self.term.stop = if v.get("is_error").and_then(Value::as_bool) == Some(true) {
481 Stop::Error
482 } else {
483 stop_from(v.get("stop_reason"))
484 };
485 Vec::new()
486 }
487 _ => Vec::new(),
488 }
489 }
490
491 fn content_blocks(&mut self, v: &Value) -> Vec<Event> {
494 let blocks = v
495 .get("message")
496 .and_then(|m| m.get("content"))
497 .and_then(Value::as_array);
498 let Some(blocks) = blocks else {
499 return Vec::new();
500 };
501 let mut out = Vec::new();
502 for block in blocks {
503 let ty = block
504 .get("type")
505 .and_then(Value::as_str)
506 .unwrap_or_default();
507 match ty {
508 "text" => {
509 if let Some(t) = block.get("text").and_then(Value::as_str) {
510 out.push(Event::Text(t.to_string()));
511 }
512 }
513 "thinking" => {
514 if let Some(t) = block.get("thinking").and_then(Value::as_str) {
515 out.push(Event::Thinking(t.to_string()));
516 }
517 }
518 "tool_use" => {
519 let name = block
520 .get("name")
521 .and_then(Value::as_str)
522 .unwrap_or("tool")
523 .to_string();
524 let id = block.get("id").and_then(Value::as_str).map(str::to_string);
525 if let Some(id) = &id {
526 self.remember_tool(id, &name);
527 }
528 out.push(Event::ToolCall {
529 id,
530 name,
531 input: block.get("input").cloned().unwrap_or(Value::Null),
532 });
533 }
534 "tool_result" => out.push(Event::ToolResult {
535 id: block
536 .get("tool_use_id")
537 .and_then(Value::as_str)
538 .inspect(|id| {
539 self.forget_tool(id);
541 })
542 .map(str::to_string),
543 ok: block
544 .get("is_error")
545 .and_then(Value::as_bool)
546 .map(|is_error| !is_error),
547 output: flatten_text(block.get("content")),
548 }),
549 _ => {}
550 }
551 }
552 out
553 }
554
555 fn codex(&mut self, v: &Value) -> Vec<Event> {
563 let ty = v.get("type").and_then(Value::as_str).unwrap_or_default();
564 if let Some(id) = v.get("thread_id").and_then(Value::as_str)
565 && usable_identifier(id)
566 {
567 self.term.session.get_or_insert_with(|| id.to_string());
568 }
569 match ty {
570 "turn.completed" => {
571 self.terminal_seen = true;
572 self.term.usage = codex_usage(v.get("usage"));
573 Vec::new()
574 }
575 "turn.failed" => {
576 self.terminal_seen = true;
577 self.term.stop = Stop::Error;
578 Vec::new()
579 }
580 "item.started" | "item.updated" | "item.completed" => {
581 let Some(item) = v.get("item") else {
582 return Vec::new();
583 };
584 let item_ty = item.get("type").and_then(Value::as_str).unwrap_or_default();
585 let id = item.get("id").and_then(Value::as_str).map(str::to_string);
586 let done = ty == "item.completed";
587
588 let name = tool_name(item, item_ty);
592 let first = id
593 .as_ref()
594 .is_none_or(|id| self.tools.insert(id.clone(), name.clone()).is_none());
595
596 match item_ty {
597 "agent_message" => {
600 if !done {
601 return Vec::new();
602 }
603 let text = item.get("text").and_then(Value::as_str).unwrap_or_default();
604 self.term.text = text.to_string();
605 vec![Event::Text(text.to_string())]
606 }
607 "reasoning" if done => item
608 .get("text")
609 .and_then(Value::as_str)
610 .map(|t| Event::Thinking(t.to_string()))
611 .into_iter()
612 .collect(),
613 "command_execution" | "mcp_tool_call" | "file_change" | "web_search" => {
614 let mut out = Vec::new();
615 if first {
616 out.push(Event::ToolCall {
617 id: id.clone(),
618 name,
619 input: codex_tool_input(item, item_ty),
620 });
621 }
622 if done {
625 if let Some(id) = &id {
626 self.forget_tool(id);
627 }
628 out.push(Event::ToolResult {
629 id,
630 ok: item
631 .get("exit_code")
632 .and_then(Value::as_i64)
633 .map(|code| code == 0),
634 output: item
635 .get("aggregated_output")
636 .and_then(Value::as_str)
637 .unwrap_or_default()
638 .to_string(),
639 });
640 }
641 out
642 }
643 _ => Vec::new(),
644 }
645 }
646 _ => Vec::new(),
647 }
648 }
649
650 fn copilot(&mut self, v: &Value) -> Vec<Event> {
657 let ty = v.get("type").and_then(Value::as_str).unwrap_or_default();
658 let data = v.get("data");
659 let field = |key: &str| -> Option<String> {
660 data.and_then(|d| d.get(key))
661 .and_then(Value::as_str)
662 .map(str::to_string)
663 };
664 match ty {
665 "assistant.message_delta" => field("deltaContent")
667 .filter(|t| !t.is_empty())
668 .map(Event::Text)
669 .into_iter()
670 .collect(),
671 "assistant.message" => {
674 if let Some(content) = field("content") {
675 self.term.text = content;
676 }
677 Vec::new()
678 }
679 "assistant.reasoning" => field("content")
680 .filter(|t| !t.is_empty())
681 .map(Event::Thinking)
682 .into_iter()
683 .collect(),
684 "tool.execution_start" => {
685 let id = field("toolCallId");
686 let name = field("toolName").unwrap_or_else(|| "tool".into());
687 if let Some(id) = &id {
688 self.remember_tool(id, &name);
689 }
690 vec![Event::ToolCall {
691 id,
692 name,
693 input: data
694 .and_then(|d| d.get("arguments"))
695 .cloned()
696 .unwrap_or(Value::Null),
697 }]
698 }
699 "tool.execution_complete" => vec![Event::ToolResult {
700 id: field("toolCallId").inspect(|id| {
701 self.forget_tool(id);
702 }),
703 ok: data.and_then(|d| d.get("success")).and_then(Value::as_bool),
704 output: data
705 .and_then(|d| d.get("result"))
706 .and_then(|r| r.get("content"))
707 .and_then(Value::as_str)
708 .unwrap_or_default()
709 .to_string(),
710 }],
711 "result" => {
713 self.terminal_seen = true;
714 if let Some(id) = v.get("sessionId").and_then(Value::as_str)
715 && usable_identifier(id)
716 {
717 self.term.session = Some(id.to_string());
718 }
719 if let Some(usage) = v.get("usage") {
720 self.term.usage.premium_requests =
721 usage.get("premiumRequests").and_then(Value::as_u64);
722 }
723 if v.get("exitCode").and_then(Value::as_i64).unwrap_or(0) != 0 {
724 self.term.stop = Stop::Error;
725 }
726 Vec::new()
727 }
728 _ => Vec::new(),
729 }
730 }
731}
732
733fn model_of(v: &Value) -> Option<String> {
736 v.get("model")
737 .or_else(|| v.get("data").and_then(|d| d.get("model")))
738 .and_then(Value::as_str)
739 .map(str::to_string)
740}
741
742fn stop_from(v: Option<&Value>) -> Stop {
744 match v.and_then(Value::as_str) {
745 None | Some("end_turn" | "stop" | "completed") => Stop::Completed,
746 Some(other) => Stop::Other(other.to_string()),
747 }
748}
749
750fn claude_rate_limit(v: Option<&Value>) -> Option<RateLimit> {
752 let v = v?;
753 Some(RateLimit {
754 status: v.get("status").and_then(Value::as_str)?.to_string(),
755 window: v
756 .get("rateLimitType")
757 .and_then(Value::as_str)
758 .map(str::to_string),
759 resets_at: v.get("resetsAt").and_then(Value::as_i64),
760 })
761}
762
763fn claude_usage(v: &Value) -> Usage {
765 let u = v.get("usage");
766 let get = |key: &str| u.and_then(|u| u.get(key)).and_then(Value::as_u64);
767 Usage {
768 input_tokens: get("input_tokens"),
769 output_tokens: get("output_tokens"),
770 cache_read_tokens: get("cache_read_input_tokens"),
771 cache_write_tokens: get("cache_creation_input_tokens"),
772 cost_usd: v.get("total_cost_usd").and_then(Value::as_f64),
773 premium_requests: None,
774 }
775}
776
777fn codex_usage(v: Option<&Value>) -> Usage {
780 let get = |key: &str| v.and_then(|u| u.get(key)).and_then(Value::as_u64);
781 Usage {
782 input_tokens: get("input_tokens"),
783 output_tokens: get("output_tokens"),
784 cache_read_tokens: get("cached_input_tokens"),
785 cache_write_tokens: get("cache_write_input_tokens"),
786 cost_usd: None,
787 premium_requests: None,
788 }
789}
790
791fn tool_name(item: &Value, item_ty: &str) -> String {
794 item.get("tool")
795 .and_then(Value::as_str)
796 .unwrap_or(item_ty)
797 .to_string()
798}
799
800fn codex_tool_input(item: &Value, item_ty: &str) -> Value {
802 match item_ty {
803 "command_execution" => serde_json::json!({ "command": item.get("command") }),
804 "mcp_tool_call" => item.get("arguments").cloned().unwrap_or(Value::Null),
805 _ => item.clone(),
808 }
809}
810
811fn flatten_text(v: Option<&Value>) -> String {
819 match v {
820 Some(Value::String(s)) => s.clone(),
821 Some(Value::Array(blocks)) => blocks
822 .iter()
823 .map(|b| match b.get("text").and_then(Value::as_str) {
824 Some(text) => text.to_string(),
825 None => b.to_string(),
826 })
827 .collect::<Vec<_>>()
828 .join("\n"),
829 Some(other) => other.to_string(),
830 None => String::new(),
831 }
832}
833
834#[cfg(test)]
835mod tests {
836 use super::*;
837
838 fn run(agent: Agent, lines: &[&str]) -> (Vec<Event>, Terminal) {
840 let mut p = Parser::new(agent, Format::Stream);
841 let events = lines.iter().flat_map(|l| p.push(l)).collect();
842 (events, p.finish())
843 }
844
845 #[test]
848 fn claude_stream_yields_start_thinking_text_and_terminal_facts() {
849 let (events, term) = run(
850 Agent::Claude,
851 &[
852 r#"{"type":"system","subtype":"init","session_id":"sess-a","model":"claude-haiku-4-5"}"#,
853 r#"{"type":"assistant","session_id":"sess-a","message":{"content":[{"type":"thinking","thinking":"brief"}]}}"#,
854 r#"{"type":"assistant","session_id":"sess-a","message":{"content":[{"type":"text","text":"pong"}]}}"#,
855 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}}"#,
856 ],
857 );
858 assert_eq!(
859 events[0],
860 Event::Started {
861 session: "sess-a".into(),
862 model: Some("claude-haiku-4-5".into())
863 }
864 );
865 assert_eq!(events[1], Event::Thinking("brief".into()));
866 assert_eq!(events[2], Event::Text("pong".into()));
867 assert_eq!(term.session.as_deref(), Some("sess-a"));
868 assert_eq!(term.text, "pong");
869 assert_eq!(term.stop, Stop::Completed);
870 assert_eq!(term.usage.input_tokens, Some(10));
871 assert_eq!(term.usage.cache_read_tokens, Some(18764));
872 assert_eq!(term.usage.cache_write_tokens, Some(7322));
873 assert_eq!(term.usage.cost_usd, Some(0.017));
874 }
875
876 #[test]
877 fn claude_started_fires_only_once() {
878 let (events, _) = run(
879 Agent::Claude,
880 &[
881 r#"{"type":"system","subtype":"init","session_id":"s"}"#,
882 r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"text","text":"a"}]}}"#,
883 r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"text","text":"b"}]}}"#,
884 ],
885 );
886 assert_eq!(
887 events
888 .iter()
889 .filter(|e| matches!(e, Event::Started { .. }))
890 .count(),
891 1
892 );
893 }
894
895 #[test]
896 fn claude_pairs_tool_use_with_its_result() {
897 let (events, _) = run(
898 Agent::Claude,
899 &[
900 r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"tool_use","id":"toolu_1","name":"Bash","input":{"command":"ls"}}]}}"#,
901 r#"{"type":"user","session_id":"s","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"a.txt"}]}}"#,
902 ],
903 );
904 let call = events
905 .iter()
906 .find(|e| matches!(e, Event::ToolCall { .. }))
907 .unwrap();
908 let Event::ToolCall { id, name, input } = call else {
909 unreachable!()
910 };
911 assert_eq!(id.as_deref(), Some("toolu_1"));
912 assert_eq!(name, "Bash");
913 assert_eq!(input["command"], "ls");
914 assert!(events.contains(&Event::ToolResult {
915 id: Some("toolu_1".into()),
916 ok: None,
917 output: "a.txt".into(),
918 }));
919 }
920
921 #[test]
922 fn claude_reports_a_rate_limit_without_failing() {
923 let (events, term) = run(
924 Agent::Claude,
925 &[
926 r#"{"type":"rate_limit_event","session_id":"s","rate_limit_info":{"status":"allowed","resetsAt":1785260400,"rateLimitType":"five_hour"}}"#,
927 ],
928 );
929 let limit = RateLimit {
930 status: "allowed".into(),
931 window: Some("five_hour".into()),
932 resets_at: Some(1_785_260_400),
933 };
934 assert!(events.contains(&Event::RateLimit(limit.clone())));
935 assert_eq!(term.rate_limit, Some(limit.clone()));
936 assert!(
937 !limit.is_blocking(),
938 "an `allowed` heartbeat is not a block"
939 );
940 }
941
942 #[test]
943 fn claude_error_result_sets_the_stop_reason() {
944 let (_, term) = run(
945 Agent::Claude,
946 &[r#"{"type":"result","is_error":true,"result":"boom","session_id":"s"}"#],
947 );
948 assert_eq!(term.stop, Stop::Error);
949 }
950
951 #[test]
952 fn copilot_streams_deltas_and_takes_its_answer_from_the_settled_message() {
953 let (events, term) = run(
954 Agent::Copilot,
955 &[
956 r#"{"type":"assistant.message_delta","data":{"messageId":"m","deltaContent":"po"}}"#,
957 r#"{"type":"assistant.message_delta","data":{"messageId":"m","deltaContent":"ng"}}"#,
958 r#"{"type":"assistant.message","data":{"messageId":"m","model":"gpt-5-mini","content":"pong"}}"#,
959 r#"{"type":"result","sessionId":"768c8e7d","exitCode":0,"usage":{"premiumRequests":0}}"#,
960 ],
961 );
962 let texts: Vec<_> = events
964 .iter()
965 .filter_map(|e| match e {
966 Event::Text(t) => Some(t.as_str()),
967 _ => None,
968 })
969 .collect();
970 assert_eq!(texts, ["po", "ng"]);
971 assert_eq!(term.text, "pong", "the answer is the settled message");
972 assert_eq!(term.session.as_deref(), Some("768c8e7d"));
973 assert_eq!(term.usage.premium_requests, Some(0));
974 }
975
976 #[test]
977 fn copilot_brackets_a_tool_call_with_its_completion() {
978 let (events, _) = run(
979 Agent::Copilot,
980 &[
981 r#"{"type":"tool.execution_start","data":{"toolCallId":"call_1","toolName":"bash","arguments":{"command":"ls"}}}"#,
982 r#"{"type":"tool.execution_complete","data":{"toolCallId":"call_1","success":true,"result":{"content":"a.txt"}}}"#,
983 ],
984 );
985 assert!(matches!(
986 &events[0],
987 Event::ToolCall { id, name, .. }
988 if id.as_deref() == Some("call_1") && name == "bash"
989 ));
990 assert_eq!(
991 events[1],
992 Event::ToolResult {
993 id: Some("call_1".into()),
994 ok: Some(true),
995 output: "a.txt".into()
996 }
997 );
998 }
999
1000 #[test]
1001 fn codex_reads_the_thread_id_and_the_completed_message() {
1002 let (events, term) = run(
1003 Agent::Codex,
1004 &[
1005 r#"{"type":"thread.started","thread_id":"0199-xyz"}"#,
1006 r#"{"type":"item.completed","item":{"id":"i1","type":"agent_message","text":"pong"}}"#,
1007 r#"{"type":"turn.completed","usage":{"input_tokens":12,"output_tokens":3,"cached_input_tokens":9}}"#,
1008 ],
1009 );
1010 assert_eq!(
1011 events[0],
1012 Event::Started {
1013 session: "0199-xyz".into(),
1014 model: None
1015 }
1016 );
1017 assert_eq!(term.session.as_deref(), Some("0199-xyz"));
1018 assert_eq!(term.text, "pong");
1019 assert_eq!(term.usage.input_tokens, Some(12));
1020 assert_eq!(term.usage.cache_read_tokens, Some(9));
1021 }
1022
1023 #[test]
1024 fn codex_command_execution_becomes_a_call_and_a_result() {
1025 let (events, _) = run(
1026 Agent::Codex,
1027 &[
1028 r#"{"type":"item.completed","item":{"id":"c1","type":"command_execution","command":"ls","exit_code":0,"aggregated_output":"a.txt"}}"#,
1029 ],
1030 );
1031 assert!(matches!(&events[0], Event::ToolCall { name, .. } if name == "command_execution"));
1032 assert_eq!(
1033 events[1],
1034 Event::ToolResult {
1035 id: Some("c1".into()),
1036 ok: Some(true),
1037 output: "a.txt".into()
1038 }
1039 );
1040 }
1041
1042 #[test]
1046 fn codex_started_then_completed_yields_one_call_and_one_result() {
1047 let (events, _) = run(
1048 Agent::Codex,
1049 &[
1050 r#"{"type":"item.started","item":{"id":"item_1","type":"command_execution","command":"/bin/zsh -lc ls","aggregated_output":"","exit_code":null,"status":"in_progress"}}"#,
1051 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"}}"#,
1052 ],
1053 );
1054 let calls = events
1055 .iter()
1056 .filter(|e| matches!(e, Event::ToolCall { .. }))
1057 .count();
1058 assert_eq!(calls, 1, "the same item must not be announced twice");
1059 let results: Vec<_> = events
1060 .iter()
1061 .filter_map(|e| match e {
1062 Event::ToolResult { output, .. } => Some(output.as_str()),
1063 _ => None,
1064 })
1065 .collect();
1066 assert_eq!(
1067 results,
1068 ["a.txt\n"],
1069 "the in-progress blank must not appear"
1070 );
1071 }
1072
1073 #[test]
1075 fn codex_last_completed_message_is_the_answer() {
1076 let (_, term) = run(
1077 Agent::Codex,
1078 &[
1079 r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"I'll list the directory."}}"#,
1080 r#"{"type":"item.completed","item":{"id":"i2","type":"agent_message","text":"DONE"}}"#,
1081 ],
1082 );
1083 assert_eq!(term.text, "DONE");
1084 }
1085
1086 #[test]
1089 fn an_enormous_tool_result_is_bounded_and_marked() {
1090 let huge = "x".repeat(MAX_EVENT_BYTES * 4);
1091 let line = serde_json::json!({
1092 "type": "user",
1093 "session_id": "s",
1094 "message": {"content": [{
1095 "type": "tool_result", "tool_use_id": "t1", "content": huge
1096 }]}
1097 })
1098 .to_string();
1099
1100 let (events, _) = run(Agent::Claude, &[&line]);
1101 let Some(Event::ToolResult { output, id, .. }) = events
1102 .iter()
1103 .find(|e| matches!(e, Event::ToolResult { .. }))
1104 .cloned()
1105 else {
1106 panic!("expected a tool result, got {events:?}")
1107 };
1108 assert!(
1109 output.len() <= MAX_EVENT_BYTES,
1110 "kept {} bytes",
1111 output.len()
1112 );
1113 assert!(
1114 output.ends_with(TRUNCATION_MARK),
1115 "truncation must be visible"
1116 );
1117 assert_eq!(id.as_deref(), Some("t1"), "the id must survive whole");
1118 }
1119
1120 #[test]
1124 fn usable_identifiers_are_never_shortened() {
1125 let id = "s".repeat(MAX_IDENTIFIER_BYTES);
1127 let line =
1128 serde_json::json!({"type": "system", "subtype": "init", "session_id": id}).to_string();
1129 let (events, term) = run(Agent::Claude, &[&line]);
1130
1131 let Some(Event::Started { session, .. }) = events.first().cloned() else {
1132 panic!("expected Started, got {events:?}")
1133 };
1134 assert_eq!(session.len(), id.len(), "the session id was shortened");
1135 assert_eq!(term.session.as_deref(), Some(id.as_str()));
1136 }
1137
1138 #[test]
1143 fn an_oversized_session_id_is_rejected_rather_than_stored() {
1144 let id = "s".repeat(MAX_IDENTIFIER_BYTES + 1);
1145 for (agent, line) in [
1146 (
1147 Agent::Claude,
1148 serde_json::json!({"type": "system", "subtype": "init", "session_id": id})
1149 .to_string(),
1150 ),
1151 (
1152 Agent::Codex,
1153 serde_json::json!({"type": "thread.started", "thread_id": id}).to_string(),
1154 ),
1155 (
1156 Agent::Copilot,
1157 serde_json::json!({"type": "result", "sessionId": id, "exitCode": 0}).to_string(),
1158 ),
1159 ] {
1160 let (events, term) = run(agent, &[&line]);
1161 assert!(term.session.is_none(), "{agent} stored an unusable id");
1162 assert!(
1163 !events.iter().any(|e| matches!(e, Event::Started { .. })),
1164 "{agent} announced a session it cannot resume"
1165 );
1166 }
1167 }
1168
1169 #[test]
1173 fn an_oversized_tool_id_drops_the_id_but_keeps_the_event() {
1174 let id = "t".repeat(MAX_IDENTIFIER_BYTES + 1);
1175 let line = serde_json::json!({
1176 "type": "assistant", "session_id": "s",
1177 "message": {"content": [{
1178 "type": "tool_use", "id": id, "name": "Bash", "input": {"command": "ls"}
1179 }]}
1180 })
1181 .to_string();
1182
1183 let (events, _) = run(Agent::Claude, &[&line]);
1184 let Some(Event::ToolCall { id: seen, name, .. }) = events
1185 .iter()
1186 .find(|e| matches!(e, Event::ToolCall { .. }))
1187 .cloned()
1188 else {
1189 panic!("the call itself must still be reported, got {events:?}")
1190 };
1191 assert_eq!(seen, None, "an unusable id must be dropped, not shortened");
1192 assert_eq!(name, "Bash");
1193 }
1194
1195 #[test]
1198 fn the_pending_tool_map_is_bounded_by_bytes_not_only_entries() {
1199 let mut parser = Parser::new(Agent::Claude, Format::Stream);
1200 for i in 0..MAX_PENDING_TOOLS {
1203 let line = serde_json::json!({
1204 "type": "assistant", "session_id": "s",
1205 "message": {"content": [{
1206 "type": "tool_use",
1207 "id": format!("{i:0>width$}", width = MAX_IDENTIFIER_BYTES),
1208 "name": "x".repeat(MAX_IDENTIFIER_BYTES),
1209 "input": {}
1210 }]}
1211 })
1212 .to_string();
1213 parser.push(&line);
1214 }
1215 assert!(
1216 parser.tool_bytes <= MAX_PENDING_TOOL_BYTES,
1217 "pending tools grew to {} bytes",
1218 parser.tool_bytes
1219 );
1220 }
1221
1222 #[test]
1225 fn a_completed_tool_call_releases_its_budget() {
1226 let mut parser = Parser::new(Agent::Claude, Format::Stream);
1227 let call = |id: &str| {
1228 serde_json::json!({
1229 "type": "assistant", "session_id": "s",
1230 "message": {"content": [{
1231 "type": "tool_use", "id": id, "name": "Bash", "input": {}
1232 }]}
1233 })
1234 .to_string()
1235 };
1236 let result = |id: &str| {
1237 serde_json::json!({
1238 "type": "user", "session_id": "s",
1239 "message": {"content": [{
1240 "type": "tool_result", "tool_use_id": id, "content": "done"
1241 }]}
1242 })
1243 .to_string()
1244 };
1245
1246 for i in 0..(MAX_PENDING_TOOLS * 4) {
1247 let id = format!("toolu_{i}");
1248 parser.push(&call(&id));
1249 parser.push(&result(&id));
1250 }
1251 assert_eq!(parser.tool_bytes, 0, "budget leaked across paired calls");
1252 assert!(parser.tools.is_empty());
1253 }
1254
1255 #[test]
1258 fn a_worst_case_event_stays_within_the_stated_ceiling() {
1259 let huge = "x".repeat(MAX_LINE);
1260 let line = serde_json::json!({
1261 "type": "assistant", "session_id": huge,
1262 "message": {"content": [{
1263 "type": "tool_use", "id": huge, "name": huge, "input": {"command": huge}
1264 }]}
1265 })
1266 .to_string();
1267
1268 let (events, _) = run(Agent::Claude, &[&line]);
1269 for event in &events {
1270 let size = serde_json::to_string(event).unwrap().len();
1271 let ceiling = MAX_EVENT_BYTES + 4 * MAX_IDENTIFIER_BYTES;
1273 assert!(size <= ceiling, "an event reached {size} bytes: {event:?}");
1274 }
1275 }
1276
1277 #[test]
1280 fn oversized_tool_arguments_stay_valid_json() {
1281 let line = serde_json::json!({
1282 "type": "assistant",
1283 "session_id": "s",
1284 "message": {"content": [{
1285 "type": "tool_use", "id": "t1", "name": "Bash",
1286 "input": {"command": "y".repeat(MAX_EVENT_BYTES * 3)}
1287 }]}
1288 })
1289 .to_string();
1290
1291 let (events, _) = run(Agent::Claude, &[&line]);
1292 let Some(Event::ToolCall { input, .. }) = events
1293 .iter()
1294 .find(|e| matches!(e, Event::ToolCall { .. }))
1295 .cloned()
1296 else {
1297 panic!("expected a tool call, got {events:?}")
1298 };
1299 assert_eq!(input["truncated"], true, "got {input}");
1300 assert!(
1301 input.is_object(),
1302 "the replacement must still be valid JSON"
1303 );
1304 assert!(input.to_string().len() <= MAX_EVENT_BYTES);
1305 }
1306
1307 #[test]
1308 fn ordinary_payloads_pass_through_untouched() {
1309 let (events, _) = run(
1310 Agent::Claude,
1311 &[
1312 r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"text","text":"pong"}]}}"#,
1313 ],
1314 );
1315 assert!(events.contains(&Event::Text("pong".into())), "{events:?}");
1316 }
1317
1318 #[test]
1319 fn capture_is_bounded_and_keeps_the_earliest_output() {
1320 let mut buf = String::new();
1321 for i in 0..50_000 {
1323 append_capped(&mut buf, &format!("line {i} aaaaaaaaaaaaaaaaaaaaaaaaaaaa"));
1324 }
1325 assert!(buf.len() <= MAX_CAPTURE, "grew to {}", buf.len());
1326 assert!(buf.starts_with("line 0 "), "the earliest output is kept");
1327 }
1328
1329 #[test]
1330 fn capping_never_splits_a_multibyte_character() {
1331 let mut buf = "x".repeat(MAX_CAPTURE - 3);
1332 assert!(append_capped(&mut buf, "🙂🙂"));
1334 assert!(buf.len() <= MAX_CAPTURE);
1335 assert!(buf.is_char_boundary(buf.len()));
1338 }
1339
1340 #[test]
1341 fn a_full_buffer_reports_that_it_took_nothing() {
1342 let mut buf = "x".repeat(MAX_CAPTURE);
1343 assert!(!append_capped(&mut buf, "more"));
1344 assert_eq!(buf.len(), MAX_CAPTURE);
1345 }
1346
1347 #[test]
1350 fn unparseable_lines_are_counted_and_sampled() {
1351 let (_, term) = run(
1352 Agent::Claude,
1353 &[
1354 "<html>an error page, not JSON</html>",
1355 "another bad line",
1356 r#"{"type":"result","result":"ok","session_id":"s"}"#,
1357 ],
1358 );
1359 assert_eq!(term.unparsed, 2);
1360 assert_eq!(
1361 term.first_unparsed.as_deref(),
1362 Some("<html>an error page, not JSON</html>")
1363 );
1364 }
1365
1366 #[test]
1367 fn a_clean_stream_reports_no_parse_failures() {
1368 let (_, term) = run(
1369 Agent::Claude,
1370 &[r#"{"type":"result","result":"ok","session_id":"s"}"#],
1371 );
1372 assert_eq!(term.unparsed, 0);
1373 assert!(term.first_unparsed.is_none());
1374 }
1375
1376 #[test]
1379 fn tool_result_blocks_that_are_not_text_are_kept_not_dropped() {
1380 let (events, _) = run(
1381 Agent::Claude,
1382 &[
1383 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"}}]}]}}"#,
1384 ],
1385 );
1386 let output = events
1387 .iter()
1388 .find_map(|e| match e {
1389 Event::ToolResult { output, .. } => Some(output),
1390 _ => None,
1391 })
1392 .unwrap_or_else(|| panic!("expected a tool result, got {events:?}"));
1393 assert!(output.contains("seen"));
1394 assert!(output.contains("image"), "the image block was dropped");
1395 }
1396
1397 #[test]
1398 fn garbage_lines_are_skipped_not_fatal() {
1399 let (events, term) = run(
1400 Agent::Claude,
1401 &[
1402 "Warning: something on stdout",
1403 "",
1404 r#"{"type":"result","result":"ok","session_id":"s"}"#,
1405 ],
1406 );
1407 assert!(events.iter().all(|e| !matches!(e, Event::Text(_))));
1408 assert_eq!(term.text, "ok");
1409 }
1410
1411 #[test]
1412 fn text_format_passes_lines_through_verbatim() {
1413 let mut p = Parser::new(Agent::Copilot, Format::Text);
1414 let events: Vec<_> = ["hello", "world"].iter().flat_map(|l| p.push(l)).collect();
1415 assert_eq!(
1416 events,
1417 [Event::Text("hello".into()), Event::Text("world".into())]
1418 );
1419 assert_eq!(p.finish().text, "hello\nworld");
1420 }
1421}