Take subagent reports out of the main transcript, and separate turns with a rule

A row per finished background task is a screenful of dividers about work the
reader was not asking after, and one of them turned out to be a whole shell
command drawn as centred prose, because its words came from somewhere with no
reason to keep them short. `Event::TaskNote` is gone entirely, along with the
row that drew it. A subagent's closing report is recorded as that subagent's
own transcript's closing text and is read in the subcard, which is where it
was already going; what the parent gets a row for is a message a subagent
genuinely sends it, which arrives by the peer path and has had one all along.

What remains is the actual defect and the smallest thing that fixes it. The
fold still refuses to grow a settled reply, so a turn boundary is always a
message boundary, and where two replies then abut it puts a `TurnBreak`
between them: a hairline, no words, no colour. Made by the fold rather than
sent by the server, because it is not something that happened -- it is the
boundary between two things that did. `joinPages` puts one in at a page seam,
which the fold never gets to see.

The task notification is still what closes a task in `Status::Waiting`'s
bookkeeping, and the registry lookup that recognises one this translator never
saw start is what makes that work for a session adopted across a restart.

Verified on the emulator: three replies, three rules, and nothing about the
helpers anywhere in the parent. 170 server tests, 85 JVM tests, ktfmt, clippy,
rustfmt and Android lint clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-09-06 21:39:19 -04:00
1 parent ef1aad8776
commit 1bbb642973
13 files changed
+171 -407

No files matched your search

