//! 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, answers: HashMap, } /// 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, pending: HashMap, /// 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, 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 { // 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 { 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 { // 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 { 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 { 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 { 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::().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 { // 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 { 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 { 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 { 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 { 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()); } }