Let the server say what is waiting, instead of the phone remembering

A message sent into a running turn was drawn as a pending bubble from
screen state, so leaving the session or restarting the app showed nothing
waiting while the queue was full. Nothing waiting is what "there is
nothing" looks like -- the reader had no way to tell it from a queue that
had already drained, and Bryan hit exactly that: a message he sent
arrived, and his phone stopped showing it after a restart.

The server now records the waiting. `MessageQueued { id, text }` goes into
the transcript when a driver takes a message it cannot deliver yet, and
is resolved by the `UserMessage` carrying the same id -- the same shape
`CommandQueued` and `CommandSent` already had, so this is one more
instance of a mechanism rather than a second one beside it.

The message itself still lands where the session read it, which is what
the last change was about; only the *waiting* is recorded early. The two
are different facts and now have different events.

Paired by id rather than by text. The old code removed the bubble whose
text matched, so sending the same thing twice cleared the wrong one and
left a message on screen that had already been read.

Both drivers that can queue do it: the echo driver too, because the phone
now draws pending bubbles from the stream and a rig that skipped the
event would exercise a state the real app never sees.

Checked on the emulator: two messages sent into a `/slow` turn, then the
app force-stopped and relaunched -- both still drawn as waiting, in the
pending style, and both resolved into ordinary bubbles when the turn
ended and the session read them.

Still outstanding, and worth knowing: an entry outlives a *server*
restart in the transcript but not in the driver's memory, so a backend
restarted mid-queue would leave the bubble drawn with nothing coming to
resolve it. Before this change that message vanished from the transcript
entirely, so the failure is now visible rather than silent -- but it is
not yet right.
This commit is contained in:
iris committed 2026-08-29 22:29:12 -04:00
1 parent ba71c798f5
commit e37e90a579
9 files changed
+177 -48

No files matched your search

+28 -9
View File
@@ -73,7 +73,9 @@ pub struct EchoDriver {
/// dropped to idle immediately, so a phone had nothing to show as
/// pending. Holding it here is what makes echo able to stand in.
busy: Arc<AtomicBool>,
queued: Arc<Mutex<Vec<String>>>,
/// Held messages with the id of the `MessageQueued` each one announced,
/// so the announcement can say which waiting bubble it resolves.
queued: Arc<Mutex<Vec<(String, String)>>>,
/// Ids of the questions awaiting an answer, in the order they were
/// asked. A list because `/ask` puts up to four on one tool call, the
/// way AskUserQuestion does, and the turn resumes when the last of
@@ -207,7 +209,15 @@ impl EchoDriver {
// session went idle the instant one arrived, and every state that
// only exists while something is queued was untestable.
if self.busy.load(Ordering::SeqCst) {
self.queued.lock().unwrap().push(text);
// The waiting is recorded, exactly as the real driver records
// it: the phone draws its pending bubbles from the server, so
// an echo session has to produce the same events or the states
// it exists to exercise are not the app's real ones.
let id = super::random_hex();
self.queued.lock().unwrap().push((id.clone(), text.clone()));
if announce {
self.emit(Event::MessageQueued { id, text });
}
return;
}
@@ -219,7 +229,10 @@ impl EchoDriver {
// from the transcript is the one thing echo must not model.
if let Some(rest) = text.strip_prefix("/peer") {
if announce {
self.emit(Event::MessageTaken { text: text.clone() });
self.emit(Event::MessageTaken {
id: None,
text: text.clone(),
});
}
self.emit(Event::PeerMessage {
from: "dev-updater-f5".to_string(),
@@ -240,7 +253,7 @@ impl EchoDriver {
// this is the typed path onto it.
if text.trim() == "/compact" {
if announce {
self.emit(Event::MessageTaken { text });
self.emit(Event::MessageTaken { id: None, text });
}
self.compact();
return;
@@ -248,7 +261,7 @@ impl EchoDriver {
if text.trim() == "/ask" {
if announce {
self.emit(Event::MessageTaken { text });
self.emit(Event::MessageTaken { id: None, text });
}
self.ask_user_question();
return;
@@ -322,7 +335,10 @@ impl EchoDriver {
// message it thinks is still queued, and the point of an echo
// provider is that it behaves like the real ones.
if announce {
send(Event::MessageTaken { text: text.clone() });
send(Event::MessageTaken {
id: None,
text: text.clone(),
});
}
send(Event::Status {
state: SessionStatus::Running,
@@ -438,14 +454,17 @@ impl EchoDriver {
/// moment a real CLI would have injected it. One place, because a turn has
/// several ways to end (a reply, an interrupt, a compaction) and every one
/// of them owes the same answer.
fn finish_turn(sink: &EventSink, queued: &Mutex<Vec<String>>, busy: &AtomicBool) {
fn finish_turn(sink: &EventSink, queued: &Mutex<Vec<(String, String)>>, busy: &AtomicBool) {
let held = std::mem::take(&mut *queued.lock().unwrap());
for text in held {
for (id, text) in held {
// Announced before it is answered, in that order: a phone showing
// the message as pending needs the signal that it has been read,
// and the answer is meaningless above a message still drawn as
// waiting.
let _ = sink.send(Event::MessageTaken { text: text.clone() });
let _ = sink.send(Event::MessageTaken {
id: Some(id),
text: text.clone(),
});
let _ = sink.send(Event::AssistantText {
delta: format!("\n(taken from the queue) You said: {text}"),
});