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

+30 -6
View File
@@ -61,7 +61,7 @@ use super::driver::{Driver, Event, EventSink, ImageRef, SessionStatus};
use super::process;
use super::transport::{Launch, Streams, Transport};
use crate::config::{ProviderConfig, SessionConfig};
use translate::{AnswerOutcome, Translator};
use translate::{AnswerOutcome, Setting, Translator};
/// How much of a failing process's stderr the exit report carries.
///
@@ -387,8 +387,26 @@ impl ClaudeDriver {
let _ = self.to_child.send(line);
}
fn send_control(&self, request: Value) {
let id = format!("req-{}", super::now() as u64);
/// Sends a control request, remembering what it asked for.
///
/// `confirms` is the setting this request will have made if the CLI
/// answers success -- see [`Translator::expect_setting`], and
/// `Driver::set_model` for why a request is not a confirmation.
/// `None` for the ones that change no setting, like an interrupt.
///
/// The id is random rather than the clock it used to be: two requests
/// in the same second shared an id, which was harmless while nothing
/// looked one up and is not any more.
fn send_control(&self, request: Value, confirms: Option<Setting>) {
let id = format!("req-{}", super::random_hex());
if let Some(setting) = confirms {
// Before the line goes out: the reader thread is already
// running, and a fast answer to a slow lock arrives first.
self.state
.lock()
.unwrap()
.expect_setting(id.clone(), setting);
}
self.send_line(
json!({"type": "control_request", "request_id": id, "request": request}).to_string(),
);
@@ -470,15 +488,21 @@ impl Driver for ClaudeDriver {
/// typed deliberately, and dropping it would lose a message that never
/// reached the transcript, with nothing on screen to say so.
fn interrupt(&self) {
self.send_control(json!({"subtype": "interrupt"}));
self.send_control(json!({"subtype": "interrupt"}), None);
}
fn set_permission_mode(&self, mode: &str) {
self.send_control(json!({"subtype": "set_permission_mode", "mode": mode}));
self.send_control(
json!({"subtype": "set_permission_mode", "mode": mode}),
Some(Setting::PermissionMode(mode.to_string())),
);
}
fn set_model(&self, model: &str) {
self.send_control(json!({"subtype": "set_model", "model": model}));
self.send_control(
json!({"subtype": "set_model", "model": model}),
Some(Setting::Model(model.to_string())),
);
}
fn compact(&self) {
+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]
+26
View File
@@ -127,6 +127,25 @@ pub enum Event {
Status {
state: SessionStatus,
},
/// What the session is set to, as the session itself reports it.
///
/// Asking for a change and having one are different things, and only
/// this one is a measurement: a model name the dialect does not know,
/// a mode it refuses, or a driver whose model is fixed at startup all
/// leave a request that was sent and nothing that changed. Reporting
/// from the request instead put the answer on the phone before the
/// question had been answered, and left it there when the answer was
/// no.
///
/// Either field alone, because the two are confirmed separately and
/// by different things -- the CLI echoes a mode change, and names the
/// model it resolved an alias to when a session starts.
Settings {
#[serde(default, skip_serializing_if = "Option::is_none")]
model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
permission_mode: Option<String>,
},
/// Per-turn token counts, where the dialect reports them.
UsageDelta {
tokens: u64,
@@ -206,6 +225,13 @@ pub trait Driver: Send + Sync {
/// spawn-only: the answer changes with what is being done, and a phone
/// is the worst place to answer "may I run this?" forty times.
fn set_permission_mode(&self, mode: &str);
// Both of the above are requests, and neither reports the outcome by
// returning. A driver that actually changes the setting owes an
// [`Event::Settings`] once it has -- that event, and not the request,
// is what the manager and the phone read. One that cannot change it
// owes an [`Event::Error`] saying why; saying nothing leaves a phone
// showing a setting nobody applied.
/// pi: native compaction; claude: `/compact`.
fn compact(&self);
/// Stop attending to the process but leave it running, because this
+52 -10
View File
@@ -678,7 +678,11 @@ impl SessionManager {
candidate.save(&self.config_path)?;
inner.config = candidate;
if let Some(session) = inner.live.get(id) {
*session.shared.permission_mode.lock().unwrap() = Some(mode.to_string());
// Asked for, not recorded: what the session is actually set to
// comes back as an `Event::Settings` if the driver makes the
// change, and as an error if it cannot. The config above is a
// different question -- what to launch this session with next
// time -- and it is answered by the request.
session.driver.set_permission_mode(mode);
}
Ok(())
@@ -696,7 +700,8 @@ impl SessionManager {
candidate.save(&self.config_path)?;
inner.config = candidate;
if let Some(session) = inner.live.get(id) {
*session.shared.model.lock().unwrap() = Some(model.to_string());
// See `set_session_permission_mode`: the driver reports what
// it is set to, this only asks.
session.driver.set_model(model);
}
Ok(())
@@ -1000,6 +1005,29 @@ fn launch(
/// The appends are synchronous file writes from an async task,
/// deliberately: each is one small line on a local disk, and funneling
/// them through one task is what makes the sequence numbering safe.
/// Whether this event tells anyone anything they do not already know.
///
/// Only the two events that report state rather than something that
/// happened can fail this: everything else is an occurrence, and an
/// occurrence is news by existing. A `Settings` naming one field is
/// judged on that field alone, since the other is not a claim that it is
/// unset.
fn is_news(event: &Event, shared: &Shared) -> bool {
match event {
Event::Status { state } => *shared.status.lock().unwrap() != *state,
Event::Settings {
model,
permission_mode,
} => {
let model_changed = model.is_some() && *shared.model.lock().unwrap() != *model;
let mode_changed = permission_mode.is_some()
&& *shared.permission_mode.lock().unwrap() != *permission_mode;
model_changed || mode_changed
}
_ => true,
}
}
async fn pump(
mut transcript: Transcript,
mut source: mpsc::UnboundedReceiver<Event>,
@@ -1016,18 +1044,32 @@ async fn pump(
Event::MessageTaken { text } => Event::UserMessage { text },
other => other,
};
// A status the session is already in is not news. Imported
// sessions make this the common case rather than a rarity: each
// sync reads the turn state off the file's newest record, and
// most of them find the same answer as the sync before -- which
// would otherwise be a transcript entry, a broadcast, and a
// Nothing changed, so there is nothing to record. Both of these
// repeat: an imported session reads the turn state off its file's
// newest record on every sync and mostly finds the answer it found
// last time, and the CLI restates its model and mode at every
// `init`, which includes the one after every compaction. Recording
// those would be a transcript entry, a broadcast and a
// recomposition on every phone, several times a minute, to say
// nothing at all.
if let Event::Status { state } = &event
&& *shared.status.lock().unwrap() == *state
{
if !is_news(&event, &shared) {
continue;
}
if let Event::Settings {
model,
permission_mode,
} = &event
{
// The session's own account of what it is set to, which is
// what the list and the session screen show. Not written
// where the change is *asked for* -- see `Event::Settings`.
if let Some(model) = model {
*shared.model.lock().unwrap() = Some(model.clone());
}
if let Some(mode) = permission_mode {
*shared.permission_mode.lock().unwrap() = Some(mode.clone());
}
}
match transcript.append(event, ts) {
Ok(entry) => {
if let Event::Status { state } = &entry.event {