use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::agent::{Agent, Format};
use crate::outcome::{RateLimit, Stop, Usage};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
#[non_exhaustive]
pub enum Event {
Started {
session: String,
model: Option<String>,
},
Thinking(String),
Text(String),
ToolCall {
id: Option<String>,
name: String,
input: Value,
},
ToolResult {
id: Option<String>,
ok: Option<bool>,
output: String,
},
RateLimit(RateLimit),
}
pub const MAX_CAPTURE: usize = 1024 * 1024;
pub const MAX_LINE: usize = 512 * 1024;
pub const MAX_EVENT_BYTES: usize = 64 * 1024;
pub const TRUNCATION_MARK: &str = "…(truncated)";
pub const MAX_IDENTIFIER_BYTES: usize = 4 * 1024;
pub(crate) const MAX_PENDING_TOOL_BYTES: usize = 256 * 1024;
pub(crate) const MAX_PENDING_TOOLS: usize = 1024;
pub(crate) fn append_capped(buf: &mut String, line: &str) -> bool {
let remaining = MAX_CAPTURE.saturating_sub(buf.len());
if remaining == 0 {
return false;
}
if line.len() < remaining {
buf.push_str(line);
buf.push('\n');
} else {
let mut cut = remaining - 1;
while cut > 0 && !line.is_char_boundary(cut) {
cut -= 1;
}
buf.push_str(&line[..cut]);
buf.push('\n');
}
true
}
fn usable_identifier(value: &str) -> bool {
value.len() <= MAX_IDENTIFIER_BYTES
}
fn accept_identifier(value: Option<String>) -> Option<String> {
value.filter(|v| usable_identifier(v))
}
fn bound_text(text: String) -> String {
if text.len() <= MAX_EVENT_BYTES {
return text;
}
let mut cut = MAX_EVENT_BYTES - TRUNCATION_MARK.len();
while cut > 0 && !text.is_char_boundary(cut) {
cut -= 1;
}
let mut out = text[..cut].to_string();
out.push_str(TRUNCATION_MARK);
out
}
fn bound_value(value: Value) -> Value {
let size = value.to_string().len();
if size <= MAX_EVENT_BYTES {
return value;
}
serde_json::json!({
"truncated": true,
"original_bytes": size,
"note": "arguments exceeded MAX_EVENT_BYTES and were dropped rather than \
truncated, which would have produced invalid JSON",
})
}
fn enforce_bounds(event: Event) -> Event {
match event {
Event::Text(text) => Event::Text(bound_text(text)),
Event::Thinking(text) => Event::Thinking(bound_text(text)),
Event::ToolCall { id, name, input } => Event::ToolCall {
id: accept_identifier(id),
name: bound_identifier(name),
input: bound_value(input),
},
Event::ToolResult { id, ok, output } => Event::ToolResult {
id: accept_identifier(id),
ok,
output: bound_text(output),
},
Event::Started { session, model } => Event::Started {
session,
model: model.map(bound_identifier),
},
Event::RateLimit(limit) => Event::RateLimit(RateLimit {
status: bound_identifier(limit.status),
window: limit.window.map(bound_identifier),
resets_at: limit.resets_at,
overage_status: limit.overage_status.map(bound_identifier),
is_using_overage: limit.is_using_overage,
}),
}
}
fn bound_identifier(text: String) -> String {
if text.len() <= MAX_IDENTIFIER_BYTES {
return text;
}
let mut cut = MAX_IDENTIFIER_BYTES - TRUNCATION_MARK.len();
while cut > 0 && !text.is_char_boundary(cut) {
cut -= 1;
}
let mut out = text[..cut].to_string();
out.push_str(TRUNCATION_MARK);
out
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Terminal {
pub session: Option<String>,
pub text: String,
pub usage: Usage,
pub stop: Stop,
pub rate_limit: Option<RateLimit>,
pub unparsed: usize,
pub first_unparsed: Option<String>,
pub structured: Option<Value>,
pub error_status: Option<u16>,
pub error_message: Option<String>,
}
fn unwrap_error_body(message: &str) -> (Option<u16>, String) {
let Ok(body) = serde_json::from_str::<Value>(message) else {
return (None, message.to_string());
};
let status = body
.get("status")
.and_then(Value::as_u64)
.and_then(|s| u16::try_from(s).ok());
let inner = body
.get("error")
.and_then(|e| e.get("message"))
.and_then(Value::as_str)
.map(str::to_string);
(status, inner.unwrap_or_else(|| message.to_string()))
}
#[derive(Debug)]
pub(crate) struct Parser {
agent: Agent,
format: Format,
term: Terminal,
tools: HashMap<String, String>,
tool_bytes: usize,
seen: Seen,
}
#[derive(Debug, Default)]
#[expect(
clippy::struct_excessive_bools,
reason = "four independent stream milestones; naming each beats packing them"
)]
struct Seen {
started: bool,
structured: bool,
terminal: bool,
deltas: bool,
}
impl Parser {
#[must_use]
pub fn new(agent: Agent, format: Format) -> Self {
Self {
agent,
format,
term: Terminal::default(),
tools: HashMap::new(),
tool_bytes: 0,
seen: Seen::default(),
}
}
pub fn push(&mut self, line: &str) -> Vec<Event> {
let line = line.trim();
if line.is_empty() {
return Vec::new();
}
if self.format == Format::Text {
append_capped(&mut self.term.text, line);
return vec![enforce_bounds(Event::Text(line.to_string()))];
}
let Ok(value) = serde_json::from_str::<Value>(line) else {
self.term.unparsed += 1;
if self.term.first_unparsed.is_none() {
let mut cut = line.len().min(512);
while cut > 0 && !line.is_char_boundary(cut) {
cut -= 1;
}
self.term.first_unparsed = Some(line[..cut].to_string());
}
return Vec::new();
};
if let Some(ty) = value.get("type").and_then(Value::as_str)
&& self.recognizes(ty)
{
self.seen.structured = true;
}
let mut out = match self.agent {
Agent::Claude => self.claude(&value),
Agent::Codex => self.codex(&value),
Agent::Copilot => self.copilot(&value),
};
out = out.into_iter().map(enforce_bounds).collect();
if !self.seen.started {
if let Some(session) = self.term.session.clone() {
self.seen.started = true;
out.insert(
0,
Event::Started {
session,
model: model_of(&value),
},
);
}
}
out
}
fn recognizes(&self, ty: &str) -> bool {
match self.agent {
Agent::Claude => matches!(
ty,
"system" | "assistant" | "user" | "result" | "rate_limit_event"
),
Agent::Codex => {
ty.starts_with("thread.") || ty.starts_with("turn.") || ty.starts_with("item.")
}
Agent::Copilot => {
ty == "result"
|| ty.starts_with("assistant.")
|| ty.starts_with("tool.")
|| ty.starts_with("session.")
}
}
}
fn remember_tool(&mut self, id: &str, name: &str) {
if !usable_identifier(id) {
return;
}
let name = bound_identifier(name.to_string());
let cost = id.len() + name.len();
if self.tools.len() >= MAX_PENDING_TOOLS
|| self.tool_bytes.saturating_add(cost) > MAX_PENDING_TOOL_BYTES
{
return;
}
self.tool_bytes += cost;
if let Some(previous) = self.tools.insert(id.to_string(), name) {
self.tool_bytes = self.tool_bytes.saturating_sub(id.len() + previous.len());
}
}
fn forget_tool(&mut self, id: &str) {
if let Some(name) = self.tools.remove(id) {
self.tool_bytes = self.tool_bytes.saturating_sub(id.len() + name.len());
}
}
pub(crate) fn saw_structured_record(&self) -> bool {
self.seen.structured
}
pub(crate) fn saw_terminal_record(&self) -> bool {
self.seen.terminal
}
#[must_use]
pub fn finish(mut self) -> Terminal {
if self.format == Format::Text {
self.term.text = self.term.text.trim_end().to_string();
}
self.term
}
fn claude(&mut self, v: &Value) -> Vec<Event> {
let ty = v.get("type").and_then(Value::as_str).unwrap_or_default();
if let Some(id) = v.get("session_id").and_then(Value::as_str)
&& usable_identifier(id)
{
self.term.session.get_or_insert_with(|| id.to_string());
}
match ty {
"rate_limit_event" => {
let limit = claude_rate_limit(v.get("rate_limit_info"));
self.term.rate_limit.clone_from(&limit);
limit.into_iter().map(Event::RateLimit).collect()
}
"stream_event" => self.claude_delta(v),
"assistant" | "user" => self.content_blocks(v),
"result" => {
self.seen.terminal = true;
if let Some(text) = v.get("result").and_then(Value::as_str) {
self.term.text = text.to_string();
}
if let Some(value) = v.get("structured_output") {
self.term.structured = Some(value.clone());
}
self.term.usage = claude_usage(v);
self.term.stop = if v.get("is_error").and_then(Value::as_bool) == Some(true) {
self.term.error_status = v
.get("api_error_status")
.and_then(Value::as_u64)
.and_then(|s| u16::try_from(s).ok());
Stop::Error
} else {
stop_from(v.get("stop_reason"))
};
Vec::new()
}
_ => Vec::new(),
}
}
fn claude_delta(&mut self, v: &Value) -> Vec<Event> {
let Some(event) = v.get("event") else {
return Vec::new();
};
if event.get("type").and_then(Value::as_str) != Some("content_block_delta") {
return Vec::new();
}
let Some(delta) = event.get("delta") else {
return Vec::new();
};
self.seen.deltas = true;
match delta.get("type").and_then(Value::as_str) {
Some("text_delta") => delta
.get("text")
.and_then(Value::as_str)
.filter(|text| !text.is_empty())
.map(|text| Event::Text(text.to_string()))
.into_iter()
.collect(),
Some("thinking_delta") => delta
.get("thinking")
.and_then(Value::as_str)
.filter(|text| !text.is_empty())
.map(|text| Event::Thinking(text.to_string()))
.into_iter()
.collect(),
_ => Vec::new(),
}
}
fn content_blocks(&mut self, v: &Value) -> Vec<Event> {
let blocks = v
.get("message")
.and_then(|m| m.get("content"))
.and_then(Value::as_array);
let Some(blocks) = blocks else {
return Vec::new();
};
let mut out = Vec::new();
for block in blocks {
let ty = block
.get("type")
.and_then(Value::as_str)
.unwrap_or_default();
match ty {
"text" if !self.seen.deltas => {
if let Some(t) = block.get("text").and_then(Value::as_str) {
out.push(Event::Text(t.to_string()));
}
}
"thinking" if !self.seen.deltas => {
if let Some(t) = block.get("thinking").and_then(Value::as_str) {
out.push(Event::Thinking(t.to_string()));
}
}
"tool_use" => {
let name = block
.get("name")
.and_then(Value::as_str)
.unwrap_or("tool")
.to_string();
let id = block.get("id").and_then(Value::as_str).map(str::to_string);
if let Some(id) = &id {
self.remember_tool(id, &name);
}
out.push(Event::ToolCall {
id,
name,
input: block.get("input").cloned().unwrap_or(Value::Null),
});
}
"tool_result" => out.push(Event::ToolResult {
id: block
.get("tool_use_id")
.and_then(Value::as_str)
.inspect(|id| {
self.forget_tool(id);
})
.map(str::to_string),
ok: block
.get("is_error")
.and_then(Value::as_bool)
.map(|is_error| !is_error),
output: flatten_text(block.get("content")),
}),
_ => {}
}
}
out
}
fn codex(&mut self, v: &Value) -> Vec<Event> {
let ty = v.get("type").and_then(Value::as_str).unwrap_or_default();
if let Some(id) = v.get("thread_id").and_then(Value::as_str)
&& usable_identifier(id)
{
self.term.session.get_or_insert_with(|| id.to_string());
}
match ty {
"turn.completed" => {
self.seen.terminal = true;
self.term.usage = codex_usage(v.get("usage"));
Vec::new()
}
"turn.failed" => {
self.seen.terminal = true;
self.term.stop = Stop::Error;
if let Some(message) = v
.get("error")
.and_then(|e| e.get("message"))
.and_then(Value::as_str)
{
let (status, message) = unwrap_error_body(message);
self.term.error_status = status;
self.term.error_message = Some(bound_text(message));
}
Vec::new()
}
"item.started" | "item.updated" | "item.completed" => {
let Some(item) = v.get("item") else {
return Vec::new();
};
let item_ty = item.get("type").and_then(Value::as_str).unwrap_or_default();
let id = item.get("id").and_then(Value::as_str).map(str::to_string);
let done = ty == "item.completed";
let name = tool_name(item, item_ty);
let first = id
.as_ref()
.is_none_or(|id| self.tools.insert(id.clone(), name.clone()).is_none());
match item_ty {
"agent_message" => {
if !done {
return Vec::new();
}
let text = item.get("text").and_then(Value::as_str).unwrap_or_default();
self.term.text = text.to_string();
vec![Event::Text(text.to_string())]
}
"reasoning" if done => item
.get("text")
.and_then(Value::as_str)
.map(|t| Event::Thinking(t.to_string()))
.into_iter()
.collect(),
"command_execution" | "mcp_tool_call" | "file_change" | "web_search" => {
let mut out = Vec::new();
if first {
out.push(Event::ToolCall {
id: id.clone(),
name,
input: codex_tool_input(item, item_ty),
});
}
if done {
if let Some(id) = &id {
self.forget_tool(id);
}
out.push(Event::ToolResult {
id,
ok: item
.get("exit_code")
.and_then(Value::as_i64)
.map(|code| code == 0),
output: item
.get("aggregated_output")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
});
}
out
}
_ => Vec::new(),
}
}
_ => Vec::new(),
}
}
fn copilot(&mut self, v: &Value) -> Vec<Event> {
let ty = v.get("type").and_then(Value::as_str).unwrap_or_default();
let data = v.get("data");
let field = |key: &str| -> Option<String> {
data.and_then(|d| d.get(key))
.and_then(Value::as_str)
.map(str::to_string)
};
match ty {
"assistant.message_delta" => field("deltaContent")
.filter(|t| !t.is_empty())
.map(Event::Text)
.into_iter()
.collect(),
"assistant.message" => {
if let Some(content) = field("content") {
self.term.text = content;
}
Vec::new()
}
"assistant.reasoning" => field("content")
.filter(|t| !t.is_empty())
.map(Event::Thinking)
.into_iter()
.collect(),
"tool.execution_start" => {
let id = field("toolCallId");
let name = field("toolName").unwrap_or_else(|| "tool".into());
if let Some(id) = &id {
self.remember_tool(id, &name);
}
vec![Event::ToolCall {
id,
name,
input: data
.and_then(|d| d.get("arguments"))
.cloned()
.unwrap_or(Value::Null),
}]
}
"tool.execution_complete" => vec![Event::ToolResult {
id: field("toolCallId").inspect(|id| {
self.forget_tool(id);
}),
ok: data.and_then(|d| d.get("success")).and_then(Value::as_bool),
output: data
.and_then(|d| d.get("result"))
.and_then(|r| r.get("content"))
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
}],
"session.usage_checkpoint" => {
if let Some(data) = v.get("data") {
self.term.usage.ai_credits_nano =
data.get("totalNanoAiu").and_then(Value::as_u64);
if let Some(premium) = data.get("totalPremiumRequests").and_then(Value::as_u64)
{
self.term.usage.premium_requests = Some(premium);
}
}
Vec::new()
}
"result" => {
self.seen.terminal = true;
if let Some(id) = v.get("sessionId").and_then(Value::as_str)
&& usable_identifier(id)
{
self.term.session = Some(id.to_string());
}
if let Some(usage) = v.get("usage") {
self.term.usage.premium_requests =
usage.get("premiumRequests").and_then(Value::as_u64);
self.term.usage.duration_ms =
usage.get("sessionDurationMs").and_then(Value::as_u64);
self.term.usage.api_duration_ms =
usage.get("totalApiDurationMs").and_then(Value::as_u64);
}
if let Some(code) = v.get("exitCode").and_then(Value::as_i64)
&& code != 0
{
self.term.stop = Stop::Error;
self.term.error_message = Some(format!("copilot exited with code {code}"));
}
Vec::new()
}
_ => Vec::new(),
}
}
}
fn model_of(v: &Value) -> Option<String> {
v.get("model")
.or_else(|| v.get("data").and_then(|d| d.get("model")))
.and_then(Value::as_str)
.map(str::to_string)
}
fn stop_from(v: Option<&Value>) -> Stop {
match v.and_then(Value::as_str) {
None | Some("end_turn" | "stop" | "completed") => Stop::Completed,
Some(other) => Stop::Other(other.to_string()),
}
}
fn claude_rate_limit(v: Option<&Value>) -> Option<RateLimit> {
let v = v?;
Some(RateLimit {
status: v.get("status").and_then(Value::as_str)?.to_string(),
window: v
.get("rateLimitType")
.and_then(Value::as_str)
.map(str::to_string),
resets_at: v.get("resetsAt").and_then(Value::as_i64),
overage_status: v
.get("overageStatus")
.and_then(Value::as_str)
.map(str::to_string),
is_using_overage: v.get("isUsingOverage").and_then(Value::as_bool),
})
}
fn claude_usage(v: &Value) -> Usage {
let u = v.get("usage");
let get = |key: &str| u.and_then(|u| u.get(key)).and_then(Value::as_u64);
let (input, read, write) = (
get("input_tokens"),
get("cache_read_input_tokens"),
get("cache_creation_input_tokens"),
);
let per_model = v
.get("modelUsage")
.and_then(Value::as_object)
.and_then(|models| models.values().next());
let of_model = |key: &str| per_model.and_then(|m| m.get(key)).and_then(Value::as_u64);
Usage {
input_tokens: input,
output_tokens: get("output_tokens"),
cache_read_tokens: read,
cache_write_tokens: write,
context_tokens: (input.is_some() || read.is_some() || write.is_some())
.then(|| input.unwrap_or(0) + read.unwrap_or(0) + write.unwrap_or(0)),
context_window: of_model("contextWindow"),
max_output_tokens: of_model("maxOutputTokens"),
reasoning_tokens: None,
cost_usd: v.get("total_cost_usd").and_then(Value::as_f64),
premium_requests: None,
ai_credits_nano: None,
duration_ms: v.get("duration_ms").and_then(Value::as_u64),
api_duration_ms: v.get("duration_api_ms").and_then(Value::as_u64),
}
}
fn codex_usage(v: Option<&Value>) -> Usage {
let get = |key: &str| v.and_then(|u| u.get(key)).and_then(Value::as_u64);
let (prompt, cached) = (get("input_tokens"), get("cached_input_tokens"));
Usage {
input_tokens: match (prompt, cached) {
(Some(prompt), Some(cached)) => Some(prompt.saturating_sub(cached)),
(prompt, _) => prompt,
},
output_tokens: get("output_tokens"),
cache_read_tokens: cached,
cache_write_tokens: get("cache_write_input_tokens"),
context_tokens: prompt,
context_window: None,
max_output_tokens: None,
reasoning_tokens: get("reasoning_output_tokens"),
cost_usd: None,
premium_requests: None,
ai_credits_nano: None,
duration_ms: None,
api_duration_ms: None,
}
}
fn tool_name(item: &Value, item_ty: &str) -> String {
item.get("tool")
.and_then(Value::as_str)
.unwrap_or(item_ty)
.to_string()
}
fn codex_tool_input(item: &Value, item_ty: &str) -> Value {
match item_ty {
"command_execution" => serde_json::json!({ "command": item.get("command") }),
"mcp_tool_call" => item.get("arguments").cloned().unwrap_or(Value::Null),
_ => item.clone(),
}
}
fn flatten_text(v: Option<&Value>) -> String {
match v {
Some(Value::String(s)) => s.clone(),
Some(Value::Array(blocks)) => blocks
.iter()
.map(|b| match b.get("text").and_then(Value::as_str) {
Some(text) => text.to_string(),
None => b.to_string(),
})
.collect::<Vec<_>>()
.join("\n"),
Some(other) => other.to_string(),
None => String::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn run(agent: Agent, lines: &[&str]) -> (Vec<Event>, Terminal) {
let mut p = Parser::new(agent, Format::Stream);
let events = lines.iter().flat_map(|l| p.push(l)).collect();
(events, p.finish())
}
#[test]
fn claude_stream_yields_start_thinking_text_and_terminal_facts() {
let (events, term) = run(
Agent::Claude,
&[
r#"{"type":"system","subtype":"init","session_id":"sess-a","model":"claude-haiku-4-5"}"#,
r#"{"type":"assistant","session_id":"sess-a","message":{"content":[{"type":"thinking","thinking":"brief"}]}}"#,
r#"{"type":"assistant","session_id":"sess-a","message":{"content":[{"type":"text","text":"pong"}]}}"#,
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}}"#,
],
);
assert_eq!(
events[0],
Event::Started {
session: "sess-a".into(),
model: Some("claude-haiku-4-5".into())
}
);
assert_eq!(events[1], Event::Thinking("brief".into()));
assert_eq!(events[2], Event::Text("pong".into()));
assert_eq!(term.session.as_deref(), Some("sess-a"));
assert_eq!(term.text, "pong");
assert_eq!(term.stop, Stop::Completed);
assert_eq!(term.usage.input_tokens, Some(10));
assert_eq!(term.usage.cache_read_tokens, Some(18764));
assert_eq!(term.usage.cache_write_tokens, Some(7322));
assert_eq!(term.usage.cost_usd, Some(0.017));
}
#[test]
fn claude_token_deltas_stream_without_duplicating_the_finished_message() {
let (events, _) = run(
Agent::Claude,
&[
r#"{"type":"system","subtype":"init","session_id":"s"}"#,
r#"{"type":"stream_event","session_id":"s","event":{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}}"#,
r#"{"type":"stream_event","session_id":"s","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"po"}}}"#,
r#"{"type":"stream_event","session_id":"s","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"ng"}}}"#,
r#"{"type":"stream_event","session_id":"s","event":{"type":"content_block_stop","index":0}}"#,
r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"text","text":"pong"}]}}"#,
r#"{"type":"result","subtype":"success","is_error":false,"result":"pong","session_id":"s"}"#,
],
);
let texts: Vec<_> = events
.iter()
.filter_map(|e| match e {
Event::Text(t) => Some(t.as_str()),
_ => None,
})
.collect();
assert_eq!(texts, ["po", "ng"], "the finished message must not repeat");
}
#[test]
fn claude_thinking_deltas_stream_without_duplication() {
let (events, _) = run(
Agent::Claude,
&[
r#"{"type":"stream_event","session_id":"s","event":{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"weighing"}}}"#,
r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"thinking","thinking":"weighing"}]}}"#,
],
);
let thoughts: Vec<_> = events
.iter()
.filter_map(|e| match e {
Event::Thinking(t) => Some(t.as_str()),
_ => None,
})
.collect();
assert_eq!(thoughts, ["weighing"]);
}
#[test]
fn a_completed_message_still_streams_when_no_deltas_arrived() {
let (events, _) = run(
Agent::Claude,
&[
r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"text","text":"pong"}]}}"#,
],
);
assert!(events.contains(&Event::Text("pong".into())), "{events:?}");
}
#[test]
fn tool_calls_survive_delta_suppression() {
let (events, _) = run(
Agent::Claude,
&[
r#"{"type":"stream_event","session_id":"s","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hi"}}}"#,
r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"tool_use","id":"t1","name":"Bash","input":{"command":"ls"}}]}}"#,
],
);
assert!(
events.iter().any(|e| matches!(e, Event::ToolCall { .. })),
"suppression must apply to text only: {events:?}"
);
}
#[test]
fn claude_started_fires_only_once() {
let (events, _) = run(
Agent::Claude,
&[
r#"{"type":"system","subtype":"init","session_id":"s"}"#,
r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"text","text":"a"}]}}"#,
r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"text","text":"b"}]}}"#,
],
);
assert_eq!(
events
.iter()
.filter(|e| matches!(e, Event::Started { .. }))
.count(),
1
);
}
#[test]
fn claude_pairs_tool_use_with_its_result() {
let (events, _) = run(
Agent::Claude,
&[
r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"tool_use","id":"toolu_1","name":"Bash","input":{"command":"ls"}}]}}"#,
r#"{"type":"user","session_id":"s","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"a.txt"}]}}"#,
],
);
let call = events
.iter()
.find(|e| matches!(e, Event::ToolCall { .. }))
.unwrap();
let Event::ToolCall { id, name, input } = call else {
unreachable!()
};
assert_eq!(id.as_deref(), Some("toolu_1"));
assert_eq!(name, "Bash");
assert_eq!(input["command"], "ls");
assert!(events.contains(&Event::ToolResult {
id: Some("toolu_1".into()),
ok: None,
output: "a.txt".into(),
}));
}
#[test]
fn claude_reports_a_rate_limit_without_failing() {
let (events, term) = run(
Agent::Claude,
&[
r#"{"type":"rate_limit_event","session_id":"s","rate_limit_info":{"status":"allowed","resetsAt":1785260400,"rateLimitType":"five_hour"}}"#,
],
);
let limit = RateLimit {
status: "allowed".into(),
window: Some("five_hour".into()),
resets_at: Some(1_785_260_400),
overage_status: None,
is_using_overage: None,
};
assert!(events.contains(&Event::RateLimit(limit.clone())));
assert_eq!(term.rate_limit, Some(limit.clone()));
assert!(
!limit.is_blocking(),
"an `allowed` heartbeat is not a block"
);
}
#[test]
fn claude_error_result_sets_the_stop_reason() {
let (_, term) = run(
Agent::Claude,
&[r#"{"type":"result","is_error":true,"result":"boom","session_id":"s"}"#],
);
assert_eq!(term.stop, Stop::Error);
}
#[test]
fn copilot_streams_deltas_and_takes_its_answer_from_the_settled_message() {
let (events, term) = run(
Agent::Copilot,
&[
r#"{"type":"assistant.message_delta","data":{"messageId":"m","deltaContent":"po"}}"#,
r#"{"type":"assistant.message_delta","data":{"messageId":"m","deltaContent":"ng"}}"#,
r#"{"type":"assistant.message","data":{"messageId":"m","model":"gpt-5-mini","content":"pong"}}"#,
r#"{"type":"result","sessionId":"768c8e7d","exitCode":0,"usage":{"premiumRequests":0}}"#,
],
);
let texts: Vec<_> = events
.iter()
.filter_map(|e| match e {
Event::Text(t) => Some(t.as_str()),
_ => None,
})
.collect();
assert_eq!(texts, ["po", "ng"]);
assert_eq!(term.text, "pong", "the answer is the settled message");
assert_eq!(term.session.as_deref(), Some("768c8e7d"));
assert_eq!(term.usage.premium_requests, Some(0));
}
#[test]
fn copilot_brackets_a_tool_call_with_its_completion() {
let (events, _) = run(
Agent::Copilot,
&[
r#"{"type":"tool.execution_start","data":{"toolCallId":"call_1","toolName":"bash","arguments":{"command":"ls"}}}"#,
r#"{"type":"tool.execution_complete","data":{"toolCallId":"call_1","success":true,"result":{"content":"a.txt"}}}"#,
],
);
assert!(matches!(
&events[0],
Event::ToolCall { id, name, .. }
if id.as_deref() == Some("call_1") && name == "bash"
));
assert_eq!(
events[1],
Event::ToolResult {
id: Some("call_1".into()),
ok: Some(true),
output: "a.txt".into()
}
);
}
#[test]
fn a_codex_failed_turn_yields_the_reason_and_the_status() {
let (_, term) = run(
Agent::Codex,
&[
r#"{"type":"thread.started","thread_id":"019fad62"}"#,
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.\"}}"}}"#,
],
);
assert_eq!(term.stop, Stop::Error);
assert_eq!(term.error_status, Some(400));
assert_eq!(
term.error_message.as_deref(),
Some(
"The 'bogus-model-xyz' model is not supported when using Codex with a ChatGPT account."
),
"the caller should get the sentence, not the envelope"
);
}
#[test]
fn a_plain_codex_failure_message_passes_through() {
let (_, term) = run(
Agent::Codex,
&[
r#"{"type":"turn.failed","error":{"message":"stream disconnected before completion"}}"#,
],
);
assert_eq!(term.error_status, None);
assert_eq!(
term.error_message.as_deref(),
Some("stream disconnected before completion")
);
}
#[test]
fn codex_reads_the_thread_id_and_the_completed_message() {
let (events, term) = run(
Agent::Codex,
&[
r#"{"type":"thread.started","thread_id":"0199-xyz"}"#,
r#"{"type":"item.completed","item":{"id":"i1","type":"agent_message","text":"pong"}}"#,
r#"{"type":"turn.completed","usage":{"input_tokens":12,"output_tokens":3,"cached_input_tokens":9}}"#,
],
);
assert_eq!(
events[0],
Event::Started {
session: "0199-xyz".into(),
model: None
}
);
assert_eq!(term.session.as_deref(), Some("0199-xyz"));
assert_eq!(term.text, "pong");
assert_eq!(term.usage.input_tokens, Some(3));
assert_eq!(term.usage.cache_read_tokens, Some(9));
assert_eq!(term.usage.context_tokens, Some(12));
}
#[test]
fn codex_command_execution_becomes_a_call_and_a_result() {
let (events, _) = run(
Agent::Codex,
&[
r#"{"type":"item.completed","item":{"id":"c1","type":"command_execution","command":"ls","exit_code":0,"aggregated_output":"a.txt"}}"#,
],
);
assert!(matches!(&events[0], Event::ToolCall { name, .. } if name == "command_execution"));
assert_eq!(
events[1],
Event::ToolResult {
id: Some("c1".into()),
ok: Some(true),
output: "a.txt".into()
}
);
}
#[test]
fn codex_started_then_completed_yields_one_call_and_one_result() {
let (events, _) = run(
Agent::Codex,
&[
r#"{"type":"item.started","item":{"id":"item_1","type":"command_execution","command":"/bin/zsh -lc ls","aggregated_output":"","exit_code":null,"status":"in_progress"}}"#,
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"}}"#,
],
);
let calls = events
.iter()
.filter(|e| matches!(e, Event::ToolCall { .. }))
.count();
assert_eq!(calls, 1, "the same item must not be announced twice");
let results: Vec<_> = events
.iter()
.filter_map(|e| match e {
Event::ToolResult { output, .. } => Some(output.as_str()),
_ => None,
})
.collect();
assert_eq!(
results,
["a.txt\n"],
"the in-progress blank must not appear"
);
}
#[test]
fn codex_last_completed_message_is_the_answer() {
let (_, term) = run(
Agent::Codex,
&[
r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"I'll list the directory."}}"#,
r#"{"type":"item.completed","item":{"id":"i2","type":"agent_message","text":"DONE"}}"#,
],
);
assert_eq!(term.text, "DONE");
}
#[test]
fn an_enormous_tool_result_is_bounded_and_marked() {
let huge = "x".repeat(MAX_EVENT_BYTES * 4);
let line = serde_json::json!({
"type": "user",
"session_id": "s",
"message": {"content": [{
"type": "tool_result", "tool_use_id": "t1", "content": huge
}]}
})
.to_string();
let (events, _) = run(Agent::Claude, &[&line]);
let Some(Event::ToolResult { output, id, .. }) = events
.iter()
.find(|e| matches!(e, Event::ToolResult { .. }))
.cloned()
else {
panic!("expected a tool result, got {events:?}")
};
assert!(
output.len() <= MAX_EVENT_BYTES,
"kept {} bytes",
output.len()
);
assert!(
output.ends_with(TRUNCATION_MARK),
"truncation must be visible"
);
assert_eq!(id.as_deref(), Some("t1"), "the id must survive whole");
}
#[test]
fn usable_identifiers_are_never_shortened() {
let id = "s".repeat(MAX_IDENTIFIER_BYTES);
let line =
serde_json::json!({"type": "system", "subtype": "init", "session_id": id}).to_string();
let (events, term) = run(Agent::Claude, &[&line]);
let Some(Event::Started { session, .. }) = events.first().cloned() else {
panic!("expected Started, got {events:?}")
};
assert_eq!(session.len(), id.len(), "the session id was shortened");
assert_eq!(term.session.as_deref(), Some(id.as_str()));
}
#[test]
fn an_oversized_session_id_is_rejected_rather_than_stored() {
let id = "s".repeat(MAX_IDENTIFIER_BYTES + 1);
for (agent, line) in [
(
Agent::Claude,
serde_json::json!({"type": "system", "subtype": "init", "session_id": id})
.to_string(),
),
(
Agent::Codex,
serde_json::json!({"type": "thread.started", "thread_id": id}).to_string(),
),
(
Agent::Copilot,
serde_json::json!({"type": "result", "sessionId": id, "exitCode": 0}).to_string(),
),
] {
let (events, term) = run(agent, &[&line]);
assert!(term.session.is_none(), "{agent} stored an unusable id");
assert!(
!events.iter().any(|e| matches!(e, Event::Started { .. })),
"{agent} announced a session it cannot resume"
);
}
}
#[test]
fn an_oversized_tool_id_drops_the_id_but_keeps_the_event() {
let id = "t".repeat(MAX_IDENTIFIER_BYTES + 1);
let line = serde_json::json!({
"type": "assistant", "session_id": "s",
"message": {"content": [{
"type": "tool_use", "id": id, "name": "Bash", "input": {"command": "ls"}
}]}
})
.to_string();
let (events, _) = run(Agent::Claude, &[&line]);
let Some(Event::ToolCall { id: seen, name, .. }) = events
.iter()
.find(|e| matches!(e, Event::ToolCall { .. }))
.cloned()
else {
panic!("the call itself must still be reported, got {events:?}")
};
assert_eq!(seen, None, "an unusable id must be dropped, not shortened");
assert_eq!(name, "Bash");
}
#[test]
fn the_pending_tool_map_is_bounded_by_bytes_not_only_entries() {
let mut parser = Parser::new(Agent::Claude, Format::Stream);
for i in 0..MAX_PENDING_TOOLS {
let line = serde_json::json!({
"type": "assistant", "session_id": "s",
"message": {"content": [{
"type": "tool_use",
"id": format!("{i:0>width$}", width = MAX_IDENTIFIER_BYTES),
"name": "x".repeat(MAX_IDENTIFIER_BYTES),
"input": {}
}]}
})
.to_string();
parser.push(&line);
}
assert!(
parser.tool_bytes <= MAX_PENDING_TOOL_BYTES,
"pending tools grew to {} bytes",
parser.tool_bytes
);
}
#[test]
fn a_completed_tool_call_releases_its_budget() {
let mut parser = Parser::new(Agent::Claude, Format::Stream);
let call = |id: &str| {
serde_json::json!({
"type": "assistant", "session_id": "s",
"message": {"content": [{
"type": "tool_use", "id": id, "name": "Bash", "input": {}
}]}
})
.to_string()
};
let result = |id: &str| {
serde_json::json!({
"type": "user", "session_id": "s",
"message": {"content": [{
"type": "tool_result", "tool_use_id": id, "content": "done"
}]}
})
.to_string()
};
for i in 0..(MAX_PENDING_TOOLS * 4) {
let id = format!("toolu_{i}");
parser.push(&call(&id));
parser.push(&result(&id));
}
assert_eq!(parser.tool_bytes, 0, "budget leaked across paired calls");
assert!(parser.tools.is_empty());
}
#[test]
fn a_worst_case_event_stays_within_the_stated_ceiling() {
let huge = "x".repeat(MAX_LINE);
let line = serde_json::json!({
"type": "assistant", "session_id": huge,
"message": {"content": [{
"type": "tool_use", "id": huge, "name": huge, "input": {"command": huge}
}]}
})
.to_string();
let (events, _) = run(Agent::Claude, &[&line]);
for event in &events {
let size = serde_json::to_string(event).unwrap().len();
let ceiling = MAX_EVENT_BYTES + 4 * MAX_IDENTIFIER_BYTES;
assert!(size <= ceiling, "an event reached {size} bytes: {event:?}");
}
}
#[test]
fn oversized_tool_arguments_stay_valid_json() {
let line = serde_json::json!({
"type": "assistant",
"session_id": "s",
"message": {"content": [{
"type": "tool_use", "id": "t1", "name": "Bash",
"input": {"command": "y".repeat(MAX_EVENT_BYTES * 3)}
}]}
})
.to_string();
let (events, _) = run(Agent::Claude, &[&line]);
let Some(Event::ToolCall { input, .. }) = events
.iter()
.find(|e| matches!(e, Event::ToolCall { .. }))
.cloned()
else {
panic!("expected a tool call, got {events:?}")
};
assert_eq!(input["truncated"], true, "got {input}");
assert!(
input.is_object(),
"the replacement must still be valid JSON"
);
assert!(input.to_string().len() <= MAX_EVENT_BYTES);
}
#[test]
fn ordinary_payloads_pass_through_untouched() {
let (events, _) = run(
Agent::Claude,
&[
r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"text","text":"pong"}]}}"#,
],
);
assert!(events.contains(&Event::Text("pong".into())), "{events:?}");
}
#[test]
fn capture_is_bounded_and_keeps_the_earliest_output() {
let mut buf = String::new();
for i in 0..50_000 {
append_capped(&mut buf, &format!("line {i} aaaaaaaaaaaaaaaaaaaaaaaaaaaa"));
}
assert!(buf.len() <= MAX_CAPTURE, "grew to {}", buf.len());
assert!(buf.starts_with("line 0 "), "the earliest output is kept");
}
#[test]
fn capping_never_splits_a_multibyte_character() {
let mut buf = "x".repeat(MAX_CAPTURE - 3);
assert!(append_capped(&mut buf, "🙂🙂"));
assert!(buf.len() <= MAX_CAPTURE);
assert!(buf.is_char_boundary(buf.len()));
}
#[test]
fn a_full_buffer_reports_that_it_took_nothing() {
let mut buf = "x".repeat(MAX_CAPTURE);
assert!(!append_capped(&mut buf, "more"));
assert_eq!(buf.len(), MAX_CAPTURE);
}
#[test]
fn unparseable_lines_are_counted_and_sampled() {
let (_, term) = run(
Agent::Claude,
&[
"<html>an error page, not JSON</html>",
"another bad line",
r#"{"type":"result","result":"ok","session_id":"s"}"#,
],
);
assert_eq!(term.unparsed, 2);
assert_eq!(
term.first_unparsed.as_deref(),
Some("<html>an error page, not JSON</html>")
);
}
#[test]
fn a_clean_stream_reports_no_parse_failures() {
let (_, term) = run(
Agent::Claude,
&[r#"{"type":"result","result":"ok","session_id":"s"}"#],
);
assert_eq!(term.unparsed, 0);
assert!(term.first_unparsed.is_none());
}
#[test]
fn tool_result_blocks_that_are_not_text_are_kept_not_dropped() {
let (events, _) = run(
Agent::Claude,
&[
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"}}]}]}}"#,
],
);
let output = events
.iter()
.find_map(|e| match e {
Event::ToolResult { output, .. } => Some(output),
_ => None,
})
.unwrap_or_else(|| panic!("expected a tool result, got {events:?}"));
assert!(output.contains("seen"));
assert!(output.contains("image"), "the image block was dropped");
}
#[test]
fn garbage_lines_are_skipped_not_fatal() {
let (events, term) = run(
Agent::Claude,
&[
"Warning: something on stdout",
"",
r#"{"type":"result","result":"ok","session_id":"s"}"#,
],
);
assert!(events.iter().all(|e| !matches!(e, Event::Text(_))));
assert_eq!(term.text, "ok");
}
#[test]
fn text_format_passes_lines_through_verbatim() {
let mut p = Parser::new(Agent::Copilot, Format::Text);
let events: Vec<_> = ["hello", "world"].iter().flat_map(|l| p.push(l)).collect();
assert_eq!(
events,
[Event::Text("hello".into()), Event::Text("world".into())]
);
assert_eq!(p.finish().text, "hello\nworld");
}
}