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:
1 parent
ef1aad8776
commit
1bbb642973
13 files changed
+171
-407
No files matched your search
@@ -126,9 +126,6 @@ seq N", so there is no separate history path to drift from the live one.
|
||||
waiting for itself and will speak again with nobody having typed anything.
|
||||
Reporting it as idle sent a "finished" notification at the one moment that
|
||||
was untrue.
|
||||
- `TaskNote { about, title, status, summary }` (2026-09-06) — a task the
|
||||
session started in the background reporting back. See "A task reporting
|
||||
back".
|
||||
- `UsageDelta { tokens, context }` — what a turn cost and how much the model
|
||||
was holding when it ended. `context` is prompt plus both cache figures,
|
||||
taken from the **last assistant message** rather than the turn's `result`:
|
||||
@@ -664,49 +661,41 @@ to end that way on 2026-09-05: the wait moved from the dialect's two minutes
|
||||
to the meter's seven when the meter changed its mind, and the message went
|
||||
out on the first check after the meter came back under the limit.
|
||||
|
||||
### A task reporting back (2026-09-06)
|
||||
### Two turns must never be drawn as one (2026-09-06)
|
||||
|
||||
**A subagent finishing is a message the session receives, and it gets a row.**
|
||||
The CLI says so on a `system/task_notification` line carrying the task's
|
||||
status and its own closing summary; the parent then wakes up and runs a turn
|
||||
because of it. Before this the parent's transcript had nothing between the
|
||||
reply that ended the previous turn and the reply that answered the
|
||||
notification, and the phone's fold grew the older message rather than starting
|
||||
a new one — so two answers were drawn as one paragraph, running together
|
||||
A turn can start with nothing recorded in front of it — a subagent reporting
|
||||
back, a peer message the CLI only owns up to at the end, a conversation the CLI
|
||||
picks up by itself. The phone's fold grew the last reply rather than starting a
|
||||
new one, so two answers were drawn as one paragraph, running together
|
||||
mid-sentence with not even a space between them.
|
||||
|
||||
Both halves were wrong and both are fixed. The fold now refuses to grow a
|
||||
*settled* reply, so a turn boundary is always a message boundary whatever
|
||||
caused it (`joinPages` carries the same rule across a page boundary). And the
|
||||
notification is recorded as `Event::TaskNote`, on its own row rather than as an
|
||||
update to the Task call's — that row is wherever the call was made, above
|
||||
everything the session has said since, and it would change where no reader is
|
||||
looking.
|
||||
**The fold refuses to grow a *settled* reply**, so a turn boundary is always a
|
||||
message boundary whatever caused it; `joinPages` carries the same rule across a
|
||||
page boundary. Where two replies then abut, the fold puts a `TurnBreak` between
|
||||
them: a hairline rule, no words, no colour. It is 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.
|
||||
|
||||
**Drawn as a divider, closed, and not carrying the subagent's words.** It
|
||||
marks a boundary, which is what the reader needs from it; the subagent's
|
||||
closing report is recorded as that subagent's own transcript's closing text,
|
||||
and repeating it in the parent puts the same paragraph in two places for
|
||||
somebody who did not ask for it. Opening the divider shows it anyway, because
|
||||
leaving the conversation to read one line has its own cost — and because a
|
||||
backgrounded *command* has no transcript of its own, so this is the only place
|
||||
its report exists at all. That one names itself from its summary and has
|
||||
nothing left to open.
|
||||
**Nothing else about a background task goes in the session's transcript.** That
|
||||
was tried and was wrong: a row per finished subagent 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. A subagent's closing report is
|
||||
recorded as that subagent's own transcript's closing text and is read there.
|
||||
The parent's transcript gets a row for a message a subagent genuinely *sends*
|
||||
it, which arrives by the peer path and already has one.
|
||||
|
||||
`status` is carried beside `summary` rather than folded into it because the
|
||||
summary is absent exactly when things went wrong, and "finished" is the wrong
|
||||
word for a task that was killed.
|
||||
|
||||
Reported once. The two lifecycle shapes (`task_notification` and
|
||||
`task_updated`) can both arrive for one task, and whichever gets here first is
|
||||
the one that finds the task open — in the translator's own `open_tasks`, or
|
||||
failing that in the registry, which is what makes an **adopted** session work.
|
||||
A backend restart picks a session's stdout back up from a recorded offset, so
|
||||
the `task_started` lines for anything already running are behind it and the
|
||||
translator never sees them; `Subagents::any_open` is the measurement that
|
||||
covers those, and `open_tasks` covers the backgrounded command, which has no
|
||||
subagent to be found in the registry at all. Both are needed and neither
|
||||
subsumes the other.
|
||||
What the notification is still used for is the status: it is what closes a task
|
||||
in `Status::Waiting`'s bookkeeping. Handled once, however many of the two
|
||||
lifecycle shapes (`task_notification`, `task_updated`) arrive — whichever gets
|
||||
there first is the one that finds the task open, in the translator's own
|
||||
`open_tasks` or, failing that, in the registry. That second lookup is what makes
|
||||
an **adopted** session work: a backend restart picks a session's stdout back up
|
||||
from a recorded offset, so the `task_started` lines for anything already running
|
||||
are behind it and the translator never sees them. `Subagents::any_open` covers
|
||||
those, and `open_tasks` covers the backgrounded command, which has no subagent
|
||||
to be found in the registry at all. Both are needed and neither subsumes the
|
||||
other.
|
||||
|
||||
### A limit a subagent hits is the session's (2026-09-06)
|
||||
|
||||
|
||||
Reference in new issue
Block a user