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;