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 pub structured: Option<Value>,
277 pub error_status: Option<u16>,
280 pub error_message: Option<String>,
284}
285
286fn unwrap_error_body(message: &str) -> (Option<u16>, String) {
294 let Ok(body) = serde_json::from_str::<Value>(message) else {
295 return (None, message.to_string());
296 };
297 let status = body
298 .get("status")
299 .and_then(Value::as_u64)
300 .and_then(|s| u16::try_from(s).ok());
301 let inner = body
302 .get("error")
303 .and_then(|e| e.get("message"))
304 .and_then(Value::as_str)
305 .map(str::to_string);
306 (status, inner.unwrap_or_else(|| message.to_string()))
307}
308
309#[derive(Debug)]
311pub(crate) struct Parser {
312 agent: Agent,
313 format: Format,
314 term: Terminal,
315 tools: HashMap<String, String>,
317 tool_bytes: usize,
320 seen: Seen,
322}
323
324#[derive(Debug, Default)]
330#[expect(
331 clippy::struct_excessive_bools,
332 reason = "four independent stream milestones; naming each beats packing them"
333)]
334struct Seen {
335 started: bool,
337 structured: bool,
340 terminal: bool,
342 deltas: bool,
349}
350
351impl Parser {
352 #[must_use]
354 pub fn new(agent: Agent, format: Format) -> Self {
355 Self {
356 agent,
357 format,
358 term: Terminal::default(),
359 tools: HashMap::new(),
360 tool_bytes: 0,
361 seen: Seen::default(),
362 }
363 }
364
365 pub fn push(&mut self, line: &str) -> Vec<Event> {
373 let line = line.trim();
374 if line.is_empty() {
375 return Vec::new();
376 }
377 if self.format == Format::Text {
380 append_capped(&mut self.term.text, line);
381 return vec![enforce_bounds(Event::Text(line.to_string()))];
382 }
383 let Ok(value) = serde_json::from_str::<Value>(line) else {
384 self.term.unparsed += 1;
385 if self.term.first_unparsed.is_none() {
386 let mut cut = line.len().min(512);
390 while cut > 0 && !line.is_char_boundary(cut) {
391 cut -= 1;
392 }
393 self.term.first_unparsed = Some(line[..cut].to_string());
394 }
395 return Vec::new();
396 };
397 if let Some(ty) = value.get("type").and_then(Value::as_str)
400 && self.recognizes(ty)
401 {
402 self.seen.structured = true;
403 }
404 let mut out = match self.agent {
405 Agent::Claude => self.claude(&value),
406 Agent::Codex => self.codex(&value),
407 Agent::Copilot => self.copilot(&value),
408 };
409 out = out.into_iter().map(enforce_bounds).collect();
412
413 if !self.seen.started {
416 if let Some(session) = self.term.session.clone() {
417 self.seen.started = true;
418 out.insert(
419 0,
420 Event::Started {
421 session,
422 model: model_of(&value),
423 },
424 );
425 }
426 }
427 out
428 }
429
430 fn recognizes(&self, ty: &str) -> bool {
432 match self.agent {
433 Agent::Claude => matches!(
434 ty,
435 "system" | "assistant" | "user" | "result" | "rate_limit_event"
436 ),
437 Agent::Codex => {
438 ty.starts_with("thread.") || ty.starts_with("turn.") || ty.starts_with("item.")
439 }
440 Agent::Copilot => {
441 ty == "result"
442 || ty.starts_with("assistant.")
443 || ty.starts_with("tool.")
444 || ty.starts_with("session.")
445 }
446 }
447 }
448
449 fn remember_tool(&mut self, id: &str, name: &str) {
452 if !usable_identifier(id) {
455 return;
456 }
457 let name = bound_identifier(name.to_string());
458 let cost = id.len() + name.len();
459 if self.tools.len() >= MAX_PENDING_TOOLS
463 || self.tool_bytes.saturating_add(cost) > MAX_PENDING_TOOL_BYTES
464 {
465 return;
466 }
467 self.tool_bytes += cost;
468 if let Some(previous) = self.tools.insert(id.to_string(), name) {
469 self.tool_bytes = self.tool_bytes.saturating_sub(id.len() + previous.len());
471 }
472 }
473
474 fn forget_tool(&mut self, id: &str) {
476 if let Some(name) = self.tools.remove(id) {
477 self.tool_bytes = self.tool_bytes.saturating_sub(id.len() + name.len());
478 }
479 }
480
481 pub(crate) fn saw_structured_record(&self) -> bool {
486 self.seen.structured
487 }
488
489 pub(crate) fn saw_terminal_record(&self) -> bool {
492 self.seen.terminal
493 }
494
495 #[must_use]
497 pub fn finish(mut self) -> Terminal {
498 if self.format == Format::Text {
499 self.term.text = self.term.text.trim_end().to_string();
500 }
501 self.term
502 }
503
504 fn claude(&mut self, v: &Value) -> Vec<Event> {
510 let ty = v.get("type").and_then(Value::as_str).unwrap_or_default();
511 if let Some(id) = v.get("session_id").and_then(Value::as_str)
514 && usable_identifier(id)
515 {
516 self.term.session.get_or_insert_with(|| id.to_string());
517 }
518 match ty {
519 "rate_limit_event" => {
520 let limit = claude_rate_limit(v.get("rate_limit_info"));
521 self.term.rate_limit.clone_from(&limit);
522 limit.into_iter().map(Event::RateLimit).collect()
523 }
524 "stream_event" => self.claude_delta(v),
526 "assistant" | "user" => self.content_blocks(v),
529 "result" => {
530 self.seen.terminal = true;
531 if let Some(text) = v.get("result").and_then(Value::as_str) {
532 self.term.text = text.to_string();
533 }
534 if let Some(value) = v.get("structured_output") {
537 self.term.structured = Some(value.clone());
538 }
539 self.term.usage = claude_usage(v);
540 self.term.stop = if v.get("is_error").and_then(Value::as_bool) == Some(true) {
543 self.term.error_status = v
544 .get("api_error_status")
545 .and_then(Value::as_u64)
546 .and_then(|s| u16::try_from(s).ok());
547 Stop::Error
548 } else {
549 stop_from(v.get("stop_reason"))
550 };
551 Vec::new()
552 }
553 _ => Vec::new(),
554 }
555 }
556
557 fn claude_delta(&mut self, v: &Value) -> Vec<Event> {
564 let Some(event) = v.get("event") else {
565 return Vec::new();
566 };
567 if event.get("type").and_then(Value::as_str) != Some("content_block_delta") {
568 return Vec::new();
569 }
570 let Some(delta) = event.get("delta") else {
571 return Vec::new();
572 };
573 self.seen.deltas = true;
576
577 match delta.get("type").and_then(Value::as_str) {
578 Some("text_delta") => delta
579 .get("text")
580 .and_then(Value::as_str)
581 .filter(|text| !text.is_empty())
582 .map(|text| Event::Text(text.to_string()))
583 .into_iter()
584 .collect(),
585 Some("thinking_delta") => delta
586 .get("thinking")
587 .and_then(Value::as_str)
588 .filter(|text| !text.is_empty())
589 .map(|text| Event::Thinking(text.to_string()))
590 .into_iter()
591 .collect(),
592 _ => Vec::new(),
596 }
597 }
598
599 fn content_blocks(&mut self, v: &Value) -> Vec<Event> {
602 let blocks = v
603 .get("message")
604 .and_then(|m| m.get("content"))
605 .and_then(Value::as_array);
606 let Some(blocks) = blocks else {
607 return Vec::new();
608 };
609 let mut out = Vec::new();
610 for block in blocks {
611 let ty = block
612 .get("type")
613 .and_then(Value::as_str)
614 .unwrap_or_default();
615 match ty {
616 "text" if !self.seen.deltas => {
621 if let Some(t) = block.get("text").and_then(Value::as_str) {
622 out.push(Event::Text(t.to_string()));
623 }
624 }
625 "thinking" if !self.seen.deltas => {
626 if let Some(t) = block.get("thinking").and_then(Value::as_str) {
627 out.push(Event::Thinking(t.to_string()));
628 }
629 }
630 "tool_use" => {
631 let name = block
632 .get("name")
633 .and_then(Value::as_str)
634 .unwrap_or("tool")
635 .to_string();
636 let id = block.get("id").and_then(Value::as_str).map(str::to_string);
637 if let Some(id) = &id {
638 self.remember_tool(id, &name);
639 }
640 out.push(Event::ToolCall {
641 id,
642 name,
643 input: block.get("input").cloned().unwrap_or(Value::Null),
644 });
645 }
646 "tool_result" => out.push(Event::ToolResult {
647 id: block
648 .get("tool_use_id")
649 .and_then(Value::as_str)
650 .inspect(|id| {
651 self.forget_tool(id);
653 })
654 .map(str::to_string),
655 ok: block
656 .get("is_error")
657 .and_then(Value::as_bool)
658 .map(|is_error| !is_error),
659 output: flatten_text(block.get("content")),
660 }),
661 _ => {}
662 }
663 }
664 out
665 }
666
667 fn codex(&mut self, v: &Value) -> Vec<Event> {
675 let ty = v.get("type").and_then(Value::as_str).unwrap_or_default();
676 if let Some(id) = v.get("thread_id").and_then(Value::as_str)
677 && usable_identifier(id)
678 {
679 self.term.session.get_or_insert_with(|| id.to_string());
680 }
681 match ty {
682 "turn.completed" => {
683 self.seen.terminal = true;
684 self.term.usage = codex_usage(v.get("usage"));
685 Vec::new()
686 }
687 "turn.failed" => {
688 self.seen.terminal = true;
689 self.term.stop = Stop::Error;
690 if let Some(message) = v
691 .get("error")
692 .and_then(|e| e.get("message"))
693 .and_then(Value::as_str)
694 {
695 let (status, message) = unwrap_error_body(message);
696 self.term.error_status = status;
697 self.term.error_message = Some(bound_text(message));
698 }
699 Vec::new()
700 }
701 "item.started" | "item.updated" | "item.completed" => {
702 let Some(item) = v.get("item") else {
703 return Vec::new();
704 };
705 let item_ty = item.get("type").and_then(Value::as_str).unwrap_or_default();
706 let id = item.get("id").and_then(Value::as_str).map(str::to_string);
707 let done = ty == "item.completed";
708
709 let name = tool_name(item, item_ty);
713 let first = id
714 .as_ref()
715 .is_none_or(|id| self.tools.insert(id.clone(), name.clone()).is_none());
716
717 match item_ty {
718 "agent_message" => {
721 if !done {
722 return Vec::new();
723 }
724 let text = item.get("text").and_then(Value::as_str).unwrap_or_default();
725 self.term.text = text.to_string();
726 vec![Event::Text(text.to_string())]
727 }
728 "reasoning" if done => item
729 .get("text")
730 .and_then(Value::as_str)
731 .map(|t| Event::Thinking(t.to_string()))
732 .into_iter()
733 .collect(),
734 "command_execution" | "mcp_tool_call" | "file_change" | "web_search" => {
735 let mut out = Vec::new();
736 if first {
737 out.push(Event::ToolCall {
738 id: id.clone(),
739 name,
740 input: codex_tool_input(item, item_ty),
741 });
742 }
743 if done {
746 if let Some(id) = &id {
747 self.forget_tool(id);
748 }
749 out.push(Event::ToolResult {
750 id,
751 ok: item
752 .get("exit_code")
753 .and_then(Value::as_i64)
754 .map(|code| code == 0),
755 output: item
756 .get("aggregated_output")
757 .and_then(Value::as_str)
758 .unwrap_or_default()
759 .to_string(),
760 });
761 }
762 out
763 }
764 _ => Vec::new(),
765 }
766 }
767 _ => Vec::new(),
768 }
769 }
770
771 fn copilot(&mut self, v: &Value) -> Vec<Event> {
778 let ty = v.get("type").and_then(Value::as_str).unwrap_or_default();
779 let data = v.get("data");
780 let field = |key: &str| -> Option<String> {
781 data.and_then(|d| d.get(key))
782 .and_then(Value::as_str)
783 .map(str::to_string)
784 };
785 match ty {
786 "assistant.message_delta" => field("deltaContent")
788 .filter(|t| !t.is_empty())
789 .map(Event::Text)
790 .into_iter()
791 .collect(),
792 "assistant.message" => {
795 if let Some(content) = field("content") {
796 self.term.text = content;
797 }
798 Vec::new()
799 }
800 "assistant.reasoning" => field("content")
801 .filter(|t| !t.is_empty())
802 .map(Event::Thinking)
803 .into_iter()
804 .collect(),
805 "tool.execution_start" => {
806 let id = field("toolCallId");
807 let name = field("toolName").unwrap_or_else(|| "tool".into());
808 if let Some(id) = &id {
809 self.remember_tool(id, &name);
810 }
811 vec![Event::ToolCall {
812 id,
813 name,
814 input: data
815 .and_then(|d| d.get("arguments"))
816 .cloned()
817 .unwrap_or(Value::Null),
818 }]
819 }
820 "tool.execution_complete" => vec![Event::ToolResult {
821 id: field("toolCallId").inspect(|id| {
822 self.forget_tool(id);
823 }),
824 ok: data.and_then(|d| d.get("success")).and_then(Value::as_bool),
825 output: data
826 .and_then(|d| d.get("result"))
827 .and_then(|r| r.get("content"))
828 .and_then(Value::as_str)
829 .unwrap_or_default()
830 .to_string(),
831 }],
832 "result" => {
834 self.seen.terminal = true;
835 if let Some(id) = v.get("sessionId").and_then(Value::as_str)
836 && usable_identifier(id)
837 {
838 self.term.session = Some(id.to_string());
839 }
840 if let Some(usage) = v.get("usage") {
841 self.term.usage.premium_requests =
842 usage.get("premiumRequests").and_then(Value::as_u64);
843 }
844 if let Some(code) = v.get("exitCode").and_then(Value::as_i64)
845 && code != 0
846 {
847 self.term.stop = Stop::Error;
848 self.term.error_message = Some(format!("copilot exited with code {code}"));
851 }
852 Vec::new()
853 }
854 _ => Vec::new(),
855 }
856 }
857}
858
859fn model_of(v: &Value) -> Option<String> {
862 v.get("model")
863 .or_else(|| v.get("data").and_then(|d| d.get("model")))
864 .and_then(Value::as_str)
865 .map(str::to_string)
866}
867
868fn stop_from(v: Option<&Value>) -> Stop {
870 match v.and_then(Value::as_str) {
871 None | Some("end_turn" | "stop" | "completed") => Stop::Completed,
872 Some(other) => Stop::Other(other.to_string()),
873 }
874}
875
876fn claude_rate_limit(v: Option<&Value>) -> Option<RateLimit> {
878 let v = v?;
879 Some(RateLimit {
880 status: v.get("status").and_then(Value::as_str)?.to_string(),
881 window: v
882 .get("rateLimitType")
883 .and_then(Value::as_str)
884 .map(str::to_string),
885 resets_at: v.get("resetsAt").and_then(Value::as_i64),
886 })
887}
888
889fn claude_usage(v: &Value) -> Usage {
891 let u = v.get("usage");
892 let get = |key: &str| u.and_then(|u| u.get(key)).and_then(Value::as_u64);
893 Usage {
894 input_tokens: get("input_tokens"),
895 output_tokens: get("output_tokens"),
896 cache_read_tokens: get("cache_read_input_tokens"),
897 cache_write_tokens: get("cache_creation_input_tokens"),
898 cost_usd: v.get("total_cost_usd").and_then(Value::as_f64),
899 premium_requests: None,
900 }
901}
902
903fn codex_usage(v: Option<&Value>) -> Usage {
906 let get = |key: &str| v.and_then(|u| u.get(key)).and_then(Value::as_u64);
907 Usage {
908 input_tokens: get("input_tokens"),
909 output_tokens: get("output_tokens"),
910 cache_read_tokens: get("cached_input_tokens"),
911 cache_write_tokens: get("cache_write_input_tokens"),
912 cost_usd: None,
913 premium_requests: None,
914 }
915}
916
917fn tool_name(item: &Value, item_ty: &str) -> String {
920 item.get("tool")
921 .and_then(Value::as_str)
922 .unwrap_or(item_ty)
923 .to_string()
924}
925
926fn codex_tool_input(item: &Value, item_ty: &str) -> Value {
928 match item_ty {
929 "command_execution" => serde_json::json!({ "command": item.get("command") }),
930 "mcp_tool_call" => item.get("arguments").cloned().unwrap_or(Value::Null),
931 _ => item.clone(),
934 }
935}
936
937fn flatten_text(v: Option<&Value>) -> String {
945 match v {
946 Some(Value::String(s)) => s.clone(),
947 Some(Value::Array(blocks)) => blocks
948 .iter()
949 .map(|b| match b.get("text").and_then(Value::as_str) {
950 Some(text) => text.to_string(),
951 None => b.to_string(),
952 })
953 .collect::<Vec<_>>()
954 .join("\n"),
955 Some(other) => other.to_string(),
956 None => String::new(),
957 }
958}
959
960#[cfg(test)]
961mod tests {
962 use super::*;
963
964 fn run(agent: Agent, lines: &[&str]) -> (Vec<Event>, Terminal) {
966 let mut p = Parser::new(agent, Format::Stream);
967 let events = lines.iter().flat_map(|l| p.push(l)).collect();
968 (events, p.finish())
969 }
970
971 #[test]
974 fn claude_stream_yields_start_thinking_text_and_terminal_facts() {
975 let (events, term) = run(
976 Agent::Claude,
977 &[
978 r#"{"type":"system","subtype":"init","session_id":"sess-a","model":"claude-haiku-4-5"}"#,
979 r#"{"type":"assistant","session_id":"sess-a","message":{"content":[{"type":"thinking","thinking":"brief"}]}}"#,
980 r#"{"type":"assistant","session_id":"sess-a","message":{"content":[{"type":"text","text":"pong"}]}}"#,
981 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}}"#,
982 ],
983 );
984 assert_eq!(
985 events[0],
986 Event::Started {
987 session: "sess-a".into(),
988 model: Some("claude-haiku-4-5".into())
989 }
990 );
991 assert_eq!(events[1], Event::Thinking("brief".into()));
992 assert_eq!(events[2], Event::Text("pong".into()));
993 assert_eq!(term.session.as_deref(), Some("sess-a"));
994 assert_eq!(term.text, "pong");
995 assert_eq!(term.stop, Stop::Completed);
996 assert_eq!(term.usage.input_tokens, Some(10));
997 assert_eq!(term.usage.cache_read_tokens, Some(18764));
998 assert_eq!(term.usage.cache_write_tokens, Some(7322));
999 assert_eq!(term.usage.cost_usd, Some(0.017));
1000 }
1001
1002 #[test]
1006 fn claude_token_deltas_stream_without_duplicating_the_finished_message() {
1007 let (events, _) = run(
1008 Agent::Claude,
1009 &[
1010 r#"{"type":"system","subtype":"init","session_id":"s"}"#,
1011 r#"{"type":"stream_event","session_id":"s","event":{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}}"#,
1012 r#"{"type":"stream_event","session_id":"s","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"po"}}}"#,
1013 r#"{"type":"stream_event","session_id":"s","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"ng"}}}"#,
1014 r#"{"type":"stream_event","session_id":"s","event":{"type":"content_block_stop","index":0}}"#,
1015 r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"text","text":"pong"}]}}"#,
1017 r#"{"type":"result","subtype":"success","is_error":false,"result":"pong","session_id":"s"}"#,
1018 ],
1019 );
1020 let texts: Vec<_> = events
1021 .iter()
1022 .filter_map(|e| match e {
1023 Event::Text(t) => Some(t.as_str()),
1024 _ => None,
1025 })
1026 .collect();
1027 assert_eq!(texts, ["po", "ng"], "the finished message must not repeat");
1028 }
1029
1030 #[test]
1032 fn claude_thinking_deltas_stream_without_duplication() {
1033 let (events, _) = run(
1034 Agent::Claude,
1035 &[
1036 r#"{"type":"stream_event","session_id":"s","event":{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"weighing"}}}"#,
1037 r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"thinking","thinking":"weighing"}]}}"#,
1038 ],
1039 );
1040 let thoughts: Vec<_> = events
1041 .iter()
1042 .filter_map(|e| match e {
1043 Event::Thinking(t) => Some(t.as_str()),
1044 _ => None,
1045 })
1046 .collect();
1047 assert_eq!(thoughts, ["weighing"]);
1048 }
1049
1050 #[test]
1053 fn a_completed_message_still_streams_when_no_deltas_arrived() {
1054 let (events, _) = run(
1055 Agent::Claude,
1056 &[
1057 r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"text","text":"pong"}]}}"#,
1058 ],
1059 );
1060 assert!(events.contains(&Event::Text("pong".into())), "{events:?}");
1061 }
1062
1063 #[test]
1066 fn tool_calls_survive_delta_suppression() {
1067 let (events, _) = run(
1068 Agent::Claude,
1069 &[
1070 r#"{"type":"stream_event","session_id":"s","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hi"}}}"#,
1071 r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"tool_use","id":"t1","name":"Bash","input":{"command":"ls"}}]}}"#,
1072 ],
1073 );
1074 assert!(
1075 events.iter().any(|e| matches!(e, Event::ToolCall { .. })),
1076 "suppression must apply to text only: {events:?}"
1077 );
1078 }
1079
1080 #[test]
1081 fn claude_started_fires_only_once() {
1082 let (events, _) = run(
1083 Agent::Claude,
1084 &[
1085 r#"{"type":"system","subtype":"init","session_id":"s"}"#,
1086 r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"text","text":"a"}]}}"#,
1087 r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"text","text":"b"}]}}"#,
1088 ],
1089 );
1090 assert_eq!(
1091 events
1092 .iter()
1093 .filter(|e| matches!(e, Event::Started { .. }))
1094 .count(),
1095 1
1096 );
1097 }
1098
1099 #[test]
1100 fn claude_pairs_tool_use_with_its_result() {
1101 let (events, _) = run(
1102 Agent::Claude,
1103 &[
1104 r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"tool_use","id":"toolu_1","name":"Bash","input":{"command":"ls"}}]}}"#,
1105 r#"{"type":"user","session_id":"s","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"a.txt"}]}}"#,
1106 ],
1107 );
1108 let call = events
1109 .iter()
1110 .find(|e| matches!(e, Event::ToolCall { .. }))
1111 .unwrap();
1112 let Event::ToolCall { id, name, input } = call else {
1113 unreachable!()
1114 };
1115 assert_eq!(id.as_deref(), Some("toolu_1"));
1116 assert_eq!(name, "Bash");
1117 assert_eq!(input["command"], "ls");
1118 assert!(events.contains(&Event::ToolResult {
1119 id: Some("toolu_1".into()),
1120 ok: None,
1121 output: "a.txt".into(),
1122 }));
1123 }
1124
1125 #[test]
1126 fn claude_reports_a_rate_limit_without_failing() {
1127 let (events, term) = run(
1128 Agent::Claude,
1129 &[
1130 r#"{"type":"rate_limit_event","session_id":"s","rate_limit_info":{"status":"allowed","resetsAt":1785260400,"rateLimitType":"five_hour"}}"#,
1131 ],
1132 );
1133 let limit = RateLimit {
1134 status: "allowed".into(),
1135 window: Some("five_hour".into()),
1136 resets_at: Some(1_785_260_400),
1137 };
1138 assert!(events.contains(&Event::RateLimit(limit.clone())));
1139 assert_eq!(term.rate_limit, Some(limit.clone()));
1140 assert!(
1141 !limit.is_blocking(),
1142 "an `allowed` heartbeat is not a block"
1143 );
1144 }
1145
1146 #[test]
1147 fn claude_error_result_sets_the_stop_reason() {
1148 let (_, term) = run(
1149 Agent::Claude,
1150 &[r#"{"type":"result","is_error":true,"result":"boom","session_id":"s"}"#],
1151 );
1152 assert_eq!(term.stop, Stop::Error);
1153 }
1154
1155 #[test]
1156 fn copilot_streams_deltas_and_takes_its_answer_from_the_settled_message() {
1157 let (events, term) = run(
1158 Agent::Copilot,
1159 &[
1160 r#"{"type":"assistant.message_delta","data":{"messageId":"m","deltaContent":"po"}}"#,
1161 r#"{"type":"assistant.message_delta","data":{"messageId":"m","deltaContent":"ng"}}"#,
1162 r#"{"type":"assistant.message","data":{"messageId":"m","model":"gpt-5-mini","content":"pong"}}"#,
1163 r#"{"type":"result","sessionId":"768c8e7d","exitCode":0,"usage":{"premiumRequests":0}}"#,
1164 ],
1165 );
1166 let texts: Vec<_> = events
1168 .iter()
1169 .filter_map(|e| match e {
1170 Event::Text(t) => Some(t.as_str()),
1171 _ => None,
1172 })
1173 .collect();
1174 assert_eq!(texts, ["po", "ng"]);
1175 assert_eq!(term.text, "pong", "the answer is the settled message");
1176 assert_eq!(term.session.as_deref(), Some("768c8e7d"));
1177 assert_eq!(term.usage.premium_requests, Some(0));
1178 }
1179
1180 #[test]
1181 fn copilot_brackets_a_tool_call_with_its_completion() {
1182 let (events, _) = run(
1183 Agent::Copilot,
1184 &[
1185 r#"{"type":"tool.execution_start","data":{"toolCallId":"call_1","toolName":"bash","arguments":{"command":"ls"}}}"#,
1186 r#"{"type":"tool.execution_complete","data":{"toolCallId":"call_1","success":true,"result":{"content":"a.txt"}}}"#,
1187 ],
1188 );
1189 assert!(matches!(
1190 &events[0],
1191 Event::ToolCall { id, name, .. }
1192 if id.as_deref() == Some("call_1") && name == "bash"
1193 ));
1194 assert_eq!(
1195 events[1],
1196 Event::ToolResult {
1197 id: Some("call_1".into()),
1198 ok: Some(true),
1199 output: "a.txt".into()
1200 }
1201 );
1202 }
1203
1204 #[test]
1208 fn a_codex_failed_turn_yields_the_reason_and_the_status() {
1209 let (_, term) = run(
1210 Agent::Codex,
1211 &[
1212 r#"{"type":"thread.started","thread_id":"019fad62"}"#,
1213 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.\"}}"}}"#,
1214 ],
1215 );
1216 assert_eq!(term.stop, Stop::Error);
1217 assert_eq!(term.error_status, Some(400));
1218 assert_eq!(
1219 term.error_message.as_deref(),
1220 Some(
1221 "The 'bogus-model-xyz' model is not supported when using Codex with a ChatGPT account."
1222 ),
1223 "the caller should get the sentence, not the envelope"
1224 );
1225 }
1226
1227 #[test]
1230 fn a_plain_codex_failure_message_passes_through() {
1231 let (_, term) = run(
1232 Agent::Codex,
1233 &[
1234 r#"{"type":"turn.failed","error":{"message":"stream disconnected before completion"}}"#,
1235 ],
1236 );
1237 assert_eq!(term.error_status, None);
1238 assert_eq!(
1239 term.error_message.as_deref(),
1240 Some("stream disconnected before completion")
1241 );
1242 }
1243
1244 #[test]
1245 fn codex_reads_the_thread_id_and_the_completed_message() {
1246 let (events, term) = run(
1247 Agent::Codex,
1248 &[
1249 r#"{"type":"thread.started","thread_id":"0199-xyz"}"#,
1250 r#"{"type":"item.completed","item":{"id":"i1","type":"agent_message","text":"pong"}}"#,
1251 r#"{"type":"turn.completed","usage":{"input_tokens":12,"output_tokens":3,"cached_input_tokens":9}}"#,
1252 ],
1253 );
1254 assert_eq!(
1255 events[0],
1256 Event::Started {
1257 session: "0199-xyz".into(),
1258 model: None
1259 }
1260 );
1261 assert_eq!(term.session.as_deref(), Some("0199-xyz"));
1262 assert_eq!(term.text, "pong");
1263 assert_eq!(term.usage.input_tokens, Some(12));
1264 assert_eq!(term.usage.cache_read_tokens, Some(9));
1265 }
1266
1267 #[test]
1268 fn codex_command_execution_becomes_a_call_and_a_result() {
1269 let (events, _) = run(
1270 Agent::Codex,
1271 &[
1272 r#"{"type":"item.completed","item":{"id":"c1","type":"command_execution","command":"ls","exit_code":0,"aggregated_output":"a.txt"}}"#,
1273 ],
1274 );
1275 assert!(matches!(&events[0], Event::ToolCall { name, .. } if name == "command_execution"));
1276 assert_eq!(
1277 events[1],
1278 Event::ToolResult {
1279 id: Some("c1".into()),
1280 ok: Some(true),
1281 output: "a.txt".into()
1282 }
1283 );
1284 }
1285
1286 #[test]
1290 fn codex_started_then_completed_yields_one_call_and_one_result() {
1291 let (events, _) = run(
1292 Agent::Codex,
1293 &[
1294 r#"{"type":"item.started","item":{"id":"item_1","type":"command_execution","command":"/bin/zsh -lc ls","aggregated_output":"","exit_code":null,"status":"in_progress"}}"#,
1295 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"}}"#,
1296 ],
1297 );
1298 let calls = events
1299 .iter()
1300 .filter(|e| matches!(e, Event::ToolCall { .. }))
1301 .count();
1302 assert_eq!(calls, 1, "the same item must not be announced twice");
1303 let results: Vec<_> = events
1304 .iter()
1305 .filter_map(|e| match e {
1306 Event::ToolResult { output, .. } => Some(output.as_str()),
1307 _ => None,
1308 })
1309 .collect();
1310 assert_eq!(
1311 results,
1312 ["a.txt\n"],
1313 "the in-progress blank must not appear"
1314 );
1315 }
1316
1317 #[test]
1319 fn codex_last_completed_message_is_the_answer() {
1320 let (_, term) = run(
1321 Agent::Codex,
1322 &[
1323 r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"I'll list the directory."}}"#,
1324 r#"{"type":"item.completed","item":{"id":"i2","type":"agent_message","text":"DONE"}}"#,
1325 ],
1326 );
1327 assert_eq!(term.text, "DONE");
1328 }
1329
1330 #[test]
1333 fn an_enormous_tool_result_is_bounded_and_marked() {
1334 let huge = "x".repeat(MAX_EVENT_BYTES * 4);
1335 let line = serde_json::json!({
1336 "type": "user",
1337 "session_id": "s",
1338 "message": {"content": [{
1339 "type": "tool_result", "tool_use_id": "t1", "content": huge
1340 }]}
1341 })
1342 .to_string();
1343
1344 let (events, _) = run(Agent::Claude, &[&line]);
1345 let Some(Event::ToolResult { output, id, .. }) = events
1346 .iter()
1347 .find(|e| matches!(e, Event::ToolResult { .. }))
1348 .cloned()
1349 else {
1350 panic!("expected a tool result, got {events:?}")
1351 };
1352 assert!(
1353 output.len() <= MAX_EVENT_BYTES,
1354 "kept {} bytes",
1355 output.len()
1356 );
1357 assert!(
1358 output.ends_with(TRUNCATION_MARK),
1359 "truncation must be visible"
1360 );
1361 assert_eq!(id.as_deref(), Some("t1"), "the id must survive whole");
1362 }
1363
1364 #[test]
1368 fn usable_identifiers_are_never_shortened() {
1369 let id = "s".repeat(MAX_IDENTIFIER_BYTES);
1371 let line =
1372 serde_json::json!({"type": "system", "subtype": "init", "session_id": id}).to_string();
1373 let (events, term) = run(Agent::Claude, &[&line]);
1374
1375 let Some(Event::Started { session, .. }) = events.first().cloned() else {
1376 panic!("expected Started, got {events:?}")
1377 };
1378 assert_eq!(session.len(), id.len(), "the session id was shortened");
1379 assert_eq!(term.session.as_deref(), Some(id.as_str()));
1380 }
1381
1382 #[test]
1387 fn an_oversized_session_id_is_rejected_rather_than_stored() {
1388 let id = "s".repeat(MAX_IDENTIFIER_BYTES + 1);
1389 for (agent, line) in [
1390 (
1391 Agent::Claude,
1392 serde_json::json!({"type": "system", "subtype": "init", "session_id": id})
1393 .to_string(),
1394 ),
1395 (
1396 Agent::Codex,
1397 serde_json::json!({"type": "thread.started", "thread_id": id}).to_string(),
1398 ),
1399 (
1400 Agent::Copilot,
1401 serde_json::json!({"type": "result", "sessionId": id, "exitCode": 0}).to_string(),
1402 ),
1403 ] {
1404 let (events, term) = run(agent, &[&line]);
1405 assert!(term.session.is_none(), "{agent} stored an unusable id");
1406 assert!(
1407 !events.iter().any(|e| matches!(e, Event::Started { .. })),
1408 "{agent} announced a session it cannot resume"
1409 );
1410 }
1411 }
1412
1413 #[test]
1417 fn an_oversized_tool_id_drops_the_id_but_keeps_the_event() {
1418 let id = "t".repeat(MAX_IDENTIFIER_BYTES + 1);
1419 let line = serde_json::json!({
1420 "type": "assistant", "session_id": "s",
1421 "message": {"content": [{
1422 "type": "tool_use", "id": id, "name": "Bash", "input": {"command": "ls"}
1423 }]}
1424 })
1425 .to_string();
1426
1427 let (events, _) = run(Agent::Claude, &[&line]);
1428 let Some(Event::ToolCall { id: seen, name, .. }) = events
1429 .iter()
1430 .find(|e| matches!(e, Event::ToolCall { .. }))
1431 .cloned()
1432 else {
1433 panic!("the call itself must still be reported, got {events:?}")
1434 };
1435 assert_eq!(seen, None, "an unusable id must be dropped, not shortened");
1436 assert_eq!(name, "Bash");
1437 }
1438
1439 #[test]
1442 fn the_pending_tool_map_is_bounded_by_bytes_not_only_entries() {
1443 let mut parser = Parser::new(Agent::Claude, Format::Stream);
1444 for i in 0..MAX_PENDING_TOOLS {
1447 let line = serde_json::json!({
1448 "type": "assistant", "session_id": "s",
1449 "message": {"content": [{
1450 "type": "tool_use",
1451 "id": format!("{i:0>width$}", width = MAX_IDENTIFIER_BYTES),
1452 "name": "x".repeat(MAX_IDENTIFIER_BYTES),
1453 "input": {}
1454 }]}
1455 })
1456 .to_string();
1457 parser.push(&line);
1458 }
1459 assert!(
1460 parser.tool_bytes <= MAX_PENDING_TOOL_BYTES,
1461 "pending tools grew to {} bytes",
1462 parser.tool_bytes
1463 );
1464 }
1465
1466 #[test]
1469 fn a_completed_tool_call_releases_its_budget() {
1470 let mut parser = Parser::new(Agent::Claude, Format::Stream);
1471 let call = |id: &str| {
1472 serde_json::json!({
1473 "type": "assistant", "session_id": "s",
1474 "message": {"content": [{
1475 "type": "tool_use", "id": id, "name": "Bash", "input": {}
1476 }]}
1477 })
1478 .to_string()
1479 };
1480 let result = |id: &str| {
1481 serde_json::json!({
1482 "type": "user", "session_id": "s",
1483 "message": {"content": [{
1484 "type": "tool_result", "tool_use_id": id, "content": "done"
1485 }]}
1486 })
1487 .to_string()
1488 };
1489
1490 for i in 0..(MAX_PENDING_TOOLS * 4) {
1491 let id = format!("toolu_{i}");
1492 parser.push(&call(&id));
1493 parser.push(&result(&id));
1494 }
1495 assert_eq!(parser.tool_bytes, 0, "budget leaked across paired calls");
1496 assert!(parser.tools.is_empty());
1497 }
1498
1499 #[test]
1502 fn a_worst_case_event_stays_within_the_stated_ceiling() {
1503 let huge = "x".repeat(MAX_LINE);
1504 let line = serde_json::json!({
1505 "type": "assistant", "session_id": huge,
1506 "message": {"content": [{
1507 "type": "tool_use", "id": huge, "name": huge, "input": {"command": huge}
1508 }]}
1509 })
1510 .to_string();
1511
1512 let (events, _) = run(Agent::Claude, &[&line]);
1513 for event in &events {
1514 let size = serde_json::to_string(event).unwrap().len();
1515 let ceiling = MAX_EVENT_BYTES + 4 * MAX_IDENTIFIER_BYTES;
1517 assert!(size <= ceiling, "an event reached {size} bytes: {event:?}");
1518 }
1519 }
1520
1521 #[test]
1524 fn oversized_tool_arguments_stay_valid_json() {
1525 let line = serde_json::json!({
1526 "type": "assistant",
1527 "session_id": "s",
1528 "message": {"content": [{
1529 "type": "tool_use", "id": "t1", "name": "Bash",
1530 "input": {"command": "y".repeat(MAX_EVENT_BYTES * 3)}
1531 }]}
1532 })
1533 .to_string();
1534
1535 let (events, _) = run(Agent::Claude, &[&line]);
1536 let Some(Event::ToolCall { input, .. }) = events
1537 .iter()
1538 .find(|e| matches!(e, Event::ToolCall { .. }))
1539 .cloned()
1540 else {
1541 panic!("expected a tool call, got {events:?}")
1542 };
1543 assert_eq!(input["truncated"], true, "got {input}");
1544 assert!(
1545 input.is_object(),
1546 "the replacement must still be valid JSON"
1547 );
1548 assert!(input.to_string().len() <= MAX_EVENT_BYTES);
1549 }
1550
1551 #[test]
1552 fn ordinary_payloads_pass_through_untouched() {
1553 let (events, _) = run(
1554 Agent::Claude,
1555 &[
1556 r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"text","text":"pong"}]}}"#,
1557 ],
1558 );
1559 assert!(events.contains(&Event::Text("pong".into())), "{events:?}");
1560 }
1561
1562 #[test]
1563 fn capture_is_bounded_and_keeps_the_earliest_output() {
1564 let mut buf = String::new();
1565 for i in 0..50_000 {
1567 append_capped(&mut buf, &format!("line {i} aaaaaaaaaaaaaaaaaaaaaaaaaaaa"));
1568 }
1569 assert!(buf.len() <= MAX_CAPTURE, "grew to {}", buf.len());
1570 assert!(buf.starts_with("line 0 "), "the earliest output is kept");
1571 }
1572
1573 #[test]
1574 fn capping_never_splits_a_multibyte_character() {
1575 let mut buf = "x".repeat(MAX_CAPTURE - 3);
1576 assert!(append_capped(&mut buf, "🙂🙂"));
1578 assert!(buf.len() <= MAX_CAPTURE);
1579 assert!(buf.is_char_boundary(buf.len()));
1582 }
1583
1584 #[test]
1585 fn a_full_buffer_reports_that_it_took_nothing() {
1586 let mut buf = "x".repeat(MAX_CAPTURE);
1587 assert!(!append_capped(&mut buf, "more"));
1588 assert_eq!(buf.len(), MAX_CAPTURE);
1589 }
1590
1591 #[test]
1594 fn unparseable_lines_are_counted_and_sampled() {
1595 let (_, term) = run(
1596 Agent::Claude,
1597 &[
1598 "<html>an error page, not JSON</html>",
1599 "another bad line",
1600 r#"{"type":"result","result":"ok","session_id":"s"}"#,
1601 ],
1602 );
1603 assert_eq!(term.unparsed, 2);
1604 assert_eq!(
1605 term.first_unparsed.as_deref(),
1606 Some("<html>an error page, not JSON</html>")
1607 );
1608 }
1609
1610 #[test]
1611 fn a_clean_stream_reports_no_parse_failures() {
1612 let (_, term) = run(
1613 Agent::Claude,
1614 &[r#"{"type":"result","result":"ok","session_id":"s"}"#],
1615 );
1616 assert_eq!(term.unparsed, 0);
1617 assert!(term.first_unparsed.is_none());
1618 }
1619
1620 #[test]
1623 fn tool_result_blocks_that_are_not_text_are_kept_not_dropped() {
1624 let (events, _) = run(
1625 Agent::Claude,
1626 &[
1627 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"}}]}]}}"#,
1628 ],
1629 );
1630 let output = events
1631 .iter()
1632 .find_map(|e| match e {
1633 Event::ToolResult { output, .. } => Some(output),
1634 _ => None,
1635 })
1636 .unwrap_or_else(|| panic!("expected a tool result, got {events:?}"));
1637 assert!(output.contains("seen"));
1638 assert!(output.contains("image"), "the image block was dropped");
1639 }
1640
1641 #[test]
1642 fn garbage_lines_are_skipped_not_fatal() {
1643 let (events, term) = run(
1644 Agent::Claude,
1645 &[
1646 "Warning: something on stdout",
1647 "",
1648 r#"{"type":"result","result":"ok","session_id":"s"}"#,
1649 ],
1650 );
1651 assert!(events.iter().all(|e| !matches!(e, Event::Text(_))));
1652 assert_eq!(term.text, "ok");
1653 }
1654
1655 #[test]
1656 fn text_format_passes_lines_through_verbatim() {
1657 let mut p = Parser::new(Agent::Copilot, Format::Text);
1658 let events: Vec<_> = ["hello", "world"].iter().flat_map(|l| p.push(l)).collect();
1659 assert_eq!(
1660 events,
1661 [Event::Text("hello".into()), Event::Text("world".into())]
1662 );
1663 assert_eq!(p.finish().text, "hello\nworld");
1664 }
1665}