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
@@ -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`.
|
||||
|
||||
@@ -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}));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user