Take rustfmt's defaults

The code was hand-formatted -- close to rustfmt's output but not it, mostly
in keeping chains and call arguments on one line where the formatter would
break them. That is a per-line decision every future change has to make
again, and reproducing it would mean a config whose only job is to preserve
how the code already looks.

So this is `cargo fmt` at its defaults, with no rustfmt.toml, which is
where the sibling dev-updater checkout already sits: it is clean at the
defaults today, so the two repos now agree on layout without either of them
configuring it.

Formatting only -- no behaviour, no renames, nothing reordered. Verified
after: cargo test (35 pass), cargo clippy --all-targets clean, cargo fmt
--check clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
This commit is contained in:
irisandClaude Opus 5 committed 2026-08-28 03:13:36 -04:00
1 parent f014094fcd
commit c12ab7f098
13 files changed
+557 -190

No files matched your search

+225 -83
View File
@@ -173,11 +173,17 @@ impl ClaudeDriver {
let _ = sink.send(Event::Error {
message: format!(
"{label} exited with {status}{}",
if detail.is_empty() { String::new() } else { format!(": {detail}") }
if detail.is_empty() {
String::new()
} else {
format!(": {detail}")
}
),
});
}
let _ = sink.send(Event::Status { state: SessionStatus::Exited });
let _ = sink.send(Event::Status {
state: SessionStatus::Exited,
});
});
}
@@ -222,7 +228,9 @@ impl Driver for ClaudeDriver {
}
// Sent mid-turn this queues for injection at the next tool
// boundary; sent while idle it starts a turn.
let _ = self.sink.send(Event::Status { state: SessionStatus::Running });
let _ = self.sink.send(Event::Status {
state: SessionStatus::Running,
});
self.send_line(
json!({"type": "user", "message": {"role": "user", "content": content}}).to_string(),
);
@@ -235,7 +243,9 @@ impl Driver for ClaudeDriver {
};
match response {
AnswerOutcome::Respond(control_response) => {
let _ = self.sink.send(Event::Status { state: SessionStatus::Running });
let _ = self.sink.send(Event::Status {
state: SessionStatus::Running,
});
self.send_line(control_response.to_string());
}
// A multi-question AskUserQuestion still waiting on the rest.
@@ -288,7 +298,10 @@ async fn read_stdout(
let mut lines = BufReader::new(stdout).lines();
while let Ok(Some(line)) = lines.next_line().await {
let Ok(message) = serde_json::from_str::<Value>(&line) else {
tracing::warn!("unparseable claude output line: {}", &line[..line.len().min(200)]);
tracing::warn!(
"unparseable claude output line: {}",
&line[..line.len().min(200)]
);
continue;
};
let (events, new_session_id) = {
@@ -329,7 +342,10 @@ fn write_resume_token(session_dir: &Path, session_id: &str) {
fn attachment_block(session_dir: &Path, id: &str) -> Result<Value> {
// Ids are server-generated hex (see routes::upload_attachment); the
// check keeps a crafted "id" from naming an arbitrary file.
if !id.chars().all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-') {
if !id
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-')
{
anyhow::bail!("invalid attachment id");
}
let path = session_dir.join("attachments").join(id);
@@ -380,13 +396,20 @@ struct Translator {
impl Translator {
fn new(session_dir: PathBuf) -> Self {
Self { session_id: None, pending: HashMap::new(), session_dir }
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()) {
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) {
@@ -405,18 +428,33 @@ impl Translator {
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}") }]
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 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) {
if message
.get("is_error")
.and_then(Value::as_bool)
.unwrap_or(false)
{
events.push(Event::Error {
message: message
.get("result")
@@ -428,7 +466,9 @@ impl Translator {
if tokens > 0 {
events.push(Event::UsageDelta { tokens });
}
events.push(Event::Status { state: SessionStatus::Idle });
events.push(Event::Status {
state: SessionStatus::Idle,
});
events
}
_ => Vec::new(),
@@ -444,7 +484,9 @@ impl Translator {
&& 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() }];
return vec![Event::AssistantText {
delta: text.to_string(),
}];
}
Vec::new()
}
@@ -457,8 +499,16 @@ impl Translator {
.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(),
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()
@@ -469,9 +519,15 @@ impl Translator {
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 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();
@@ -515,9 +571,16 @@ impl Translator {
}
self.pending.insert(
request_id.clone(),
PendingRequest { request_id, input, questions, answers: HashMap::new() },
PendingRequest {
request_id,
input,
questions,
answers: HashMap::new(),
},
);
events.push(Event::Status { state: SessionStatus::AwaitingInput });
events.push(Event::Status {
state: SessionStatus::AwaitingInput,
});
events
}
@@ -610,7 +673,9 @@ impl Translator {
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()?;
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
@@ -620,10 +685,9 @@ impl Translator {
.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))
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;
@@ -649,7 +713,9 @@ mod tests {
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"}"#],
&[
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"));
@@ -660,39 +726,59 @@ mod tests {
// 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() }]);
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() },
]);
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"}"#,
]);
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());
}
@@ -700,16 +786,29 @@ mod tests {
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 {
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 });
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 {
@@ -721,16 +820,22 @@ mod tests {
response["response"]["response"]["updatedInput"]["command"],
"rm -rf /tmp/x"
);
assert!(matches!(translator.answer("req-1", "Allow"), AnswerOutcome::Unknown));
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"}}}"#,
]);
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");
};
@@ -743,13 +848,20 @@ mod tests {
// 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 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())),
Event::Question {
id,
prompt,
options,
} => Some((id.clone(), prompt.clone(), options.clone())),
_ => None,
})
.collect();
@@ -760,7 +872,10 @@ mod tests {
// 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));
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");
};
@@ -787,44 +902,71 @@ mod tests {
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(),
});
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 },
]);
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 });
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}"#,
]);
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());
}
}
+27 -8
View File
@@ -26,17 +26,27 @@ pub enum Event {
/// What the user sent, echoed into the transcript by the manager (not
/// by drivers) so every device renders the full conversation from the
/// one stream.
UserMessage { text: String },
UserMessage {
text: String,
},
/// Streaming assistant text; the phone renders the concatenation as
/// markdown.
AssistantText { delta: String },
AssistantText {
delta: String,
},
ToolStart {
id: String,
tool: String,
input: serde_json::Value,
},
ToolUpdate { id: String, output: String },
ToolEnd { id: String, output: String },
ToolUpdate {
id: String,
output: String,
},
ToolEnd {
id: String,
output: String,
},
/// An image the session produced or was sent, saved under the session
/// dir and referenced by id; the phone fetches it by URL.
Image {
@@ -53,11 +63,20 @@ pub enum Event {
/// The manager's record of a question being answered, so a rendered
/// question card resolves on every device, not just the one that
/// answered it.
Answered { id: String, answer: String },
Status { state: SessionStatus },
Answered {
id: String,
answer: String,
},
Status {
state: SessionStatus,
},
/// Per-turn token counts, where the dialect reports them.
UsageDelta { tokens: u64 },
Error { message: String },
UsageDelta {
tokens: u64,
},
Error {
message: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+45 -14
View File
@@ -28,8 +28,13 @@ pub struct EchoDriver {
impl EchoDriver {
pub fn new(sink: EventSink) -> Self {
let driver = Self { sink, pending_question: Mutex::new(None) };
driver.emit(Event::Status { state: SessionStatus::Idle });
let driver = Self {
sink,
pending_question: Mutex::new(None),
};
driver.emit(Event::Status {
state: SessionStatus::Idle,
});
driver
}
@@ -53,22 +58,30 @@ impl Driver for EchoDriver {
format!("Echo asks: {}", rest.trim())
};
*self.pending_question.lock().unwrap() = Some(id.clone());
self.emit(Event::Status { state: SessionStatus::Running });
self.emit(Event::Status {
state: SessionStatus::Running,
});
self.emit(Event::Question {
id,
prompt,
options: vec!["Yes".to_string(), "No".to_string()],
});
self.emit(Event::Status { state: SessionStatus::AwaitingInput });
self.emit(Event::Status {
state: SessionStatus::AwaitingInput,
});
return;
}
let run_tool = text.strip_prefix("/tool").map(|rest| rest.trim().to_string());
let run_tool = text
.strip_prefix("/tool")
.map(|rest| rest.trim().to_string());
tokio::spawn(async move {
let send = |event: Event| {
let _ = sink.send(event);
};
send(Event::Status { state: SessionStatus::Running });
send(Event::Status {
state: SessionStatus::Running,
});
if let Some(input) = run_tool {
let id = format!("t-{}", super::random_hex());
@@ -78,18 +91,30 @@ impl Driver for EchoDriver {
input: serde_json::json!({ "input": input }),
});
tokio::time::sleep(DELTA_DELAY).await;
send(Event::ToolUpdate { id: id.clone(), output: "working...".to_string() });
send(Event::ToolUpdate {
id: id.clone(),
output: "working...".to_string(),
});
tokio::time::sleep(DELTA_DELAY).await;
send(Event::ToolEnd { id, output: format!("echoed: {input}") });
send(Event::ToolEnd {
id,
output: format!("echoed: {input}"),
});
}
// Word-at-a-time so streaming is visibly streaming.
for word in format!("You said: {text}").split_inclusive(' ') {
send(Event::AssistantText { delta: word.to_string() });
send(Event::AssistantText {
delta: word.to_string(),
});
tokio::time::sleep(DELTA_DELAY).await;
}
send(Event::UsageDelta { tokens: text.split_whitespace().count() as u64 });
send(Event::Status { state: SessionStatus::Idle });
send(Event::UsageDelta {
tokens: text.split_whitespace().count() as u64,
});
send(Event::Status {
state: SessionStatus::Idle,
});
});
}
@@ -101,7 +126,9 @@ impl Driver for EchoDriver {
self.emit(Event::AssistantText {
delta: format!("You answered: {answer}"),
});
self.emit(Event::Status { state: SessionStatus::Idle });
self.emit(Event::Status {
state: SessionStatus::Idle,
});
}
_ => self.emit(Event::Error {
message: format!("no question {id} is awaiting an answer"),
@@ -113,7 +140,9 @@ impl Driver for EchoDriver {
// Nothing real to stop; a pending question is abandoned so the
// session isn't stuck awaiting input forever.
*self.pending_question.lock().unwrap() = None;
self.emit(Event::Status { state: SessionStatus::Idle });
self.emit(Event::Status {
state: SessionStatus::Idle,
});
}
fn set_model(&self, model: &str) {
@@ -129,6 +158,8 @@ impl Driver for EchoDriver {
}
fn shutdown(&self) {
self.emit(Event::Status { state: SessionStatus::Exited });
self.emit(Event::Status {
state: SessionStatus::Exited,
});
}
}
+47 -18
View File
@@ -35,7 +35,10 @@ use transcript::{SeqEvent, Transcript};
const EVENT_BUFFER: usize = 256;
pub fn now() -> f64 {
SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs_f64()
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs_f64()
}
/// What the phone needs to spawn a session -- the spawn screen's fields.
@@ -99,7 +102,9 @@ impl LiveSession {
// Attachments render in the transcript like any produced image --
// the files route serves uploads by the same ref.
for image in &images {
let _ = self.sink.send(Event::Image { image: image.clone() });
let _ = self.sink.send(Event::Image {
image: image.clone(),
});
}
let _ = self.sink.send(Event::UserMessage { text: text.clone() });
self.driver.send_user_message(text, images);
@@ -132,7 +137,9 @@ impl LiveSession {
/// The session's directory (attachments in, produced files out live in
/// `attachments/` and `files/` under it).
pub fn dir(&self) -> &Path {
self.transcript_path.parent().expect("transcript lives in the session dir")
self.transcript_path
.parent()
.expect("transcript lives in the session dir")
}
/// Stores one uploaded attachment, returning the id `POST /message`
@@ -194,9 +201,9 @@ impl SessionManager {
// unreachable ssh host, a provider that was edited away --
// shows as exited rather than taking the whole server down
// with it, and can still be deleted from the phone.
match resolve(&config, meta)
.and_then(|(provider, host)| launch(meta.clone(), &provider, host.as_ref(), &data_dir))
{
match resolve(&config, meta).and_then(|(provider, host)| {
launch(meta.clone(), &provider, host.as_ref(), &data_dir)
}) {
Ok(session) => {
live.insert(meta.id.clone(), session);
}
@@ -462,12 +469,21 @@ fn launch(
let driver: Box<dyn Driver> = match provider.kind {
DriverKind::Echo => Box::new(EchoDriver::new(sink.clone())),
DriverKind::ClaudeCli => {
Box::new(ClaudeDriver::spawn(&meta, provider, host, &dir, sink.clone())?)
}
DriverKind::ClaudeCli => Box::new(ClaudeDriver::spawn(
&meta,
provider,
host,
&dir,
sink.clone(),
)?),
};
tokio::spawn(pump(transcript, source, Arc::clone(&shared), events.clone()));
tokio::spawn(pump(
transcript,
source,
Arc::clone(&shared),
events.clone(),
));
Ok(Arc::new(LiveSession {
meta,
@@ -547,7 +563,12 @@ mod tests {
}
fn is_idle(event: &Event) -> bool {
matches!(event, Event::Status { state: SessionStatus::Idle })
matches!(
event,
Event::Status {
state: SessionStatus::Idle
}
)
}
/// Collects one full echo turn: everything up to the idle that follows
@@ -611,25 +632,33 @@ mod tests {
assert!(manager.sessions().is_empty());
assert!(manager.session(&info.id).is_none());
assert!(!data_dir.join(&info.id).exists());
assert!(Config::load(&config_path).expect("reload").sessions.is_empty());
assert!(
Config::load(&config_path)
.expect("reload")
.sessions
.is_empty()
);
assert!(manager.delete_session(&info.id).is_err());
}
#[tokio::test]
async fn questions_round_trip_through_answer() {
let dir = tempfile::tempdir().expect("tempdir");
let manager = SessionManager::new(
dir.path().join("config.ron"),
dir.path().join("sessions"),
)
.expect("manager");
let manager =
SessionManager::new(dir.path().join("config.ron"), dir.path().join("sessions"))
.expect("manager");
let info = manager.spawn_session(echo_spec()).expect("spawn");
let session = manager.session(&info.id).expect("live session");
let mut rx = session.subscribe();
session.send_message("/question deploy?".to_string(), Vec::new());
let seen = collect_until(&mut rx, |event| {
matches!(event, Event::Status { state: SessionStatus::AwaitingInput })
matches!(
event,
Event::Status {
state: SessionStatus::AwaitingInput
}
)
})
.await;
let question_id = seen
+45 -12
View File
@@ -45,17 +45,26 @@ impl Transcript {
.mode(0o600)
.open(path)
.with_context(|| format!("open transcript {}", path.display()))?;
Ok(Self { file, next_seq: last_seq + 1 })
Ok(Self {
file,
next_seq: last_seq + 1,
})
}
/// Appends `event`, assigning it the next sequence number. Flushed per
/// event: each line is tiny, and the transcript is the source of truth
/// a crash must not lose the tail of.
pub fn append(&mut self, event: Event, ts: f64) -> Result<SeqEvent> {
let entry = SeqEvent { seq: self.next_seq, ts, event };
let entry = SeqEvent {
seq: self.next_seq,
ts,
event,
};
let mut line = serde_json::to_string(&entry).context("serialize event")?;
line.push('\n');
self.file.write_all(line.as_bytes()).context("append to transcript")?;
self.file
.write_all(line.as_bytes())
.context("append to transcript")?;
self.next_seq += 1;
Ok(entry)
}
@@ -86,7 +95,10 @@ pub fn read_after(path: &Path, after: u64) -> Result<Vec<SeqEvent>> {
}
fn last_seq(path: &Path) -> Result<u64> {
Ok(read_after(path, 0)?.last().map(|entry| entry.seq).unwrap_or(0))
Ok(read_after(path, 0)?
.last()
.map(|entry| entry.seq)
.unwrap_or(0))
}
#[cfg(test)]
@@ -95,7 +107,9 @@ mod tests {
use crate::session::driver::SessionStatus;
fn text(delta: &str) -> Event {
Event::AssistantText { delta: delta.to_string() }
Event::AssistantText {
delta: delta.to_string(),
}
}
#[test]
@@ -135,7 +149,11 @@ mod tests {
#[test]
fn a_missing_file_reads_as_empty() {
let dir = tempfile::tempdir().expect("tempdir");
assert!(read_after(&dir.path().join("nope.jsonl"), 0).expect("read").is_empty());
assert!(
read_after(&dir.path().join("nope.jsonl"), 0)
.expect("read")
.is_empty()
);
}
#[test]
@@ -150,18 +168,33 @@ mod tests {
tool: "bash".into(),
input: serde_json::json!({"command": "ls"}),
},
Event::ToolUpdate { id: "t1".into(), output: "partial".into() },
Event::ToolEnd { id: "t1".into(), output: "done".into() },
Event::Image { image: "img1".into() },
Event::ToolUpdate {
id: "t1".into(),
output: "partial".into(),
},
Event::ToolEnd {
id: "t1".into(),
output: "done".into(),
},
Event::Image {
image: "img1".into(),
},
Event::Question {
id: "q1".into(),
prompt: "Allow?".into(),
options: vec!["Yes".into(), "No".into()],
},
Event::Answered { id: "q1".into(), answer: "Yes".into() },
Event::Status { state: SessionStatus::Idle },
Event::Answered {
id: "q1".into(),
answer: "Yes".into(),
},
Event::Status {
state: SessionStatus::Idle,
},
Event::UsageDelta { tokens: 42 },
Event::Error { message: "boom".into() },
Event::Error {
message: "boom".into(),
},
];
let mut transcript = Transcript::open(&path).expect("open");