Separate the Claude dialect from the Claude process
claude.rs held two things that change for unrelated reasons. One spawns the CLI, resumes it with --resume after a crash, writes lines to it and shuts it down; the other turns a stream-json line into common events. A CLI wire format change touches only the second, a change to how sessions are launched only the first, and at 830 lines a reader had to work out which half they were in. So the translator, the pending-request bookkeeping and the answer outcome move to session/claude/translate.rs, and all twelve tests go with them -- every one was already a translation test, replaying recorded lines with no process involved, which is the clearest evidence the seam was already there. 366 lines and 628, from 830 plus tests in one file. Pure code motion: no behaviour, no renames, and the only edits are the visibility the split makes necessary. The probing record stays in the driver file, since it is the provenance for both halves -- the flags are that file's, the message catalogue is what translate implements. Verified: 35 tests pass (the same 35), clippy clean, fmt clean, and cargo doc resolves with broken_intra_doc_links denied. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
This commit is contained in:
1 parent
585e4a0369
commit
4e760a4a72
2 files changed
+640
-609
No files matched your search
+12
-609
@@ -1,6 +1,15 @@
|
||||
//! The Claude Code driver: `claude -p` speaking stream-json on stdio,
|
||||
//! translated into the common event model.
|
||||
//!
|
||||
//! This half owns the process -- spawning it (locally or through `ssh`),
|
||||
//! resuming it after a crash, writing lines to it, and shutting it down.
|
||||
//! Turning a line into [`Event`]s is [`translate`], which changes when the
|
||||
//! CLI's wire format does rather than when any of the above does.
|
||||
//!
|
||||
//! The probing record below stays here, since it is the provenance for
|
||||
//! both halves: the flags are this file's, the message catalogue is what
|
||||
//! `translate` implements.
|
||||
//!
|
||||
//! Wire format pinned against CLI 2.1.237 by probing (2026-08-24; scripts
|
||||
//! summarized here since they live outside the repo):
|
||||
//!
|
||||
@@ -22,7 +31,6 @@
|
||||
//! - `control_request{subtype:set_model}` answers success;
|
||||
//! `{subtype:interrupt}` stops the turn.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
@@ -33,12 +41,15 @@ use tokio::sync::{mpsc, oneshot};
|
||||
|
||||
use super::driver::{Driver, Event, EventSink, ImageRef, SessionStatus};
|
||||
use crate::config::{HostConfig, ProviderConfig, SessionConfig};
|
||||
use translate::{AnswerOutcome, Translator};
|
||||
|
||||
/// Where the driver remembers its CLI session id between backend runs --
|
||||
/// the whole crash-recovery story: respawning with `--resume <id>` picks
|
||||
/// the conversation back up from Claude's own session files. Kept in the
|
||||
/// session directory rather than config.ron so the shared schema stays
|
||||
/// free of per-driver state.
|
||||
mod translate;
|
||||
|
||||
const RESUME_FILE: &str = "claude-session.json";
|
||||
|
||||
/// Grace period between closing stdin (the polite exit) and SIGKILL.
|
||||
@@ -362,611 +373,3 @@ fn attachment_block(session_dir: &Path, id: &str) -> Result<Value> {
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
/// What answering a question produced.
|
||||
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 `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.
|
||||
struct Translator {
|
||||
session_id: Option<String>,
|
||||
pending: HashMap<String, PendingRequest>,
|
||||
session_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl Translator {
|
||||
fn new(session_dir: PathBuf) -> Self {
|
||||
Self {
|
||||
session_id: None,
|
||||
pending: HashMap::new(),
|
||||
session_dir,
|
||||
}
|
||||
}
|
||||
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") => {
|
||||
if message.get("subtype").and_then(Value::as_str) == Some("init")
|
||||
&& let Some(id) = message.get("session_id").and_then(Value::as_str)
|
||||
{
|
||||
self.session_id = Some(id.to_string());
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
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"];
|
||||
if response.get("subtype").and_then(Value::as_str) == Some("error") {
|
||||
let error = response
|
||||
.get("error")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("unknown");
|
||||
vec![Event::Error {
|
||||
message: format!("claude rejected a request: {error}"),
|
||||
}]
|
||||
} else {
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
|
||||
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();
|
||||
let options = question
|
||||
.get("options")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|option| option.get("label").and_then(Value::as_str))
|
||||
.map(String::from)
|
||||
.collect();
|
||||
events.push(Event::Question {
|
||||
id: format!("{request_id}#{i}"),
|
||||
prompt: text.clone(),
|
||||
options,
|
||||
});
|
||||
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}"),
|
||||
options: vec!["Allow".to_string(), "Deny".to_string()],
|
||||
});
|
||||
}
|
||||
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.
|
||||
fn answer(&mut self, question_id: &str, answer: &str) -> AnswerOutcome {
|
||||
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> {
|
||||
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();
|
||||
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) = self.save_image(part) {
|
||||
events.push(Event::Image { image: name });
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
events.push(Event::ToolEnd {
|
||||
id: block
|
||||
.get("tool_use_id")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
output: texts.join("\n"),
|
||||
});
|
||||
}
|
||||
events
|
||||
}
|
||||
|
||||
/// Decodes one base64 image block into `files/` and returns its ref.
|
||||
fn save_image(&self, 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::random_hex());
|
||||
let dir = self.session_dir.join("files");
|
||||
if let Err(err) = crate::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::*;
|
||||
|
||||
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_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"}"#,
|
||||
],
|
||||
);
|
||||
assert!(events.is_empty());
|
||||
assert_eq!(translator.session_id.as_deref(), Some("5ecf21da-d53f"));
|
||||
}
|
||||
|
||||
#[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,
|
||||
} = &events[0]
|
||||
else {
|
||||
panic!("expected a question, got {events:?}");
|
||||
};
|
||||
assert_eq!(id, "req-1");
|
||||
assert!(prompt.contains("Bash") && prompt.contains("rm -rf /tmp/x"));
|
||||
assert_eq!(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", "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", "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", "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,
|
||||
} => Some((id.clone(), prompt.clone(), options.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(questions.len(), 2);
|
||||
assert_eq!(questions[0].0, "req-3#0");
|
||||
assert_eq!(questions[0].1, "Which color?");
|
||||
assert_eq!(questions[0].2, vec!["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", "Blue"),
|
||||
AnswerOutcome::Pending
|
||||
));
|
||||
let AnswerOutcome::Respond(response) = translator.answer("req-3#1", "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 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 } = &events[0] else {
|
||||
panic!("expected an image event, got {events:?}");
|
||||
};
|
||||
assert!(image.ends_with(".png"));
|
||||
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 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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user