Show the model and mode the session has, not the ones it was asked for

Picking either from the phone wrote the choice straight into the
session's state and then sent the request. Asking and having are
different things, and the difference is not rare: `auto` is a permission
mode the CLI accepts on the command line, silently resolves to
`default`, and refuses outright over the control channel -- "auto mode
unavailable for this model" -- so a session spawned in auto was in
default and one switched to auto stayed where it was, with the phone
reporting auto in both cases.

So the drivers report what they are set to and the manager follows that.
Measured, because the confirmations are not uniform: a model change
answers success with no value, so what was asked is remembered until the
answer arrives; a mode change echoes the mode it became, and that answer
wins over the request; and `init` names both -- resolving `haiku` to
claude-haiku-4-5-20251001 -- which also covers a session adopted from a
terminal that set them outside this app. A driver that cannot change
either already says so with an error, and now that error is the whole
story rather than a note beside a display that changed anyway.

The config keeps the requested value, deliberately: that answers a
different question, which is what to launch this session with next time.

Two things fall out. Control request ids are random rather than the
clock, because two in the same second shared an id and something now
looks them up. And the phone shortens a resolved name for the button --
`haiku-4-5` -- since the full one is what the CLI reports and roughly
twice the room that row has once Stop is in it.
This commit is contained in:
iris committed 2026-08-29 15:36:53 -04:00
1 parent 404066fa7d
commit 3eccf7e443
8 files changed
+359 -29

No files matched your search

