Never run two turns into one, and say when a session waits on its own work

A turn started by something with no row of its own -- a subagent reporting
back, a peer message the CLI only owns up to at the end -- met the previous
reply with nothing between it, and the fold grew that reply rather than
starting a new one. Two answers were drawn as one paragraph, running together
mid-sentence with not even a space between them. The fold now refuses to grow
a settled reply, and `joinPages` carries the same rule across a page boundary.

The other half is the row. `Event::TaskNote` records a background task
reporting back -- a subagent that finished, or a backgrounded command -- with
its title, how it ended and what it said; `TaskNoteRow` draws it as a card,
since somebody said this, and its own row rather than an update to the Task
call's, which is above everything the session has said since. Reported once
however many of the CLI's two lifecycle shapes arrive.

`SessionStatus::Waiting` is a session whose own turn is over while work it
started is not. `Idle` means "waiting for a person" and this means the
opposite, so reporting it as idle sent a "finished" notification at the one
moment that was untrue. Drawn as "waiting" in `waitingColor`; the queue and
the held-command boundary release on either end-of-turn status, so a message
sent while a subagent runs is not held until it finishes.

And a usage limit the account hits inside a subagent now reaches the session
as well as the subagent's transcript. `resume.rs` can only schedule against a
session, and a background Task outliving its parent's turn is the ordinary
case, so auto-resume was doing nothing at all for it.

The status word and its colour were two `when`s on two screens, and the second
missed `waiting` silently; they are `sessionStatusWord`/`sessionStatusColour`
now. Echo's `/subagent n` reproduces the whole shape, staggered a second
apart. Verified on the emulator against the sandbox: 169 server tests, ktfmt,
clippy, rustfmt, Android lint and the JVM unit tests all clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-09-06 18:57:30 -04:00
1 parent 74c07d687a
commit 5711c2568a
17 files changed
+891 -85

No files matched your search

+67 -7
View File
@@ -52,6 +52,10 @@
//! "helper k", its prompt recorded as its own first user message: a
//! streamed reply, one Bash call, then it finishes about three seconds
//! later, the same lifecycle a real Task call has -- see `SUBAGENTS.md`.
//! The parent's own turn ends in `waiting` rather than `idle` while they
//! run, each one reports back with a `TaskNote`, and the parent answers it
//! -- which is the whole of the shape a real background Task produces, and
//! the one where two replies used to be drawn as one paragraph.
//!
//! `/slow` earns its place: a queued message, a Stop button and a spinner are
//! states that only exist mid-turn, and the obvious way to get one -- ask a
@@ -430,13 +434,27 @@ impl EchoDriver {
}),
});
subagents.start(&id, &title, Some(&prompt));
helpers.push((id, sink.clone(), Arc::clone(&subagents)));
helpers.push((k, id, title, sink.clone(), Arc::clone(&subagents)));
}
for (id, sink, subagents) in helpers {
tokio::spawn(run_helper(id, sink, subagents));
// How many are still to report, so the last one to finish
// is the one that puts the session back to idle -- see
// `SessionStatus::Waiting`.
let outstanding = Arc::new(AtomicU64::new(helpers.len() as u64));
for (k, id, title, sink, subagents) in helpers {
tokio::spawn(run_helper(
k,
id,
title,
sink,
subagents,
Arc::clone(&outstanding),
));
}
// Not idle: the session's turn is over but its helpers are
// still going, and it will speak again with nobody having
// typed anything.
let _ = sink.send(Event::Status {
state: SessionStatus::Idle,
state: SessionStatus::Waiting,
});
});
return;
@@ -878,7 +896,18 @@ async fn write_beat(sink: &EventSink, session_dir: &Path, beat: usize) {
/// its `Running` state can be seen on the phone before it finishes. The
/// parent's own Task call for it ends at the same moment, the same way a
/// real Task's `tool_result` ends it.
async fn run_helper(id: String, sink: EventSink, subagents: Arc<Subagents>) {
/// One echo subagent's whole life, ending in the report its parent wakes up
/// for. `outstanding` is how many helpers are still to report; the one that
/// takes it to zero is the one that says the session is idle again, and `k` is
/// which helper this is, which is what staggers them.
async fn run_helper(
k: usize,
id: String,
title: String,
sink: EventSink,
subagents: Arc<Subagents>,
outstanding: Arc<AtomicU64>,
) {
let start = tokio::time::Instant::now();
for word in "Working on it now.".split_inclusive(' ') {
subagents.record(
@@ -906,16 +935,47 @@ async fn run_helper(id: String, sink: EventSink, subagents: Arc<Subagents>) {
output: "helper done".to_string(),
},
);
let target = Duration::from_secs(3);
// Staggered, one second apart: two helpers reporting at the same instant
// interleave the parent's replies word by word, which is a fixture
// artefact -- a real CLI runs one turn at a time -- and it hides the very
// thing this is a fixture for.
let target = Duration::from_secs(2 + k as u64);
let elapsed = start.elapsed();
if elapsed < target {
tokio::time::sleep(target - elapsed).await;
}
subagents.finish(&id);
let _ = sink.send(Event::ToolEnd {
id,
id: id.clone(),
output: "subagent finished".to_string(),
});
// The message the session receives, and then the turn it runs because of
// it: the parent has to say something afterwards, since the defect this
// reproduces is two replies meeting with nothing between them.
let summary = format!("{title} finished and had nothing to report.");
let _ = sink.send(Event::TaskNote {
about: id,
title: Some(title.clone()),
status: "completed".to_string(),
summary: Some(summary),
});
let _ = sink.send(Event::Status {
state: SessionStatus::Running,
});
for word in format!("Noted, {title} is done.").split_inclusive(' ') {
let _ = sink.send(Event::AssistantText {
delta: word.to_string(),
});
tokio::time::sleep(DELTA_DELAY).await;
}
let last = outstanding.fetch_sub(1, Ordering::SeqCst) <= 1;
let _ = sink.send(Event::Status {
state: if last {
SessionStatus::Idle
} else {
SessionStatus::Waiting
},
});
}
/// A message written during a turn and waiting for it to end: the id of the