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 ApprovalRequest(crate::approval::Approval),
72 RateLimit(RateLimit),
74}
75
76pub const MAX_CAPTURE: usize = 1024 * 1024;
83
84pub const MAX_LINE: usize = 512 * 1024;
91
92pub const MAX_EVENT_BYTES: usize = 64 * 1024;
103
104pub const TRUNCATION_MARK: &str = "…(truncated)";
107
108pub const MAX_IDENTIFIER_BYTES: usize = 4 * 1024;
120
121pub(crate) const MAX_PENDING_TOOL_BYTES: usize = 256 * 1024;
127
128pub(crate) const MAX_PENDING_TOOLS: usize = 1024;
133
134pub(crate) fn append_capped(buf: &mut String, line: &str) -> bool {
141 let remaining = MAX_CAPTURE.saturating_sub(buf.len());
142 if remaining == 0 {
143 return false;
144 }
145 if line.len() < remaining {
147 buf.push_str(line);
148 buf.push('\n');
149 } else {
150 let mut cut = remaining - 1;
152 while cut > 0 && !line.is_char_boundary(cut) {
153 cut -= 1;
154 }
155 buf.push_str(&line[..cut]);
156 buf.push('\n');
157 }
158 true
159}
160
161fn usable_identifier(value: &str) -> bool {
166 value.len() <= MAX_IDENTIFIER_BYTES
167}
168
169fn accept_identifier(value: Option<String>) -> Option<String> {
171 value.filter(|v| usable_identifier(v))
172}
173
174fn bound_text(text: String) -> String {
176 if text.len() <= MAX_EVENT_BYTES {
177 return text;
178 }
179 let mut cut = MAX_EVENT_BYTES - TRUNCATION_MARK.len();
180 while cut > 0 && !text.is_char_boundary(cut) {
181 cut -= 1;
182 }
183 let mut out = text[..cut].to_string();
184 out.push_str(TRUNCATION_MARK);
185 out
186}
187
188fn bound_value(value: Value) -> Value {
194 let size = value.to_string().len();
195 if size <= MAX_EVENT_BYTES {
196 return value;
197 }
198 serde_json::json!({
199 "truncated": true,
200 "original_bytes": size,
201 "note": "arguments exceeded MAX_EVENT_BYTES and were dropped rather than \
202 truncated, which would have produced invalid JSON",
203 })
204}
205
206fn enforce_bounds(event: Event) -> Event {
213 match event {
214 Event::Text(text) => Event::Text(bound_text(text)),
215 Event::Thinking(text) => Event::Thinking(bound_text(text)),
216 Event::ToolCall { id, name, input } => Event::ToolCall {
220 id: accept_identifier(id),
221 name: bound_identifier(name),
222 input: bound_value(input),
223 },
224 Event::ToolResult { id, ok, output } => Event::ToolResult {
225 id: accept_identifier(id),
226 ok,
227 output: bound_text(output),
228 },
229 Event::Started { session, model } => Event::Started {
233 session,
234 model: model.map(bound_identifier),
235 },
236 Event::ApprovalRequest(approval) => {
237 Event::ApprovalRequest(crate::approval::Approval {
238 id: approval.id,
242 tool: bound_identifier(approval.tool),
243 input: bound_value(approval.input),
244 })
245 }
246 Event::RateLimit(limit) => Event::RateLimit(RateLimit {
247 status: bound_identifier(limit.status),
248 window: limit.window.map(bound_identifier),
249 resets_at: limit.resets_at,
250 overage_status: limit.overage_status.map(bound_identifier),
251 is_using_overage: limit.is_using_overage,
252 }),
253 }
254}
255
256fn bound_identifier(text: String) -> String {
261 if text.len() <= MAX_IDENTIFIER_BYTES {
262 return text;
263 }
264 let mut cut = MAX_IDENTIFIER_BYTES - TRUNCATION_MARK.len();
265 while cut > 0 && !text.is_char_boundary(cut) {
266 cut -= 1;
267 }
268 let mut out = text[..cut].to_string();
269 out.push_str(TRUNCATION_MARK);
270 out
271}
272
273#[derive(Debug, Clone, Default, PartialEq)]
275pub struct Terminal {
276 pub session: Option<String>,
278 pub model: Option<String>,
285 pub text: String,
287 pub usage: Usage,
289 pub stop: Stop,
291 pub rate_limit: Option<RateLimit>,
293 pub unparsed: usize,
299 pub first_unparsed: Option<String>,
301 pub structured: Option<Value>,
303 pub error_status: Option<u16>,
306 pub error_message: Option<String>,
310}
311
312fn unwrap_error_body(message: &str) -> (Option<u16>, String) {
320 let Ok(body) = serde_json::from_str::<Value>(message) else {
321 return (None, message.to_string());
322 };
323 let status = body
324 .get("status")
325 .and_then(Value::as_u64)
326 .and_then(|s| u16::try_from(s).ok());
327 let inner = body
328 .get("error")
329 .and_then(|e| e.get("message"))
330 .and_then(Value::as_str)
331 .map(str::to_string);
332 (status, inner.unwrap_or_else(|| message.to_string()))
333}
334
335#[derive(Debug)]
337pub(crate) struct Parser {
338 agent: Agent,
339 format: Format,
340 term: Terminal,
341 tools: HashMap<String, String>,
343 tool_bytes: usize,
346 seen: Seen,
348}
349
350#[derive(Debug, Default)]
356#[expect(
357 clippy::struct_excessive_bools,
358 reason = "four independent stream milestones; naming each beats packing them"
359)]
360struct Seen {
361 started: bool,
363 structured: bool,
366 terminal: bool,
368 deltas: bool,
375}
376
377impl Parser {
378 #[must_use]
380 pub fn new(agent: Agent, format: Format) -> Self {
381 Self {
382 agent,
383 format,
384 term: Terminal::default(),
385 tools: HashMap::new(),
386 tool_bytes: 0,
387 seen: Seen::default(),
388 }
389 }
390
391 pub fn push(&mut self, line: &str) -> Vec<Event> {
399 let line = line.trim();
400 if line.is_empty() {
401 return Vec::new();
402 }
403 if self.format == Format::Text {
406 append_capped(&mut self.term.text, line);
407 return vec![enforce_bounds(Event::Text(line.to_string()))];
408 }
409 let Ok(value) = serde_json::from_str::<Value>(line) else {
410 self.term.unparsed += 1;
411 if self.term.first_unparsed.is_none() {
412 let mut cut = line.len().min(512);
416 while cut > 0 && !line.is_char_boundary(cut) {
417 cut -= 1;
418 }
419 self.term.first_unparsed = Some(line[..cut].to_string());
420 }
421 return Vec::new();
422 };
423 if let Some(ty) = value.get("type").and_then(Value::as_str)
426 && self.recognizes(ty)
427 {
428 self.seen.structured = true;
429 }
430 let mut out = match self.agent {
431 Agent::Claude => self.claude(&value),
432 Agent::Codex => self.codex(&value),
433 Agent::Copilot => self.copilot(&value),
434 };
435 out = out.into_iter().map(enforce_bounds).collect();
438
439 if !self.seen.started {
442 if let Some(session) = self.term.session.clone() {
443 self.seen.started = true;
444 let model = model_of(&value);
445 self.term.model.clone_from(&model);
446 out.insert(0, Event::Started { session, model });
447 }
448 }
449 out
450 }
451
452 pub(crate) fn saw_terminal(&self) -> bool {
459 self.seen.terminal
460 }
461
462 fn recognizes(&self, ty: &str) -> bool {
464 match self.agent {
465 Agent::Claude => matches!(
466 ty,
467 "system" | "assistant" | "user" | "result" | "rate_limit_event" | "control_request"
468 ),
469 Agent::Codex => {
470 ty.starts_with("thread.") || ty.starts_with("turn.") || ty.starts_with("item.")
471 }
472 Agent::Copilot => {
473 ty == "result"
474 || ty.starts_with("assistant.")
475 || ty.starts_with("tool.")
476 || ty.starts_with("session.")
477 }
478 }
479 }
480
481 fn remember_tool(&mut self, id: &str, name: &str) {
484 if !usable_identifier(id) {
487 return;
488 }
489 let name = bound_identifier(name.to_string());
490 let cost = id.len() + name.len();
491 if self.tools.len() >= MAX_PENDING_TOOLS
495 || self.tool_bytes.saturating_add(cost) > MAX_PENDING_TOOL_BYTES
496 {
497 return;
498 }
499 self.tool_bytes += cost;
500 if let Some(previous) = self.tools.insert(id.to_string(), name) {
501 self.tool_bytes = self.tool_bytes.saturating_sub(id.len() + previous.len());
503 }
504 }
505
506 fn forget_tool(&mut self, id: &str) {
508 if let Some(name) = self.tools.remove(id) {
509 self.tool_bytes = self.tool_bytes.saturating_sub(id.len() + name.len());
510 }
511 }
512
513 pub(crate) fn saw_structured_record(&self) -> bool {
518 self.seen.structured
519 }
520
521 pub(crate) fn saw_terminal_record(&self) -> bool {
524 self.seen.terminal
525 }
526
527 #[must_use]
529 pub fn finish(mut self) -> Terminal {
530 if self.format == Format::Text {
531 self.term.text = self.term.text.trim_end().to_string();
532 }
533 self.term
534 }
535
536 fn claude(&mut self, v: &Value) -> Vec<Event> {
542 let ty = v.get("type").and_then(Value::as_str).unwrap_or_default();
543 if let Some(id) = v.get("session_id").and_then(Value::as_str)
546 && usable_identifier(id)
547 {
548 self.term.session.get_or_insert_with(|| id.to_string());
549 }
550 match ty {
551 "rate_limit_event" => {
552 let limit = claude_rate_limit(v.get("rate_limit_info"));
553 self.term.rate_limit.clone_from(&limit);
554 limit.into_iter().map(Event::RateLimit).collect()
555 }
556 "stream_event" => self.claude_delta(v),
558 "control_request" => {
566 let Some(request) = v.get("request") else {
567 return Vec::new();
568 };
569 if request.get("subtype").and_then(Value::as_str) != Some("can_use_tool") {
570 return Vec::new();
571 }
572 let Some(id) = v.get("request_id").and_then(Value::as_str) else {
573 return Vec::new();
577 };
578 if !usable_identifier(id) {
579 return Vec::new();
580 }
581 vec![Event::ApprovalRequest(crate::approval::Approval {
582 id: id.to_string(),
583 tool: request
584 .get("tool_name")
585 .and_then(Value::as_str)
586 .unwrap_or("unknown")
587 .to_string(),
588 input: request.get("input").cloned().unwrap_or(Value::Null),
589 })]
590 }
591 "assistant" | "user" => self.content_blocks(v),
592 "result" => {
593 self.seen.terminal = true;
594 if let Some(text) = v.get("result").and_then(Value::as_str) {
595 self.term.text = text.to_string();
596 }
597 if let Some(value) = v.get("structured_output") {
600 self.term.structured = Some(value.clone());
601 }
602 self.term.usage = claude_usage(v, self.term.model.as_deref());
603 self.term.stop = if v.get("is_error").and_then(Value::as_bool) == Some(true) {
606 self.term.error_status = v
607 .get("api_error_status")
608 .and_then(Value::as_u64)
609 .and_then(|s| u16::try_from(s).ok());
610 Stop::Error
611 } else {
612 stop_from(v.get("stop_reason"))
613 };
614 Vec::new()
615 }
616 _ => Vec::new(),
617 }
618 }
619
620 fn claude_delta(&mut self, v: &Value) -> Vec<Event> {
627 let Some(event) = v.get("event") else {
628 return Vec::new();
629 };
630 if event.get("type").and_then(Value::as_str) != Some("content_block_delta") {
631 return Vec::new();
632 }
633 let Some(delta) = event.get("delta") else {
634 return Vec::new();
635 };
636 self.seen.deltas = true;
639
640 match delta.get("type").and_then(Value::as_str) {
641 Some("text_delta") => delta
642 .get("text")
643 .and_then(Value::as_str)
644 .filter(|text| !text.is_empty())
645 .map(|text| Event::Text(text.to_string()))
646 .into_iter()
647 .collect(),
648 Some("thinking_delta") => delta
649 .get("thinking")
650 .and_then(Value::as_str)
651 .filter(|text| !text.is_empty())
652 .map(|text| Event::Thinking(text.to_string()))
653 .into_iter()
654 .collect(),
655 _ => Vec::new(),
659 }
660 }
661
662 fn content_blocks(&mut self, v: &Value) -> Vec<Event> {
665 let blocks = v
666 .get("message")
667 .and_then(|m| m.get("content"))
668 .and_then(Value::as_array);
669 let Some(blocks) = blocks else {
670 return Vec::new();
671 };
672 let mut out = Vec::new();
673 for block in blocks {
674 let ty = block
675 .get("type")
676 .and_then(Value::as_str)
677 .unwrap_or_default();
678 match ty {
679 "text" if !self.seen.deltas => {
684 if let Some(t) = block.get("text").and_then(Value::as_str) {
685 out.push(Event::Text(t.to_string()));
686 }
687 }
688 "thinking" if !self.seen.deltas => {
689 if let Some(t) = block.get("thinking").and_then(Value::as_str) {
690 out.push(Event::Thinking(t.to_string()));
691 }
692 }
693 "tool_use" => {
694 let name = block
695 .get("name")
696 .and_then(Value::as_str)
697 .unwrap_or("tool")
698 .to_string();
699 let id = block.get("id").and_then(Value::as_str).map(str::to_string);
700 if let Some(id) = &id {
701 self.remember_tool(id, &name);
702 }
703 out.push(Event::ToolCall {
704 id,
705 name,
706 input: block.get("input").cloned().unwrap_or(Value::Null),
707 });
708 }
709 "tool_result" => out.push(Event::ToolResult {
710 id: block
711 .get("tool_use_id")
712 .and_then(Value::as_str)
713 .inspect(|id| {
714 self.forget_tool(id);
716 })
717 .map(str::to_string),
718 ok: block
719 .get("is_error")
720 .and_then(Value::as_bool)
721 .map(|is_error| !is_error),
722 output: flatten_text(block.get("content")),
723 }),
724 _ => {}
725 }
726 }
727 out
728 }
729
730 fn codex(&mut self, v: &Value) -> Vec<Event> {
738 let ty = v.get("type").and_then(Value::as_str).unwrap_or_default();
739 if let Some(id) = v.get("thread_id").and_then(Value::as_str)
740 && usable_identifier(id)
741 {
742 self.term.session.get_or_insert_with(|| id.to_string());
743 }
744 match ty {
745 "turn.completed" => {
746 self.seen.terminal = true;
747 self.term.usage = codex_usage(v.get("usage"));
748 Vec::new()
749 }
750 "turn.failed" => {
751 self.seen.terminal = true;
752 self.term.stop = Stop::Error;
753 if let Some(message) = v
754 .get("error")
755 .and_then(|e| e.get("message"))
756 .and_then(Value::as_str)
757 {
758 let (status, message) = unwrap_error_body(message);
759 self.term.error_status = status;
760 self.term.error_message = Some(bound_text(message));
761 }
762 Vec::new()
763 }
764 "item.started" | "item.updated" | "item.completed" => {
765 let Some(item) = v.get("item") else {
766 return Vec::new();
767 };
768 let item_ty = item.get("type").and_then(Value::as_str).unwrap_or_default();
769 let id = item.get("id").and_then(Value::as_str).map(str::to_string);
770 let done = ty == "item.completed";
771
772 let name = tool_name(item, item_ty);
776 let first = id
777 .as_ref()
778 .is_none_or(|id| self.tools.insert(id.clone(), name.clone()).is_none());
779
780 match item_ty {
781 "agent_message" => {
784 if !done {
785 return Vec::new();
786 }
787 let text = item.get("text").and_then(Value::as_str).unwrap_or_default();
788 self.term.text = text.to_string();
789 vec![Event::Text(text.to_string())]
790 }
791 "reasoning" if done => item
792 .get("text")
793 .and_then(Value::as_str)
794 .map(|t| Event::Thinking(t.to_string()))
795 .into_iter()
796 .collect(),
797 "command_execution" | "mcp_tool_call" | "file_change" | "web_search" => {
798 let mut out = Vec::new();
799 if first {
800 out.push(Event::ToolCall {
801 id: id.clone(),
802 name,
803 input: codex_tool_input(item, item_ty),
804 });
805 }
806 if done {
809 if let Some(id) = &id {
810 self.forget_tool(id);
811 }
812 out.push(Event::ToolResult {
813 id,
814 ok: item
815 .get("exit_code")
816 .and_then(Value::as_i64)
817 .map(|code| code == 0),
818 output: item
819 .get("aggregated_output")
820 .and_then(Value::as_str)
821 .unwrap_or_default()
822 .to_string(),
823 });
824 }
825 out
826 }
827 _ => Vec::new(),
828 }
829 }
830 _ => Vec::new(),
831 }
832 }
833
834 fn copilot(&mut self, v: &Value) -> Vec<Event> {
841 let ty = v.get("type").and_then(Value::as_str).unwrap_or_default();
842 let data = v.get("data");
843 let field = |key: &str| -> Option<String> {
844 data.and_then(|d| d.get(key))
845 .and_then(Value::as_str)
846 .map(str::to_string)
847 };
848 match ty {
849 "assistant.message_delta" => field("deltaContent")
851 .filter(|t| !t.is_empty())
852 .map(Event::Text)
853 .into_iter()
854 .collect(),
855 "assistant.message" => {
858 if let Some(content) = field("content") {
859 self.term.text = content;
860 }
861 Vec::new()
862 }
863 "assistant.reasoning" => field("content")
864 .filter(|t| !t.is_empty())
865 .map(Event::Thinking)
866 .into_iter()
867 .collect(),
868 "tool.execution_start" => {
869 let id = field("toolCallId");
870 let name = field("toolName").unwrap_or_else(|| "tool".into());
871 if let Some(id) = &id {
872 self.remember_tool(id, &name);
873 }
874 vec![Event::ToolCall {
875 id,
876 name,
877 input: data
878 .and_then(|d| d.get("arguments"))
879 .cloned()
880 .unwrap_or(Value::Null),
881 }]
882 }
883 "tool.execution_complete" => vec![Event::ToolResult {
884 id: field("toolCallId").inspect(|id| {
885 self.forget_tool(id);
886 }),
887 ok: data.and_then(|d| d.get("success")).and_then(Value::as_bool),
888 output: data
889 .and_then(|d| d.get("result"))
890 .and_then(|r| r.get("content"))
891 .and_then(Value::as_str)
892 .unwrap_or_default()
893 .to_string(),
894 }],
895 "session.usage_checkpoint" => {
900 if let Some(data) = v.get("data") {
901 self.term.usage.ai_credits_nano =
902 data.get("totalNanoAiu").and_then(Value::as_u64);
903 if let Some(premium) = data.get("totalPremiumRequests").and_then(Value::as_u64)
904 {
905 self.term.usage.premium_requests = Some(premium);
906 }
907 }
908 Vec::new()
909 }
910 "result" => {
912 self.seen.terminal = true;
913 if let Some(id) = v.get("sessionId").and_then(Value::as_str)
914 && usable_identifier(id)
915 {
916 self.term.session = Some(id.to_string());
917 }
918 if let Some(usage) = v.get("usage") {
919 self.term.usage.premium_requests =
920 usage.get("premiumRequests").and_then(Value::as_u64);
921 self.term.usage.duration_ms =
922 usage.get("sessionDurationMs").and_then(Value::as_u64);
923 self.term.usage.api_duration_ms =
924 usage.get("totalApiDurationMs").and_then(Value::as_u64);
925 }
926 if let Some(code) = v.get("exitCode").and_then(Value::as_i64)
927 && code != 0
928 {
929 self.term.stop = Stop::Error;
930 self.term.error_message = Some(format!("copilot exited with code {code}"));
933 }
934 Vec::new()
935 }
936 _ => Vec::new(),
937 }
938 }
939}
940
941fn model_of(v: &Value) -> Option<String> {
944 v.get("model")
945 .or_else(|| v.get("data").and_then(|d| d.get("model")))
946 .and_then(Value::as_str)
947 .map(str::to_string)
948}
949
950fn stop_from(v: Option<&Value>) -> Stop {
952 match v.and_then(Value::as_str) {
953 None | Some("end_turn" | "stop" | "completed") => Stop::Completed,
954 Some(other) => Stop::Other(other.to_string()),
955 }
956}
957
958fn claude_rate_limit(v: Option<&Value>) -> Option<RateLimit> {
960 let v = v?;
961 Some(RateLimit {
962 status: v.get("status").and_then(Value::as_str)?.to_string(),
963 window: v
964 .get("rateLimitType")
965 .and_then(Value::as_str)
966 .map(str::to_string),
967 resets_at: v.get("resetsAt").and_then(Value::as_i64),
968 overage_status: v
969 .get("overageStatus")
970 .and_then(Value::as_str)
971 .map(str::to_string),
972 is_using_overage: v.get("isUsingOverage").and_then(Value::as_bool),
973 })
974}
975
976fn claude_usage(v: &Value, model: Option<&str>) -> Usage {
978 let u = v.get("usage");
979 let get = |key: &str| u.and_then(|u| u.get(key)).and_then(Value::as_u64);
980 let (input, read, write) = (
981 get("input_tokens"),
982 get("cache_read_input_tokens"),
983 get("cache_creation_input_tokens"),
984 );
985 let per_model = v
993 .get("modelUsage")
994 .and_then(Value::as_object)
995 .and_then(
996 |models| match (model.and_then(|m| models.get(m)), models.len()) {
997 (Some(entry), _) => Some(entry),
998 (None, 1) => models.values().next(),
1000 (None, _) => None,
1003 },
1004 );
1005 let of_model = |key: &str| per_model.and_then(|m| m.get(key)).and_then(Value::as_u64);
1006 Usage {
1007 input_tokens: input,
1008 output_tokens: get("output_tokens"),
1009 cache_read_tokens: read,
1010 cache_write_tokens: write,
1011 context_tokens: (input.is_some() || read.is_some() || write.is_some())
1015 .then(|| input.unwrap_or(0) + read.unwrap_or(0) + write.unwrap_or(0)),
1016 context_window: of_model("contextWindow"),
1017 max_output_tokens: of_model("maxOutputTokens"),
1018 reasoning_tokens: None,
1019 cost_usd: v.get("total_cost_usd").and_then(Value::as_f64),
1020 premium_requests: None,
1021 ai_credits_nano: None,
1022 duration_ms: v.get("duration_ms").and_then(Value::as_u64),
1023 api_duration_ms: v.get("duration_api_ms").and_then(Value::as_u64),
1024 }
1025}
1026
1027fn codex_usage(v: Option<&Value>) -> Usage {
1030 let get = |key: &str| v.and_then(|u| u.get(key)).and_then(Value::as_u64);
1031 let (prompt, cached) = (get("input_tokens"), get("cached_input_tokens"));
1032 Usage {
1033 input_tokens: match (prompt, cached) {
1041 (Some(prompt), Some(cached)) => Some(prompt.saturating_sub(cached)),
1042 (prompt, _) => prompt,
1043 },
1044 output_tokens: get("output_tokens"),
1045 cache_read_tokens: cached,
1046 cache_write_tokens: get("cache_write_input_tokens"),
1047 context_tokens: prompt,
1048 context_window: None,
1049 max_output_tokens: None,
1050 reasoning_tokens: get("reasoning_output_tokens"),
1051 cost_usd: None,
1052 premium_requests: None,
1053 ai_credits_nano: None,
1054 duration_ms: None,
1055 api_duration_ms: None,
1056 }
1057}
1058
1059fn tool_name(item: &Value, item_ty: &str) -> String {
1062 item.get("tool")
1063 .and_then(Value::as_str)
1064 .unwrap_or(item_ty)
1065 .to_string()
1066}
1067
1068fn codex_tool_input(item: &Value, item_ty: &str) -> Value {
1070 match item_ty {
1071 "command_execution" => serde_json::json!({ "command": item.get("command") }),
1072 "mcp_tool_call" => item.get("arguments").cloned().unwrap_or(Value::Null),
1073 _ => item.clone(),
1076 }
1077}
1078
1079fn flatten_text(v: Option<&Value>) -> String {
1087 match v {
1088 Some(Value::String(s)) => s.clone(),
1089 Some(Value::Array(blocks)) => blocks
1090 .iter()
1091 .map(|b| match b.get("text").and_then(Value::as_str) {
1092 Some(text) => text.to_string(),
1093 None => b.to_string(),
1094 })
1095 .collect::<Vec<_>>()
1096 .join("\n"),
1097 Some(other) => other.to_string(),
1098 None => String::new(),
1099 }
1100}
1101
1102#[cfg(test)]
1103mod tests {
1104 use super::*;
1105
1106 fn run(agent: Agent, lines: &[&str]) -> (Vec<Event>, Terminal) {
1108 let mut p = Parser::new(agent, Format::Stream);
1109 let events = lines.iter().flat_map(|l| p.push(l)).collect();
1110 (events, p.finish())
1111 }
1112
1113 #[test]
1116 fn claude_stream_yields_start_thinking_text_and_terminal_facts() {
1117 let (events, term) = run(
1118 Agent::Claude,
1119 &[
1120 r#"{"type":"system","subtype":"init","session_id":"sess-a","model":"claude-haiku-4-5"}"#,
1121 r#"{"type":"assistant","session_id":"sess-a","message":{"content":[{"type":"thinking","thinking":"brief"}]}}"#,
1122 r#"{"type":"assistant","session_id":"sess-a","message":{"content":[{"type":"text","text":"pong"}]}}"#,
1123 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}}"#,
1124 ],
1125 );
1126 assert_eq!(
1127 events[0],
1128 Event::Started {
1129 session: "sess-a".into(),
1130 model: Some("claude-haiku-4-5".into())
1131 }
1132 );
1133 assert_eq!(events[1], Event::Thinking("brief".into()));
1134 assert_eq!(events[2], Event::Text("pong".into()));
1135 assert_eq!(term.session.as_deref(), Some("sess-a"));
1136 assert_eq!(term.text, "pong");
1137 assert_eq!(term.stop, Stop::Completed);
1138 assert_eq!(term.usage.input_tokens, Some(10));
1139 assert_eq!(term.usage.cache_read_tokens, Some(18764));
1140 assert_eq!(term.usage.cache_write_tokens, Some(7322));
1141 assert_eq!(term.usage.cost_usd, Some(0.017));
1142 }
1143
1144 #[test]
1152 fn the_window_binds_to_the_runs_model_not_the_haiku_helper() {
1153 let (_, term) = run(
1154 Agent::Claude,
1155 &[
1156 r#"{"type":"system","subtype":"init","session_id":"sess-1m","model":"claude-sonnet-5[1m]"}"#,
1157 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}}}"#,
1158 ],
1159 );
1160 assert_eq!(term.model.as_deref(), Some("claude-sonnet-5[1m]"));
1161 assert_eq!(
1162 term.usage.context_window,
1163 Some(1_000_000),
1164 "the helper's 200k window must not shadow the real one"
1165 );
1166 assert_eq!(term.usage.max_output_tokens, Some(64_000));
1167 assert_eq!(term.usage.context_tokens, Some(2 + 27_128 + 9_825));
1169 }
1170
1171 #[test]
1174 fn an_unmatchable_window_is_absent_not_guessed() {
1175 let (_, term) = run(
1176 Agent::Claude,
1177 &[
1178 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}}}"#,
1180 ],
1181 );
1182 assert_eq!(term.usage.context_window, None);
1183 let (_, single) = run(
1185 Agent::Claude,
1186 &[
1187 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}}}"#,
1188 ],
1189 );
1190 assert_eq!(single.usage.context_window, Some(200_000));
1191 }
1192
1193 #[test]
1194 fn claude_token_deltas_stream_without_duplicating_the_finished_message() {
1195 let (events, _) = run(
1196 Agent::Claude,
1197 &[
1198 r#"{"type":"system","subtype":"init","session_id":"s"}"#,
1199 r#"{"type":"stream_event","session_id":"s","event":{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}}"#,
1200 r#"{"type":"stream_event","session_id":"s","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"po"}}}"#,
1201 r#"{"type":"stream_event","session_id":"s","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"ng"}}}"#,
1202 r#"{"type":"stream_event","session_id":"s","event":{"type":"content_block_stop","index":0}}"#,
1203 r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"text","text":"pong"}]}}"#,
1205 r#"{"type":"result","subtype":"success","is_error":false,"result":"pong","session_id":"s"}"#,
1206 ],
1207 );
1208 let texts: Vec<_> = events
1209 .iter()
1210 .filter_map(|e| match e {
1211 Event::Text(t) => Some(t.as_str()),
1212 _ => None,
1213 })
1214 .collect();
1215 assert_eq!(texts, ["po", "ng"], "the finished message must not repeat");
1216 }
1217
1218 #[test]
1220 fn claude_thinking_deltas_stream_without_duplication() {
1221 let (events, _) = run(
1222 Agent::Claude,
1223 &[
1224 r#"{"type":"stream_event","session_id":"s","event":{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"weighing"}}}"#,
1225 r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"thinking","thinking":"weighing"}]}}"#,
1226 ],
1227 );
1228 let thoughts: Vec<_> = events
1229 .iter()
1230 .filter_map(|e| match e {
1231 Event::Thinking(t) => Some(t.as_str()),
1232 _ => None,
1233 })
1234 .collect();
1235 assert_eq!(thoughts, ["weighing"]);
1236 }
1237
1238 #[test]
1241 fn a_completed_message_still_streams_when_no_deltas_arrived() {
1242 let (events, _) = run(
1243 Agent::Claude,
1244 &[
1245 r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"text","text":"pong"}]}}"#,
1246 ],
1247 );
1248 assert!(events.contains(&Event::Text("pong".into())), "{events:?}");
1249 }
1250
1251 #[test]
1254 fn tool_calls_survive_delta_suppression() {
1255 let (events, _) = run(
1256 Agent::Claude,
1257 &[
1258 r#"{"type":"stream_event","session_id":"s","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hi"}}}"#,
1259 r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"tool_use","id":"t1","name":"Bash","input":{"command":"ls"}}]}}"#,
1260 ],
1261 );
1262 assert!(
1263 events.iter().any(|e| matches!(e, Event::ToolCall { .. })),
1264 "suppression must apply to text only: {events:?}"
1265 );
1266 }
1267
1268 #[test]
1269 fn claude_started_fires_only_once() {
1270 let (events, _) = run(
1271 Agent::Claude,
1272 &[
1273 r#"{"type":"system","subtype":"init","session_id":"s"}"#,
1274 r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"text","text":"a"}]}}"#,
1275 r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"text","text":"b"}]}}"#,
1276 ],
1277 );
1278 assert_eq!(
1279 events
1280 .iter()
1281 .filter(|e| matches!(e, Event::Started { .. }))
1282 .count(),
1283 1
1284 );
1285 }
1286
1287 #[test]
1288 fn claude_pairs_tool_use_with_its_result() {
1289 let (events, _) = run(
1290 Agent::Claude,
1291 &[
1292 r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"tool_use","id":"toolu_1","name":"Bash","input":{"command":"ls"}}]}}"#,
1293 r#"{"type":"user","session_id":"s","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"a.txt"}]}}"#,
1294 ],
1295 );
1296 let call = events
1297 .iter()
1298 .find(|e| matches!(e, Event::ToolCall { .. }))
1299 .unwrap();
1300 let Event::ToolCall { id, name, input } = call else {
1301 unreachable!()
1302 };
1303 assert_eq!(id.as_deref(), Some("toolu_1"));
1304 assert_eq!(name, "Bash");
1305 assert_eq!(input["command"], "ls");
1306 assert!(events.contains(&Event::ToolResult {
1307 id: Some("toolu_1".into()),
1308 ok: None,
1309 output: "a.txt".into(),
1310 }));
1311 }
1312
1313 #[test]
1318 fn an_approval_request_carries_the_tool_and_its_arguments() {
1319 let (events, _) = run(
1320 Agent::Claude,
1321 &[
1322 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"}}}"#,
1323 ],
1324 );
1325 let [Event::ApprovalRequest(approval)] = &events[..] else {
1326 panic!("expected one approval request, got {events:?}")
1327 };
1328 assert_eq!(approval.id, "req-7");
1329 assert_eq!(approval.tool, "Bash");
1330 assert_eq!(approval.input["command"], "touch created-by-probe.txt");
1331 }
1332
1333 #[test]
1337 fn an_unanswerable_approval_request_is_dropped() {
1338 for line in [
1339 r#"{"type":"control_request","request":{"subtype":"can_use_tool","tool_name":"Bash","input":{}}}"#,
1341 &format!(
1343 r#"{{"type":"control_request","request_id":"{}","request":{{"subtype":"can_use_tool","tool_name":"Bash","input":{{}}}}}}"#,
1344 "x".repeat(MAX_IDENTIFIER_BYTES + 1)
1345 ),
1346 ] {
1347 let (events, _) = run(Agent::Claude, &[line]);
1348 assert!(
1349 events.is_empty(),
1350 "an unanswerable request must not reach a consumer: {events:?}"
1351 );
1352 }
1353 }
1354
1355 #[test]
1357 fn a_control_request_that_is_not_an_approval_is_ignored() {
1358 let (events, _) = run(
1359 Agent::Claude,
1360 &[r#"{"type":"control_request","request_id":"r","request":{"subtype":"initialize"}}"#],
1361 );
1362 assert!(events.is_empty(), "{events:?}");
1363 }
1364
1365 #[test]
1366 fn claude_reports_a_rate_limit_without_failing() {
1367 let (events, term) = run(
1368 Agent::Claude,
1369 &[
1370 r#"{"type":"rate_limit_event","session_id":"s","rate_limit_info":{"status":"allowed","resetsAt":1785260400,"rateLimitType":"five_hour"}}"#,
1371 ],
1372 );
1373 let limit = RateLimit {
1374 status: "allowed".into(),
1375 window: Some("five_hour".into()),
1376 resets_at: Some(1_785_260_400),
1377 overage_status: None,
1378 is_using_overage: None,
1379 };
1380 assert!(events.contains(&Event::RateLimit(limit.clone())));
1381 assert_eq!(term.rate_limit, Some(limit.clone()));
1382 assert!(
1383 !limit.is_blocking(),
1384 "an `allowed` heartbeat is not a block"
1385 );
1386 }
1387
1388 #[test]
1389 fn claude_error_result_sets_the_stop_reason() {
1390 let (_, term) = run(
1391 Agent::Claude,
1392 &[r#"{"type":"result","is_error":true,"result":"boom","session_id":"s"}"#],
1393 );
1394 assert_eq!(term.stop, Stop::Error);
1395 }
1396
1397 #[test]
1398 fn copilot_streams_deltas_and_takes_its_answer_from_the_settled_message() {
1399 let (events, term) = run(
1400 Agent::Copilot,
1401 &[
1402 r#"{"type":"assistant.message_delta","data":{"messageId":"m","deltaContent":"po"}}"#,
1403 r#"{"type":"assistant.message_delta","data":{"messageId":"m","deltaContent":"ng"}}"#,
1404 r#"{"type":"assistant.message","data":{"messageId":"m","model":"gpt-5-mini","content":"pong"}}"#,
1405 r#"{"type":"result","sessionId":"768c8e7d","exitCode":0,"usage":{"premiumRequests":0}}"#,
1406 ],
1407 );
1408 let texts: Vec<_> = events
1410 .iter()
1411 .filter_map(|e| match e {
1412 Event::Text(t) => Some(t.as_str()),
1413 _ => None,
1414 })
1415 .collect();
1416 assert_eq!(texts, ["po", "ng"]);
1417 assert_eq!(term.text, "pong", "the answer is the settled message");
1418 assert_eq!(term.session.as_deref(), Some("768c8e7d"));
1419 assert_eq!(term.usage.premium_requests, Some(0));
1420 }
1421
1422 #[test]
1423 fn copilot_brackets_a_tool_call_with_its_completion() {
1424 let (events, _) = run(
1425 Agent::Copilot,
1426 &[
1427 r#"{"type":"tool.execution_start","data":{"toolCallId":"call_1","toolName":"bash","arguments":{"command":"ls"}}}"#,
1428 r#"{"type":"tool.execution_complete","data":{"toolCallId":"call_1","success":true,"result":{"content":"a.txt"}}}"#,
1429 ],
1430 );
1431 assert!(matches!(
1432 &events[0],
1433 Event::ToolCall { id, name, .. }
1434 if id.as_deref() == Some("call_1") && name == "bash"
1435 ));
1436 assert_eq!(
1437 events[1],
1438 Event::ToolResult {
1439 id: Some("call_1".into()),
1440 ok: Some(true),
1441 output: "a.txt".into()
1442 }
1443 );
1444 }
1445
1446 #[test]
1450 fn a_codex_failed_turn_yields_the_reason_and_the_status() {
1451 let (_, term) = run(
1452 Agent::Codex,
1453 &[
1454 r#"{"type":"thread.started","thread_id":"019fad62"}"#,
1455 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.\"}}"}}"#,
1456 ],
1457 );
1458 assert_eq!(term.stop, Stop::Error);
1459 assert_eq!(term.error_status, Some(400));
1460 assert_eq!(
1461 term.error_message.as_deref(),
1462 Some(
1463 "The 'bogus-model-xyz' model is not supported when using Codex with a ChatGPT account."
1464 ),
1465 "the caller should get the sentence, not the envelope"
1466 );
1467 }
1468
1469 #[test]
1472 fn a_plain_codex_failure_message_passes_through() {
1473 let (_, term) = run(
1474 Agent::Codex,
1475 &[
1476 r#"{"type":"turn.failed","error":{"message":"stream disconnected before completion"}}"#,
1477 ],
1478 );
1479 assert_eq!(term.error_status, None);
1480 assert_eq!(
1481 term.error_message.as_deref(),
1482 Some("stream disconnected before completion")
1483 );
1484 }
1485
1486 #[test]
1487 fn codex_reads_the_thread_id_and_the_completed_message() {
1488 let (events, term) = run(
1489 Agent::Codex,
1490 &[
1491 r#"{"type":"thread.started","thread_id":"0199-xyz"}"#,
1492 r#"{"type":"item.completed","item":{"id":"i1","type":"agent_message","text":"pong"}}"#,
1493 r#"{"type":"turn.completed","usage":{"input_tokens":12,"output_tokens":3,"cached_input_tokens":9}}"#,
1494 ],
1495 );
1496 assert_eq!(
1497 events[0],
1498 Event::Started {
1499 session: "0199-xyz".into(),
1500 model: None
1501 }
1502 );
1503 assert_eq!(term.session.as_deref(), Some("0199-xyz"));
1504 assert_eq!(term.text, "pong");
1505 assert_eq!(term.usage.input_tokens, Some(3));
1510 assert_eq!(term.usage.cache_read_tokens, Some(9));
1511 assert_eq!(term.usage.context_tokens, Some(12));
1512 }
1513
1514 #[test]
1515 fn codex_command_execution_becomes_a_call_and_a_result() {
1516 let (events, _) = run(
1517 Agent::Codex,
1518 &[
1519 r#"{"type":"item.completed","item":{"id":"c1","type":"command_execution","command":"ls","exit_code":0,"aggregated_output":"a.txt"}}"#,
1520 ],
1521 );
1522 assert!(matches!(&events[0], Event::ToolCall { name, .. } if name == "command_execution"));
1523 assert_eq!(
1524 events[1],
1525 Event::ToolResult {
1526 id: Some("c1".into()),
1527 ok: Some(true),
1528 output: "a.txt".into()
1529 }
1530 );
1531 }
1532
1533 #[test]
1537 fn codex_started_then_completed_yields_one_call_and_one_result() {
1538 let (events, _) = run(
1539 Agent::Codex,
1540 &[
1541 r#"{"type":"item.started","item":{"id":"item_1","type":"command_execution","command":"/bin/zsh -lc ls","aggregated_output":"","exit_code":null,"status":"in_progress"}}"#,
1542 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"}}"#,
1543 ],
1544 );
1545 let calls = events
1546 .iter()
1547 .filter(|e| matches!(e, Event::ToolCall { .. }))
1548 .count();
1549 assert_eq!(calls, 1, "the same item must not be announced twice");
1550 let results: Vec<_> = events
1551 .iter()
1552 .filter_map(|e| match e {
1553 Event::ToolResult { output, .. } => Some(output.as_str()),
1554 _ => None,
1555 })
1556 .collect();
1557 assert_eq!(
1558 results,
1559 ["a.txt\n"],
1560 "the in-progress blank must not appear"
1561 );
1562 }
1563
1564 #[test]
1566 fn codex_last_completed_message_is_the_answer() {
1567 let (_, term) = run(
1568 Agent::Codex,
1569 &[
1570 r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"I'll list the directory."}}"#,
1571 r#"{"type":"item.completed","item":{"id":"i2","type":"agent_message","text":"DONE"}}"#,
1572 ],
1573 );
1574 assert_eq!(term.text, "DONE");
1575 }
1576
1577 #[test]
1580 fn an_enormous_tool_result_is_bounded_and_marked() {
1581 let huge = "x".repeat(MAX_EVENT_BYTES * 4);
1582 let line = serde_json::json!({
1583 "type": "user",
1584 "session_id": "s",
1585 "message": {"content": [{
1586 "type": "tool_result", "tool_use_id": "t1", "content": huge
1587 }]}
1588 })
1589 .to_string();
1590
1591 let (events, _) = run(Agent::Claude, &[&line]);
1592 let Some(Event::ToolResult { output, id, .. }) = events
1593 .iter()
1594 .find(|e| matches!(e, Event::ToolResult { .. }))
1595 .cloned()
1596 else {
1597 panic!("expected a tool result, got {events:?}")
1598 };
1599 assert!(
1600 output.len() <= MAX_EVENT_BYTES,
1601 "kept {} bytes",
1602 output.len()
1603 );
1604 assert!(
1605 output.ends_with(TRUNCATION_MARK),
1606 "truncation must be visible"
1607 );
1608 assert_eq!(id.as_deref(), Some("t1"), "the id must survive whole");
1609 }
1610
1611 #[test]
1615 fn usable_identifiers_are_never_shortened() {
1616 let id = "s".repeat(MAX_IDENTIFIER_BYTES);
1618 let line =
1619 serde_json::json!({"type": "system", "subtype": "init", "session_id": id}).to_string();
1620 let (events, term) = run(Agent::Claude, &[&line]);
1621
1622 let Some(Event::Started { session, .. }) = events.first().cloned() else {
1623 panic!("expected Started, got {events:?}")
1624 };
1625 assert_eq!(session.len(), id.len(), "the session id was shortened");
1626 assert_eq!(term.session.as_deref(), Some(id.as_str()));
1627 }
1628
1629 #[test]
1634 fn an_oversized_session_id_is_rejected_rather_than_stored() {
1635 let id = "s".repeat(MAX_IDENTIFIER_BYTES + 1);
1636 for (agent, line) in [
1637 (
1638 Agent::Claude,
1639 serde_json::json!({"type": "system", "subtype": "init", "session_id": id})
1640 .to_string(),
1641 ),
1642 (
1643 Agent::Codex,
1644 serde_json::json!({"type": "thread.started", "thread_id": id}).to_string(),
1645 ),
1646 (
1647 Agent::Copilot,
1648 serde_json::json!({"type": "result", "sessionId": id, "exitCode": 0}).to_string(),
1649 ),
1650 ] {
1651 let (events, term) = run(agent, &[&line]);
1652 assert!(term.session.is_none(), "{agent} stored an unusable id");
1653 assert!(
1654 !events.iter().any(|e| matches!(e, Event::Started { .. })),
1655 "{agent} announced a session it cannot resume"
1656 );
1657 }
1658 }
1659
1660 #[test]
1664 fn an_oversized_tool_id_drops_the_id_but_keeps_the_event() {
1665 let id = "t".repeat(MAX_IDENTIFIER_BYTES + 1);
1666 let line = serde_json::json!({
1667 "type": "assistant", "session_id": "s",
1668 "message": {"content": [{
1669 "type": "tool_use", "id": id, "name": "Bash", "input": {"command": "ls"}
1670 }]}
1671 })
1672 .to_string();
1673
1674 let (events, _) = run(Agent::Claude, &[&line]);
1675 let Some(Event::ToolCall { id: seen, name, .. }) = events
1676 .iter()
1677 .find(|e| matches!(e, Event::ToolCall { .. }))
1678 .cloned()
1679 else {
1680 panic!("the call itself must still be reported, got {events:?}")
1681 };
1682 assert_eq!(seen, None, "an unusable id must be dropped, not shortened");
1683 assert_eq!(name, "Bash");
1684 }
1685
1686 #[test]
1689 fn the_pending_tool_map_is_bounded_by_bytes_not_only_entries() {
1690 let mut parser = Parser::new(Agent::Claude, Format::Stream);
1691 for i in 0..MAX_PENDING_TOOLS {
1694 let line = serde_json::json!({
1695 "type": "assistant", "session_id": "s",
1696 "message": {"content": [{
1697 "type": "tool_use",
1698 "id": format!("{i:0>width$}", width = MAX_IDENTIFIER_BYTES),
1699 "name": "x".repeat(MAX_IDENTIFIER_BYTES),
1700 "input": {}
1701 }]}
1702 })
1703 .to_string();
1704 parser.push(&line);
1705 }
1706 assert!(
1707 parser.tool_bytes <= MAX_PENDING_TOOL_BYTES,
1708 "pending tools grew to {} bytes",
1709 parser.tool_bytes
1710 );
1711 }
1712
1713 #[test]
1716 fn a_completed_tool_call_releases_its_budget() {
1717 let mut parser = Parser::new(Agent::Claude, Format::Stream);
1718 let call = |id: &str| {
1719 serde_json::json!({
1720 "type": "assistant", "session_id": "s",
1721 "message": {"content": [{
1722 "type": "tool_use", "id": id, "name": "Bash", "input": {}
1723 }]}
1724 })
1725 .to_string()
1726 };
1727 let result = |id: &str| {
1728 serde_json::json!({
1729 "type": "user", "session_id": "s",
1730 "message": {"content": [{
1731 "type": "tool_result", "tool_use_id": id, "content": "done"
1732 }]}
1733 })
1734 .to_string()
1735 };
1736
1737 for i in 0..(MAX_PENDING_TOOLS * 4) {
1738 let id = format!("toolu_{i}");
1739 parser.push(&call(&id));
1740 parser.push(&result(&id));
1741 }
1742 assert_eq!(parser.tool_bytes, 0, "budget leaked across paired calls");
1743 assert!(parser.tools.is_empty());
1744 }
1745
1746 #[test]
1749 fn a_worst_case_event_stays_within_the_stated_ceiling() {
1750 let huge = "x".repeat(MAX_LINE);
1751 let line = serde_json::json!({
1752 "type": "assistant", "session_id": huge,
1753 "message": {"content": [{
1754 "type": "tool_use", "id": huge, "name": huge, "input": {"command": huge}
1755 }]}
1756 })
1757 .to_string();
1758
1759 let (events, _) = run(Agent::Claude, &[&line]);
1760 for event in &events {
1761 let size = serde_json::to_string(event).unwrap().len();
1762 let ceiling = MAX_EVENT_BYTES + 4 * MAX_IDENTIFIER_BYTES;
1764 assert!(size <= ceiling, "an event reached {size} bytes: {event:?}");
1765 }
1766 }
1767
1768 #[test]
1771 fn oversized_tool_arguments_stay_valid_json() {
1772 let line = serde_json::json!({
1773 "type": "assistant",
1774 "session_id": "s",
1775 "message": {"content": [{
1776 "type": "tool_use", "id": "t1", "name": "Bash",
1777 "input": {"command": "y".repeat(MAX_EVENT_BYTES * 3)}
1778 }]}
1779 })
1780 .to_string();
1781
1782 let (events, _) = run(Agent::Claude, &[&line]);
1783 let Some(Event::ToolCall { input, .. }) = events
1784 .iter()
1785 .find(|e| matches!(e, Event::ToolCall { .. }))
1786 .cloned()
1787 else {
1788 panic!("expected a tool call, got {events:?}")
1789 };
1790 assert_eq!(input["truncated"], true, "got {input}");
1791 assert!(
1792 input.is_object(),
1793 "the replacement must still be valid JSON"
1794 );
1795 assert!(input.to_string().len() <= MAX_EVENT_BYTES);
1796 }
1797
1798 #[test]
1799 fn ordinary_payloads_pass_through_untouched() {
1800 let (events, _) = run(
1801 Agent::Claude,
1802 &[
1803 r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"text","text":"pong"}]}}"#,
1804 ],
1805 );
1806 assert!(events.contains(&Event::Text("pong".into())), "{events:?}");
1807 }
1808
1809 #[test]
1810 fn capture_is_bounded_and_keeps_the_earliest_output() {
1811 let mut buf = String::new();
1812 for i in 0..50_000 {
1814 append_capped(&mut buf, &format!("line {i} aaaaaaaaaaaaaaaaaaaaaaaaaaaa"));
1815 }
1816 assert!(buf.len() <= MAX_CAPTURE, "grew to {}", buf.len());
1817 assert!(buf.starts_with("line 0 "), "the earliest output is kept");
1818 }
1819
1820 #[test]
1821 fn capping_never_splits_a_multibyte_character() {
1822 let mut buf = "x".repeat(MAX_CAPTURE - 3);
1823 assert!(append_capped(&mut buf, "🙂🙂"));
1825 assert!(buf.len() <= MAX_CAPTURE);
1826 assert!(buf.is_char_boundary(buf.len()));
1829 }
1830
1831 #[test]
1832 fn a_full_buffer_reports_that_it_took_nothing() {
1833 let mut buf = "x".repeat(MAX_CAPTURE);
1834 assert!(!append_capped(&mut buf, "more"));
1835 assert_eq!(buf.len(), MAX_CAPTURE);
1836 }
1837
1838 #[test]
1841 fn unparseable_lines_are_counted_and_sampled() {
1842 let (_, term) = run(
1843 Agent::Claude,
1844 &[
1845 "<html>an error page, not JSON</html>",
1846 "another bad line",
1847 r#"{"type":"result","result":"ok","session_id":"s"}"#,
1848 ],
1849 );
1850 assert_eq!(term.unparsed, 2);
1851 assert_eq!(
1852 term.first_unparsed.as_deref(),
1853 Some("<html>an error page, not JSON</html>")
1854 );
1855 }
1856
1857 #[test]
1858 fn a_clean_stream_reports_no_parse_failures() {
1859 let (_, term) = run(
1860 Agent::Claude,
1861 &[r#"{"type":"result","result":"ok","session_id":"s"}"#],
1862 );
1863 assert_eq!(term.unparsed, 0);
1864 assert!(term.first_unparsed.is_none());
1865 }
1866
1867 #[test]
1870 fn tool_result_blocks_that_are_not_text_are_kept_not_dropped() {
1871 let (events, _) = run(
1872 Agent::Claude,
1873 &[
1874 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"}}]}]}}"#,
1875 ],
1876 );
1877 let output = events
1878 .iter()
1879 .find_map(|e| match e {
1880 Event::ToolResult { output, .. } => Some(output),
1881 _ => None,
1882 })
1883 .unwrap_or_else(|| panic!("expected a tool result, got {events:?}"));
1884 assert!(output.contains("seen"));
1885 assert!(output.contains("image"), "the image block was dropped");
1886 }
1887
1888 #[test]
1889 fn garbage_lines_are_skipped_not_fatal() {
1890 let (events, term) = run(
1891 Agent::Claude,
1892 &[
1893 "Warning: something on stdout",
1894 "",
1895 r#"{"type":"result","result":"ok","session_id":"s"}"#,
1896 ],
1897 );
1898 assert!(events.iter().all(|e| !matches!(e, Event::Text(_))));
1899 assert_eq!(term.text, "ok");
1900 }
1901
1902 #[test]
1903 fn text_format_passes_lines_through_verbatim() {
1904 let mut p = Parser::new(Agent::Copilot, Format::Text);
1905 let events: Vec<_> = ["hello", "world"].iter().flat_map(|l| p.push(l)).collect();
1906 assert_eq!(
1907 events,
1908 [Event::Text("hello".into()), Event::Text("world".into())]
1909 );
1910 assert_eq!(p.finish().text, "hello\nworld");
1911 }
1912}