From 3af250298261d18f486de8889c10df857059232d Mon Sep 17 00:00:00 2001 From: iris-ai <4+iris-ai@noreply.localhost> Date: Sun, 13 Sep 2026 22:30:32 -0400 Subject: [PATCH] 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 --- PLAN.md | 21 ++++++++++++ server/src/session/claude.rs | 23 ++++++------- server/src/session/codex.rs | 18 ++++++++--- server/src/session/driver.rs | 63 ++++++++++++++++++++++++++++++++++++ 4 files changed, 107 insertions(+), 18 deletions(-) diff --git a/PLAN.md b/PLAN.md index 72ff792..c1b3fa7 100644 --- a/PLAN.md +++ b/PLAN.md @@ -453,6 +453,27 @@ error row a screen away. `Unknown` is not "we could not find out": a driver that is gone reported 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) A session's process is **left running when the backend stops and adopted diff --git a/server/src/session/claude.rs b/server/src/session/claude.rs index b8afb6c..908f074 100644 --- a/server/src/session/claude.rs +++ b/server/src/session/claude.rs @@ -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`. diff --git a/server/src/session/codex.rs b/server/src/session/codex.rs index c26bae2..33712f0 100644 --- a/server/src/session/codex.rs +++ b/server/src/session/codex.rs @@ -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, @@ -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> { - 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> { 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})); } diff --git a/server/src/session/driver.rs b/server/src/session/driver.rs index 951c034..9de780e 100644 --- a/server/src/session/driver.rs +++ b/server/src/session/driver.rs @@ -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 /// `.` and is an [`ImageRef`] like any other; any other file /// keeps its own name after the hex, `-`, 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