+196 -8
View File
@@ -28,6 +28,19 @@ 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
/// only way to report what was accepted is to remember what was asked.
/// `set_permission_mode` does echo its mode back, and so does a
/// `system/status` line a moment later; both are handled where they
/// arrive, and this covers the one that says nothing.
pub(super) enum Setting {
Model(String),
PermissionMode(String),
}
/// A `can_use_tool` request we've surfaced to the phone and not yet
/// answered. For plain permissions there is one implicit question
/// (Allow/Deny); for AskUserQuestion, one per entry in `questions`.
@@ -47,6 +60,10 @@ struct PendingRequest {
pub(super) struct Translator {
pub(super) session_id: Option<String>,
pending: HashMap<String, PendingRequest>,
/// Settings asked for and not yet answered, by request id. Its path
/// out is the response: every entry is removed when one arrives,
/// whether it succeeded or failed.
asked: HashMap<String, Setting>,
session_dir: PathBuf,
}
@@ -55,9 +72,19 @@ impl Translator {
Self {
session_id: None,
pending: HashMap::new(),
asked: HashMap::new(),
session_dir,
}
}
/// Remembers what a control request was for, so its answer can say so.
///
/// Called before the request goes out, not after: the reader thread is
/// already running and a fast CLI can answer before this side gets
/// back to it.
pub(super) fn expect_setting(&mut self, request_id: String, setting: Setting) {
self.asked.insert(request_id, setting);
}
pub(super) fn translate(&mut self, message: &Value) -> Vec<Event> {
// Events from subagents (Task tool internals) carry a
// parent_tool_use_id; the transcript shows the Task tool's own
@@ -76,16 +103,45 @@ impl Translator {
Some("control_request") => self.translate_control_request(message),
Some("control_response") => {
let response = &message["response"];
// Answered either way, so the request stops being pending
// either way -- a rejected setting that stayed here would
// be applied by the next request that reused its id.
let asked = response
.get("request_id")
.and_then(Value::as_str)
.and_then(|id| self.asked.remove(id));
if response.get("subtype").and_then(Value::as_str) == Some("error") {
let error = response
.get("error")
.and_then(Value::as_str)
.unwrap_or("unknown");
vec![Event::Error {
return vec![Event::Error {
message: format!("claude rejected a request: {error}"),
}]
} else {
Vec::new()
}];
}
// Success, so the setting this request asked for is now
// the session's, and this is the only place that says so:
// the response carries no value of its own for a model.
match asked {
Some(Setting::Model(model)) => vec![Event::Settings {
model: Some(model),
permission_mode: None,
}],
Some(Setting::PermissionMode(mode)) => vec![Event::Settings {
model: None,
// The CLI echoes this one, and its answer wins:
// `auto` and `manual` are names it accepts on the
// way in and reports back under another name, so
// repeating the request here would show a mode the
// session is not in.
permission_mode: Some(
response["response"]["mode"]
.as_str()
.map(str::to_string)
.unwrap_or(mode),
),
}],
None => Vec::new(),
}
}
Some("result") => {
@@ -150,7 +206,21 @@ impl Translator {
if let Some(id) = message.get("session_id").and_then(Value::as_str) {
self.session_id = Some(id.to_string());
}
Vec::new()
// The CLI's own account of what it is set to, and the only
// one that resolves an alias: a session launched with
// `--model haiku` reports `claude-haiku-4-5-20251001`
// here. It arrives again after a compaction, which is
// free -- the manager drops a setting it is already in.
vec![Event::Settings {
model: message
.get("model")
.and_then(Value::as_str)
.map(str::to_string),
permission_mode: message
.get("permissionMode")
.and_then(Value::as_str)
.map(str::to_string),
}]
}
Some("status") => self.translate_status(message),
Some("compact_boundary") => {
@@ -178,6 +248,18 @@ impl Translator {
/// state this build does not recognise is left alone rather than
/// mapped onto the nearest one we do.
fn translate_status(&self, message: &Value) -> Vec<Event> {
// A mode change the CLI has made, announced a moment after it
// answers the request that asked for it. Measured on 2.1.237:
// `{"subtype":"status","status":null,"permissionMode":"plan"}`,
// which is a leaving edge carrying no compaction result -- so it
// is checked before the compaction reading below, which would
// otherwise fall through to nothing.
if let Some(mode) = message.get("permissionMode").and_then(Value::as_str) {
return vec![Event::Settings {
model: None,
permission_mode: Some(mode.to_string()),
}];
}
if let Some(status) = message.get("status").and_then(Value::as_str) {
return match status {
"compacting" => vec![Event::Status {
@@ -471,17 +553,123 @@ mod tests {
}
#[test]
fn captures_the_resume_token_from_init() {
fn captures_the_resume_token_and_the_settings_from_init() {
let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf());
let events = translate_lines(
&mut translator,
&[
r#"{"type":"system","subtype":"init","cwd":"/x","session_id":"5ecf21da-d53f","tools":[],"model":"claude-haiku-4-5-20251001"}"#,
r#"{"type":"system","subtype":"init","cwd":"/x","session_id":"5ecf21da-d53f","tools":[],"model":"claude-haiku-4-5-20251001","permissionMode":"acceptEdits"}"#,
],
);
assert!(events.is_empty());
assert_eq!(translator.session_id.as_deref(), Some("5ecf21da-d53f"));
// The resolved model, which is the point: a session launched with
// `--model haiku` is reported by its full name here, and that is
// the name the phone should be showing.
assert_eq!(
events,
vec![Event::Settings {
model: Some("claude-haiku-4-5-20251001".to_string()),
permission_mode: Some("acceptEdits".to_string()),
}]
);
}
#[test]
fn a_setting_is_reported_when_the_cli_accepts_it_and_not_before() {
let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf());
// What `set_model` does: remember, send, and say nothing yet.
translator.expect_setting("req-a".to_string(), Setting::Model("sonnet".to_string()));
translator.expect_setting(
"req-b".to_string(),
Setting::PermissionMode("plan".to_string()),
);
// Success carries no model of its own -- measured on 2.1.237 --
// so what was asked for is the only answer available.
let events = translate_lines(
&mut translator,
&[
r#"{"type":"control_response","response":{"subtype":"success","request_id":"req-a"}}"#,
],
);
assert_eq!(
events,
vec![Event::Settings {
model: Some("sonnet".to_string()),
permission_mode: None,
}]
);
// A mode the CLI answers with a value of its own is taken from
// that value: `auto` on the way in is `default` coming back, and
// the request is not the answer.
translator.expect_setting(
"req-c".to_string(),
Setting::PermissionMode("auto".to_string()),
);
let events = translate_lines(
&mut translator,
&[
r#"{"type":"control_response","response":{"subtype":"success","request_id":"req-c","response":{"mode":"default"}}}"#,
],
);
assert_eq!(
events,
vec![Event::Settings {
model: None,
permission_mode: Some("default".to_string()),
}]
);
// A refusal changes nothing, and says why rather than claiming a
// setting that was rejected.
let events = translate_lines(
&mut translator,
&[
r#"{"type":"control_response","response":{"subtype":"error","request_id":"req-b","error":"unknown mode"}}"#,
],
);
assert_eq!(
events,
vec![Event::Error {
message: "claude rejected a request: unknown mode".to_string()
}]
);
// And neither request is still waiting: a second answer to either
// id reports nothing at all.
let events = translate_lines(
&mut translator,
&[
r#"{"type":"control_response","response":{"subtype":"success","request_id":"req-a"}}"#,
r#"{"type":"control_response","response":{"subtype":"success","request_id":"req-b"}}"#,
],
);
assert!(events.is_empty(), "{events:?}");
}
#[test]
fn a_mode_the_cli_announces_is_taken_from_the_announcement() {
// The line it sends just after answering `set_permission_mode`,
// which is also how a mode changed from the terminal arrives.
let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf());
let events = translate_lines(
&mut translator,
&[
r#"{"type":"system","subtype":"status","status":null,"permissionMode":"plan","session_id":"s"}"#,
],
);
assert_eq!(
events,
vec![Event::Settings {
model: None,
permission_mode: Some("plan".to_string()),
}]
);
}
#[test]