Files
ai-app/server/src/session/claude/translate.rs
T
irisandClaude Fable 5.1 9fa09b0af1 Show a session's subagents as subcards, each with a read-only transcript
A subagent is a second transcript owned by a session, in the same event
model, with no process and no controls. The claude translator routes lines
carrying parent_tool_use_id to a per-subagent translator and transcript
under <session>/subagents/<tool_use_id>; three routes expose the list, a
transcript page and the SSE stream. Echo grows /subagent [n] as the rig.

On the phone a card with subagents ends in a chevron expander, collapsed by
default, opening to outlined subcards styled like dev-updater's components;
a subcard opens SessionScreen in read-only form, addressed through
TranscriptAddress so paging, cache and stream are shared.

Design in SUBAGENTS.md; choices awaiting review in DECISIONS.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 13:41:15 -04:00

1644 lines
71 KiB
Rust

//! The stream-json dialect: CLI lines in, common [`Event`]s out.
//!
//! Split from the driver beside it because the two change for unrelated
//! reasons. This half moves when the CLI's wire format does, which is what the
//! tests at the bottom pin by replaying recorded lines; the driver half moves
//! when spawning, resuming or shutting down changes.
//!
//! The one side effect here is saving images a tool result carries into the
//! session directory; everything else is pure, which is what makes the mapping
//! testable without a process.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use serde_json::{Value, json};
use super::super::driver::{Event, QuestionOption, SessionStatus, context_tokens};
use super::super::subagent::Subagents;
/// Whether this line is the CLI opening a fresh model call.
///
/// `message_start` begins one assistant message, and the CLI sends the previous
/// call's tool results back before opening the next -- so this is the first
/// moment at which anything written since the last one can have been read.
/// Nothing earlier will do: the deltas and `tool_use` of a message *already in
/// flight* keep arriving after a steer is written, and none of them saw it.
///
/// Only present because the driver passes `--include-partial-messages`, which
/// is why the caller keeps a fallback that does not depend on it.
pub(super) fn starts_a_model_call(message: &Value) -> bool {
message.get("type").and_then(Value::as_str) == Some("stream_event")
&& message["event"].get("type").and_then(Value::as_str) == Some("message_start")
}
/// What answering a question produced.
pub(super) enum AnswerOutcome {
/// Send this control_response line to the CLI.
Respond(Value),
/// Part of a multi-question request; more answers still needed.
Pending,
Unknown,
}
/// A setting a control request asked for, held until the CLI says whether it
/// took. The CLI answers `set_model` with a bare success -- no value -- so the
/// only way to report what was accepted is to remember what was asked.
/// `set_permission_mode` does echo its mode back.
pub(super) enum Setting {
Model(String),
PermissionMode(String),
}
/// A `can_use_tool` request we've surfaced to the phone and not yet answered.
/// For plain permissions there is one implicit question (Allow/Deny); for
/// AskUserQuestion, one per entry in `questions`.
struct PendingRequest {
request_id: String,
input: Value,
/// Question text per sub-question, in order -- the keys the answers map
/// uses. Empty for a plain permission request.
questions: Vec<String>,
answers: HashMap<String, String>,
}
/// Translation state: stream-json lines in, common events out.
pub(super) struct Translator {
pub(super) session_id: Option<String>,
pending: HashMap<String, PendingRequest>,
/// Settings asked for and not yet answered, by request id. Its path out is
/// the response: every entry is removed when one arrives, whether it
/// succeeded or failed.
asked: HashMap<String, Setting>,
/// Whether this side asked the turn to stop.
///
/// The CLI reports an interrupted turn the same way it reports one that
/// broke -- a `result` with `is_error` set -- so the line cannot tell them
/// apart, and somebody who pressed Stop was shown "the turn ended with an
/// error". What separates them is that *we* asked.
///
/// Its path out is that result, so a genuine failure in a later turn is
/// still reported.
interrupting: bool,
/// The input side of the newest assistant message, waiting for the `result`
/// that ends the turn to carry it out.
///
/// Read from the assistant message rather than the result's own usage,
/// which is the whole turn added up: measured on 2026-08-30 against 2.1.237,
/// a two-message turn reported `cache_read_input_tokens` of 40,211, being
/// 14,259 and 25,952 -- the same conversation counted twice. The model held
/// 26,131. A turn with ten tool calls would overstate it tenfold.
///
/// Its path out is that result, so a turn whose messages carried no usage
/// reports none rather than repeating the previous turn's.
context: Option<u64>,
session_dir: PathBuf,
/// This session's subagents, shared with every child translator below --
/// see `SUBAGENTS.md`. One registry per session, so a subagent started
/// through this translator or any of its children lands in the same
/// place a route reads it back from.
subagents: Arc<Subagents>,
/// One translator per subagent id, holding *its* streaming and
/// tool-tracking state -- separate from the parent's because tool ids
/// are unique but a `stream_event`'s content-block index is not, and
/// parallel subagents interleave their deltas on one stdout.
children: HashMap<String, Arc<Mutex<Translator>>>,
}
impl Translator {
pub(super) fn new(session_dir: PathBuf, subagents: Arc<Subagents>) -> Self {
Self {
session_id: None,
pending: HashMap::new(),
asked: HashMap::new(),
interrupting: false,
context: None,
session_dir,
subagents,
children: HashMap::new(),
}
}
/// Remembers what a control request was for, so its answer can say so.
/// Called before the request goes out: the reader thread is already running
/// and a fast CLI can answer before this side gets back to it.
pub(super) fn expect_setting(&mut self, request_id: String, setting: Setting) {
self.asked.insert(request_id, setting);
}
/// Says that the turn about to end was stopped on purpose. Called before
/// the request goes out, for the reason [`Translator::expect_setting`]
/// gives.
pub(super) fn expect_interrupt(&mut self) {
self.interrupting = true;
}
pub(super) fn translate(&mut self, message: &Value) -> Vec<Event> {
// Events from subagents (Task tool internals) carry a
// parent_tool_use_id; the transcript shows the Task tool's own
// start/end instead of every nested step. Routed into that
// subagent's own transcript rather than dropped -- see
// `SUBAGENTS.md`.
if let Some(parent_id) = message.get("parent_tool_use_id").and_then(Value::as_str) {
return self.translate_child(parent_id, message);
}
self.dispatch(message)
}
/// A line belonging to a subagent rather than to this translator's own
/// session. Always returns nothing to the *caller*: everything it
/// produces goes into the subagent's own transcript instead.
fn translate_child(&mut self, id: &str, message: &Value) -> Vec<Event> {
match self.subagents.get(id) {
Some(subagent) if !subagent.is_open() => {
// The Task call already ended (or this line is stale from a
// resumed conversation) -- see `SUBAGENTS.md`'s lifecycle.
tracing::debug!("dropping a line for subagent {id}, which has already finished");
return Vec::new();
}
Some(_) => {}
None => {
// Nobody has heard of this id yet: the Task call itself
// either has not been seen or never will be. Started here
// with the best title available -- the tool name of this
// first line -- since SUBAGENTS.md's real title only
// arrives with the Task call.
self.subagents.start(id, &fallback_title(message), None);
}
}
let child = self
.children
.entry(id.to_string())
.or_insert_with(|| {
Arc::new(Mutex::new(Translator::new(
self.session_dir.clone(),
Arc::clone(&self.subagents),
)))
})
.clone();
let events = child.lock().unwrap().dispatch(message);
for event in events {
self.subagents.record(id, event);
}
Vec::new()
}
fn dispatch(&mut self, message: &Value) -> Vec<Event> {
match message.get("type").and_then(Value::as_str) {
Some("system") => self.translate_system(message),
// The CLI's own announcement that `/clear` took effect, sent just
// before the fresh `init` carrying the new session_id. Measured
// against 2.1.237: this used to watch for the id being *replaced*,
// which is the same event seen through a side effect. The
// announcement lands before the new init rather than after it.
Some("conversation_reset") => vec![Event::Cleared],
Some("stream_event") => self.translate_stream_event(&message["event"]),
Some("assistant") => self.translate_assistant(&message["message"]),
Some("user") => self.translate_user(message),
Some("control_request") => self.translate_control_request(message),
Some("control_response") => {
let response = &message["response"];
// Answered either way, so the request stops being pending
// either way -- a rejected setting that stayed here would be
// applied by the next request that reused its id.
let asked = response
.get("request_id")
.and_then(Value::as_str)
.and_then(|id| self.asked.remove(id));
if response.get("subtype").and_then(Value::as_str) == Some("error") {
let error = response
.get("error")
.and_then(Value::as_str)
.unwrap_or("unknown");
return vec![Event::Error {
message: format!("claude rejected a request: {error}"),
}];
}
// Success, so the setting this request asked for is now the
// session's, and this is the only place that says so: the
// response carries no value of its own for a model.
match asked {
Some(Setting::Model(model)) => vec![Event::Settings {
model: Some(model),
permission_mode: None,
}],
Some(Setting::PermissionMode(mode)) => vec![Event::Settings {
model: None,
// The CLI echoes this one, and its answer wins: `auto`
// and `manual` are names it accepts on the way in and
// reports back under another name, so repeating the
// request would show a mode the session is not in.
permission_mode: Some(
response["response"]["mode"]
.as_str()
.map(str::to_string)
.unwrap_or(mode),
),
}],
None => Vec::new(),
}
}
Some("result") => {
let usage = &message["usage"];
let tokens = usage
.get("input_tokens")
.and_then(Value::as_u64)
.unwrap_or(0)
+ usage
.get("output_tokens")
.and_then(Value::as_u64)
.unwrap_or(0);
let mut events = Vec::new();
// A turn another agent started, which is only knowable here.
//
// Measured against 2.1.237 (2026-08-31) by sending a real
// cross-session message to a real stream-json session: the CLI
// emits no `user` record for it and nothing in the
// partial-message stream mentions it. The whole of it arrives as
// an `origin` object on the turn's `result`, in the same shape
// the session file records -- so this is `import::peer_message`
// reading a different record.
//
// The cost is the position: the note lands after the reply it
// caused, because at no earlier point does the CLI say why the
// turn started. Taken deliberately over a second reader tailing
// the CLI's own session file, which is two sources of truth for
// one conversation and a poll per live session.
//
// Only peer-caused turns carry it: four ordinary results over a
// real session's stdout had no `origin` between them.
if let Some(peer) = crate::session::import::peer_message(message) {
events.push(peer);
}
// Whichever way this result went, the interrupt it may have
// been answering is now spent.
let asked_to_stop = std::mem::take(&mut self.interrupting);
if !asked_to_stop
&& message
.get("is_error")
.and_then(Value::as_bool)
.unwrap_or(false)
{
let said = message.get("result").and_then(Value::as_str);
events.push(match said.and_then(usage_limit) {
Some(resets_at) => Event::LimitReached { resets_at },
None => Event::Error {
message: said.unwrap_or("the turn ended with an error").to_string(),
},
});
}
let context = self.context.take();
if tokens > 0 {
events.push(Event::UsageDelta { tokens, context });
}
events.push(Event::Status {
state: SessionStatus::Idle,
});
events
}
_ => Vec::new(),
}
}
/// The CLI's own notices: which session this is, and what it is doing that
/// is not a turn.
///
/// Compaction is the whole of that second kind, and it is announced rather
/// than inferred. Measured against 2.1.237 (2026-08-29) by driving a session
/// through `/compact`, one produces in order:
///
/// - `{"subtype":"status","status":"compacting"}` -- the start;
/// - `{"subtype":"status","status":null,"compact_result":"success"}`, or
/// `"failed"` with a `compact_error` -- the end;
/// - a fresh `init` carrying the same `session_id`;
/// - `{"subtype":"compact_boundary","compact_metadata":{…}}` with the token
/// counts, and only when it succeeded;
/// - the turn's ordinary `result`, which returns it to idle.
///
/// The keys are snake_case here and camelCase in the CLI's own transcript
/// file, which records the same events -- so reading the shape off that
/// file, the obvious place to look, gets every field name wrong and
/// silently yields a compaction with no numbers in it.
fn translate_system(&mut self, message: &Value) -> Vec<Event> {
match message.get("subtype").and_then(Value::as_str) {
Some("init") => {
if let Some(id) = message.get("session_id").and_then(Value::as_str) {
self.session_id = Some(id.to_string());
}
// The CLI's own account of what it is set to, and the only one
// that resolves an alias: a session launched with
// `--model haiku` reports `claude-haiku-4-5-20251001` here. It
// arrives again after a compaction, which is free.
vec![Event::Settings {
model: message
.get("model")
.and_then(Value::as_str)
.map(str::to_string),
permission_mode: message
.get("permissionMode")
.and_then(Value::as_str)
.map(str::to_string),
}]
}
Some("status") => self.translate_status(message),
Some("compact_boundary") => {
let meta = &message["compact_metadata"];
vec![Event::Compacted {
pre_tokens: meta.get("pre_tokens").and_then(Value::as_u64),
post_tokens: meta.get("post_tokens").and_then(Value::as_u64),
trigger: meta
.get("trigger")
.and_then(Value::as_str)
.map(str::to_string),
}]
}
_ => Vec::new(),
}
}
/// A `system/status` line: the CLI entering or leaving a state that is not
/// a turn.
///
/// A null `status` is the leaving edge, and it carries how the thing went.
/// The turn it happened inside is still going when it ends -- the `result`
/// has not arrived -- so leaving says `Running`. A state this build does
/// not recognise is left alone rather than mapped onto the nearest one.
fn translate_status(&self, message: &Value) -> Vec<Event> {
// A mode change the CLI has made, announced a moment after it answers
// the request. Measured on 2.1.237:
// `{"subtype":"status","status":null,"permissionMode":"plan"}`, which
// is a leaving edge carrying no compaction result -- so it is checked
// before the compaction reading below.
if let Some(mode) = message.get("permissionMode").and_then(Value::as_str) {
return vec![Event::Settings {
model: None,
permission_mode: Some(mode.to_string()),
}];
}
if let Some(status) = message.get("status").and_then(Value::as_str) {
return match status {
"compacting" => vec![Event::Status {
state: SessionStatus::Compacting,
}],
_ => Vec::new(),
};
}
let Some(result) = message.get("compact_result").and_then(Value::as_str) else {
return Vec::new();
};
let mut events = Vec::new();
if result != "success" {
// The CLI's own sentence, because it is specific enough to act on:
// "Not enough messages to compact." is a complete answer.
events.push(Event::Error {
message: match message.get("compact_error").and_then(Value::as_str) {
Some(why) => format!("compaction failed: {why}"),
None => format!("compaction {result}"),
},
});
}
events.push(Event::Status {
state: SessionStatus::Running,
});
events
}
/// Raw API streaming: only text deltas become events. Consolidated blocks
/// arriving later re-carry the same text, so those are skipped in
/// `translate_assistant` -- one source per fact.
fn translate_stream_event(&mut self, event: &Value) -> Vec<Event> {
if event.get("type").and_then(Value::as_str) == Some("content_block_delta")
&& let Some(delta) = event["delta"].get("text")
&& event["delta"].get("type").and_then(Value::as_str) == Some("text_delta")
&& let Some(text) = delta.as_str()
{
return vec![Event::AssistantText {
delta: text.to_string(),
}];
}
Vec::new()
}
fn translate_assistant(&mut self, message: &Value) -> Vec<Event> {
if let Some(usage) = message.get("usage") {
let field = |name: &str| usage.get(name).and_then(Value::as_u64).unwrap_or(0);
self.context = Some(context_tokens(
field("input_tokens"),
field("cache_creation_input_tokens"),
field("cache_read_input_tokens"),
));
}
let Some(content) = message.get("content").and_then(Value::as_array) else {
return Vec::new();
};
content
.iter()
.filter(|block| block.get("type").and_then(Value::as_str) == Some("tool_use"))
.map(|block| {
let id = block
.get("id")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let tool = block
.get("name")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let input = block.get("input").cloned().unwrap_or(Value::Null);
// A subagent this call is about to start -- see
// `SUBAGENTS.md`'s lifecycle #1. The parent's own transcript
// still shows only the Task call itself, below.
if tool == "Task" || tool == "Agent" {
self.start_subagent_from_task(&id, &input);
}
Event::ToolStart { id, tool, input }
})
.collect()
}
/// Starts the subagent a Task call names, with the title and prompt
/// SUBAGENTS.md describes: the call's `description`, then
/// `(<subagent_type>)` when one is given, falling back to the tool's own
/// name when there is no description to build one from.
fn start_subagent_from_task(&self, id: &str, input: &Value) {
let description = text_field(input, "description");
let subagent_type = text_field(input, "subagent_type");
let prompt = input.get("prompt").and_then(Value::as_str);
let title = match (description, subagent_type) {
(Some(description), Some(subagent_type)) => {
format!("{description} ({subagent_type})")
}
(Some(description), None) => description,
(None, _) => "Task".to_string(),
};
self.subagents.start(id, &title, prompt);
}
fn translate_control_request(&mut self, message: &Value) -> Vec<Event> {
let request = &message["request"];
if request.get("subtype").and_then(Value::as_str) != Some("can_use_tool") {
return Vec::new();
}
let request_id = message
.get("request_id")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let tool_name = request
.get("tool_name")
.and_then(Value::as_str)
.unwrap_or("a tool");
let input = request.get("input").cloned().unwrap_or(Value::Null);
// Measured, not matched: the request names the call it is about, so the
// phone never has to guess which tool row a permission belongs to.
let about = request
.get("tool_use_id")
.and_then(Value::as_str)
.map(String::from);
let mut events = Vec::new();
let mut questions = Vec::new();
if tool_name == "AskUserQuestion" {
for (i, question) in input
.get("questions")
.and_then(Value::as_array)
.into_iter()
.flatten()
.enumerate()
{
let text = question
.get("question")
.and_then(Value::as_str)
.unwrap_or("(question)")
.to_string();
// Everything the reader decides on, carried in the event. The
// alternative -- and what this was -- is the phone reaching into
// the tool call's input for the parts the event dropped, which
// puts this dialect's schema where no other dialect can reach it.
let options = question
.get("options")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|option| {
Some(QuestionOption {
label: option.get("label").and_then(Value::as_str)?.to_string(),
description: text_field(option, "description"),
preview: text_field(option, "preview"),
})
})
.collect();
events.push(Event::Question {
id: format!("{request_id}#{i}"),
prompt: text.clone(),
header: text_field(question, "header"),
options,
multi_select: question
.get("multiSelect")
.and_then(Value::as_bool)
.unwrap_or(false),
// The call that is asking, so all of this draws as one
// thing. It used to be `None` on the grounds that a question
// the model asked is not permission for a call -- true, and
// beside the point: the reader was shown the AskUserQuestion
// call *and* its questions as two separate cards.
about: about.clone(),
});
questions.push(text);
}
} else {
let summary = serde_json::to_string_pretty(&input).unwrap_or_default();
let summary: String = summary.chars().take(600).collect();
events.push(Event::Question {
id: request_id.clone(),
prompt: format!("Allow {tool_name}?\n{summary}"),
// No header: the question is about the call it names, and the
// phone draws it on that call's own row.
header: None,
options: vec![
QuestionOption::plain("Allow"),
QuestionOption::plain("Deny"),
],
multi_select: false,
about: about.clone(),
});
}
self.pending.insert(
request_id.clone(),
PendingRequest {
request_id,
input,
questions,
answers: HashMap::new(),
},
);
events.push(Event::Status {
state: SessionStatus::AwaitingInput,
});
events
}
/// Applies one answer from the phone. Question ids are the control request
/// id, suffixed `#i` for AskUserQuestion sub-questions.
pub(super) fn answer(&mut self, question_id: &str, answers: &[String]) -> AnswerOutcome {
// Where this dialect's shape is put on: the CLI's `answers` map is
// string-valued whatever the question, so several choices become one
// line here rather than everything upstream pretending a question can
// only ever have one answer.
let answer = answers.join(", ");
let answer = answer.as_str();
let (request_id, sub) = match question_id.split_once('#') {
Some((request_id, index)) => (request_id, index.parse::<usize>().ok()),
None => (question_id, None),
};
let Some(pending) = self.pending.get_mut(request_id) else {
return AnswerOutcome::Unknown;
};
let response = if let Some(index) = sub {
let Some(question) = pending.questions.get(index) else {
return AnswerOutcome::Unknown;
};
pending.answers.insert(question.clone(), answer.to_string());
if pending.answers.len() < pending.questions.len() {
return AnswerOutcome::Pending;
}
let mut updated = pending.input.clone();
updated["answers"] = serde_json::to_value(&pending.answers).expect("string map");
json!({"behavior": "allow", "updatedInput": updated})
} else if answer.eq_ignore_ascii_case("deny") {
json!({"behavior": "deny", "message": "The user denied this from the phone."})
} else {
json!({"behavior": "allow", "updatedInput": pending.input})
};
let request_id = pending.request_id.clone();
self.pending.remove(&request_id);
AnswerOutcome::Respond(json!({
"type": "control_response",
"response": {"subtype": "success", "request_id": request_id, "response": response},
}))
}
/// `user` messages: tool results become ToolEnd, with any image parts saved
/// into the session dir and referenced by an Image event. Replayed and
/// synthetic user text is skipped -- the manager already recorded the
/// user's side.
fn translate_user(&self, message: &Value) -> Vec<Event> {
// Only tool results are here. The CLI never echoes a person's own
// message back on stdout -- measured, because the obvious way to learn
// that a queued message had been taken was to watch for it coming back
// -- so the driver reports that itself, at the line it writes.
let Some(content) = message["message"].get("content").and_then(Value::as_array) else {
return Vec::new();
};
let mut events = Vec::new();
for block in content {
if block.get("type").and_then(Value::as_str) != Some("tool_result") {
continue;
}
let mut texts = Vec::new();
// Held until the call's id is in hand a few lines below: an image is
// drawn under the call that produced it, so it has to carry that id.
let mut images = Vec::new();
match block.get("content") {
Some(Value::String(text)) => texts.push(text.clone()),
Some(Value::Array(parts)) => {
for part in parts {
match part.get("type").and_then(Value::as_str) {
Some("text") => {
if let Some(text) = part.get("text").and_then(Value::as_str) {
texts.push(text.to_string());
}
}
Some("image") => {
if let Some(name) = save_image(&self.session_dir, part) {
images.push(name);
}
}
_ => {}
}
}
}
_ => {}
}
let about = block
.get("tool_use_id")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
for image in images {
events.push(Event::Image {
image,
about: Some(about.clone()),
});
}
events.push(Event::ToolEnd {
id: about.clone(),
output: texts.join("\n"),
});
// A no-op unless `about` is a subagent's own id -- see
// `SUBAGENTS.md`'s lifecycle #3: the parent gets this `ToolEnd`
// like any other tool result, and the subagent it names (if it
// names one) gets its `Status::Exited`.
self.subagents.finish(&about);
}
events
}
}
/// The title to start a subagent under when its own first line arrives
/// before (or without) its Task call ever being seen: the tool name of that
/// first line, which is the only thing known about it yet. `"subagent"` for
/// a line this cannot even find a tool name in, such as one that opens with
/// something other than a tool call.
fn fallback_title(message: &Value) -> String {
message["message"]["content"]
.as_array()
.into_iter()
.flatten()
.find(|block| block.get("type").and_then(Value::as_str) == Some("tool_use"))
.and_then(|block| block.get("name"))
.and_then(Value::as_str)
.unwrap_or("subagent")
.to_string()
}
/// Whether a failed turn failed because the account is out of quota, and when
/// the CLI said the limit lifts.
///
/// The wording is the CLI's: a turn stopped by the limit ends with `is_error`
/// and a result of `Claude AI usage limit reached|1788546972`, the reset being
/// epoch seconds after a pipe. Matched on the sentence rather than on a code
/// because the CLI sends none, so this is deliberately loose about everything
/// but the four words.
///
/// The two `None`s mean different things and both are real. The outer one is
/// "some other failure". The inner one is "the limit is reached and the CLI did
/// not say until when" -- which is not a reason to invent a time: `crate::resume`
/// asks the usage endpoint before sending anything, and that answer is the one
/// that decides.
///
/// Milliseconds are accepted as well as seconds and told apart by magnitude,
/// since a wrong guess would schedule a resume tens of thousands of years out
/// and look exactly like auto-resume being broken.
fn usage_limit(result: &str) -> Option<Option<f64>> {
if !result.to_ascii_lowercase().contains("usage limit reached") {
return None;
}
let stamp = result
.rsplit('|')
.next()
.and_then(|tail| tail.trim().parse::<f64>().ok())
.filter(|stamp| *stamp > 0.0)
.map(|stamp| if stamp > 1e11 { stamp / 1000.0 } else { stamp });
Some(stamp)
}
/// A string field that is there and not empty, or `None`. The CLI omits these
/// rather than sending them empty, but a caller that sends `""` means the same
/// thing and should not produce a description that draws as a blank line.
fn text_field(value: &Value, name: &str) -> Option<String> {
value
.get(name)
.and_then(Value::as_str)
.filter(|text| !text.trim().is_empty())
.map(str::to_string)
}
/// Decodes one base64 image block into `files/` and returns its ref.
///
/// A free function rather than a method because the import replay needs exactly
/// this too: a session's history carries the same image blocks as its live
/// output. Two copies would be two naming schemes for one directory.
pub(in crate::session) fn save_image(session_dir: &Path, part: &Value) -> Option<String> {
let source = part.get("source")?;
let data = source.get("data")?.as_str()?;
use base64::Engine;
let bytes = base64::engine::general_purpose::STANDARD
.decode(data)
.ok()?;
// Screenshots are the overwhelming case and they are PNG; an unrecognized
// type is more likely a dialect change than a JPEG.
let extension = source
.get("media_type")
.and_then(Value::as_str)
.and_then(crate::media::extension_for)
.unwrap_or("png");
let name = format!("{}.{extension}", super::super::random_hex());
let dir = session_dir.join("files");
if let Err(err) = wg_app_link::private::create_dir(&dir)
.map_err(std::io::Error::other)
.and_then(|()| std::fs::write(dir.join(&name), bytes))
{
tracing::error!("couldn't save produced image: {err}");
return None;
}
Some(name)
}
#[cfg(test)]
mod tests {
use super::*;
/// What a phone sends back: everything chosen, even when that is one.
fn chose(answer: &str) -> Vec<String> {
vec![answer.to_string()]
}
fn labels(options: &[QuestionOption]) -> Vec<&str> {
options.iter().map(|option| option.label.as_str()).collect()
}
fn translate_lines(translator: &mut Translator, lines: &[&str]) -> Vec<Event> {
lines
.iter()
.flat_map(|line| translator.translate(&serde_json::from_str(line).expect("json")))
.collect()
}
/// A fresh, empty subagent registry over the same temp dir a test's
/// translator writes into -- every test here is about the parent's own
/// events, so what a registry does with a subagent is `subagent.rs`'s
/// tests to make, not these.
fn test_subagents(dir: &tempfile::TempDir) -> Arc<Subagents> {
Arc::new(Subagents::new(dir.path().to_path_buf()))
}
#[test]
fn captures_the_resume_token_and_the_settings_from_init() {
let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
let events = translate_lines(
&mut translator,
&[
r#"{"type":"system","subtype":"init","cwd":"/x","session_id":"5ecf21da-d53f","tools":[],"model":"claude-haiku-4-5-20251001","permissionMode":"acceptEdits"}"#,
],
);
assert_eq!(translator.session_id.as_deref(), Some("5ecf21da-d53f"));
// The resolved model, which is the point: a session launched with
// `--model haiku` is reported by its full name here.
assert_eq!(
events,
vec![Event::Settings {
model: Some("claude-haiku-4-5-20251001".to_string()),
permission_mode: Some("acceptEdits".to_string()),
}]
);
}
#[test]
fn a_setting_is_reported_when_the_cli_accepts_it_and_not_before() {
let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
// What `set_model` does: remember, send, and say nothing yet.
translator.expect_setting("req-a".to_string(), Setting::Model("sonnet".to_string()));
translator.expect_setting(
"req-b".to_string(),
Setting::PermissionMode("plan".to_string()),
);
// Success carries no model of its own -- measured on 2.1.237 -- so what
// was asked for is the only answer available.
let events = translate_lines(
&mut translator,
&[
r#"{"type":"control_response","response":{"subtype":"success","request_id":"req-a"}}"#,
],
);
assert_eq!(
events,
vec![Event::Settings {
model: Some("sonnet".to_string()),
permission_mode: None,
}]
);
// A mode the CLI answers with a value of its own is taken from that
// value: `auto` on the way in is `default` coming back.
translator.expect_setting(
"req-c".to_string(),
Setting::PermissionMode("auto".to_string()),
);
let events = translate_lines(
&mut translator,
&[
r#"{"type":"control_response","response":{"subtype":"success","request_id":"req-c","response":{"mode":"default"}}}"#,
],
);
assert_eq!(
events,
vec![Event::Settings {
model: None,
permission_mode: Some("default".to_string()),
}]
);
// A refusal changes nothing, and says why rather than claiming a
// setting that was rejected.
let events = translate_lines(
&mut translator,
&[
r#"{"type":"control_response","response":{"subtype":"error","request_id":"req-b","error":"unknown mode"}}"#,
],
);
assert_eq!(
events,
vec![Event::Error {
message: "claude rejected a request: unknown mode".to_string()
}]
);
// And neither request is still waiting: a second answer to either id
// reports nothing at all.
let events = translate_lines(
&mut translator,
&[
r#"{"type":"control_response","response":{"subtype":"success","request_id":"req-a"}}"#,
r#"{"type":"control_response","response":{"subtype":"success","request_id":"req-b"}}"#,
],
);
assert!(events.is_empty(), "{events:?}");
}
#[test]
fn a_mode_the_cli_announces_is_taken_from_the_announcement() {
// The line it sends just after answering `set_permission_mode`, which is
// also how a mode changed from the terminal arrives.
let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
let events = translate_lines(
&mut translator,
&[
r#"{"type":"system","subtype":"status","status":null,"permissionMode":"plan","session_id":"s"}"#,
],
);
assert_eq!(
events,
vec![Event::Settings {
model: None,
permission_mode: Some("plan".to_string()),
}]
);
}
#[test]
fn streams_text_deltas_and_skips_the_consolidated_copy() {
// Real lines (trimmed) from the 2.1.237 probe.
let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
let events = translate_lines(
&mut translator,
&[
r#"{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"Done."}},"session_id":"s","parent_tool_use_id":null}"#,
r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Done."}]},"parent_tool_use_id":null,"session_id":"s"}"#,
r#"{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"hmm"}},"session_id":"s","parent_tool_use_id":null}"#,
],
);
assert_eq!(
events,
vec![Event::AssistantText {
delta: "Done.".to_string()
}]
);
}
#[test]
fn tool_use_and_result_become_tool_events() {
let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
let events = translate_lines(
&mut translator,
&[
r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"toolu_01","name":"Bash","input":{"command":"echo probe-ok"}}]},"parent_tool_use_id":null}"#,
r#"{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01","type":"tool_result","content":"probe-ok","is_error":false}]},"parent_tool_use_id":null}"#,
],
);
assert_eq!(
events,
vec![
Event::ToolStart {
id: "toolu_01".to_string(),
tool: "Bash".to_string(),
input: serde_json::json!({"command": "echo probe-ok"}),
},
Event::ToolEnd {
id: "toolu_01".to_string(),
output: "probe-ok".to_string()
},
]
);
}
#[test]
fn subagent_events_are_not_duplicated_into_the_transcript() {
let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
let events = translate_lines(
&mut translator,
&[
r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"toolu_02","name":"Bash","input":{}}]},"parent_tool_use_id":"toolu_parent"}"#,
],
);
assert!(events.is_empty());
}
/// A child line does not just vanish from the parent -- it lands in its
/// own subagent's transcript, with that transcript's own sequence
/// numbers, starting at 1 like any other.
#[test]
fn a_child_line_lands_in_its_own_subagents_transcript() {
let dir = tempfile::tempdir().expect("tempdir");
let subagents = test_subagents(&dir);
let mut translator = Translator::new(dir.path().to_path_buf(), Arc::clone(&subagents));
translate_lines(
&mut translator,
&[
r#"{"type":"assistant","message":{"content":[{"type":"tool_use","id":"toolu_c1","name":"Bash","input":{"command":"echo hi"}}]},"parent_tool_use_id":"toolu_parent"}"#,
],
);
let subagent = subagents.get("toolu_parent").expect("subagent started");
let lines = crate::session::transcript::read_after(&subagent.transcript_path(), 0)
.expect("read subagent transcript");
assert_eq!(lines[0].seq, 1);
assert_eq!(
lines[0].event,
Event::Status {
state: SessionStatus::Running
}
);
assert!(
lines.iter().any(
|entry| matches!(&entry.event, Event::ToolStart { tool, .. } if tool == "Bash")
)
);
}
/// The title and prompt shown for a subagent come from the Task call
/// that started it, not from anything guessed at its first line.
#[test]
fn the_subagent_takes_its_title_and_prompt_from_the_task_call() {
let dir = tempfile::tempdir().expect("tempdir");
let subagents = test_subagents(&dir);
let mut translator = Translator::new(dir.path().to_path_buf(), Arc::clone(&subagents));
translate_lines(
&mut translator,
&[
r#"{"type":"assistant","message":{"content":[{"type":"tool_use","id":"toolu_task","name":"Task","input":{"description":"Investigate the bug","prompt":"Find why X fails","subagent_type":"general-purpose"}}]},"parent_tool_use_id":null}"#,
],
);
let rows = subagents.list(true);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].title, "Investigate the bug (general-purpose)");
let subagent = subagents.get(&rows[0].id).expect("subagent");
let lines = crate::session::transcript::read_after(&subagent.transcript_path(), 0)
.expect("read subagent transcript");
assert!(lines.iter().any(
|entry| matches!(&entry.event, Event::UserMessage { text, .. } if text == "Find why X fails")
));
}
/// The parent's `tool_result` for the Task id is what ends the
/// subagent -- SUBAGENTS.md's lifecycle #3 -- and nothing else does.
#[test]
fn the_parents_tool_result_finishes_the_subagent() {
let dir = tempfile::tempdir().expect("tempdir");
let subagents = test_subagents(&dir);
let mut translator = Translator::new(dir.path().to_path_buf(), Arc::clone(&subagents));
translate_lines(
&mut translator,
&[
r#"{"type":"assistant","message":{"content":[{"type":"tool_use","id":"toolu_task2","name":"Task","input":{"description":"helper"}}]},"parent_tool_use_id":null}"#,
],
);
let subagent = subagents.get("toolu_task2").expect("subagent started");
assert!(subagent.is_open());
translate_lines(
&mut translator,
&[
r#"{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_task2","content":"done","is_error":false}]},"parent_tool_use_id":null}"#,
],
);
assert!(!subagent.is_open());
}
/// Two subagents running at once keep two separate transcripts: tool ids
/// are unique but a `stream_event`'s content-block index is not, so
/// sharing translation state between them would cross their streams.
#[test]
fn two_parallel_subagents_keep_separate_transcripts() {
let dir = tempfile::tempdir().expect("tempdir");
let subagents = test_subagents(&dir);
let mut translator = Translator::new(dir.path().to_path_buf(), Arc::clone(&subagents));
translate_lines(
&mut translator,
&[
r#"{"type":"assistant","message":{"content":[{"type":"tool_use","id":"toolu_a","name":"Bash","input":{}}]},"parent_tool_use_id":"toolu_task_a"}"#,
r#"{"type":"assistant","message":{"content":[{"type":"tool_use","id":"toolu_b","name":"Read","input":{}}]},"parent_tool_use_id":"toolu_task_b"}"#,
],
);
let a = subagents.get("toolu_task_a").expect("subagent a");
let b = subagents.get("toolu_task_b").expect("subagent b");
let a_events = crate::session::transcript::read_after(&a.transcript_path(), 0)
.expect("read a's transcript");
let b_events = crate::session::transcript::read_after(&b.transcript_path(), 0)
.expect("read b's transcript");
assert!(
a_events.iter().any(
|entry| matches!(&entry.event, Event::ToolStart { tool, .. } if tool == "Bash")
)
);
assert!(
b_events.iter().any(
|entry| matches!(&entry.event, Event::ToolStart { tool, .. } if tool == "Read")
)
);
assert!(
!a_events.iter().any(
|entry| matches!(&entry.event, Event::ToolStart { tool, .. } if tool == "Read")
)
);
assert!(
!b_events.iter().any(
|entry| matches!(&entry.event, Event::ToolStart { tool, .. } if tool == "Bash")
)
);
}
#[test]
fn a_permission_request_becomes_an_allow_deny_question() {
let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
let events = translate_lines(
&mut translator,
&[
r#"{"type":"control_request","request_id":"req-1","request":{"subtype":"can_use_tool","tool_name":"Bash","input":{"command":"rm -rf /tmp/x"},"tool_use_id":"toolu_03"}}"#,
],
);
let Event::Question {
id,
prompt,
options,
about,
..
} = &events[0]
else {
panic!("expected a question, got {events:?}");
};
assert_eq!(id, "req-1");
// The call being asked about, so the phone draws the ask on that tool's
// row instead of as a second card repeating its input.
assert_eq!(about.as_deref(), Some("toolu_03"));
assert!(prompt.contains("Bash") && prompt.contains("rm -rf /tmp/x"));
assert_eq!(labels(options), ["Allow", "Deny"]);
assert_eq!(
events[1],
Event::Status {
state: SessionStatus::AwaitingInput
}
);
// Allowing echoes the input back; the request is then gone.
let AnswerOutcome::Respond(response) = translator.answer("req-1", &chose("Allow")) else {
panic!("expected a control response");
};
assert_eq!(response["response"]["request_id"], "req-1");
assert_eq!(response["response"]["response"]["behavior"], "allow");
assert_eq!(
response["response"]["response"]["updatedInput"]["command"],
"rm -rf /tmp/x"
);
assert!(matches!(
translator.answer("req-1", &chose("Allow")),
AnswerOutcome::Unknown
));
}
#[test]
fn denying_a_permission_sends_deny() {
let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
translate_lines(
&mut translator,
&[
r#"{"type":"control_request","request_id":"req-2","request":{"subtype":"can_use_tool","tool_name":"Write","input":{"file_path":"/etc/passwd"}}}"#,
],
);
let AnswerOutcome::Respond(response) = translator.answer("req-2", &chose("Deny")) else {
panic!("expected a control response");
};
assert_eq!(response["response"]["response"]["behavior"], "deny");
}
#[test]
fn ask_user_question_rides_the_same_flow_with_answers_keyed_by_question() {
// The real 2.1.237 shape, verified live: answers go back inside
// updatedInput, keyed by the question text.
let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
let events = translate_lines(
&mut translator,
&[
r#"{"type":"control_request","request_id":"req-3","request":{"subtype":"can_use_tool","tool_name":"AskUserQuestion","input":{"questions":[{"question":"Which color?","header":"Color","options":[{"label":"Red"},{"label":"Blue"}],"multiSelect":false},{"question":"Which size?","header":"Size","options":[{"label":"S"},{"label":"L"}],"multiSelect":false}]},"tool_use_id":"toolu_04","requires_user_interaction":true}}"#,
],
);
let questions: Vec<_> = events
.iter()
.filter_map(|event| match event {
Event::Question {
id,
prompt,
options,
multi_select,
..
} => Some((id.clone(), prompt.clone(), options.clone(), *multi_select)),
_ => None,
})
.collect();
assert_eq!(questions.len(), 2);
assert_eq!(questions[0].0, "req-3#0");
// Both belong to the call that asked, so a phone draws them on it.
assert!(events.iter().all(|event| match event {
Event::Question { about, .. } => about.as_deref() == Some("toolu_04"),
_ => true,
}));
assert_eq!(questions[0].1, "Which color?");
assert_eq!(labels(&questions[0].2), ["Red", "Blue"]);
// First answer alone isn't enough; the response goes out when the
// last sub-question is answered, with all answers aboard.
assert!(matches!(
translator.answer("req-3#0", &chose("Blue")),
AnswerOutcome::Pending
));
let AnswerOutcome::Respond(response) = translator.answer("req-3#1", &chose("L")) else {
panic!("expected a control response");
};
let updated = &response["response"]["response"]["updatedInput"];
assert_eq!(updated["answers"]["Which color?"], "Blue");
assert_eq!(updated["answers"]["Which size?"], "L");
assert_eq!(updated["questions"][0]["question"], "Which color?");
}
#[test]
fn a_question_carries_what_it_takes_to_answer_it() {
// Descriptions and previews are what the reader decides on, and a
// multi-select is how many answers the question takes. All of it travels
// in the event: a phone that had to read this dialect's tool input to
// find them would be the only place that knew how.
let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
let events = translate_lines(
&mut translator,
&[
r#"{"type":"control_request","request_id":"req-9","request":{"subtype":"can_use_tool","tool_name":"AskUserQuestion","input":{"questions":[{"question":"Which to collapse?","header":"Collapsed","multiSelect":true,"options":[{"label":"Tool calls","description":"A run becomes one card."},{"label":"Peer messages","description":"From other agents.","preview":"from: dev-updater\\npull before you touch it"}]}]},"tool_use_id":"toolu_09"}}"#,
],
);
let Event::Question {
header,
options,
multi_select,
..
} = &events[0]
else {
panic!("expected a question, got {events:?}");
};
assert_eq!(header.as_deref(), Some("Collapsed"));
assert!(multi_select);
assert_eq!(
options[0].description.as_deref(),
Some("A run becomes one card.")
);
assert!(options[0].preview.is_none());
assert!(
options[1]
.preview
.as_deref()
.unwrap()
.contains("dev-updater")
);
// Two choices, one answer: the joining is this dialect's shape, done
// where it is spoken. The CLI's answers map holds strings.
let AnswerOutcome::Respond(response) = translator.answer(
"req-9#0",
&["Tool calls".to_string(), "Peer messages".to_string()],
) else {
panic!("expected a control response");
};
assert_eq!(
response["response"]["response"]["updatedInput"]["answers"]["Which to collapse?"],
"Tool calls, Peer messages"
);
}
#[test]
fn images_in_tool_results_are_saved_and_referenced() {
let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
// A 1x1 PNG, the smallest real payload worth round-tripping.
let png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
let line = format!(
r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"tool_result","tool_use_id":"toolu_05","content":[{{"type":"text","text":"took a screenshot"}},{{"type":"image","source":{{"type":"base64","media_type":"image/png","data":"{png}"}}}}]}}]}},"parent_tool_use_id":null}}"#
);
let events = translator.translate(&serde_json::from_str(&line).expect("json"));
let Event::Image { image, about } = &events[0] else {
panic!("expected an image event, got {events:?}");
};
assert!(image.ends_with(".png"));
// Named as belonging to the call that produced it, so a phone draws it
// under that row rather than beside it.
assert_eq!(about.as_deref(), Some("toolu_05"));
let saved = dir.path().join("files").join(image);
assert!(saved.is_file(), "image not saved at {}", saved.display());
assert_eq!(
events[1],
Event::ToolEnd {
id: "toolu_05".to_string(),
output: "took a screenshot".to_string(),
}
);
}
#[test]
fn a_turn_result_reports_usage_and_returns_to_idle() {
let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
let events = translate_lines(
&mut translator,
&[
r#"{"type":"result","subtype":"success","is_error":false,"num_turns":2,"stop_reason":"end_turn","session_id":"s","total_cost_usd":0.0149,"usage":{"input_tokens":18,"output_tokens":164}}"#,
],
);
assert_eq!(
events,
vec![
Event::UsageDelta {
tokens: 182,
context: None
},
Event::Status {
state: SessionStatus::Idle
},
]
);
}
/// A turn another agent started says so, on the record that carries it.
///
/// The line is the real shape, taken from a real cross-session message sent
/// to a real stream-json session on 2.1.237 (2026-08-31) -- including the
/// `from` socket path, which is deliberately *not* what a reader is shown:
/// the sending session's `name` is what they recognise it by. The `body` is
/// the message as written; the content the model is given wraps the same
/// text in a preamble written for the model rather than for a person.
///
/// The note comes before the usage and the idle, so it sits as close to the
/// turn it explains as the wire allows.
#[test]
fn a_turn_started_by_another_agent_records_who_and_what() {
let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
let events = translate_lines(
&mut translator,
&[
r#"{"type":"result","subtype":"success","is_error":false,"session_id":"s","usage":{"input_tokens":2,"output_tokens":5},"origin":{"kind":"peer","from":"uds:/run/user/1000/cc-socks/137108.sock","verifiedPeerPid":137108,"msg_id":"1e729740","name":"ai-app-2-fb","fromMode":"prompting","body":"Reply with just the word ACK."}}"#,
],
);
assert_eq!(
events,
vec![
Event::PeerMessage {
from: "ai-app-2-fb".to_string(),
text: "Reply with just the word ACK.".to_string(),
// Stamped by the pump, which is the only place that knows
// what seq the turn started at.
turn_start: None,
},
Event::UsageDelta {
tokens: 7,
context: None
},
Event::Status {
state: SessionStatus::Idle
},
]
);
}
/// And an ordinary turn does not, which is the half that decides whether
/// the check above is a check or a rubber stamp. Measured over a real
/// session's stdout: four results, no `origin` between them.
#[test]
fn an_ordinary_turn_carries_no_peer_note() {
let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
let events = translate_lines(
&mut translator,
&[
r#"{"type":"result","subtype":"success","is_error":false,"session_id":"s","usage":{"input_tokens":2,"output_tokens":5}}"#,
],
);
assert!(
!events
.iter()
.any(|event| matches!(event, Event::PeerMessage { .. })),
"a turn nobody else started must not be attributed to anyone: {events:?}"
);
}
/// The context is the last assistant message's, not the result's.
///
/// Real figures from a two-message haiku turn on 2.1.237, captured
/// 2026-08-30. The result adds the turn up -- its `cache_read_input_tokens`
/// of 40,211 is 14,259 and 25,952, the same conversation counted twice -- so
/// reading the context off it would report a size the model never held, by
/// more the more tool calls a turn makes.
#[test]
fn the_context_is_what_the_last_message_held_not_the_turn_added_up() {
let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
let events = translate_lines(
&mut translator,
&[
r#"{"type":"assistant","message":{"content":[],"usage":{"input_tokens":9,"cache_creation_input_tokens":11693,"cache_read_input_tokens":14259,"output_tokens":3}}}"#,
r#"{"type":"assistant","message":{"content":[],"usage":{"input_tokens":8,"cache_creation_input_tokens":171,"cache_read_input_tokens":25952,"output_tokens":2}}}"#,
r#"{"type":"result","subtype":"success","is_error":false,"session_id":"s","usage":{"input_tokens":17,"cache_creation_input_tokens":11864,"cache_read_input_tokens":40211,"output_tokens":156}}"#,
],
);
assert_eq!(
events.first(),
Some(&Event::UsageDelta {
tokens: 173,
context: Some(26_131),
})
);
// Taken by that result, so a following turn whose messages carry no
// usage reports none rather than repeating this one's.
let events = translate_lines(
&mut translator,
&[
r#"{"type":"result","subtype":"success","is_error":false,"session_id":"s","usage":{"input_tokens":4,"output_tokens":9}}"#,
],
);
assert_eq!(
events.first(),
Some(&Event::UsageDelta {
tokens: 13,
context: None,
})
);
}
#[test]
fn a_compaction_reports_its_start_and_what_it_recovered() {
// Real lines (trimmed) from a 2.1.237 session driven through `/compact`.
// Note the snake_case keys -- the CLI's transcript file writes the same
// records in camelCase.
let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
let events = translate_lines(
&mut translator,
&[
r#"{"type":"system","subtype":"status","status":"compacting","session_id":"s","uuid":"u1"}"#,
r#"{"type":"system","subtype":"status","status":null,"compact_result":"success","session_id":"s","uuid":"u2"}"#,
r#"{"type":"system","subtype":"compact_boundary","session_id":"s","uuid":"u3","compact_metadata":{"trigger":"manual","pre_tokens":28719,"post_tokens":1125,"duration_ms":17130}}"#,
],
);
assert_eq!(
events,
vec![
Event::Status {
state: SessionStatus::Compacting
},
Event::Status {
state: SessionStatus::Running
},
Event::Compacted {
pre_tokens: Some(28719),
post_tokens: Some(1125),
trigger: Some("manual".to_string()),
},
]
);
}
#[test]
fn a_failed_compaction_says_why_and_leaves_the_turn_running() {
let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
let events = translate_lines(
&mut translator,
&[
r#"{"type":"system","subtype":"status","status":"compacting","session_id":"s","uuid":"u1"}"#,
r#"{"type":"system","subtype":"status","status":null,"compact_result":"failed","compact_error":"Not enough messages to compact.","session_id":"s","uuid":"u2"}"#,
],
);
assert_eq!(
events,
vec![
Event::Status {
state: SessionStatus::Compacting
},
Event::Error {
message: "compaction failed: Not enough messages to compact.".to_string()
},
Event::Status {
state: SessionStatus::Running
},
]
);
}
#[test]
fn a_boundary_without_counts_says_so_rather_than_inventing_them() {
let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
let events = translate_lines(
&mut translator,
&[
r#"{"type":"system","subtype":"compact_boundary","session_id":"s","compact_metadata":{"trigger":"auto"}}"#,
],
);
assert_eq!(
events,
vec![Event::Compacted {
pre_tokens: None,
post_tokens: None,
trigger: Some("auto".to_string()),
}]
);
}
#[test]
fn an_error_result_surfaces_the_message() {
let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
let events = translate_lines(
&mut translator,
&[
r#"{"type":"result","subtype":"error_during_execution","is_error":true,"result":"something broke","usage":{}}"#,
],
);
assert_eq!(
events[0],
Event::Error {
message: "something broke".to_string()
}
);
assert_eq!(
*events.last().unwrap(),
Event::Status {
state: SessionStatus::Idle
}
);
}
/// Running out of quota is a state, not a failure of the work.
///
/// The naive reading -- an error result like any other -- is what shipped
/// before this: the transcript said "Claude AI usage limit reached|…" in
/// red, which is neither readable nor actionable, and nothing above the
/// driver could tell it apart from a broken tool call.
#[test]
fn a_turn_stopped_by_the_usage_limit_says_so_and_carries_the_reset() {
let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
let events = translate_lines(
&mut translator,
&[
r#"{"type":"result","subtype":"error_during_execution","is_error":true,"result":"Claude AI usage limit reached|1788546972","usage":{}}"#,
],
);
assert_eq!(
events[0],
Event::LimitReached {
resets_at: Some(1_788_546_972.0)
}
);
}
#[test]
fn a_limit_the_cli_gave_no_reset_for_is_reported_without_one() {
let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
let events = translate_lines(
&mut translator,
&[
r#"{"type":"result","subtype":"error_during_execution","is_error":true,"result":"Claude AI usage limit reached","usage":{}}"#,
],
);
// Not a time this side invented: the meter is asked before anything is
// sent, and a made-up reset would only decide when to ask.
assert_eq!(events[0], Event::LimitReached { resets_at: None });
}
#[test]
fn a_reset_in_milliseconds_is_not_read_as_the_year_58000() {
assert_eq!(
usage_limit("Claude AI usage limit reached|1788546972000"),
Some(Some(1_788_546_972.0))
);
// And anything that is not the limit stays an ordinary failure.
assert_eq!(usage_limit("something broke"), None);
}
/// Pressing Stop is not a failure, and the CLI cannot tell you which it was.
///
/// An interrupted turn arrives as exactly the same shape a broken one does,
/// so somebody who pressed the button was shown "the turn ended with an
/// error". What separates the two is that this side asked. The second half
/// of this test is the one that matters, because the naive fix -- never
/// reporting an error result -- passes the first half and silences every
/// genuine failure afterwards.
#[test]
fn a_turn_stopped_on_purpose_is_not_an_error() {
let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
let stopped_result = r#"{"type":"result","subtype":"error_during_execution","is_error":true,"result":"Interrupted by user","usage":{}}"#;
translator.expect_interrupt();
let events = translate_lines(&mut translator, &[stopped_result]);
assert!(
!events
.iter()
.any(|event| matches!(event, Event::Error { .. })),
"a stop the driver asked for was reported as a failure: {events:?}"
);
assert_eq!(
*events.last().unwrap(),
Event::Status {
state: SessionStatus::Idle
},
"an interrupted turn still has to end the turn"
);
// The interrupt is spent, so the next failure is a failure again.
let later = translate_lines(&mut translator, &[stopped_result]);
assert!(
later
.iter()
.any(|event| matches!(event, Event::Error { .. })),
"a later failure was swallowed by an interrupt that had already been answered"
);
}
#[test]
fn replayed_and_synthetic_user_text_is_skipped() {
let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
let events = translate_lines(
&mut translator,
&[
r#"{"type":"user","message":{"role":"user","content":[{"type":"text","text":"hi"}]},"isReplay":true,"parent_tool_use_id":null}"#,
r#"{"type":"user","message":{"role":"user","content":[{"type":"text","text":"[continue]"}]},"isSynthetic":true,"parent_tool_use_id":null}"#,
],
);
assert!(events.is_empty());
}
}