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

+27
View File
@@ -228,6 +228,33 @@ PLAN.md's "Auto-resume" is the design; day to day:
backend restart. A day after the limit was hit it gives up and says so in
the transcript.
## A session waiting on its own work
Since 2026-09-06 a session whose turn ended with a **backgrounded subagent or
command still running** reports `waiting` rather than `idle` — its own status,
drawn as the word "waiting" in `waitingColor` on both screens. `idle` means
"waiting for a person" and this means the opposite, so it also suppresses the
"finished" notification, which used to arrive at the one moment it was untrue.
Two things fall out of it and are easy to get wrong again: 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 the subagent finishes; and
`sessionWorking("waiting")` is deliberately **false** — nothing is being
written, and the fold uses that same predicate to decide a reply is settled.
- **A task reporting back is a row** (`Event::TaskNote`, `TaskNoteRow`), and
the reply that answers it is a **new** message. The fold refuses to grow a
settled reply; without that, two turns with nothing recorded between them
were folded into one and ran together mid-sentence. `./ui-sandbox.sh` plus
`/subagent 8` in an echo session is the whole rig — the helpers stagger a
second apart so each report and the reply to it are legible.
- **A usage limit a subagent hits reaches the session**, not just the
subagent's own transcript; auto-resume can only schedule against a session.
That is the case where the main agent is idle and a background Task is
still burning quota.
- **The status word and its colour are `sessionStatusWord` /
`sessionStatusColour`**, shared by the list and the session screen. They
were two `when`s, and the second one silently missed `waiting`.
## Shared appearance
- **A row something is happening to is dimmed, drained of colour, and says
+54 -1
View File
@@ -118,7 +118,17 @@ seq N", so there is no separate history path to drift from the live one.
a permission is a question with two bare options, not a different kind.
- `Answered { id, answer }` — so a question card resolves on every connected
device, not just the one that answered.
- `Status { state }` — idle / running / awaiting-input / compacting / exited.
- `Status { state }` — idle / running / awaiting-input / compacting /
**waiting** / exited / unknown. `waiting` (2026-09-06) is the session's own
turn being over while work it started is not: a backgrounded subagent, or a
command left running. Its own state because `idle` and it differ in *kind*
`idle` means the session is waiting for a person, and this means it is
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`:
@@ -654,6 +664,49 @@ 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)
**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
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`, drawn as a card naming who
reported and what they said — a card rather than a divider, because 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 and would change where no reader
is looking.
`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. `title` is the subagent's; a backgrounded
command has none and its summary names itself, so the card says "a background
task" rather than inventing one.
Reported once. The two lifecycle shapes (`task_notification` and
`task_updated`) can both arrive for one task, and the translator's `tasks` map
is what says which got there first — removing the entry is also what stops the
task counting as outstanding, which is what decides `Status::Waiting`.
### A limit a subagent hits is the session's (2026-09-06)
A background Task runs on long after its parent's turn ended, so **the account
running out while the main agent is idle is the ordinary shape of the problem
rather than an edge of it.** `translate_child` used to record everything a
subagent produced into the subagent's own transcript and return nothing, which
meant `Event::LimitReached` never reached the session — and the session is the
only thing `resume.rs` can schedule against. That session then waited for a
person for ever, with nothing anywhere saying so. The limit is hoisted now: it
goes into the subagent's transcript, where it happened, *and* out to the
session, which is what auto-resume needs.
### Subagents (2026-09-05)
**A subagent is a second transcript owned by a session, in the same event
+25 -4
View File
@@ -77,9 +77,28 @@ transcript is still being written to and its process is the session's to stop.
kept as a second detector for a dialect that does say either, and must
never be the only one again.
`Status Exited` either way; the subagent's vocabulary has no `Idle`, so
the equivalent event `dispatch` produces for an ordinary session is
dropped rather than written.
`Status Exited` either way; the subagent's vocabulary has no `Idle` or
`Waiting`, so the end-of-turn status `dispatch` produces for an ordinary
session is dropped rather than written.
**The ending is also reported to the parent** (2026-09-06), as
`Event::TaskNote { about, title, status, summary }`: the notification is a
message the session received, and the turn it wakes up and runs would
otherwise begin with nothing in front of it -- which drew two replies as
one paragraph. Reported once however many of the two lifecycle shapes
arrive; the `task_id -> tool_use_id` entry is removed as it is reported,
which is what says the first one got there. See PLAN.md's "A task
reporting back".
**While any task is outstanding the session's turn ends in
`Status Waiting` rather than `Idle`** -- the same `tasks` map, asked
whether it is empty. `Idle` means "waiting for a person", and a session
with a backgrounded subagent is not doing that.
**A limit the account hits inside a subagent is hoisted to the session**
as well as recorded here, because `resume.rs` can only schedule against a
session, and a background subagent outliving its parent's turn is the
ordinary case -- see PLAN.md's "A limit a subagent hits is the session's".
4. **A child line for a subagent that already finished reopens it**
(`Status Running`) rather than being dropped: a background Task can be
sent another message long after its first turn ended, and that is
@@ -145,7 +164,9 @@ list and the delete cannot disagree about it.
`status` is the transcript's last `Status` event, serialised like a session's
(`running`, `exited`), except that a subagent whose session is not itself
running cannot be running: the list answers `unknown` for that one. The
running cannot be running: the list answers `unknown` for that one. A
subagent never reports `waiting`: that is a session's word for having
outstanding work of its own, and a subagent has none. The
phone words these as *running*, *finished* and *unknown* on the subcard.
The count on `SessionInfo` is a directory listing, so the list stays cheap.
@@ -108,6 +108,28 @@ sealed class SessionEvent {
val turnStart: Long? = null,
) : SessionEvent()
/**
* A task the session started in the background reporting back: a subagent that has finished, or
* a backgrounded command.
*
* It is a message the session *received*, and the turn it wakes up and runs follows it. Without
* a row for it, that turn's reply met the previous one with nothing in between and the two were
* folded into a single message -- one answer running straight into the next mid-sentence.
*/
data class TaskNote(
/** The tool call it belongs to; a subagent is named by that id. */
val about: String,
/**
* What the reader knows the task as -- a subagent's title. Null for a backgrounded command,
* which its own summary names; the row says so rather than inventing a title.
*/
val title: String?,
/** How it ended, in the CLI's word: "completed", "failed", "cancelled". */
val status: String,
/** What it said on the way out, where it said anything. */
val summary: String?,
) : SessionEvent()
/**
* A command the session was asked to run on itself and cannot run yet. Resolved by
* [CommandSent] with the same id; a command that ran straight away has only that one.
@@ -252,6 +274,13 @@ fun parseSeqEvent(json: String): SeqEvent {
body.getString("text"),
if (body.has("turnStart")) body.getLong("turnStart") else null,
)
"taskNote" ->
SessionEvent.TaskNote(
about = body.getString("about"),
title = body.optString("title").ifEmpty { null },
status = body.getString("status"),
summary = body.optString("summary").ifEmpty { null },
)
"commandQueued" ->
SessionEvent.CommandQueued(body.getString("id"), body.getString("text"))
"commandSent" -> SessionEvent.CommandSent(body.getString("id"), body.getString("text"))
@@ -783,18 +783,8 @@ private fun subagentStatusLabel(status: String) =
@Composable
fun StatusText(status: String) {
val (label, color) =
when (status) {
"awaitingInput" -> "your turn" to awaitingColor
"running" -> "running" to runningColor
"compacting" -> "compacting" to commandColor
"exited" -> "exited" to MaterialTheme.colorScheme.onSurfaceVariant
// Said in words, because it differs in kind from the others rather than in degree: the
// session is not idle and has not exited, nobody has been able to find out which. A
// muted colour alone would read as one of the quiet states.
"unknown" -> "can't tell" to MaterialTheme.colorScheme.onSurfaceVariant
else -> status to MaterialTheme.colorScheme.onSurfaceVariant
}
val label = sessionStatusWord(status)
val color = sessionStatusColour(status)
Row(verticalAlignment = Alignment.CenterVertically) {
if (sessionWorking(status)) {
// The same colour as the word beside it: the two are one signal, and a spinner in the
@@ -1603,6 +1603,7 @@ fun SessionScreen(
is TranscriptItem.CompactedNote ->
CompactedRow(item)
is TranscriptItem.LimitNote -> LimitRow(item)
is TranscriptItem.TaskNote -> TaskNoteRow(item)
// Never reached: a peer message is flattened into
// its own units. Here because a `when` over the
// item kinds has to stay exhaustive.
@@ -2262,19 +2263,14 @@ private fun SessionStatusRow(
// Every remaining state says which one it is, including the quiet one. The row used to
// name only `exited` and leave the rest blank, so a session sitting idle and one whose
// status nobody could read looked identical -- and a turn that had just been stopped
// showed nothing at all. The words are the session list's own, so one state is not
// called two things depending which screen you are on.
// showed nothing at all. The words and the colour are `sessionStatusWord`'s and
// `sessionStatusColour`'s, shared with the session list so one state is not called two
// things -- or drawn two colours -- depending which screen you are on.
else ->
Text(
when (status) {
"idle" -> "idle"
"exited" -> if (subagent) "finished" else "exited"
"awaitingInput" -> "your turn"
"unknown" -> "can't tell"
else -> status
},
sessionStatusWord(status, subagent),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
color = sessionStatusColour(status),
modifier = Modifier.weight(1f),
)
}
@@ -0,0 +1,56 @@
package com.example.aiapp
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
/**
* What a session's status is called on screen, and what colour it is drawn in.
*
* One pair of functions rather than a branch on each screen that shows a status. There were two,
* and the second silently fell short the moment the server grew a state: `waiting` arrived and the
* session list learned the word and the colour while the session screen's status row printed the
* wire's own word in the muted grey every quiet state uses. That comment already said the words
* were "the session list's own"; this is what makes that true rather than a promise.
*
* A subagent's own three states are deliberately not here -- see `subagentStatusLabel`, which
* collapses everything it does not recognise rather than passing it through, because a subagent has
* fewer states than a session and reporting one it cannot have is worse than reporting none.
*/
fun sessionStatusWord(status: String, subagent: Boolean = false): String =
when (status) {
"idle" -> "idle"
"running" -> "running"
"compacting" -> "compacting"
// Its own word, because the state it is easily mistaken for means the opposite: "idle"
// invites the reader to type something, and a waiting session is going to carry on without
// them. See `SessionStatus::Waiting`.
"waiting" -> "waiting"
"awaitingInput" -> "your turn"
// A subagent's process was always its parent's, so it had none of its own to merely stop.
"exited" -> if (subagent) "finished" else "exited"
// Said in words, because it differs in kind from the others rather than in degree: the
// session is not idle and has not exited, nobody has been able to find out which. A muted
// colour alone would read as one of the quiet states.
"unknown" -> "can't tell"
// A state this build has never heard of, said as itself. The nearest word we do know would
// read as a fact somebody established.
else -> status
}
/**
* The colour that goes with [sessionStatusWord]: the accent is spent on the states that are about
* to do something or want something, and every quiet one shares the muted colour.
*
* Stated beside whatever draws it rather than inherited -- a colour that carries meaning has to
* carry its own contrast, since the surface under it will not change to rescue it.
*/
@Composable
fun sessionStatusColour(status: String): Color =
when (status) {
"awaitingInput" -> awaitingColor
"running" -> runningColor
"compacting" -> commandColor
"waiting" -> waitingColor
else -> MaterialTheme.colorScheme.onSurfaceVariant
}
@@ -0,0 +1,89 @@
package com.example.aiapp
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
/**
* A background task reporting back: a subagent that finished, or a backgrounded command.
*
* A card rather than a divider, and for the same reason a peer message is one -- somebody said
* this. A divider is a fact about the conversation ("everything above is out of context"); this is
* a message that arrived, and the turn under it is the session answering it.
*
* The card is also what separates the two turns. Before it existed, a reply woken by one of these
* met the previous reply with nothing between them and the transcript ran them into one paragraph,
* mid-sentence. The row being *there* is most of the fix; what it says is the rest.
*
* Drawn whole rather than in [cardPiece] slices, unlike a peer message: a summary is one sentence
* the CLI wrote, so there is no unbounded case to bound. If one ever arrives long enough to be
* worth splitting, it belongs in the same flatten a peer message goes through.
*/
@Composable
fun TaskNoteRow(item: TranscriptItem.TaskNote, modifier: Modifier = Modifier) {
Column(
modifier.cardPiece(
top = true,
bottom = true,
fill = CardDefaults.cardColors().containerColor,
)
) {
Text(
taskNoteHeading(item.title, item.status),
style = MaterialTheme.typography.titleSmall,
color = taskNoteColor(item.status),
)
if (item.summary != null) {
Text(
item.summary,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 4.dp),
)
}
}
}
/**
* What the card says: who reported, and how it went.
*
* Its own function so the wording is testable without a screen, and because the case that decides
* whether this is any good is the one nobody builds a screen for -- a task that failed or was
* killed. "Message from" is the right sentence for exactly one of the endings; using it for all of
* them would report a task that died as one that had something to say.
*
* A status word this build has never seen is said as itself rather than mapped onto the nearest
* one, since the nearest one would read as a decision somebody made.
*/
fun taskNoteHeading(title: String?, status: String): String {
// A backgrounded command has no title of its own -- its summary names it -- so the card says
// what it was rather than inventing a name for it.
val who = title ?: "a background task"
return when (status) {
"completed" -> "Message from $who"
"failed" -> "$who failed"
"cancelled" -> "$who was cancelled"
else -> "$who: $status"
}
}
/**
* The heading's colour: coloured only where something went wrong.
*
* A task that finished and said something is the ordinary case and takes the ordinary text colour;
* the accent is spent on the one ending a reader would want to find by scanning. Cancelled is
* neither -- somebody chose it, and a deliberate choice is not a problem to report -- and a word
* this build does not recognise is not coloured as a failure, because it is not one. It says
* itself, which is the difference in *kind* that no colour can carry.
*/
@Composable
private fun taskNoteColor(status: String): Color =
when (status) {
"failed" -> failedColor
else -> MaterialTheme.colorScheme.onSurface
}
@@ -134,6 +134,20 @@ val clearedColor: Color
val awaitingColor: Color
@Composable get() = Mocha.Peach
/**
* Waiting on itself: the session's turn is over, but a subagent or a backgrounded command it
* started is still going, and it will speak again with nobody having typed anything.
*
* Its own colour rather than [awaitingColor], which is the opposite state -- that one means the
* reader has something to do, and this one means they specifically do not. Not [runningColor]
* either: nothing is being written, and a green "running" on a session that will say nothing for
* ten minutes is the wrong promise. Blue for the same reason [commandColor] is blue -- not stuck,
* but not replying to you either -- and a different blue because that one is the session acting on
* itself rather than getting on with what was asked.
*/
val waitingColor: Color
@Composable get() = Mocha.Sky
/** Approaching a limit -- still fine, worth seeing. */
val warningColor: Color
@Composable get() = Mocha.Yellow
@@ -143,6 +143,26 @@ sealed class TranscriptItem {
get() = arrived
}
/**
* A task the session started in the background reporting back: a subagent that finished, or a
* backgrounded command.
*
* Its own row rather than an update to the Task call's, which is wherever the call was made --
* above everything the session has said since, where a reader at the bottom would never see it
* change. Here it is where it arrived, in front of the turn it caused.
*/
data class TaskNote(
override val seq: Long,
/** The tool call it belongs to; a subagent is named by that id. */
val about: String,
/**
* The subagent's title, or null for a backgrounded command -- see [SessionEvent.TaskNote].
*/
val title: String?,
val status: String,
val summary: String?,
) : TranscriptItem()
/**
* A command the session ran on itself -- `/compact`, `/rename`. Kept in the transcript rather
* than only shown while it waits, because it explains what follows: a conversation that
@@ -261,9 +281,10 @@ fun joinPages(earlier: List<TranscriptItem>, later: List<TranscriptItem>): List<
/**
* Rejoins a message the page boundary cut, and hands back the two pages to concatenate.
*
* [foldEvent] never leaves two assistant messages next to each other inside one page, so two
* meeting at a join are always the two halves of one reply, and leaving them apart drew a single
* answer as two with a paragraph break through the middle of a sentence.
* [foldEvent] never leaves an *unfinished* assistant message with another behind it inside one
* page, so an unsettled one at a join is always the far half of the reply the boundary cut, and
* leaving the two apart drew a single answer as two with a paragraph break through the middle of a
* sentence. Two settled replies meeting there are two turns and stay two.
*
* The newer half keeps its identity, for the reason [adoptRun] gives. It grows by what the older
* half brings, which is safe here and nowhere else -- the join is at the oldest end of what is
@@ -278,6 +299,10 @@ private fun healSplitMessage(
if (head !is TranscriptItem.AssistantMsg || tail !is TranscriptItem.AssistantMsg) {
return earlier to later
}
// A settled reply is a whole turn, so the two are two answers that happen to meet at the
// boundary rather than one cut in half -- the same distinction the fold makes, and joining them
// here would put back exactly the run-together paragraph it stops.
if (head.settled) return earlier to later
return earlier.dropLast(1) to (listOf(tail.copy(text = head.text + tail.text)) + later.drop(1))
}
@@ -364,9 +389,13 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
// first of them: a row whose identity changed with every delta would be a new row on
// every frame, and the list would jump for the whole of a streamed answer.
val last = items.lastOrNull()
if (last is TranscriptItem.AssistantMsg) {
// A message growing again is not finished, whatever a status said in between.
items.dropLast(1) + last.copy(text = last.text + event.delta, settled = false)
// Only into a reply that is still arriving. A settled one is a turn that ended, and
// text after it belongs to the next turn -- a separate message, drawn as its own row.
// Growing it instead ran two answers together with not even a space between them,
// which is what happens whenever a turn starts with nothing recorded in front of it:
// a subagent reporting back, or a peer message the CLI only owns up to at the end.
if (last is TranscriptItem.AssistantMsg && !last.settled) {
items.dropLast(1) + last.copy(text = last.text + event.delta)
} else {
items + TranscriptItem.AssistantMsg(entry.seq, event.delta)
}
@@ -446,6 +475,15 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
}
}
is SessionEvent.PeerMessage -> placePeerNote(items, entry.seq, event)
is SessionEvent.TaskNote ->
items +
TranscriptItem.TaskNote(
entry.seq,
event.about,
event.title,
event.status,
event.summary,
)
is SessionEvent.CommandSent -> items + TranscriptItem.CommandRow(entry.seq, event.text)
// Screen-level state, not transcript rows -- see SessionScreen.
is SessionEvent.CommandQueued -> items
@@ -0,0 +1,96 @@
package com.example.aiapp
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Where one turn ends and the next begins, which is the part of the fold that had no way of saying
* anything was wrong: two replies run together read as one long answer, and the seam is somewhere
* in the middle of a sentence.
*/
class TranscriptItemsTest {
private var seq = 0L
private fun fold(items: List<TranscriptItem>, event: SessionEvent) =
foldEvent(items, SeqEvent(seq = ++seq, ts = 1.0, event = event))
private fun fold(vararg events: SessionEvent) =
events.fold(emptyList<TranscriptItem>()) { items, event -> fold(items, event) }
private fun texts(items: List<TranscriptItem>) =
items.filterIsInstance<TranscriptItem.AssistantMsg>().map { it.text }
@Test
fun text_after_the_turn_ended_is_a_new_reply_rather_than_more_of_the_last_one() {
val items =
fold(
SessionEvent.AssistantText("You'll get the one-line notice when it lands."),
SessionEvent.Status("idle"),
SessionEvent.AssistantText("Dev Updater fix is pushed."),
)
assertEquals(
listOf("You'll get the one-line notice when it lands.", "Dev Updater fix is pushed."),
texts(items),
)
}
@Test
fun deltas_of_one_reply_still_accumulate_into_it() {
val items =
fold(
SessionEvent.AssistantText("Still "),
SessionEvent.AssistantText("running "),
SessionEvent.Status("running"),
SessionEvent.AssistantText("its tests."),
)
assertEquals(listOf("Still running its tests."), texts(items))
}
@Test
fun a_task_reporting_back_is_a_row_between_the_two_turns() {
val items =
fold(
SessionEvent.AssistantText("Launched it."),
SessionEvent.Status("waiting"),
SessionEvent.TaskNote("toolu_1", "the Dev Updater agent", "completed", "pushed"),
SessionEvent.AssistantText("Noted."),
)
assertEquals(3, items.size, "$items")
assertTrue(items[1] is TranscriptItem.TaskNote, "$items")
assertEquals(listOf("Launched it.", "Noted."), texts(items))
}
/**
* The page-join half of the same rule. A boundary that cuts one reply leaves an unfinished half
* to be rejoined; a boundary that lands between two turns must not join anything, or paging
* back puts the run-together paragraph straight back.
*/
@Test
fun paging_back_rejoins_a_cut_reply_and_leaves_two_finished_ones_apart() {
val cut =
joinPages(
listOf(TranscriptItem.AssistantMsg(1, "half a ")),
listOf(TranscriptItem.AssistantMsg(2, "sentence", settled = true)),
)
assertEquals(listOf("half a sentence"), texts(cut))
val whole =
joinPages(
listOf(TranscriptItem.AssistantMsg(1, "One turn.", settled = true)),
listOf(TranscriptItem.AssistantMsg(2, "The next.", settled = true)),
)
assertEquals(listOf("One turn.", "The next."), texts(whole))
}
/** The endings nobody builds a screen for -- see [taskNoteHeading]. */
@Test
fun a_task_note_says_which_ending_it_was() {
assertEquals("Message from helper 1", taskNoteHeading("helper 1", "completed"))
assertEquals("Message from a background task", taskNoteHeading(null, "completed"))
assertEquals("helper 1 failed", taskNoteHeading("helper 1", "failed"))
assertEquals("helper 1 was cancelled", taskNoteHeading("helper 1", "cancelled"))
// A word this build has never seen is said as itself, not mapped onto the nearest one.
assertEquals("helper 1: evicted", taskNoteHeading("helper 1", "evicted"))
}
}
+5 -1
View File
@@ -938,10 +938,14 @@ fn translate_line(
{
return false;
}
// Either status the turn can end in -- see `SessionStatus::Waiting`.
// A turn that ended with a subagent still running is over for this
// queue's purposes: the CLI will read the next message, and holding
// one back until the subagent reported would sit on it indefinitely.
if matches!(
event,
Event::Status {
state: SessionStatus::Idle
state: SessionStatus::Idle | SessionStatus::Waiting
}
) {
// The case that must not be missed: a message written after the
+297 -37
View File
@@ -108,12 +108,29 @@ pub(super) struct Translator {
/// that only the change into that state is reported -- see
/// [`Translator::translate_rate_limit`].
rate_limited: bool,
/// Which Task call each running task belongs to: the CLI's `task_id`
/// Which Task call each *unfinished* task belongs to: the CLI's `task_id`
/// against the `tool_use_id` this side names a subagent by.
///
/// Needed because the line that says a task *ended* comes in two shapes
/// and only one of them carries the tool id -- see [`Translator::translate_task`].
/// Needed because the line that says a task ended comes in two shapes and
/// only one of them carries the tool id -- see [`Translator::translate_task`].
///
/// Emptied entry by entry as tasks report back, which makes it the answer
/// to two further questions: whether an ending has already been reported
/// (the two shapes can both arrive for one task, and the row belongs in
/// the transcript once), and whether the session still has work outstanding
/// when its own turn ends, which is the difference between `Idle` and
/// [`SessionStatus::Waiting`].
tasks: HashMap<String, String>,
/// Whether a turn is open, judged from this translator's own output: the
/// events that [`super::proves_a_turn`] accepts open one, and the status
/// that ends a turn closes it.
///
/// The same rule the driver uses to announce `Running`, read from the same
/// side of the translation, because two rules for "is a turn running"
/// would disagree the first time either moved. What it decides here is
/// narrow: whether a task reporting back means the session has gone idle,
/// or is merely one of several things happening inside a turn.
in_turn: bool,
}
impl Translator {
@@ -129,6 +146,7 @@ impl Translator {
children: HashMap::new(),
rate_limited: false,
tasks: HashMap::new(),
in_turn: false,
}
}
@@ -191,22 +209,29 @@ impl Translator {
})
.clone();
let events = child.lock().unwrap().dispatch(message);
// A limit the account hit while this subagent was working. It belongs
// in the subagent's transcript, which is where it happened -- and it
// also has to reach the session, which is the only thing auto-resume
// can schedule against: a background subagent can run on long after
// its parent's own turn ended, so "the main agent was idle when the
// limit hit" is the ordinary case rather than an edge of one, and
// swallowing it here left that session waiting for a person for ever.
let mut hoisted = Vec::new();
for event in events {
// The subagent's own vocabulary is Running/Exited/Unknown, never
// Idle -- a background Task is either working or it has ended,
// never merely "between turns" the way a session is. Dropped
// here rather than never produced, so a `result` line's own
// `Idle` (dispatch's ordinary end-of-turn event, for a subagent
// Idle or Waiting -- a background Task is either working or it has
// ended, never merely "between turns" the way a session is.
// Dropped here rather than never produced, so a `result` line's
// own end-of-turn status (dispatch's ordinary one, for a subagent
// dialect that ever sends one) is caught the same way a
// `message_delta` would be.
if !matches!(
event,
Event::Status {
state: SessionStatus::Idle
}
) {
self.subagents.record(id, event);
if closes_a_turn(&event) {
continue;
}
if matches!(event, Event::LimitReached { .. }) {
hoisted.push(event.clone());
}
self.subagents.record(id, event);
}
// What actually ends a subagent's turn: not the parent's
// `tool_result`, which for a background Task arrives at launch
@@ -215,10 +240,26 @@ impl Translator {
if ends_a_turn(message) {
self.subagents.finish(id);
}
Vec::new()
hoisted
}
/// One line of this translator's own session, with [`Translator::in_turn`]
/// kept up to date from what came out of it.
///
/// Here rather than in each arm because it has to hold for every line
/// there is: the set of events that prove a turn is running is
/// [`super::proves_a_turn`]'s, and no arm should have to remember it.
fn dispatch(&mut self, message: &Value) -> Vec<Event> {
let events = self.translate_line(message);
if events.iter().any(closes_a_turn) {
self.in_turn = false;
} else if events.iter().any(super::proves_a_turn) {
self.in_turn = true;
}
events
}
fn translate_line(&mut self, message: &Value) -> Vec<Event> {
match message.get("type").and_then(Value::as_str) {
Some("system") => self.translate_system(message),
// The CLI's own announcement that `/clear` took effect, sent just
@@ -327,8 +368,17 @@ impl Translator {
if tokens > 0 {
events.push(Event::UsageDelta { tokens, context });
}
// Idle means "waiting for a person", and a session with a
// backgrounded subagent or command still running is not doing
// that -- it is waiting for itself, and will speak again with
// nobody having typed anything. Reported as what it is, so
// that nothing tells the reader the work has finished.
events.push(Event::Status {
state: SessionStatus::Idle,
state: if self.tasks.is_empty() {
SessionStatus::Idle
} else {
SessionStatus::Waiting
},
});
events
}
@@ -477,20 +527,18 @@ impl Translator {
if let (Some(task), Some(tool)) = (task_id, tool_use_id) {
self.tasks.insert(task.to_string(), tool.to_string());
}
// The tool call this line is about, from the line itself or from
// whichever earlier line did carry it.
let about = tool_use_id
.map(str::to_string)
.or_else(|| task_id.and_then(|task| self.tasks.get(task).cloned()));
match message.get("subtype").and_then(Value::as_str) {
Some("task_notification") => {
let Some(id) = tool_use_id else {
return Vec::new();
};
if !ended(message.get("status").and_then(Value::as_str)) {
return Vec::new();
}
if let Some(summary) = text_field(message, "summary") {
self.subagents
.record(id, Event::AssistantText { delta: summary });
}
self.subagents.finish(id);
}
Some("task_notification") => self.task_ended(
task_id,
about,
message.get("status").and_then(Value::as_str),
text_field(message, "summary"),
),
Some("task_updated") => {
let status = message
.get("patch")
@@ -504,21 +552,81 @@ impl Translator {
// failed or was cancelled has not been observed here, and the
// failure to avoid is the one this whole function exists for:
// a subagent that nothing ever finishes.
if status != Some("completed")
&& ended(status)
&& let Some(id) = task_id.and_then(|task| self.tasks.get(task))
{
let id = id.clone();
self.subagents.finish(&id);
if status == Some("completed") {
return Vec::new();
}
self.task_ended(task_id, about, status, None)
}
// `task_started` and `task_progress`: the mapping above is the
// whole of what they are for. The subagent itself is created by
// the Task `tool_use` in the parent's own message, which arrives
// first and carries the title this side shows.
_ => {}
_ => Vec::new(),
}
Vec::new()
}
/// 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.
///
/// 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.
fn task_ended(
&mut self,
task_id: Option<&str>,
about: Option<String>,
status: Option<&str>,
summary: Option<String>,
) -> Vec<Event> {
if !ended(status) {
return Vec::new();
}
let Some(about) = about else {
return Vec::new();
};
// Nothing under that id: either this task has already been reported,
// or its `task_started` was never seen. Both are "say nothing"; the
// first would be a duplicate row and the second a row for a task this
// translator cannot say anything about.
if task_id.is_none_or(|task| self.tasks.remove(task).is_none()) {
return Vec::new();
}
if let Some(summary) = &summary {
self.subagents.record(
&about,
Event::AssistantText {
delta: summary.clone(),
},
);
}
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,
}];
// 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
// `result` will decide between the two statuses when it lands.
if self.tasks.is_empty() && !self.in_turn {
events.push(Event::Status {
state: SessionStatus::Idle,
});
}
events
}
/// A null `status` is the leaving edge, and it carries how the thing went.
@@ -894,6 +1002,21 @@ fn is_known_refusal(status: &str) -> bool {
matches!(status, "rejected" | "blocked" | "exceeded" | "limited")
}
/// Whether this event is the end of a turn: the two statuses a turn can
/// finish in, and no others.
///
/// A sibling of [`super::proves_a_turn`] and deliberately shaped like it --
/// see [`Translator::in_turn`]. `Exited` is not here: a process that has gone
/// ends the session rather than the turn, and nothing after it can start one.
fn closes_a_turn(event: &Event) -> bool {
matches!(
event,
Event::Status {
state: SessionStatus::Idle | SessionStatus::Waiting
}
)
}
/// Whether a task status word means the task is over.
///
/// Written as "not one of the words that mean it is still going" rather than
@@ -1286,6 +1409,143 @@ mod tests {
assert!(subagent.is_open());
}
/// The end of a turn is not the end of the work when the session
/// backgrounded something, and `Idle` says it is. Everything that reads a
/// status hangs off this: the phone's word for the row, whether a
/// "finished" notification goes out, and what auto-resume is looking at.
#[test]
fn a_turn_that_ends_with_a_task_still_running_is_waiting_rather_than_idle() {
let dir = tempfile::tempdir().expect("tempdir");
let subagents = test_subagents(&dir);
let mut translator = Translator::new(dir.path().to_path_buf(), Arc::clone(&subagents));
let result = r#"{"type":"result","subtype":"success","is_error":false,"usage":{}}"#;
translate_lines(
&mut translator,
&[
r#"{"type":"assistant","message":{"content":[{"type":"tool_use","id":"toolu_bg","name":"Task","input":{"description":"the Dev Updater agent"}}]},"parent_tool_use_id":null}"#,
r#"{"type":"system","subtype":"task_started","task_id":"t1","tool_use_id":"toolu_bg","is_backgrounded":true}"#,
],
);
assert_eq!(
translate_lines(&mut translator, &[result]).last(),
Some(&Event::Status {
state: SessionStatus::Waiting
})
);
// The task reports back: the message the session received, and only
// now the session is genuinely waiting for a person.
assert_eq!(
translate_lines(
&mut translator,
&[
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
},
]
);
// Reported once. The two lifecycle shapes can both arrive for one
// task, and a second row for it would be a message that never came.
assert!(
translate_lines(
&mut translator,
&[
r#"{"type":"system","subtype":"task_updated","task_id":"t1","patch":{"status":"failed"}}"#,
],
)
.is_empty()
);
// And with nothing outstanding, the next turn ends idle as before.
assert_eq!(
translate_lines(&mut translator, &[result]).last(),
Some(&Event::Status {
state: SessionStatus::Idle
})
);
}
/// A task ending *inside* a turn says nothing about the session's status:
/// the turn is still running, and its own `result` decides. Without the
/// `in_turn` guard this reported the session idle in the middle of one,
/// which releases the message queue and tells every phone the work is
/// over.
#[test]
fn a_task_ending_during_a_turn_does_not_report_the_session_idle() {
let dir = tempfile::tempdir().expect("tempdir");
let subagents = test_subagents(&dir);
let mut translator = Translator::new(dir.path().to_path_buf(), Arc::clone(&subagents));
let events = translate_lines(
&mut translator,
&[
r#"{"type":"assistant","message":{"content":[{"type":"tool_use","id":"toolu_fg","name":"Task","input":{"description":"a helper"}}]},"parent_tool_use_id":null}"#,
r#"{"type":"system","subtype":"task_started","task_id":"t2","tool_use_id":"toolu_fg","is_backgrounded":false}"#,
r#"{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"still going"}},"parent_tool_use_id":null}"#,
r#"{"type":"system","subtype":"task_notification","task_id":"t2","tool_use_id":"toolu_fg","status":"completed","summary":"done"}"#,
],
);
assert!(
!events.iter().any(closes_a_turn),
"the turn has not ended: {events:?}"
);
assert!(
events
.iter()
.any(|event| matches!(event, Event::TaskNote { .. }))
);
}
/// A background subagent can still be working long after its parent's own
/// turn ended, so the account running out while one is mid-flight is the
/// ordinary shape of the problem rather than an edge of it. The limit
/// belongs in the subagent's transcript *and* has to reach the session,
/// which is the only thing `crate::resume` can schedule against.
#[test]
fn a_limit_a_subagent_hits_reaches_the_session_as_well_as_the_subagent() {
let dir = tempfile::tempdir().expect("tempdir");
let subagents = test_subagents(&dir);
let mut translator = Translator::new(dir.path().to_path_buf(), Arc::clone(&subagents));
translate_lines(
&mut translator,
&[
r#"{"type":"assistant","message":{"content":[{"type":"tool_use","id":"toolu_lim","name":"Task","input":{"description":"a helper"}}]},"parent_tool_use_id":null}"#,
],
);
let hit = translate_lines(
&mut translator,
&[
r#"{"type":"result","subtype":"error","is_error":true,"result":"Claude usage limit reached|1788726600","usage":{},"parent_tool_use_id":"toolu_lim"}"#,
],
);
assert_eq!(
hit,
vec![Event::LimitReached {
resets_at: Some(1788726600.0)
}],
"the session has to hear about it, or nothing resumes"
);
let subagent = subagents.get("toolu_lim").expect("subagent started");
let lines =
crate::session::transcript::read_after(&subagent.transcript_path(), 0).expect("read");
assert!(
lines
.iter()
.any(|entry| matches!(entry.event, Event::LimitReached { .. })),
"and so does the transcript it happened in: {lines:?}"
);
}
/// The second limit detector, on the shape the CLI actually sends. The
/// `allowed` line is copied from a real 2.1.237 run on 2026-09-06; the
/// refused one is the same line with the status changed, which is the
+43
View File
@@ -208,6 +208,37 @@ 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 it is a message the session *received*, and without
/// it the turn it wakes up and runs has nothing in front of it. Two
/// replies then 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.
///
/// 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.
#[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.
@@ -408,6 +439,18 @@ pub enum SessionStatus {
Running,
AwaitingInput,
Compacting,
/// The session's own turn is over, but work it started is still going:
/// a backgrounded subagent, or a command left running.
///
/// Its own state rather than `Idle` because the two differ in kind and
/// only one of them is an invitation. `Idle` means the session is
/// waiting for a person; this means it is waiting for itself, and a
/// notification saying the work had finished would have been wrong. It
/// is also not `Running`: nothing is being written to the transcript,
/// the reply that ended the turn is finished, and a spinner on a session
/// that will not speak again until a task reports back is a promise
/// nobody can keep.
Waiting,
Exited,
/// There is a process recorded for this session and the machine will not
/// say whether it is still running.
+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
+15 -7
View File
@@ -2525,11 +2525,15 @@ fn notification_for(
) -> Option<NotificationKind> {
match (was, now) {
(_, SessionStatus::AwaitingInput) => Some(NotificationKind::AwaitingInput),
(SessionStatus::Running | SessionStatus::Compacting, SessionStatus::Idle)
if unread == 0 =>
{
Some(NotificationKind::Finished)
}
(
SessionStatus::Running | SessionStatus::Compacting | SessionStatus::Waiting,
SessionStatus::Idle,
) if unread == 0 => Some(NotificationKind::Finished),
// Nothing for a turn that ended into `Waiting`. The session stopped
// talking, but a subagent it started is still working and will make it
// talk again -- "finished" there is the announcement arriving at the
// one moment it is not true. The `Waiting` -> `Idle` above is the same
// work actually ending, and that is where it is said.
_ => None,
}
}
@@ -2651,7 +2655,7 @@ async fn pump(
state: SessionStatus::Running,
} if turn_start.is_none() => turn_start = Some(entry.seq),
Event::Status {
state: SessionStatus::Idle | SessionStatus::Exited,
state: SessionStatus::Idle | SessionStatus::Waiting | SessionStatus::Exited,
} => turn_start = None,
_ => {}
}
@@ -2660,8 +2664,12 @@ async fn pump(
// idle session and goes out rather than queueing behind
// itself.
match &entry.event {
// `Waiting` alongside `Idle`: both mean the CLI's own turn
// is over and it will accept a command, and holding one
// until the last background task reported back would sit
// on it for as long as that task takes.
Event::Status {
state: SessionStatus::Idle,
state: SessionStatus::Idle | SessionStatus::Waiting,
} => commands.take_one(),
Event::Status {
state: SessionStatus::Exited,
+22
View File
@@ -67,6 +67,10 @@ 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
@@ -96,6 +100,13 @@ 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()) {
@@ -200,6 +211,7 @@ 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),
@@ -258,6 +270,16 @@ 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())
}
/// Appends one event to a subagent's own transcript. A no-op, with a
/// debug log, for an id nothing was started under -- a child line for a
/// subagent this registry never opened is dropped rather than guessed