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

+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
// 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
// a trace any other way. Named after the text, so the words come first.
// a trace any other way.
let mut files = Vec::new();
for id in &attachments {
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();
// 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
@@ -541,6 +529,15 @@ impl Driver for ClaudeDriver {
});
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 {
// Into the running turn, now. Announced when the CLI shows it has
// 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.
#[serde(default)]
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,
#[serde(default)]
attachments: Vec<AttachmentRef>,
@@ -202,10 +210,12 @@ impl Driver for CodexDriver {
return;
}
let id = state.running.then(super::random_hex).unwrap_or_default();
let steering = state.running;
state.running = true;
state.waiting.push_back(Waiting {
id: id.clone(),
client_id: format!("ai-app-{}", super::random_hex()),
steering,
text: text.clone(),
attachments: attachments.clone(),
});
@@ -463,7 +473,7 @@ fn sandbox_policy(mode: &str) -> 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();
for attachment in &message.attachments {
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)?);
}
} else {
if !text.is_empty() {
text.push_str("\n\n");
}
text.push_str(&format!("Attached file: {}", path.display()));
files.push(path);
}
}
let text = super::driver::message_body(&message.text, &files, message.steering);
if !text.is_empty() {
input.insert(0, json!({"type": "text", "text": text}));
}
+63
View File
@@ -36,6 +36,52 @@ pub(in crate::session) fn store_image(
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
/// `<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
@@ -651,6 +697,23 @@ pub trait Driver: Send + Sync {
mod tests {
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
/// 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