Bryan reported no divider when clearing. It was not this code -- the backend serving him started at 17:06, three hours before `Event::Cleared` existed, so it has no such event to send and `/clear` reaches it as an unrecognised passthrough. Verified against a current build: the event is recorded. Probing the CLI to establish that turned up something better than what was here. `/clear` in stream-json mode emits a dedicated `conversation_reset` line and *then* a fresh `init` with the new session id -- so watching the id be replaced, which is what this did, was reading the event through one of its side effects. The announcement says it directly, and it arrives first, so the divider now lands above the new conversation rather than after its opening line. That also removes the reasoning the previous commit needed about which id changes count. There is one signal now instead of an inference with two exceptions, and the test that used to pin those exceptions became `an_init_alone_is_never_a_clear`, which covers all three ways an init arrives: a session's first, the one a compaction re-announces with the same id, and the one following a resume. The resume token still follows the id, unchanged -- one CLI event with two observable effects, and each half now reads the half it needs. Verified end to end against a real claude-cli session: message, /clear, message, and the transcript reads userMessage / assistantText / cleared / userMessage, in that order. 74 tests, clippy and rustfmt clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VETa8afmpWaYezLCqJhDB8
1137 lines
47 KiB
Rust
1137 lines
47 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 -- a new
|
|
//! message subtype, a field that changed shape -- and that is what the
|
|
//! tests at the bottom pin, replaying recorded lines. The driver half
|
|
//! moves when spawning, resuming or shutting down changes, and never
|
|
//! reads a line itself.
|
|
//!
|
|
//! The one side effect here is saving images a tool result carries into
|
|
//! the session directory (they would bloat the transcript as base64);
|
|
//! everything else is pure, which is what makes the mapping testable
|
|
//! without a process.
|
|
|
|
use std::collections::HashMap;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use serde_json::{Value, json};
|
|
|
|
use super::super::driver::{Event, QuestionOption, SessionStatus};
|
|
|
|
/// 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, and so does a
|
|
/// `system/status` line a moment later; both are handled where they
|
|
/// arrive, and this covers the one that says nothing.
|
|
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. The one
|
|
/// side effect is saving images a tool result carries into the session
|
|
/// dir (they'd bloat the transcript as base64); everything else is pure,
|
|
/// so the dialect mapping is unit-testable from recorded lines.
|
|
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>,
|
|
session_dir: PathBuf,
|
|
}
|
|
|
|
impl Translator {
|
|
pub(super) fn new(session_dir: PathBuf) -> Self {
|
|
Self {
|
|
session_id: None,
|
|
pending: HashMap::new(),
|
|
asked: HashMap::new(),
|
|
session_dir,
|
|
}
|
|
}
|
|
|
|
/// Remembers what a control request was for, so its answer can say so.
|
|
///
|
|
/// Called before the request goes out, not after: 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);
|
|
}
|
|
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.
|
|
if message
|
|
.get("parent_tool_use_id")
|
|
.is_some_and(|id| !id.is_null())
|
|
{
|
|
return Vec::new();
|
|
}
|
|
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` that carries the new
|
|
// session_id. Measured against 2.1.237 rather than inferred:
|
|
// this used to watch for the id being *replaced*, which is the
|
|
// same event seen through one of its side effects. Taking the
|
|
// announcement instead means the transcript's divider is the
|
|
// CLI saying "I did this", and it 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 here 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();
|
|
if message
|
|
.get("is_error")
|
|
.and_then(Value::as_bool)
|
|
.unwrap_or(false)
|
|
{
|
|
events.push(Event::Error {
|
|
message: message
|
|
.get("result")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("the turn ended with an error")
|
|
.to_string(),
|
|
});
|
|
}
|
|
if tokens > 0 {
|
|
events.push(Event::UsageDelta { tokens });
|
|
}
|
|
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 CLI 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` saying why -- 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 is what returns it to idle.
|
|
///
|
|
/// The keys are snake_case here and camelCase in the CLI's own
|
|
/// transcript file, which records the same events. Reading the shape
|
|
/// off that file -- the obvious place to find one, since it is on
|
|
/// disk -- 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 -- the manager drops a setting it is already in.
|
|
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. Whatever it was, the turn it happened inside is still going
|
|
/// when it ends -- the `result` has not arrived yet -- so leaving says
|
|
/// `Running`, which is also the only place in this file that does. A
|
|
/// state this build does not recognise is left alone rather than
|
|
/// mapped onto the nearest one we do.
|
|
fn translate_status(&self, message: &Value) -> Vec<Event> {
|
|
// A mode change the CLI has made, announced a moment after it
|
|
// answers the request that asked for it. 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, which would
|
|
// otherwise fall through to nothing.
|
|
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> {
|
|
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| Event::ToolStart {
|
|
id: block
|
|
.get("id")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or_default()
|
|
.to_string(),
|
|
tool: block
|
|
.get("name")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or_default()
|
|
.to_string(),
|
|
input: block.get("input").cloned().unwrap_or(Value::Null),
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
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 by comparing inputs.
|
|
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 in the
|
|
// app 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 for one event, and the call
|
|
// itself said nothing they could act on.
|
|
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 (the
|
|
/// phone fetches them from `/sessions/{id}/files/{ref}`). 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 nothing in this function marks one as read.
|
|
// 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 rather than merely arrive next to it.
|
|
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"),
|
|
});
|
|
}
|
|
events
|
|
}
|
|
}
|
|
|
|
/// 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, and a reader who can see a screenshot while it happens
|
|
/// should still see it after a restart. Two copies of this 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()
|
|
}
|
|
|
|
#[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());
|
|
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, and that is
|
|
// the name the phone should be showing.
|
|
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());
|
|
|
|
// 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, and
|
|
// the request is not the answer.
|
|
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());
|
|
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());
|
|
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());
|
|
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());
|
|
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());
|
|
}
|
|
|
|
#[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());
|
|
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());
|
|
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());
|
|
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,
|
|
// and no other provider could reach it.
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let mut translator = Translator::new(dir.path().to_path_buf());
|
|
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());
|
|
// 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());
|
|
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 },
|
|
Event::Status {
|
|
state: SessionStatus::Idle
|
|
},
|
|
]
|
|
);
|
|
}
|
|
|
|
#[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());
|
|
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());
|
|
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());
|
|
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());
|
|
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
|
|
}
|
|
);
|
|
}
|
|
|
|
#[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());
|
|
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());
|
|
}
|
|
}
|