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:
1 parent
59ebd75b46
commit
3af2502982
4 files changed
+107
-18
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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}));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user