+31 -52
View File
@@ -594,19 +594,19 @@ impl Translator {
/// A task reporting back, from whichever of the two lines got here first.
///
/// Reported once. The two shapes can both arrive for one task, and the
/// [`Translator::tasks`] entry is what says which of them is the first --
/// it is removed here, so a second line for the same task finds nothing
/// and says nothing. That is also what stops a task being counted as
/// outstanding for ever.
/// Handled once. The two shapes can both arrive for one task, and what
/// says which of them is the first is that it finds the task still open.
///
/// Three things come out of it, and the third is the one that is easy to
/// leave out. The summary goes into the subagent's own transcript, which
/// is the only place its closing words ever appear; the subagent is
/// finished; and the *parent* gets an [`Event::TaskNote`], because a
/// message arriving is something that happened to this session and the
/// turn it wakes up and runs would otherwise begin with nothing in front
/// of it.
/// **Nothing about it reaches the parent's transcript**, deliberately.
/// The summary is the subagent's own closing words and goes into the
/// subagent's own transcript, which is the only place it belongs; a row
/// per finished task in the session's transcript is a screen of dividers
/// about work the reader was not asking after, and the session did not
/// receive a message it could act on. What *does* reach the parent is a
/// message a subagent genuinely sends it, which arrives by the peer
/// path. The only thing produced here is the status: a session with
/// nothing outstanding any more has stopped being
/// [`SessionStatus::Waiting`].
fn task_ended(
&mut self,
about: Option<String>,
@@ -631,23 +631,12 @@ impl Translator {
if !self.open_tasks.remove(&about) && !self.subagents.is_open(&about) {
return Vec::new();
}
if let Some(summary) = &summary {
self.subagents.record(
&about,
Event::AssistantText {
delta: summary.clone(),
},
);
if let Some(summary) = summary {
self.subagents
.record(&about, Event::AssistantText { delta: summary });
}
self.subagents.finish(&about);
let mut events = vec![Event::TaskNote {
title: self.subagents.title_of(&about),
about,
// Present by construction: `ended` says no to a line with no
// status at all.
status: status.unwrap_or_default().to_string(),
summary,
}];
let mut events = Vec::new();
// The last outstanding task, with the session's own turn already
// over: it has stopped being `Waiting` and nothing else will say so.
// Inside a turn there is nothing to announce -- the turn's own
@@ -1473,17 +1462,9 @@ mod tests {
r#"{"type":"system","subtype":"task_notification","task_id":"t1","tool_use_id":"toolu_bg","status":"completed","summary":"pushed as c41c36f"}"#,
],
),
vec![
Event::TaskNote {
about: "toolu_bg".into(),
title: Some("the Dev Updater agent".into()),
status: "completed".into(),
summary: Some("pushed as c41c36f".into()),
},
Event::Status {
state: SessionStatus::Idle
},
]
vec![Event::Status {
state: SessionStatus::Idle
}]
);
// Reported once. The two lifecycle shapes can both arrive for one
@@ -1539,17 +1520,9 @@ mod tests {
r#"{"type":"system","subtype":"task_notification","task_id":"old","tool_use_id":"toolu_old","status":"completed","summary":"pushed"}"#,
],
),
vec![
Event::TaskNote {
about: "toolu_old".into(),
title: Some("the Dev Updater agent".into()),
status: "completed".into(),
summary: Some("pushed".into()),
},
Event::Status {
state: SessionStatus::Idle
},
]
vec![Event::Status {
state: SessionStatus::Idle
}]
);
// Once: `finish` closed it, so the second shape finds nothing.
assert!(
@@ -1586,10 +1559,16 @@ mod tests {
!events.iter().any(closes_a_turn),
"the turn has not ended: {events:?}"
);
// The helper's summary went where it belongs and nowhere else.
let subagent = subagents.get("toolu_fg").expect("subagent started");
let lines =
crate::session::transcript::read_after(&subagent.transcript_path(), 0).expect("read");
assert!(
events
.iter()
.any(|event| matches!(event, Event::TaskNote { .. }))
lines.iter().any(|entry| matches!(
&entry.event,
Event::AssistantText { delta } if delta == "done"
)),
"{lines:?}"
);
}
-40
View File
@@ -208,46 +208,6 @@ pub enum Event {
#[serde(default, skip_serializing_if = "Option::is_none")]
turn_start: Option<u64>,
},
/// A task the session started in the background reporting back: a
/// subagent that has finished, or a backgrounded command.
///
/// Recorded because the turn the session wakes up and runs would
/// otherwise have nothing in front of it: two replies met with no row
/// between them and were folded into one, so a phone drew the answer to
/// a question nobody could see as a continuation of the previous
/// sentence. It is drawn as a **divider** rather than as a message --
/// what it marks is the boundary, and the subagent's own words are in
/// the subagent's own transcript, which is where somebody who wants them
/// looks. Repeating them here would be the same text in two places, and
/// the copy is the one that goes stale.
///
/// Its own kind rather than an update to the Task call's row: that row
/// is wherever the call was made, which is above everything the session
/// has said since, and a reader at the bottom of the transcript would
/// never see it change.
TaskNote {
/// The `tool_use` id it belongs to. A subagent is named by that id,
/// so this is also how a phone opens the one that just finished.
about: String,
/// What the reader knows the task as -- a subagent's title. `None`
/// for a backgrounded command, which its own summary names; the row
/// says so rather than inventing a title for it.
#[serde(default, skip_serializing_if = "Option::is_none")]
title: Option<String>,
/// How it ended, in the CLI's word: `completed`, `failed`,
/// `cancelled`. Carried rather than folded into the summary because
/// the summary is absent exactly when things went wrong, and
/// "finished" is the wrong word for a task that was killed.
status: String,
/// What it said on the way out, where it said anything.
///
/// Only ever *shown* for a task with no [`TaskNote::title`], which is
/// a backgrounded command: it has no transcript of its own, so this
/// is the only record there is of it. A subagent's report is recorded
/// as that subagent's own closing text and is not repeated here.
#[serde(default, skip_serializing_if = "Option::is_none")]
summary: Option<String>,
},
/// The manager's record of a question being answered, so a rendered
/// question card resolves on every device rather than only the one that
/// answered.
+9 -12
View File
@@ -53,9 +53,10 @@
//! 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.
//! run, each records its closing report in its own transcript, and the
//! parent then runs a turn answering 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
@@ -956,18 +957,14 @@ async fn run_helper(
);
subagents.finish(&id);
let _ = sink.send(Event::ToolEnd {
id: id.clone(),
id,
output: "subagent finished".to_string(),
});
// The boundary the session's next turn begins at, and then that turn: the
// And then the turn the session runs because the task reported back. The
// parent has to say something afterwards, since the defect this
// reproduces is two replies meeting with nothing between them.
let _ = sink.send(Event::TaskNote {
about: id,
title: Some(title.clone()),
status: "completed".to_string(),
summary: Some(summary),
});
// reproduces is two replies meeting with nothing between them -- and
// nothing at all about the helper goes into the *parent's* transcript,
// which is the shape being reproduced.
let _ = sink.send(Event::Status {
state: SessionStatus::Running,
});
-22
View File
@@ -67,10 +67,6 @@ pub struct SubagentInfo {
/// session's but with no driver behind it.
pub struct Subagent {
dir: PathBuf,
/// What a reader knows this subagent as -- `Meta::title`, kept here so
/// naming one costs no file read. Never changes: a subagent is titled
/// once, when it is created.
title: String,
transcript: Mutex<Transcript>,
events: broadcast::Sender<SeqEvent>,
/// Mirrors the transcript's last `Status` event, kept live rather than
@@ -100,13 +96,6 @@ impl Subagent {
*self.status.lock().unwrap() != SessionStatus::Exited
}
/// What a reader knows this subagent as. Empty for one started from a
/// child line before its Task call was seen and never renamed since --
/// see `Subagents::get`, which passes no title on a reopen.
pub fn title(&self) -> &str {
&self.title
}
fn append(&self, event: Event) {
let mut transcript = self.transcript.lock().unwrap();
match transcript.append(event, super::now()) {
@@ -211,7 +200,6 @@ impl Subagents {
let (events, _) = broadcast::channel(EVENT_BUFFER);
Ok(Arc::new(Subagent {
dir,
title: meta.title.clone(),
transcript: Mutex::new(transcript),
events,
status: Mutex::new(status),
@@ -270,16 +258,6 @@ impl Subagents {
}
}
/// What the subagent named `id` is called, or `None` for an id that is
/// not a subagent's at all -- a backgrounded command's tool call reaches
/// here with the same shape, and answering it with a made-up name is
/// worse than answering "this is not one".
pub fn title_of(&self, id: &str) -> Option<String> {
self.get(id)
.map(|subagent| subagent.title().to_string())
.filter(|title| !title.is_empty())
}
/// Whether the subagent named `id` exists and has not finished. `false`
/// for an id that is not a subagent's at all -- a backgrounded command's
/// tool call reaches here with the same shape.