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

@@ -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"))
}
}