From 6236f0d5bd5ef3d647955f3cc5e4a5c47cc63ded Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Mon, 31 Aug 2026 23:02:49 -0400 Subject: [PATCH] Show a message another agent sent, on a session this server is running Peer messages were only ever produced by the *import* path, reading them out of the CLI's own session file. A message another agent sent to a session this server was driving appeared nowhere, so the session started working on something nobody on the phone had asked for and there was nothing on screen to explain it. Measured rather than guessed, because the obvious place to look for it is empty: a real cross-session message sent to a real `--input-format stream-json` session on CLI 2.1.237 produces **no `user` record**, and nothing in the partial-message stream mentions it either. The whole of it arrives as an `origin` object on the turn's `result`, in exactly the shape the session file records -- so `import::peer_message` now reads both, one function for one wire format. Two copies would drift the first time a field is renamed, and the half that drifted would go on producing nothing, which is indistinguishable from nobody having sent anything. The cost is the position: the note lands after the reply it caused rather than above it, because at no earlier point in the turn does the CLI say why the turn started. Taken deliberately over the alternative -- a second reader tailing the CLI's own session file for the one record stdout does not carry, which is two sources of truth for one conversation and a poll per live session. Recorded in PLAN.md so that if the CLI ever announces the injection where it happens, the next reader knows to move it there. Both halves tested: the real record shape, and an ordinary result carrying no `origin` -- which is the half that decides whether the check is a check. Four ordinary results on a real session's stdout had none between them. --- AGENTS.md | 7 ++ PLAN.md | 25 +++++++ server/src/session/claude/translate.rs | 91 ++++++++++++++++++++++++++ server/src/session/import.rs | 11 +++- 4 files changed, 132 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 56bdcce..0cf8e4a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -273,6 +273,13 @@ first if a remote spawn ever mangles an argument. reply into the tool output under it -- and a container per row leaves whatever was drawn without one silently unselectable, which nothing on screen reports. Rows keep their tap handlers; selection is a long press. +- **A message from another agent reaches a live session on the turn's + `result`, not before.** Measured on CLI 2.1.237 by sending a real + cross-session message to a real stream-json session: no `user` record, and + nothing in the partial-message stream -- the whole of it is an `origin` + object on the `result`, the same shape the session file records, which is + why `import::peer_message` reads both. So the note is drawn *after* the + reply it caused; that is the wire, not a bug. See PLAN.md. - **A queued message can be tapped to take it back**, which is `POST /sessions/{id}/unqueue` and a `messageDropped` event -- see PLAN.md's "Taking a queued message back". On a **Claude** session it always refuses, diff --git a/PLAN.md b/PLAN.md index 7e37ceb..eb5f2a2 100644 --- a/PLAN.md +++ b/PLAN.md @@ -247,6 +247,31 @@ turn. Claude's dialect: a `user` message on stdin mid-stream; pi's: `steer`. - Images in: base64 image content blocks in the stream-json user message. - Working directory, host, and model are spawn-screen fields. +### A message from another agent, on a live session (measured 2026-08-31) + +Peer messages were only ever produced by the *import* path, reading them out +of the CLI's own session file — so a message another agent sent a session +this server was running never appeared at all, and the session simply +started working on something nobody on the phone had asked for. + +Measured rather than guessed, by sending a real cross-session message to a +real `--input-format stream-json` session on CLI 2.1.237: the CLI emits **no +`user` record** for it, and nothing in the partial-message stream mentions +it. The whole of it arrives as an `origin` object on the turn's `result`, in +the same shape the session file records — `kind: "peer"`, the sending +session's `name`, and the message as `body` — so `import::peer_message` reads +both, and there is one function for one wire format. Only peer-caused turns +carry it: four ordinary results on a real session's stdout had no `origin` +between them. + +**The cost is the position.** The note lands after the reply it caused rather +than above it, because at no earlier point in the turn does the CLI say why +the turn started. The alternative is a second reader tailing the CLI's own +session file for the one record stdout does not carry — two sources of truth +for one conversation and a poll per live session — and it was rejected on +that. If the CLI ever announces the injection at the point it happens, this +moves to that record and the ordering comes right with it. + ### Taking a queued message back (decided 2026-08-31) A message sent into a running turn is drawn as a bubble waiting below the diff --git a/server/src/session/claude/translate.rs b/server/src/session/claude/translate.rs index b1c1b5b..17a7d51 100644 --- a/server/src/session/claude/translate.rs +++ b/server/src/session/claude/translate.rs @@ -221,6 +221,33 @@ impl Translator { .and_then(Value::as_u64) .unwrap_or(0); let mut events = Vec::new(); + // A turn another agent started, which is only knowable here. + // + // Measured against CLI 2.1.237 (2026-08-31) by sending a + // real cross-session message to a real stream-json session: + // the CLI emits no `user` record for it, and nothing in the + // partial-message stream mentions it either. The whole of + // it arrives as an `origin` object on the turn's `result`, + // in the same shape the session file records -- so this is + // `import::peer_message` reading a different record. + // + // The cost is the position: the note lands after the reply + // it caused rather than above it, because at no earlier + // point in the turn does the CLI say why the turn started. + // Taken deliberately over the alternative, which is a + // second reader tailing the CLI's own session file for the + // one record stdout does not carry -- two sources of truth + // for one conversation, and a poll per live session. What + // it buys is the thing that was missing entirely: a session + // that starts working on something nobody on this phone + // asked for is otherwise unexplainable from the phone. + // + // Only peer-caused turns carry it: measured over a real + // session's stdout, four ordinary results and no `origin` + // between them. + if let Some(peer) = crate::session::import::peer_message(message) { + events.push(peer); + } // Whichever way this result went, the interrupt it may have // been answering is now spent. let asked_to_stop = std::mem::take(&mut self.interrupting); @@ -1089,6 +1116,70 @@ mod tests { ); } + /// A turn another agent started says so, on the record that carries it. + /// + /// The line is the real shape, taken from a real cross-session message + /// sent to a real stream-json session on CLI 2.1.237 (2026-08-31) -- + /// including the `from` socket path, which is deliberately *not* what a + /// reader is shown: the sending session's `name` is what they recognise + /// it by. The `body` is the message as it was written; the content the + /// model is given beside it wraps the same text in a preamble and a + /// `` tag, which is written for the model rather + /// than for a person. + /// + /// The note comes before the usage and the idle, so it sits as close to + /// the turn it explains as the wire allows -- which is after the reply, + /// not above it. See the comment at the callsite for why that is the + /// best available position rather than an oversight. + #[test] + fn a_turn_started_by_another_agent_records_who_and_what() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut translator = Translator::new(dir.path().to_path_buf()); + let events = translate_lines( + &mut translator, + &[ + r#"{"type":"result","subtype":"success","is_error":false,"session_id":"s","usage":{"input_tokens":2,"output_tokens":5},"origin":{"kind":"peer","from":"uds:/run/user/1000/cc-socks/137108.sock","verifiedPeerPid":137108,"msg_id":"1e729740","name":"ai-app-2-fb","fromMode":"prompting","body":"Reply with just the word ACK."}}"#, + ], + ); + assert_eq!( + events, + vec![ + Event::PeerMessage { + from: "ai-app-2-fb".to_string(), + text: "Reply with just the word ACK.".to_string(), + }, + Event::UsageDelta { + tokens: 7, + context: None + }, + Event::Status { + state: SessionStatus::Idle + }, + ] + ); + } + + /// And an ordinary turn does not, which is the half that decides + /// whether the check above is a check or a rubber stamp. Measured over + /// a real session's stdout: four results, no `origin` between them. + #[test] + fn an_ordinary_turn_carries_no_peer_note() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut translator = Translator::new(dir.path().to_path_buf()); + let events = translate_lines( + &mut translator, + &[ + r#"{"type":"result","subtype":"success","is_error":false,"session_id":"s","usage":{"input_tokens":2,"output_tokens":5}}"#, + ], + ); + assert!( + !events + .iter() + .any(|event| matches!(event, Event::PeerMessage { .. })), + "a turn nobody else started must not be attributed to anyone: {events:?}" + ); + } + /// The context is the last assistant message's, not the result's. /// /// Real figures from a two-message haiku turn on 2.1.237, captured diff --git a/server/src/session/import.rs b/server/src/session/import.rs index 74bd783..c43f6dc 100644 --- a/server/src/session/import.rs +++ b/server/src/session/import.rs @@ -520,7 +520,7 @@ pub fn events_from(text: &str, session_dir: &std::path::Path) -> Vec { events } -/// A message from another agent, as the CLI records one. +/// A message from another agent, as the CLI reports one. /// /// Measured from a real session file (2026-08-29): the record is a `user` /// one marked `isMeta`, and its `origin` carries `kind: "peer"`, the @@ -529,7 +529,14 @@ pub fn events_from(text: &str, session_dir: &std::path::Path) -> Vec { /// preamble and a `` tag, which is written for the /// model that has to read it rather than for a person -- so the body is /// what a reader is shown, and the name is who they are told sent it. -fn peer_message(record: &Value) -> Option { +/// +/// Shared with the live driver (`claude::translate`), which finds the same +/// `origin` object on a different record -- so this reads the object and +/// not the record around it. One function because it is one wire format: +/// two copies would drift the first time the CLI renames a field, and the +/// half that drifted would go on producing nothing at all, which is +/// indistinguishable from nobody having sent anything. +pub(in crate::session) fn peer_message(record: &Value) -> Option { let origin = record.get("origin")?; if origin.get("kind").and_then(Value::as_str) != Some("peer") { return None;