Condense the documentation and thin the server's comments
The markdown had accumulated a lot that was stale rather than wrong. PLAN.md still described pi as the llama.cpp harness, a refcounted LlamaServerManager, and a providers-by-hosts cross-product, all of which were superseded or never built; it also carried a second copy of the HTTP table that routes.rs owns. EXPLORER.md and TRANSCRIPT_CACHE.md held implementation checklists for work that has since landed. AGENTS.md restated most of PLAN.md's design instead of being the working-notes layer it says it is. 3225 lines of markdown to 2180, with the stale sections gone rather than reworded. On the server, comments explaining what the code already says are out and the ones recording a constraint, a measurement or an incident are kept but cut to a few lines each: 5504 comment lines to 4586. Four doc comments in session/mod.rs, and one each in process.rs and usage.rs, had drifted onto the item above the one they describe -- functions were reordered without them, so `stop_session`'s doc sat on `set_session_cwd`, `stat_of`'s on `struct Stat`, and `UsageMonitor`'s on `type Cached`. Each is back on its own item. routes.rs's module table also claimed later phases would add `/hosts`, which setups replaced. cargo test (127 passed), clippy --all-targets and fmt are clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e3e02d55f7
commit
79682f03a7
24 files changed
+4572
-6821
No files matched your search
@@ -1,16 +1,13 @@
|
||||
//! 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.
|
||||
//! reasons. This half moves when the CLI's wire format does, which is what the
|
||||
//! tests at the bottom pin by replaying recorded lines; the driver half moves
|
||||
//! when spawning, resuming or shutting down changes.
|
||||
//!
|
||||
//! The one side effect here is saving images a tool result carries into
|
||||
//! the session directory (they would bloat the transcript as base64);
|
||||
//! everything else is pure, which is what makes the mapping testable
|
||||
//! without a process.
|
||||
//! The one side effect here is saving images a tool result carries into the
|
||||
//! session directory; everything else is pure, which is what makes the mapping
|
||||
//! testable without a process.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -21,17 +18,14 @@ use super::super::driver::{Event, QuestionOption, SessionStatus, context_tokens}
|
||||
|
||||
/// Whether this line is the CLI opening a fresh model call.
|
||||
///
|
||||
/// `message_start` begins one assistant message, and the CLI sends the
|
||||
/// previous call's tool results back before it opens the next -- so this
|
||||
/// is the first moment at which anything written since the last one can
|
||||
/// have been read. Nothing earlier will do: the text deltas and the
|
||||
/// `tool_use` block of a message *already in flight* keep arriving after
|
||||
/// a steer is written, and none of them saw it.
|
||||
/// `message_start` begins one assistant message, and the CLI sends the previous
|
||||
/// call's tool results back before opening the next -- so this is the first
|
||||
/// moment at which anything written since the last one can have been read.
|
||||
/// Nothing earlier will do: the deltas and `tool_use` of a message *already in
|
||||
/// flight* keep arriving after a steer is written, and none of them saw it.
|
||||
///
|
||||
/// Only present because the driver passes `--include-partial-messages`.
|
||||
/// Without it there are no `stream_event` lines at all and this is never
|
||||
/// true, which is why the caller keeps a fallback that does not depend on
|
||||
/// it.
|
||||
/// Only present because the driver passes `--include-partial-messages`, which
|
||||
/// is why the caller keeps a fallback that does not depend on it.
|
||||
pub(super) fn starts_a_model_call(message: &Value) -> bool {
|
||||
message.get("type").and_then(Value::as_str) == Some("stream_event")
|
||||
&& message["event"].get("type").and_then(Value::as_str) == Some("message_start")
|
||||
@@ -46,71 +40,56 @@ pub(super) enum AnswerOutcome {
|
||||
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
|
||||
/// 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.
|
||||
/// `set_permission_mode` does echo its mode back.
|
||||
pub(super) enum Setting {
|
||||
Model(String),
|
||||
PermissionMode(String),
|
||||
}
|
||||
|
||||
/// A `can_use_tool` request we've surfaced to the phone and not yet
|
||||
/// answered. For plain permissions there is one implicit question
|
||||
/// (Allow/Deny); for AskUserQuestion, one per entry in `questions`.
|
||||
/// 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.
|
||||
/// 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.
|
||||
/// Translation state: stream-json lines in, common events out.
|
||||
pub(super) struct Translator {
|
||||
pub(super) session_id: Option<String>,
|
||||
pending: HashMap<String, PendingRequest>,
|
||||
/// Settings asked for and not yet answered, by request id. Its path
|
||||
/// out is the response: every entry is removed when one arrives,
|
||||
/// whether it succeeded or failed.
|
||||
/// Settings asked for and not yet answered, by request id. Its path out is
|
||||
/// the response: every entry is removed when one arrives, whether it
|
||||
/// succeeded or failed.
|
||||
asked: HashMap<String, Setting>,
|
||||
/// Whether this side asked the turn to stop.
|
||||
///
|
||||
/// The CLI reports an interrupted turn the same way it reports one that
|
||||
/// broke -- a `result` with `is_error` set -- so the line itself cannot
|
||||
/// tell them apart, and a person who pressed Stop was shown "the turn
|
||||
/// ended with an error" for doing exactly what the button says. What
|
||||
/// separates them is not in the message at all: it is that *we* asked.
|
||||
/// So the driver says so before the request goes out, the same way it
|
||||
/// does for a setting, and this remembers it until the result lands.
|
||||
/// broke -- a `result` with `is_error` set -- so the line cannot tell them
|
||||
/// apart, and somebody who pressed Stop was shown "the turn ended with an
|
||||
/// error". What separates them is that *we* asked.
|
||||
///
|
||||
/// Its path out is that result -- set by `expect_interrupt`, cleared by
|
||||
/// the next `result` whichever way it went, so a genuine failure in a
|
||||
/// later turn is still reported.
|
||||
/// Its path out is that result, so a genuine failure in a later turn is
|
||||
/// still reported.
|
||||
interrupting: bool,
|
||||
/// The input side of the newest assistant message, waiting for the
|
||||
/// `result` that ends the turn to carry it out.
|
||||
/// The input side of the newest assistant message, waiting for the `result`
|
||||
/// that ends the turn to carry it out.
|
||||
///
|
||||
/// Read from the assistant message rather than from the result's own
|
||||
/// usage, which is the whole turn added up: measured on 2026-08-30
|
||||
/// against CLI 2.1.237, a two-message turn reported
|
||||
/// `cache_read_input_tokens` of 40,211 in its result, being 14,259 and
|
||||
/// 25,952 from the two messages -- the same conversation counted
|
||||
/// twice. The model never held 40,211; it held 26,131, which is the
|
||||
/// last message's three input figures. A turn with ten tool calls
|
||||
/// would overstate it tenfold.
|
||||
/// Read from the assistant message rather than the result's own usage,
|
||||
/// which is the whole turn added up: measured on 2026-08-30 against 2.1.237,
|
||||
/// a two-message turn reported `cache_read_input_tokens` of 40,211, being
|
||||
/// 14,259 and 25,952 -- the same conversation counted twice. The model held
|
||||
/// 26,131. A turn with ten tool calls would overstate it tenfold.
|
||||
///
|
||||
/// Its path out is that result, which takes it -- so a turn whose
|
||||
/// messages carried no usage reports none rather than repeating the
|
||||
/// previous turn's.
|
||||
/// Its path out is that result, so a turn whose messages carried no usage
|
||||
/// reports none rather than repeating the previous turn's.
|
||||
context: Option<u64>,
|
||||
session_dir: PathBuf,
|
||||
}
|
||||
@@ -128,17 +107,15 @@ impl Translator {
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Called before the request goes out: the reader thread is already running
|
||||
/// and a fast CLI can answer before this side gets back to it.
|
||||
pub(super) fn expect_setting(&mut self, request_id: String, setting: Setting) {
|
||||
self.asked.insert(request_id, setting);
|
||||
}
|
||||
|
||||
/// Says that the turn about to end was stopped on purpose -- see
|
||||
/// [`Translator::interrupting`]. Called before the request goes out,
|
||||
/// for the reason [`Translator::expect_setting`] gives.
|
||||
/// Says that the turn about to end was stopped on purpose. Called before
|
||||
/// the request goes out, for the reason [`Translator::expect_setting`]
|
||||
/// gives.
|
||||
pub(super) fn expect_interrupt(&mut self) {
|
||||
self.interrupting = true;
|
||||
}
|
||||
@@ -154,14 +131,11 @@ impl Translator {
|
||||
}
|
||||
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.
|
||||
// The CLI's own announcement that `/clear` took effect, sent just
|
||||
// before the fresh `init` carrying the new session_id. Measured
|
||||
// against 2.1.237: this used to watch for the id being *replaced*,
|
||||
// which is the same event seen through a side effect. The
|
||||
// announcement lands before the new init rather than after it.
|
||||
Some("conversation_reset") => vec![Event::Cleared],
|
||||
Some("stream_event") => self.translate_stream_event(&message["event"]),
|
||||
Some("assistant") => self.translate_assistant(&message["message"]),
|
||||
@@ -170,8 +144,8 @@ impl Translator {
|
||||
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.
|
||||
// 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)
|
||||
@@ -185,9 +159,9 @@ impl Translator {
|
||||
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.
|
||||
// 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),
|
||||
@@ -195,11 +169,10 @@ impl Translator {
|
||||
}],
|
||||
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.
|
||||
// The CLI echoes this one, and its answer wins: `auto`
|
||||
// and `manual` are names it accepts on the way in and
|
||||
// reports back under another name, so repeating the
|
||||
// request would show a mode the session is not in.
|
||||
permission_mode: Some(
|
||||
response["response"]["mode"]
|
||||
.as_str()
|
||||
@@ -223,28 +196,22 @@ impl Translator {
|
||||
let mut events = Vec::new();
|
||||
// A turn another agent started, which is only knowable here.
|
||||
//
|
||||
// Measured against CLI 2.1.237 (2026-08-31) by sending a
|
||||
// real cross-session message to a real stream-json session:
|
||||
// the CLI emits no `user` record for it, and nothing in the
|
||||
// partial-message stream mentions it either. The whole of
|
||||
// it arrives as an `origin` object on the turn's `result`,
|
||||
// in the same shape the session file records -- so this is
|
||||
// `import::peer_message` reading a different record.
|
||||
// Measured against 2.1.237 (2026-08-31) by sending a real
|
||||
// cross-session message to a real stream-json session: the CLI
|
||||
// emits no `user` record for it and nothing in the
|
||||
// partial-message stream mentions it. The whole of it arrives as
|
||||
// an `origin` object on the turn's `result`, in the same shape
|
||||
// the session file records -- so this is `import::peer_message`
|
||||
// reading a different record.
|
||||
//
|
||||
// The cost is the position: the note lands after the reply
|
||||
// it caused rather than above it, because at no earlier
|
||||
// point in the turn does the CLI say why the turn started.
|
||||
// Taken deliberately over the alternative, which is a
|
||||
// second reader tailing the CLI's own session file for the
|
||||
// one record stdout does not carry -- two sources of truth
|
||||
// for one conversation, and a poll per live session. What
|
||||
// it buys is the thing that was missing entirely: a session
|
||||
// that starts working on something nobody on this phone
|
||||
// asked for is otherwise unexplainable from the phone.
|
||||
// The cost is the position: the note lands after the reply it
|
||||
// caused, because at no earlier point does the CLI say why the
|
||||
// turn started. Taken deliberately over a second reader tailing
|
||||
// the CLI's own session file, which is two sources of truth for
|
||||
// one conversation and a poll per live session.
|
||||
//
|
||||
// Only peer-caused turns carry it: measured over a real
|
||||
// session's stdout, four ordinary results and no `origin`
|
||||
// between them.
|
||||
// Only peer-caused turns carry it: four ordinary results over a
|
||||
// real session's stdout had no `origin` between them.
|
||||
if let Some(peer) = crate::session::import::peer_message(message) {
|
||||
events.push(peer);
|
||||
}
|
||||
@@ -278,37 +245,35 @@ impl Translator {
|
||||
}
|
||||
}
|
||||
|
||||
/// The CLI's own notices: which session this is, and what it is doing
|
||||
/// that is not a turn.
|
||||
/// 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:
|
||||
/// Compaction is the whole of that second kind, and it is announced rather
|
||||
/// than inferred. Measured against 2.1.237 (2026-08-29) by driving a session
|
||||
/// through `/compact`, one produces in order:
|
||||
///
|
||||
/// - `{"subtype":"status","status":"compacting"}` -- the start;
|
||||
/// - `{"subtype":"status","status":null,"compact_result":"success"}`,
|
||||
/// or `"failed"` with a `compact_error` saying why -- the end;
|
||||
/// - `{"subtype":"status","status":null,"compact_result":"success"}`, or
|
||||
/// `"failed"` with a `compact_error` -- the end;
|
||||
/// - a fresh `init` carrying the same `session_id`;
|
||||
/// - `{"subtype":"compact_boundary","compact_metadata":{…}}` with the
|
||||
/// token counts, and only when it succeeded;
|
||||
/// - the turn's ordinary `result`, which is what returns it to idle.
|
||||
/// - `{"subtype":"compact_boundary","compact_metadata":{…}}` with the token
|
||||
/// counts, and only when it succeeded;
|
||||
/// - the turn's ordinary `result`, which returns it to idle.
|
||||
///
|
||||
/// The keys are snake_case here and camelCase in the CLI's own
|
||||
/// transcript file, which records the same events. 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.
|
||||
/// The keys are snake_case here and camelCase in the CLI's own transcript
|
||||
/// file, which records the same events -- so reading the shape off that
|
||||
/// file, the obvious place to look, gets every field name wrong and
|
||||
/// silently yields a compaction with no numbers in it.
|
||||
fn translate_system(&mut self, message: &Value) -> Vec<Event> {
|
||||
match message.get("subtype").and_then(Value::as_str) {
|
||||
Some("init") => {
|
||||
if let Some(id) = message.get("session_id").and_then(Value::as_str) {
|
||||
self.session_id = Some(id.to_string());
|
||||
}
|
||||
// The CLI's own account of what it is set to, and the only
|
||||
// one that resolves an alias: a session launched with
|
||||
// `--model haiku` reports `claude-haiku-4-5-20251001`
|
||||
// here. It arrives again after a compaction, which is
|
||||
// free -- the manager drops a setting it is already in.
|
||||
// The CLI's own account of what it is set to, and the only one
|
||||
// that resolves an alias: a session launched with
|
||||
// `--model haiku` reports `claude-haiku-4-5-20251001` here. It
|
||||
// arrives again after a compaction, which is free.
|
||||
vec![Event::Settings {
|
||||
model: message
|
||||
.get("model")
|
||||
@@ -336,22 +301,19 @@ impl Translator {
|
||||
}
|
||||
}
|
||||
|
||||
/// A `system/status` line: the CLI entering or leaving a state that is
|
||||
/// not a turn.
|
||||
/// 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.
|
||||
/// A null `status` is the leaving edge, and it carries how the thing went.
|
||||
/// The turn it happened inside is still going when it ends -- the `result`
|
||||
/// has not arrived -- so leaving says `Running`. A state this build does
|
||||
/// not recognise is left alone rather than mapped onto the nearest one.
|
||||
fn translate_status(&self, message: &Value) -> Vec<Event> {
|
||||
// A mode change the CLI has made, announced a moment after it
|
||||
// answers the request 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.
|
||||
// A mode change the CLI has made, announced a moment after it answers
|
||||
// the request. Measured on 2.1.237:
|
||||
// `{"subtype":"status","status":null,"permissionMode":"plan"}`, which
|
||||
// is a leaving edge carrying no compaction result -- so it is checked
|
||||
// before the compaction reading below.
|
||||
if let Some(mode) = message.get("permissionMode").and_then(Value::as_str) {
|
||||
return vec![Event::Settings {
|
||||
model: None,
|
||||
@@ -371,8 +333,8 @@ impl Translator {
|
||||
};
|
||||
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.
|
||||
// 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}"),
|
||||
@@ -386,9 +348,9 @@ impl Translator {
|
||||
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.
|
||||
/// 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")
|
||||
@@ -448,9 +410,8 @@ impl Translator {
|
||||
.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.
|
||||
// Measured, not matched: the request names the call it is about, so the
|
||||
// phone never has to guess which tool row a permission belongs to.
|
||||
let about = request
|
||||
.get("tool_use_id")
|
||||
.and_then(Value::as_str)
|
||||
@@ -471,11 +432,10 @@ impl Translator {
|
||||
.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.
|
||||
// Everything the reader decides on, carried in the event. The
|
||||
// alternative -- and what this was -- is the phone reaching into
|
||||
// the tool call's input for the parts the event dropped, which
|
||||
// puts this dialect's schema where no other dialect can reach it.
|
||||
let options = question
|
||||
.get("options")
|
||||
.and_then(Value::as_array)
|
||||
@@ -499,12 +459,10 @@ impl Translator {
|
||||
.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.
|
||||
// thing. It used to be `None` on the grounds that a question
|
||||
// the model asked is not permission for a call -- true, and
|
||||
// beside the point: the reader was shown the AskUserQuestion
|
||||
// call *and* its questions as two separate cards.
|
||||
about: about.clone(),
|
||||
});
|
||||
questions.push(text);
|
||||
@@ -515,8 +473,8 @@ impl Translator {
|
||||
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.
|
||||
// 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"),
|
||||
@@ -541,13 +499,13 @@ impl Translator {
|
||||
events
|
||||
}
|
||||
|
||||
/// Applies one answer from the phone. Question ids are the control
|
||||
/// request id, suffixed `#i` for AskUserQuestion sub-questions.
|
||||
/// 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.
|
||||
// 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('#') {
|
||||
@@ -583,17 +541,15 @@ impl Translator {
|
||||
}))
|
||||
}
|
||||
|
||||
/// `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
|
||||
/// `user` messages: tool results become ToolEnd, with any image parts saved
|
||||
/// into the session dir and referenced by an Image event. Replayed and
|
||||
/// synthetic user text is skipped -- the manager already recorded the
|
||||
/// user's side.
|
||||
fn translate_user(&self, message: &Value) -> Vec<Event> {
|
||||
// Only tool results are here. The CLI never echoes a person's own
|
||||
// message back on stdout -- measured, because the obvious way to
|
||||
// learn that a queued message had been taken was to watch for it
|
||||
// coming back -- so nothing in this function marks one as read.
|
||||
// The driver reports that itself, at the line it writes.
|
||||
// message back on stdout -- measured, because the obvious way to learn
|
||||
// that a queued message had been taken was to watch for it coming back
|
||||
// -- so the driver reports that itself, at the line it writes.
|
||||
let Some(content) = message["message"].get("content").and_then(Value::as_array) else {
|
||||
return Vec::new();
|
||||
};
|
||||
@@ -603,9 +559,8 @@ impl Translator {
|
||||
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.
|
||||
// Held until the call's id is in hand a few lines below: an image is
|
||||
// drawn under the call that produced it, so it has to carry that id.
|
||||
let mut images = Vec::new();
|
||||
match block.get("content") {
|
||||
Some(Value::String(text)) => texts.push(text.clone()),
|
||||
@@ -648,11 +603,9 @@ impl Translator {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// 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)
|
||||
@@ -663,11 +616,9 @@ fn text_field(value: &Value, name: &str) -> Option<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.
|
||||
/// A free function rather than a method because the import replay needs exactly
|
||||
/// this too: a session's history carries the same image blocks as its live
|
||||
/// output. Two copies would be two naming schemes for one directory.
|
||||
pub(in crate::session) fn save_image(session_dir: &Path, part: &Value) -> Option<String> {
|
||||
let source = part.get("source")?;
|
||||
let data = source.get("data")?.as_str()?;
|
||||
@@ -675,8 +626,8 @@ pub(in crate::session) fn save_image(session_dir: &Path, part: &Value) -> Option
|
||||
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.
|
||||
// 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)
|
||||
@@ -726,8 +677,7 @@ mod tests {
|
||||
);
|
||||
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.
|
||||
// `--model haiku` is reported by its full name here.
|
||||
assert_eq!(
|
||||
events,
|
||||
vec![Event::Settings {
|
||||
@@ -749,8 +699,8 @@ mod tests {
|
||||
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.
|
||||
// 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,
|
||||
&[
|
||||
@@ -765,9 +715,8 @@ mod tests {
|
||||
}]
|
||||
);
|
||||
|
||||
// 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.
|
||||
// A mode the CLI answers with a value of its own is taken from that
|
||||
// value: `auto` on the way in is `default` coming back.
|
||||
translator.expect_setting(
|
||||
"req-c".to_string(),
|
||||
Setting::PermissionMode("auto".to_string()),
|
||||
@@ -801,8 +750,8 @@ mod tests {
|
||||
}]
|
||||
);
|
||||
|
||||
// And neither request is still waiting: a second answer to either
|
||||
// id reports nothing at all.
|
||||
// And neither request is still waiting: a second answer to either id
|
||||
// reports nothing at all.
|
||||
let events = translate_lines(
|
||||
&mut translator,
|
||||
&[
|
||||
@@ -815,8 +764,8 @@ mod tests {
|
||||
|
||||
#[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.
|
||||
// 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(
|
||||
@@ -916,8 +865,8 @@ mod tests {
|
||||
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.
|
||||
// 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"]);
|
||||
@@ -1013,10 +962,9 @@ mod tests {
|
||||
#[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.
|
||||
// multi-select is how many answers the question takes. All of it travels
|
||||
// in the event: a phone that had to read this dialect's tool input to
|
||||
// find them would be the only place that knew how.
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let mut translator = Translator::new(dir.path().to_path_buf());
|
||||
let events = translate_lines(
|
||||
@@ -1049,8 +997,8 @@ mod tests {
|
||||
.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.
|
||||
// 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()],
|
||||
@@ -1078,8 +1026,8 @@ mod tests {
|
||||
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.
|
||||
// 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());
|
||||
@@ -1118,19 +1066,15 @@ mod tests {
|
||||
|
||||
/// A turn another agent started says so, on the record that carries it.
|
||||
///
|
||||
/// The line is the real shape, taken from a real cross-session message
|
||||
/// sent to a real stream-json session on CLI 2.1.237 (2026-08-31) --
|
||||
/// including the `from` socket path, which is deliberately *not* what a
|
||||
/// reader is shown: the sending session's `name` is what they recognise
|
||||
/// it by. The `body` is the message as it was written; the content the
|
||||
/// model is given beside it wraps the same text in a preamble and a
|
||||
/// `<cross-session-message>` tag, which is written for the model rather
|
||||
/// than for a person.
|
||||
/// The line is the real shape, taken from a real cross-session message sent
|
||||
/// to a real stream-json session on 2.1.237 (2026-08-31) -- including the
|
||||
/// `from` socket path, which is deliberately *not* what a reader is shown:
|
||||
/// the sending session's `name` is what they recognise it by. The `body` is
|
||||
/// the message as written; the content the model is given wraps the same
|
||||
/// text in a preamble written for the model rather than for a person.
|
||||
///
|
||||
/// The note comes before the usage and the idle, so it sits as close to
|
||||
/// the turn it explains as the wire allows -- which is after the reply,
|
||||
/// not above it. See the comment at the callsite for why that is the
|
||||
/// best available position rather than an oversight.
|
||||
/// The note comes before the usage and the idle, so it sits as close to the
|
||||
/// turn it explains as the wire allows.
|
||||
#[test]
|
||||
fn a_turn_started_by_another_agent_records_who_and_what() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
@@ -1147,8 +1091,8 @@ mod tests {
|
||||
Event::PeerMessage {
|
||||
from: "ai-app-2-fb".to_string(),
|
||||
text: "Reply with just the word ACK.".to_string(),
|
||||
// Stamped by the pump, which is the only place that
|
||||
// knows what seq the turn started at.
|
||||
// Stamped by the pump, which is the only place that knows
|
||||
// what seq the turn started at.
|
||||
turn_start: None,
|
||||
},
|
||||
Event::UsageDelta {
|
||||
@@ -1162,9 +1106,9 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// And an ordinary turn does not, which is the half that decides
|
||||
/// whether the check above is a check or a rubber stamp. Measured over
|
||||
/// a real session's stdout: four results, no `origin` between them.
|
||||
/// And an ordinary turn does not, which is the half that decides whether
|
||||
/// the check above is a check or a rubber stamp. Measured over a real
|
||||
/// session's stdout: four results, no `origin` between them.
|
||||
#[test]
|
||||
fn an_ordinary_turn_carries_no_peer_note() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
@@ -1186,12 +1130,10 @@ mod tests {
|
||||
/// The context is the last assistant message's, not the result's.
|
||||
///
|
||||
/// Real figures from a two-message haiku turn on 2.1.237, captured
|
||||
/// 2026-08-30. The result adds the turn up -- its
|
||||
/// `cache_read_input_tokens` of 40,211 is 14,259 and 25,952, the same
|
||||
/// conversation counted twice -- so reading the context off it would
|
||||
/// report a size the model never held, and by more the more tool calls
|
||||
/// a turn makes. The last message's three input figures are what it
|
||||
/// was holding when the turn ended.
|
||||
/// 2026-08-30. The result adds the turn up -- its `cache_read_input_tokens`
|
||||
/// of 40,211 is 14,259 and 25,952, the same conversation counted twice -- so
|
||||
/// reading the context off it would report a size the model never held, by
|
||||
/// more the more tool calls a turn makes.
|
||||
#[test]
|
||||
fn the_context_is_what_the_last_message_held_not_the_turn_added_up() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
@@ -1231,9 +1173,9 @@ mod tests {
|
||||
|
||||
#[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.
|
||||
// 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(
|
||||
@@ -1333,16 +1275,14 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Pressing Stop is not a failure, and the CLI cannot tell you which it
|
||||
/// was.
|
||||
/// Pressing Stop is not a failure, and the CLI cannot tell you which it was.
|
||||
///
|
||||
/// An interrupted turn arrives as exactly the same shape a broken one
|
||||
/// does -- `is_error` set, on a `result` -- so somebody who pressed the
|
||||
/// button was shown "the turn ended with an error" for doing what the
|
||||
/// button says. What separates the two is not in the line: it is that
|
||||
/// this side asked. The second half of this test is the one that
|
||||
/// matters, because the naive fix -- never reporting an error result --
|
||||
/// passes the first half and silences every genuine failure afterwards.
|
||||
/// An interrupted turn arrives as exactly the same shape a broken one does,
|
||||
/// so somebody who pressed the button was shown "the turn ended with an
|
||||
/// error". What separates the two is that this side asked. The second half
|
||||
/// of this test is the one that matters, because the naive fix -- never
|
||||
/// reporting an error result -- passes the first half and silences every
|
||||
/// genuine failure afterwards.
|
||||
#[test]
|
||||
fn a_turn_stopped_on_purpose_is_not_an_error() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
|
||||
Reference in new issue
Block a user