Draw a peer message above the turn it started, not below it
The live Claude Code path only learns a turn was another agent's when the turn ends -- the whole of the message arrives as an `origin` object on the `result` -- so the note was appended after everything it caused, and the transcript showed the answer above the question. It cannot be recorded in place: by the time anyone knows, the reply is already written, and the transcript is append-only. So the event carries where it belongs instead. `PeerMessage` gains `turnStart`, the seq of the status that opened its turn, stamped by the pump -- the only thing that knows a seq and the only thing that sees every driver's turns. The phone gives the note that seq, so it sorts into place rather than being drawn out of order at the end. A status draws no row, so there is nothing for it to collide with and the list stays sorted, which the scroll anchor and paging both depend on. Absent where there is nothing to correct: a message replayed out of a session file by `import` is already in the right place, and one that opened no turn has no turn to sit above. Both stay where they arrive. The echo driver gets `/peer-turn` for the live shape, beside `/peer` for the in-place one. Verified on the emulator both ways, live and on replay, plus an ordinary `/tools` turn to confirm the run grouping the insertion cuts through is unaffected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7525fc925a
commit
465645cefb
9 files changed
+246
-13
No files matched your search
@@ -2200,14 +2200,29 @@ async fn pump(
|
||||
// the order the transcript has them -- and because the answer has to
|
||||
// survive being asked a moment later than the driver would have said it.
|
||||
let mut unread: usize = 0;
|
||||
// Where the turn currently running began: the seq of the `Status`
|
||||
// that opened it, which is recorded before any of the turn's own
|
||||
// output. Held here because the pump is the only place that knows a
|
||||
// seq at all, and the only one that sees every driver's turns.
|
||||
let mut turn_start: Option<u64> = None;
|
||||
while let Some(event) = source.recv().await {
|
||||
let ts = now();
|
||||
// Taking a message is how it enters the conversation, and the
|
||||
// conversation is what a phone renders -- so the event becomes the
|
||||
// message here rather than being carried alongside it. One rule
|
||||
// for where a user's message sits: where the session read it.
|
||||
//
|
||||
// A peer message is stamped with the same knowledge for the
|
||||
// opposite reason: it arrives *after* everything it caused, and
|
||||
// the position is the only way a reader can put it back where it
|
||||
// happened -- see `Event::PeerMessage::turn_start`.
|
||||
let event = match event {
|
||||
Event::MessageTaken { id, text, images } => Event::UserMessage { id, text, images },
|
||||
Event::PeerMessage { from, text, .. } => Event::PeerMessage {
|
||||
from,
|
||||
text,
|
||||
turn_start,
|
||||
},
|
||||
other => other,
|
||||
};
|
||||
// Where the session row's figure comes from. Kept here rather than
|
||||
@@ -2264,6 +2279,20 @@ async fn pump(
|
||||
}
|
||||
*shared.last_activity.lock().unwrap() = ts;
|
||||
*shared.written.lock().unwrap() += 1;
|
||||
// The turn's own first line, kept for whatever arrives at
|
||||
// the end of it needing to say where it started. Only the
|
||||
// *opening* status counts: a turn that pauses for a
|
||||
// question or a compaction and resumes is still the turn
|
||||
// that began where it began.
|
||||
match &entry.event {
|
||||
Event::Status {
|
||||
state: SessionStatus::Running,
|
||||
} if turn_start.is_none() => turn_start = Some(entry.seq),
|
||||
Event::Status {
|
||||
state: SessionStatus::Idle | SessionStatus::Exited,
|
||||
} => turn_start = None,
|
||||
_ => {}
|
||||
}
|
||||
// The boundary a held command was waiting for, and the one
|
||||
// place that sees every driver's. Done after the status is
|
||||
// recorded, so the command that runs next sees an idle
|
||||
@@ -2423,6 +2452,62 @@ mod tests {
|
||||
assert!(matches!(events.try_recv(), Ok(Event::CommandSent { .. })));
|
||||
}
|
||||
|
||||
/// A peer message is stamped with where its turn began, so a phone can
|
||||
/// draw it above the reply it caused rather than below it.
|
||||
///
|
||||
/// The live CLI reveals the message only on the turn's `result`, and an
|
||||
/// append-only transcript cannot go back and insert it -- so the
|
||||
/// position has to travel with the event. Without it the note is drawn
|
||||
/// at the end of the turn, which reads as an answer printed above its
|
||||
/// question.
|
||||
#[tokio::test]
|
||||
async fn a_peer_message_carries_the_seq_its_turn_started_at() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let config_path = dir.path().join("config.ron");
|
||||
let data_dir = dir.path().join("sessions");
|
||||
seed_echo_only(&config_path);
|
||||
let manager = SessionManager::new(config_path, data_dir.clone(), data_dir.join("models"))
|
||||
.expect("manager");
|
||||
let info = manager.spawn_session(echo_spec()).expect("spawn");
|
||||
let session = manager.session(&info.id).expect("live");
|
||||
let mut rx = session.subscribe();
|
||||
|
||||
session.send_message("/peer-turn".to_string(), Vec::new());
|
||||
let seen = collect_until(&mut rx, |event| matches!(event, Event::PeerMessage { .. })).await;
|
||||
|
||||
let opened = seen
|
||||
.iter()
|
||||
.find(|entry| {
|
||||
matches!(
|
||||
entry.event,
|
||||
Event::Status {
|
||||
state: SessionStatus::Running
|
||||
}
|
||||
)
|
||||
})
|
||||
.expect("the turn's opening status")
|
||||
.seq;
|
||||
let note = seen.last().expect("the peer message");
|
||||
let Event::PeerMessage { turn_start, .. } = ¬e.event else {
|
||||
unreachable!()
|
||||
};
|
||||
assert_eq!(*turn_start, Some(opened), "{seen:?}");
|
||||
// And it is worth stamping only because it is genuinely behind the
|
||||
// turn it explains.
|
||||
assert!(note.seq > opened, "{seen:?}");
|
||||
|
||||
// A message that opened no turn is left where it arrived: an
|
||||
// import replays those in place already.
|
||||
session.send_message("/peer".to_string(), Vec::new());
|
||||
let alone =
|
||||
collect_until(&mut rx, |event| matches!(event, Event::PeerMessage { .. })).await;
|
||||
let Some(Event::PeerMessage { turn_start, .. }) = alone.last().map(|entry| &entry.event)
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
assert_eq!(*turn_start, None, "{alone:?}");
|
||||
}
|
||||
|
||||
/// A command waits for the *driver* to be between turns, not for the
|
||||
/// recorded status to say idle.
|
||||
///
|
||||
|
||||
Reference in new issue
Block a user