Tell the model when a message was a steer

A message typed during a turn reaches the model at the next model call if
the turn has one left, and otherwise as the opening line of the next turn --
Claude's read out of the fifo after the turn ended, Codex's requeued when
turn/steer is refused. Read there it is indistinguishable from a reply, so
the model treats the answer it just gave as seen.

Both drivers now compose the text the CLI receives through
driver::message_body, which prefixes a note saying the message was written
without having seen the rest of that turn. The transcript still holds the
words that were typed; only the CLI's copy carries the note.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
iris-aiandClaude Opus 5 committed 2026-09-13 22:30:32 -04:00
1 parent 59ebd75b46
commit 3af2502982
4 files changed
+107 -18

No files matched your search

+21
View File
@@ -453,6 +453,27 @@ error row a screen away.
`Unknown` is not "we could not find out": a driver that is gone reported `Unknown` is not "we could not find out": a driver that is gone reported
everything it was holding when it closed. everything it was holding when it closed.
### A steer says that it is one (2026-09-13)
A message typed during a turn reaches the model with a bracketed note in
front of it saying it was written without having seen the rest of that turn
(`driver::message_body`, and `STEERING_NOTE` beside it). The transcript keeps
the words that were typed; only the copy the CLI is handed carries the note.
The reason is that where a steer lands is not ours to choose. It reaches the
model at the next model call if the turn has one left, and otherwise as the
opening line of the *next* turn -- Claude's read out of the fifo after the
turn ended, Codex's requeued when `turn/steer` is refused as
`activeTurnNotSteerable`. In that second case nothing distinguishes it from
an ordinary reply, so the model treats the answer it just gave as read and
answers around it. Bryan reported this as the ordinary experience of steering
from the phone: the interruption is meant to arrive mid-work, and it lands
after the fact often enough to matter.
It is prefixed on every steer rather than only on the ones that land late,
because the two are the same message until the CLI reads it, and the note is
true either way: a steer never saw the rest of the turn it was typed into.
### Session processes outlive the backend (2026-08-29) ### Session processes outlive the backend (2026-08-29)
A session's process is **left running when the backend stops and adopted A session's process is **left running when the backend stops and adopted
+10 -13
View File
@@ -503,7 +503,7 @@ impl Driver for ClaudeDriver {
// An image goes into the message itself; the model looks at it. Any // An image goes into the message itself; the model looks at it. Any
// other file stays where the upload put it and the message says where, // other file stays where the upload put it and the message says where,
// because the CLI can read a file by path and a model cannot be handed // because the CLI can read a file by path and a model cannot be handed
// a trace any other way. Named after the text, so the words come first. // a trace any other way.
let mut files = Vec::new(); let mut files = Vec::new();
for id in &attachments { for id in &attachments {
let sent = if crate::media::media_type_for(id).is_some() { let sent = if crate::media::media_type_for(id).is_some() {
@@ -517,18 +517,6 @@ impl Driver for ClaudeDriver {
}); });
} }
} }
let mut body = text.clone();
for path in files {
if !body.is_empty() {
body.push_str("\n\n");
}
body.push_str(&format!("Attached file: {}", path.display()));
}
if !body.is_empty() {
content.push(json!({"type": "text", "text": body}));
}
let line =
json!({"type": "user", "message": {"role": "user", "content": content}}).to_string();
let mut queue = self.queue.lock().unwrap(); let mut queue = self.queue.lock().unwrap();
// Saying so beats writing into a fifo that nothing is reading, which is // Saying so beats writing into a fifo that nothing is reading, which is
// what this used to do -- the message went nowhere and looked exactly // what this used to do -- the message went nowhere and looked exactly
@@ -541,6 +529,15 @@ impl Driver for ClaudeDriver {
}); });
return; return;
} }
// Built under the lock because whether this is a steer decides what the
// CLI is told, and the answer must be the same one the branch below acts
// on -- see `super::driver::message_body`.
let body = super::driver::message_body(&text, &files, queue.running);
if !body.is_empty() {
content.push(json!({"type": "text", "text": body}));
}
let line =
json!({"type": "user", "message": {"role": "user", "content": content}}).to_string();
if queue.running { if queue.running {
// Into the running turn, now. Announced when the CLI shows it has // Into the running turn, now. Announced when the CLI shows it has
// been round the model again -- see `Queue`. // been round the model again -- see `Queue`.
+13 -5
View File
@@ -44,6 +44,14 @@ struct Waiting {
/// The id Codex echoes on its user-message item. /// The id Codex echoes on its user-message item.
#[serde(default)] #[serde(default)]
client_id: String, client_id: String,
/// Typed while a turn was already running, so the CLI is told as much --
/// see `super::driver::message_body`. Recorded at the moment it was typed
/// rather than read off the turn state when it is dispatched, because a
/// steer Codex refuses as `activeTurnNotSteerable` is requeued and sent as
/// the start of the next turn, which is precisely the case the note exists
/// for.
#[serde(default)]
steering: bool,
text: String, text: String,
#[serde(default)] #[serde(default)]
attachments: Vec<AttachmentRef>, attachments: Vec<AttachmentRef>,
@@ -202,10 +210,12 @@ impl Driver for CodexDriver {
return; return;
} }
let id = state.running.then(super::random_hex).unwrap_or_default(); let id = state.running.then(super::random_hex).unwrap_or_default();
let steering = state.running;
state.running = true; state.running = true;
state.waiting.push_back(Waiting { state.waiting.push_back(Waiting {
id: id.clone(), id: id.clone(),
client_id: format!("ai-app-{}", super::random_hex()), client_id: format!("ai-app-{}", super::random_hex()),
steering,
text: text.clone(), text: text.clone(),
attachments: attachments.clone(), attachments: attachments.clone(),
}); });
@@ -463,7 +473,7 @@ fn sandbox_policy(mode: &str) -> Value {
} }
fn input_for(inner: &Inner, message: &Waiting) -> Result<Vec<Value>> { fn input_for(inner: &Inner, message: &Waiting) -> Result<Vec<Value>> {
let mut text = message.text.clone(); let mut files = Vec::new();
let mut input = Vec::new(); let mut input = Vec::new();
for attachment in &message.attachments { for attachment in &message.attachments {
let path = attachment_path(&inner.session_dir, attachment)?; let path = attachment_path(&inner.session_dir, attachment)?;
@@ -477,12 +487,10 @@ fn input_for(inner: &Inner, message: &Waiting) -> Result<Vec<Value>> {
input.push(inline_image(&path, media_type)?); input.push(inline_image(&path, media_type)?);
} }
} else { } else {
if !text.is_empty() { files.push(path);
text.push_str("\n\n");
}
text.push_str(&format!("Attached file: {}", path.display()));
} }
} }
let text = super::driver::message_body(&message.text, &files, message.steering);
if !text.is_empty() { if !text.is_empty() {
input.insert(0, json!({"type": "text", "text": text})); input.insert(0, json!({"type": "text", "text": text}));
} }
+63
View File
@@ -36,6 +36,52 @@ pub(in crate::session) fn store_image(
Some(name) Some(name)
} }
/// Prefixed to a message that was typed while a turn was already running.
///
/// A steer goes to the CLI the moment it arrives, but *when the model reads
/// it* is not ours to decide: it lands at the next model call if there is
/// one, and a turn that ends first delivers it as the opening line of the
/// next turn instead -- Claude's from the fifo, Codex's requeued after
/// `activeTurnNotSteerable`. Read there it looks like a reply to the answer
/// just given, so the model acts as if the person had seen that answer, which
/// is exactly what they had not. Nothing else distinguishes the two cases by
/// the time the model sees them, so the note is the only thing that can carry
/// the fact.
const STEERING_NOTE: &str = "[Sent while you were still working, so it was written without \
having seen the rest of that turn. Treat it as steering the work in progress, not as a \
reply to anything you said after it was sent.]";
/// The text a CLI receives for one message: the note above when this is a
/// steer, the typed words, and the paths of any attachment the model has to
/// read from disk rather than being handed inline.
///
/// The paths come after the words because a model reading a list of files
/// before the request treats the list as the request.
pub(in crate::session) fn message_body(
text: &str,
files: &[std::path::PathBuf],
steering: bool,
) -> String {
let mut body = String::new();
if steering {
body.push_str(STEERING_NOTE);
}
for part in std::iter::once(text.to_string()).chain(
files
.iter()
.map(|path| format!("Attached file: {}", path.display())),
) {
if part.is_empty() {
continue;
}
if !body.is_empty() {
body.push_str("\n\n");
}
body.push_str(&part);
}
body
}
/// The name an upload is stored and served under: an image is /// The name an upload is stored and served under: an image is
/// `<hex>.<extension>` and is an [`ImageRef`] like any other; any other file /// `<hex>.<extension>` and is an [`ImageRef`] like any other; any other file
/// keeps its own name after the hex, `<hex>-<name>`, because the name is what /// keeps its own name after the hex, `<hex>-<name>`, because the name is what
@@ -651,6 +697,23 @@ pub trait Driver: Send + Sync {
mod tests { mod tests {
use super::*; use super::*;
/// A steer says so to the model and nowhere else: the note goes only into
/// what the CLI is handed, while the transcript keeps what was typed.
#[test]
fn only_a_steer_carries_the_note() {
let files = [std::path::PathBuf::from("/tmp/x/trace.txt")];
let plain = message_body("do the thing", &files, false);
assert_eq!(plain, "do the thing\n\nAttached file: /tmp/x/trace.txt");
let steer = message_body("do the thing", &files, true);
assert_eq!(steer, format!("{STEERING_NOTE}\n\n{plain}"));
// Attachments with nothing typed still need the note, and must not
// arrive with a blank line where the words would have been.
assert_eq!(
message_body("", &files, true),
format!("{STEERING_NOTE}\n\nAttached file: /tmp/x/trace.txt")
);
}
/// A tripwire for the wire format, not for serde. The app reads these /// A tripwire for the wire format, not for serde. The app reads these
/// names, and getting one wrong does not fail loudly: a field the app /// names, and getting one wrong does not fail loudly: a field the app
/// cannot find reads as a field the server chose not to send, which /// cannot find reads as a field the server chose not to send, which