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
@@ -241,17 +241,17 @@ sent while a subagent runs is not held until the subagent finishes; and
|
|||||||
`sessionWorking("waiting")` is deliberately **false** — nothing is being
|
`sessionWorking("waiting")` is deliberately **false** — nothing is being
|
||||||
written, and the fold uses that same predicate to decide a reply is settled.
|
written, and the fold uses that same predicate to decide a reply is settled.
|
||||||
|
|
||||||
- **A task reporting back is a divider** (`Event::TaskNote`, `TaskNoteRow`),
|
- **Nothing subagent-specific goes in the main agent's transcript** unless a
|
||||||
and the reply that answers it is a **new** message. The fold refuses to grow
|
subagent sends it a real message that wakes it — which is the peer path, and
|
||||||
a settled reply; without that, two turns with nothing recorded between them
|
already has a row. A row per finished background task was tried and was a
|
||||||
were folded into one and ran together mid-sentence. The divider is **closed**
|
screenful of dividers about work nobody was asking after, one of them a whole
|
||||||
and does not say what the subagent said: that is recorded as the subagent's
|
shell command. A subagent's report is its own transcript's closing text and
|
||||||
own transcript's closing text and belongs there, not repeated in its
|
is read in the subcard.
|
||||||
parent's. Opening it shows the report anyway, and a backgrounded *command*
|
- **Two replies that meet are separated by a `TurnBreak`** — a hairline, no
|
||||||
— which has no transcript of its own — names itself from its summary and has
|
words. The reply that follows a turn boundary is a **new** message: the fold
|
||||||
nothing left to open. `./ui-sandbox.sh` plus `/subagent 8` in an echo session
|
refuses to grow a settled reply, and without that the two ran together
|
||||||
is the whole rig; the helpers stagger a second apart so each report and the
|
mid-sentence. `./ui-sandbox.sh` plus `/subagent 3` in an echo session is the
|
||||||
reply to it are legible.
|
whole rig; the helpers stagger a second apart so each reply is its own.
|
||||||
- **Whether work is outstanding has two sources and needs both.** The
|
- **Whether work is outstanding has two sources and needs both.** The
|
||||||
translator's `open_tasks` is what it watched start — the only thing that
|
translator's `open_tasks` is what it watched start — the only thing that
|
||||||
knows about a backgrounded command — and `Subagents::any_open` reads the
|
knows about a backgrounded command — and `Subagents::any_open` reads the
|
||||||
|
|||||||
@@ -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.
|
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
|
Reporting it as idle sent a "finished" notification at the one moment that
|
||||||
was untrue.
|
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
|
- `UsageDelta { tokens, context }` — what a turn cost and how much the model
|
||||||
was holding when it ended. `context` is prompt plus both cache figures,
|
was holding when it ended. `context` is prompt plus both cache figures,
|
||||||
taken from the **last assistant message** rather than the turn's `result`:
|
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
|
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.
|
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.**
|
A turn can start with nothing recorded in front of it — a subagent reporting
|
||||||
The CLI says so on a `system/task_notification` line carrying the task's
|
back, a peer message the CLI only owns up to at the end, a conversation the CLI
|
||||||
status and its own closing summary; the parent then wakes up and runs a turn
|
picks up by itself. The phone's fold grew the last reply rather than starting a
|
||||||
because of it. Before this the parent's transcript had nothing between the
|
new one, so two answers were drawn as one paragraph, running together
|
||||||
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.
|
mid-sentence with not even a space between them.
|
||||||
|
|
||||||
Both halves were wrong and both are fixed. The fold now refuses to grow a
|
**The fold refuses to grow a *settled* reply**, so a turn boundary is always a
|
||||||
*settled* reply, so a turn boundary is always a message boundary whatever
|
message boundary whatever caused it; `joinPages` carries the same rule across a
|
||||||
caused it (`joinPages` carries the same rule across a page boundary). And the
|
page boundary. Where two replies then abut, the fold puts a `TurnBreak` between
|
||||||
notification is recorded as `Event::TaskNote`, on its own row rather than as an
|
them: a hairline rule, no words, no colour. It is made by the fold rather than
|
||||||
update to the Task call's — that row is wherever the call was made, above
|
sent by the server because it is not something that happened — it is the
|
||||||
everything the session has said since, and it would change where no reader is
|
boundary between two things that did.
|
||||||
looking.
|
|
||||||
|
|
||||||
**Drawn as a divider, closed, and not carrying the subagent's words.** It
|
**Nothing else about a background task goes in the session's transcript.** That
|
||||||
marks a boundary, which is what the reader needs from it; the subagent's
|
was tried and was wrong: a row per finished subagent is a screenful of dividers
|
||||||
closing report is recorded as that subagent's own transcript's closing text,
|
about work the reader was not asking after, and one of them turned out to be a
|
||||||
and repeating it in the parent puts the same paragraph in two places for
|
whole shell command drawn as centred prose, because its words came from
|
||||||
somebody who did not ask for it. Opening the divider shows it anyway, because
|
somewhere with no reason to keep them short. A subagent's closing report is
|
||||||
leaving the conversation to read one line has its own cost — and because a
|
recorded as that subagent's own transcript's closing text and is read there.
|
||||||
backgrounded *command* has no transcript of its own, so this is the only place
|
The parent's transcript gets a row for a message a subagent genuinely *sends*
|
||||||
its report exists at all. That one names itself from its summary and has
|
it, which arrives by the peer path and already has one.
|
||||||
nothing left to open.
|
|
||||||
|
|
||||||
`status` is carried beside `summary` rather than folded into it because the
|
What the notification is still used for is the status: it is what closes a task
|
||||||
summary is absent exactly when things went wrong, and "finished" is the wrong
|
in `Status::Waiting`'s bookkeeping. Handled once, however many of the two
|
||||||
word for a task that was killed.
|
lifecycle shapes (`task_notification`, `task_updated`) arrive — whichever gets
|
||||||
|
there first is the one that finds the task open, in the translator's own
|
||||||
Reported once. The two lifecycle shapes (`task_notification` and
|
`open_tasks` or, failing that, in the registry. That second lookup is what makes
|
||||||
`task_updated`) can both arrive for one task, and whichever gets here first is
|
an **adopted** session work: a backend restart picks a session's stdout back up
|
||||||
the one that finds the task open — in the translator's own `open_tasks`, or
|
from a recorded offset, so the `task_started` lines for anything already running
|
||||||
failing that in the registry, which is what makes an **adopted** session work.
|
are behind it and the translator never sees them. `Subagents::any_open` covers
|
||||||
A backend restart picks a session's stdout back up from a recorded offset, so
|
those, and `open_tasks` covers the backgrounded command, which has no subagent
|
||||||
the `task_started` lines for anything already running are behind it and the
|
to be found in the registry at all. Both are needed and neither subsumes the
|
||||||
translator never sees them; `Subagents::any_open` is the measurement that
|
other.
|
||||||
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)
|
### A limit a subagent hits is the session's (2026-09-06)
|
||||||
|
|
||||||
|
|||||||
+12
-10
@@ -81,22 +81,24 @@ transcript is still being written to and its process is the session's to stop.
|
|||||||
`Waiting`, so the end-of-turn status `dispatch` produces for an ordinary
|
`Waiting`, so the end-of-turn status `dispatch` produces for an ordinary
|
||||||
session is dropped rather than written.
|
session is dropped rather than written.
|
||||||
|
|
||||||
**The ending is also marked in the parent** (2026-09-06), as
|
**The ending reaches the parent's transcript as nothing at all**
|
||||||
`Event::TaskNote { about, title, status, summary }`: the turn the session
|
(2026-09-06). It was tried, and a row per finished subagent is a screenful
|
||||||
wakes up and runs would otherwise begin with nothing in front of it, which
|
of dividers about work the reader was not asking after; the closing report
|
||||||
drew two replies as one paragraph. It is a **divider**, closed, and does
|
is *this* transcript's last line and here is where somebody reads it. What
|
||||||
not repeat the summary -- that is this subagent's own closing text, and
|
the parent gets a row for is a message a subagent genuinely sends it, which
|
||||||
here is not where somebody reads it. Reported once however many of the two
|
arrives by the peer path. The two lifecycle shapes are still handled once:
|
||||||
lifecycle shapes arrive: whichever gets there first is the one that finds
|
whichever gets there first is the one that finds the task still open, and
|
||||||
the task still open, and `finish` below closes it. See PLAN.md's "A task
|
`finish` below closes it. See PLAN.md's "Two turns must never be drawn as
|
||||||
reporting back".
|
one".
|
||||||
|
|
||||||
**While any task is outstanding the session's turn ends in
|
**While any task is outstanding the session's turn ends in
|
||||||
`Status Waiting` rather than `Idle`.** `Idle` means "waiting for a person",
|
`Status Waiting` rather than `Idle`.** `Idle` means "waiting for a person",
|
||||||
and a session with a backgrounded subagent is not doing that. Two sources:
|
and a session with a backgrounded subagent is not doing that. Two sources:
|
||||||
the translator's `open_tasks`, and `Subagents::any_open` -- which is what
|
the translator's `open_tasks`, and `Subagents::any_open` -- which is what
|
||||||
covers a subagent launched before a backend restart adopted the session,
|
covers a subagent launched before a backend restart adopted the session,
|
||||||
whose `task_started` is behind the offset its stdout is read from.
|
whose `task_started` is behind the offset its stdout is read from. That
|
||||||
|
second lookup is also what lets such a subagent's ending be recognised at
|
||||||
|
all.
|
||||||
|
|
||||||
**A limit the account hits inside a subagent is hoisted to the session**
|
**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
|
as well as recorded here, because `resume.rs` can only schedule against a
|
||||||
|
|||||||
@@ -26,18 +26,9 @@ import java.time.format.FormatStyle
|
|||||||
* two it was is said by the words and the colour.
|
* two it was is said by the words and the colour.
|
||||||
*
|
*
|
||||||
* The rules take [color] too, so the whole divider reads as one mark of one kind.
|
* The rules take [color] too, so the whole divider reads as one mark of one kind.
|
||||||
*
|
|
||||||
* [trailing] is drawn inside the rules, beside the words -- the one divider that opens onto
|
|
||||||
* something needs its chevron there, and giving it its own copy of this layout is how the two would
|
|
||||||
* come to sit at different heights.
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun TranscriptDivider(
|
fun TranscriptDivider(text: String, color: Color, modifier: Modifier = Modifier) {
|
||||||
text: String,
|
|
||||||
color: Color,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
trailing: (@Composable () -> Unit)? = null,
|
|
||||||
) {
|
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
@@ -45,11 +36,32 @@ fun TranscriptDivider(
|
|||||||
) {
|
) {
|
||||||
HorizontalDivider(Modifier.weight(1f), color = color)
|
HorizontalDivider(Modifier.weight(1f), color = color)
|
||||||
Text(text, style = MaterialTheme.typography.bodySmall, color = color)
|
Text(text, style = MaterialTheme.typography.bodySmall, color = color)
|
||||||
trailing?.invoke()
|
|
||||||
HorizontalDivider(Modifier.weight(1f), color = color)
|
HorizontalDivider(Modifier.weight(1f), color = color)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rule between two replies that met with nothing said in between -- see
|
||||||
|
* [TranscriptItem.TurnBreak].
|
||||||
|
*
|
||||||
|
* No words and no colour. Every other divider here reports something that happened and is worth
|
||||||
|
* finding by scanning; this one only says "these are two", and it appears once per turn that
|
||||||
|
* started without anybody typing. Saying more was a screenful of announcements about background
|
||||||
|
* work the reader was not asking after -- one of them a whole shell command, drawn as centred prose
|
||||||
|
* because the words came from somewhere that had no reason to keep them short.
|
||||||
|
*
|
||||||
|
* The outline colour is the scheme's one for structure rather than for meaning, which is what this
|
||||||
|
* is. Inset from both edges so it reads as a separator between two rows rather than as the top edge
|
||||||
|
* of the one under it.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun TurnBreakRow(modifier: Modifier = Modifier) {
|
||||||
|
HorizontalDivider(
|
||||||
|
modifier.fillMaxWidth().padding(horizontal = 48.dp, vertical = 6.dp),
|
||||||
|
color = MaterialTheme.colorScheme.outlineVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The mark a clear leaves.
|
* The mark a clear leaves.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -108,28 +108,6 @@ sealed class SessionEvent {
|
|||||||
val turnStart: Long? = null,
|
val turnStart: Long? = null,
|
||||||
) : SessionEvent()
|
) : 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
|
* 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.
|
* [CommandSent] with the same id; a command that ran straight away has only that one.
|
||||||
@@ -274,13 +252,6 @@ fun parseSeqEvent(json: String): SeqEvent {
|
|||||||
body.getString("text"),
|
body.getString("text"),
|
||||||
if (body.has("turnStart")) body.getLong("turnStart") else null,
|
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" ->
|
"commandQueued" ->
|
||||||
SessionEvent.CommandQueued(body.getString("id"), body.getString("text"))
|
SessionEvent.CommandQueued(body.getString("id"), body.getString("text"))
|
||||||
"commandSent" -> SessionEvent.CommandSent(body.getString("id"), body.getString("text"))
|
"commandSent" -> SessionEvent.CommandSent(body.getString("id"), body.getString("text"))
|
||||||
|
|||||||
@@ -288,10 +288,6 @@ fun SessionScreen(
|
|||||||
// Which messages from other agents are open, by the seq that identifies their row. Closed by
|
// Which messages from other agents are open, by the seq that identifies their row. Closed by
|
||||||
// default, which is the rule for anything new in this transcript.
|
// default, which is the rule for anything new in this transcript.
|
||||||
var expandedNotes by remember { mutableStateOf(setOf<Long>()) }
|
var expandedNotes by remember { mutableStateOf(setOf<Long>()) }
|
||||||
// Which task reports are open, by the seq that identifies their row. Closed by default, which
|
|
||||||
// is the rule for anything new in this transcript -- and doubly so here, since what one opens
|
|
||||||
// onto is already in the subagent's own transcript.
|
|
||||||
var expandedTaskNotes by remember { mutableStateOf(setOf<Long>()) }
|
|
||||||
// Which memory notes are open, by the note's own text. Held here rather than in the card so a
|
// Which memory notes are open, by the note's own text. Held here rather than in the card so a
|
||||||
// note opened and scrolled past is still open on the way back.
|
// note opened and scrolled past is still open on the way back.
|
||||||
var openMemories by remember { mutableStateOf(setOf<String>()) }
|
var openMemories by remember { mutableStateOf(setOf<String>()) }
|
||||||
@@ -1607,25 +1603,7 @@ fun SessionScreen(
|
|||||||
is TranscriptItem.CompactedNote ->
|
is TranscriptItem.CompactedNote ->
|
||||||
CompactedRow(item)
|
CompactedRow(item)
|
||||||
is TranscriptItem.LimitNote -> LimitRow(item)
|
is TranscriptItem.LimitNote -> LimitRow(item)
|
||||||
is TranscriptItem.TaskNote ->
|
is TranscriptItem.TurnBreak -> TurnBreakRow()
|
||||||
TaskNoteRow(
|
|
||||||
item,
|
|
||||||
open = item.seq in expandedTaskNotes,
|
|
||||||
// Anchored, so the edge the reader pressed
|
|
||||||
// stays where it was.
|
|
||||||
onToggle = {
|
|
||||||
toggleAnchored(row) {
|
|
||||||
expandedTaskNotes =
|
|
||||||
if (
|
|
||||||
item.seq in
|
|
||||||
expandedTaskNotes
|
|
||||||
)
|
|
||||||
expandedTaskNotes - item.seq
|
|
||||||
else
|
|
||||||
expandedTaskNotes + item.seq
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
// Never reached: a peer message is flattened into
|
// Never reached: a peer message is flattened into
|
||||||
// its own units. Here because a `when` over the
|
// its own units. Here because a `when` over the
|
||||||
// item kinds has to stay exhaustive.
|
// item kinds has to stay exhaustive.
|
||||||
|
|||||||
@@ -1,110 +0,0 @@
|
|||||||
package com.example.aiapp
|
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
|
||||||
import androidx.compose.ui.semantics.semantics
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The mark a background task reporting back leaves: a subagent that finished, or a backgrounded
|
|
||||||
* command.
|
|
||||||
*
|
|
||||||
* A divider, drawn like a clear or a compaction, because what it marks is a *boundary*. The turn
|
|
||||||
* below it is the session answering something that arrived, and without a row there the reply that
|
|
||||||
* ended the previous turn and the reply that answers this one met with nothing between them -- the
|
|
||||||
* fold grew the older message and drew two answers as one paragraph, running together mid-sentence.
|
|
||||||
*
|
|
||||||
* **Closed, and the subagent's words are not what it says.** A subagent's closing report is
|
|
||||||
* recorded as its own transcript's closing text, which is where somebody who wants it looks;
|
|
||||||
* putting it in the parent by default is the same paragraph in two places for a reader who did not
|
|
||||||
* ask for it. Opened, this shows it anyway, because having to leave the conversation to read one
|
|
||||||
* line is its own cost -- and because a backgrounded *command* has no transcript of its own, so
|
|
||||||
* here is the only place its report exists at all.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun TaskNoteRow(
|
|
||||||
item: TranscriptItem.TaskNote,
|
|
||||||
open: Boolean,
|
|
||||||
onToggle: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
// Blue is the session's own background work -- the same thing `waiting` means in the status
|
|
||||||
// row, so "this is the session getting on with something it started" is learned once. Red only
|
|
||||||
// where something actually went wrong; a cancelled task is a choice somebody made.
|
|
||||||
val colour = if (item.status == "failed") failedColor else waitingColor
|
|
||||||
val line = taskNoteSummary(item.title, item.status, item.summary)
|
|
||||||
// Nothing behind the line: a task that ended without a word, or one whose report *is* the line
|
|
||||||
// already. A control that opens onto nothing, or onto a copy of what is above it, teaches the
|
|
||||||
// reader that the control means nothing.
|
|
||||||
val expandable = item.summary != null && item.summary != line
|
|
||||||
Column(
|
|
||||||
modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.then(if (expandable) Modifier.clickable(onClick = onToggle) else Modifier)
|
|
||||||
) {
|
|
||||||
TranscriptDivider(
|
|
||||||
line,
|
|
||||||
colour,
|
|
||||||
trailing =
|
|
||||||
if (!expandable) null
|
|
||||||
else {
|
|
||||||
{
|
|
||||||
Chevron(
|
|
||||||
if (open) Pointing.Up else Pointing.Down,
|
|
||||||
colour = colour,
|
|
||||||
// The row is the control and the chevron is all of its marking, so the
|
|
||||||
// name belongs here: it is the only thing a screen reader has to read.
|
|
||||||
modifier =
|
|
||||||
Modifier.semantics {
|
|
||||||
contentDescription =
|
|
||||||
if (open) "Hide the report" else "Show the report"
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if (open && expandable) {
|
|
||||||
// Not the divider's colour: this is what the task said rather than a mark we drew, and
|
|
||||||
// colouring a quotation as if it were part of the rule around it makes the rule look
|
|
||||||
// like it is carrying some of the meaning.
|
|
||||||
Text(
|
|
||||||
item.summary.orEmpty(),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.padding(bottom = 8.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What the divider says: what 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. "Reported back" is the right phrase for exactly one of the endings; using it for all of
|
|
||||||
* them would announce 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 fact somebody established.
|
|
||||||
*/
|
|
||||||
fun taskNoteSummary(title: String?, status: String, summary: String?): String {
|
|
||||||
// A backgrounded command has no title of its own and its summary is already a whole sentence --
|
|
||||||
// `Background command "..." completed (exit code 0)`. Naming it from that beats "a background
|
|
||||||
// task", which says nothing, and there is nothing left behind the line to open.
|
|
||||||
if (title == null && summary != null && status == "completed") return summary
|
|
||||||
val who = title ?: "A background task"
|
|
||||||
return when (status) {
|
|
||||||
"completed" -> "$who reported back"
|
|
||||||
"failed" -> "$who failed"
|
|
||||||
"cancelled" -> "$who was cancelled"
|
|
||||||
else -> "$who: $status"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -144,24 +144,27 @@ sealed class TranscriptItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A task the session started in the background reporting back: a subagent that finished, or a
|
* Where one turn ended and the next began with nothing said in between.
|
||||||
* backgrounded command.
|
|
||||||
*
|
*
|
||||||
* Its own row rather than an update to the Task call's, which is wherever the call was made --
|
* A rule and no words. Two replies meet like this whenever a turn starts without anybody typing
|
||||||
* above everything the session has said since, where a reader at the bottom would never see it
|
* -- a subagent reporting back, a session the CLI picked up by itself -- and drawn with only
|
||||||
* change. Here it is where it arrived, in front of the turn it caused.
|
* the ordinary gap between them they read as one answer with a paragraph break through the
|
||||||
|
* middle of it. What the reader needs is to see that these are two; what started the turn is
|
||||||
|
* somebody else's transcript's business, and a row per background task is a screenful of
|
||||||
|
* dividers about work nobody was asking after.
|
||||||
|
*
|
||||||
|
* 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. See [foldEvent].
|
||||||
*/
|
*/
|
||||||
data class TaskNote(
|
data class TurnBreak(override val seq: Long) : TranscriptItem() {
|
||||||
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].
|
* Its own key, because it shares a [seq] with the reply it sits above -- that reply's first
|
||||||
|
* delta is the event this was made at, and a keyed list refuses two items with one key by
|
||||||
|
* taking the app down.
|
||||||
*/
|
*/
|
||||||
val title: String?,
|
override val key: Any
|
||||||
val status: String,
|
get() = "break$seq"
|
||||||
val summary: String?,
|
}
|
||||||
) : TranscriptItem()
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A command the session ran on itself -- `/compact`, `/rename`. Kept in the transcript rather
|
* A command the session ran on itself -- `/compact`, `/rename`. Kept in the transcript rather
|
||||||
@@ -302,7 +305,9 @@ private fun healSplitMessage(
|
|||||||
// A settled reply is a whole turn, so the two are two answers that happen to meet at the
|
// 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
|
// 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.
|
// here would put back exactly the run-together paragraph it stops.
|
||||||
if (head.settled) return earlier to later
|
// The rule between them is put in here too, since the fold that would have made it never saw
|
||||||
|
// these two side by side.
|
||||||
|
if (head.settled) return earlier to (listOf(TranscriptItem.TurnBreak(tail.seq)) + later)
|
||||||
return earlier.dropLast(1) to (listOf(tail.copy(text = head.text + tail.text)) + later.drop(1))
|
return earlier.dropLast(1) to (listOf(tail.copy(text = head.text + tail.text)) + later.drop(1))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,7 +402,13 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
|
|||||||
if (last is TranscriptItem.AssistantMsg && !last.settled) {
|
if (last is TranscriptItem.AssistantMsg && !last.settled) {
|
||||||
items.dropLast(1) + last.copy(text = last.text + event.delta)
|
items.dropLast(1) + last.copy(text = last.text + event.delta)
|
||||||
} else {
|
} else {
|
||||||
items + TranscriptItem.AssistantMsg(entry.seq, event.delta)
|
// A rule between the two, and only where they actually meet: anything that draws a
|
||||||
|
// row of its own -- a message, a command, a peer note -- is already the boundary.
|
||||||
|
val between =
|
||||||
|
if (last is TranscriptItem.AssistantMsg)
|
||||||
|
listOf(TranscriptItem.TurnBreak(entry.seq))
|
||||||
|
else emptyList()
|
||||||
|
items + between + TranscriptItem.AssistantMsg(entry.seq, event.delta)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is SessionEvent.ToolStart ->
|
is SessionEvent.ToolStart ->
|
||||||
@@ -475,15 +486,6 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
is SessionEvent.PeerMessage -> placePeerNote(items, entry.seq, event)
|
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)
|
is SessionEvent.CommandSent -> items + TranscriptItem.CommandRow(entry.seq, event.text)
|
||||||
// Screen-level state, not transcript rows -- see SessionScreen.
|
// Screen-level state, not transcript rows -- see SessionScreen.
|
||||||
is SessionEvent.CommandQueued -> items
|
is SessionEvent.CommandQueued -> items
|
||||||
|
|||||||
@@ -47,18 +47,40 @@ class TranscriptItemsTest {
|
|||||||
assertEquals(listOf("Still running its tests."), texts(items))
|
assertEquals(listOf("Still running its tests."), texts(items))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rule that replaced the wall of reports. A turn that starts with nothing recorded in front
|
||||||
|
* of it -- a subagent finishing, the CLI picking a conversation back up -- leaves two replies
|
||||||
|
* abutting, and only the break says they are two.
|
||||||
|
*/
|
||||||
@Test
|
@Test
|
||||||
fun a_task_reporting_back_is_a_row_between_the_two_turns() {
|
fun two_replies_that_meet_are_separated_by_a_rule_and_nothing_else() {
|
||||||
val items =
|
val items =
|
||||||
fold(
|
fold(
|
||||||
SessionEvent.AssistantText("Launched it."),
|
SessionEvent.AssistantText("Launched it."),
|
||||||
SessionEvent.Status("waiting"),
|
SessionEvent.Status("waiting"),
|
||||||
SessionEvent.TaskNote("toolu_1", "the Dev Updater agent", "completed", "pushed"),
|
|
||||||
SessionEvent.AssistantText("Noted."),
|
SessionEvent.AssistantText("Noted."),
|
||||||
)
|
)
|
||||||
assertEquals(3, items.size, "$items")
|
assertEquals(3, items.size, "$items")
|
||||||
assertTrue(items[1] is TranscriptItem.TaskNote, "$items")
|
assertTrue(items[1] is TranscriptItem.TurnBreak, "$items")
|
||||||
assertEquals(listOf("Launched it.", "Noted."), texts(items))
|
assertEquals(listOf("Launched it.", "Noted."), texts(items))
|
||||||
|
// Distinct keys: the break shares the reply's seq, and two items with one key take the
|
||||||
|
// app down.
|
||||||
|
assertEquals(3, items.map { it.key }.toSet().size, "$items")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A reply after anything that draws a row of its own needs no rule: that row is the boundary.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun a_reply_after_a_row_of_its_own_gets_no_rule() {
|
||||||
|
val items =
|
||||||
|
fold(
|
||||||
|
SessionEvent.AssistantText("Launched it."),
|
||||||
|
SessionEvent.Status("idle"),
|
||||||
|
SessionEvent.UserMessage("carry on", null, emptyList()),
|
||||||
|
SessionEvent.AssistantText("Noted."),
|
||||||
|
)
|
||||||
|
assertTrue(items.none { it is TranscriptItem.TurnBreak }, "$items")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -81,23 +103,7 @@ class TranscriptItemsTest {
|
|||||||
listOf(TranscriptItem.AssistantMsg(2, "The next.", settled = true)),
|
listOf(TranscriptItem.AssistantMsg(2, "The next.", settled = true)),
|
||||||
)
|
)
|
||||||
assertEquals(listOf("One turn.", "The next."), texts(whole))
|
assertEquals(listOf("One turn.", "The next."), texts(whole))
|
||||||
}
|
// And the rule between them, which the fold that would have made it never got to see.
|
||||||
|
assertTrue(whole.any { it is TranscriptItem.TurnBreak }, "$whole")
|
||||||
/** The endings nobody builds a screen for -- see [taskNoteSummary]. */
|
|
||||||
@Test
|
|
||||||
fun a_task_note_says_which_ending_it_was() {
|
|
||||||
val said = "it said hello"
|
|
||||||
// A subagent is named, and its own words stay in its own transcript rather than being
|
|
||||||
// repeated here.
|
|
||||||
assertEquals("helper 1 reported back", taskNoteSummary("helper 1", "completed", said))
|
|
||||||
assertEquals("helper 1 failed", taskNoteSummary("helper 1", "failed", null))
|
|
||||||
assertEquals("helper 1 was cancelled", taskNoteSummary("helper 1", "cancelled", said))
|
|
||||||
// A word this build has never seen is said as itself, not mapped onto the nearest one.
|
|
||||||
assertEquals("helper 1: evicted", taskNoteSummary("helper 1", "evicted", said))
|
|
||||||
// A backgrounded command has no title and no transcript of its own, so its summary is the
|
|
||||||
// only record of it there is -- and it is already a sentence.
|
|
||||||
val command = """Background command "build the kernel" completed (exit code 0)"""
|
|
||||||
assertEquals(command, taskNoteSummary(null, "completed", command))
|
|
||||||
assertEquals("A background task failed", taskNoteSummary(null, "failed", null))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -594,19 +594,19 @@ impl Translator {
|
|||||||
|
|
||||||
/// A task reporting back, from whichever of the two lines got here first.
|
/// 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
|
/// Handled once. The two shapes can both arrive for one task, and what
|
||||||
/// [`Translator::tasks`] entry is what says which of them is the first --
|
/// says which of them is the first is that it finds the task still open.
|
||||||
/// 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
|
/// **Nothing about it reaches the parent's transcript**, deliberately.
|
||||||
/// leave out. The summary goes into the subagent's own transcript, which
|
/// The summary is the subagent's own closing words and goes into the
|
||||||
/// is the only place its closing words ever appear; the subagent is
|
/// subagent's own transcript, which is the only place it belongs; a row
|
||||||
/// finished; and the *parent* gets an [`Event::TaskNote`], because a
|
/// per finished task in the session's transcript is a screen of dividers
|
||||||
/// message arriving is something that happened to this session and the
|
/// about work the reader was not asking after, and the session did not
|
||||||
/// turn it wakes up and runs would otherwise begin with nothing in front
|
/// receive a message it could act on. What *does* reach the parent is a
|
||||||
/// of it.
|
/// 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(
|
fn task_ended(
|
||||||
&mut self,
|
&mut self,
|
||||||
about: Option<String>,
|
about: Option<String>,
|
||||||
@@ -631,23 +631,12 @@ impl Translator {
|
|||||||
if !self.open_tasks.remove(&about) && !self.subagents.is_open(&about) {
|
if !self.open_tasks.remove(&about) && !self.subagents.is_open(&about) {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
if let Some(summary) = &summary {
|
if let Some(summary) = summary {
|
||||||
self.subagents.record(
|
self.subagents
|
||||||
&about,
|
.record(&about, Event::AssistantText { delta: summary });
|
||||||
Event::AssistantText {
|
|
||||||
delta: summary.clone(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
self.subagents.finish(&about);
|
self.subagents.finish(&about);
|
||||||
let mut events = vec![Event::TaskNote {
|
let mut events = Vec::new();
|
||||||
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
|
// The last outstanding task, with the session's own turn already
|
||||||
// over: it has stopped being `Waiting` and nothing else will say so.
|
// over: it has stopped being `Waiting` and nothing else will say so.
|
||||||
// Inside a turn there is nothing to announce -- the turn's own
|
// 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"}"#,
|
r#"{"type":"system","subtype":"task_notification","task_id":"t1","tool_use_id":"toolu_bg","status":"completed","summary":"pushed as c41c36f"}"#,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
vec![
|
vec![Event::Status {
|
||||||
Event::TaskNote {
|
state: SessionStatus::Idle
|
||||||
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
|
// 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"}"#,
|
r#"{"type":"system","subtype":"task_notification","task_id":"old","tool_use_id":"toolu_old","status":"completed","summary":"pushed"}"#,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
vec![
|
vec![Event::Status {
|
||||||
Event::TaskNote {
|
state: SessionStatus::Idle
|
||||||
about: "toolu_old".into(),
|
}]
|
||||||
title: Some("the Dev Updater agent".into()),
|
|
||||||
status: "completed".into(),
|
|
||||||
summary: Some("pushed".into()),
|
|
||||||
},
|
|
||||||
Event::Status {
|
|
||||||
state: SessionStatus::Idle
|
|
||||||
},
|
|
||||||
]
|
|
||||||
);
|
);
|
||||||
// Once: `finish` closed it, so the second shape finds nothing.
|
// Once: `finish` closed it, so the second shape finds nothing.
|
||||||
assert!(
|
assert!(
|
||||||
@@ -1586,10 +1559,16 @@ mod tests {
|
|||||||
!events.iter().any(closes_a_turn),
|
!events.iter().any(closes_a_turn),
|
||||||
"the turn has not ended: {events:?}"
|
"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!(
|
assert!(
|
||||||
events
|
lines.iter().any(|entry| matches!(
|
||||||
.iter()
|
&entry.event,
|
||||||
.any(|event| matches!(event, Event::TaskNote { .. }))
|
Event::AssistantText { delta } if delta == "done"
|
||||||
|
)),
|
||||||
|
"{lines:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -208,46 +208,6 @@ pub enum Event {
|
|||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
turn_start: Option<u64>,
|
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
|
/// The manager's record of a question being answered, so a rendered
|
||||||
/// question card resolves on every device rather than only the one that
|
/// question card resolves on every device rather than only the one that
|
||||||
/// answered.
|
/// answered.
|
||||||
|
|||||||
@@ -53,9 +53,10 @@
|
|||||||
//! streamed reply, one Bash call, then it finishes about three seconds
|
//! streamed reply, one Bash call, then it finishes about three seconds
|
||||||
//! later, the same lifecycle a real Task call has -- see `SUBAGENTS.md`.
|
//! 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
|
//! 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
|
//! run, each records its closing report in its own transcript, and the
|
||||||
//! -- which is the whole of the shape a real background Task produces, and
|
//! parent then runs a turn answering it -- which is the whole of the shape a
|
||||||
//! the one where two replies used to be drawn as one paragraph.
|
//! 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
|
//! `/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
|
//! 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);
|
subagents.finish(&id);
|
||||||
let _ = sink.send(Event::ToolEnd {
|
let _ = sink.send(Event::ToolEnd {
|
||||||
id: id.clone(),
|
id,
|
||||||
output: "subagent finished".to_string(),
|
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
|
// parent has to say something afterwards, since the defect this
|
||||||
// reproduces is two replies meeting with nothing between them.
|
// reproduces is two replies meeting with nothing between them -- and
|
||||||
let _ = sink.send(Event::TaskNote {
|
// nothing at all about the helper goes into the *parent's* transcript,
|
||||||
about: id,
|
// which is the shape being reproduced.
|
||||||
title: Some(title.clone()),
|
|
||||||
status: "completed".to_string(),
|
|
||||||
summary: Some(summary),
|
|
||||||
});
|
|
||||||
let _ = sink.send(Event::Status {
|
let _ = sink.send(Event::Status {
|
||||||
state: SessionStatus::Running,
|
state: SessionStatus::Running,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -67,10 +67,6 @@ pub struct SubagentInfo {
|
|||||||
/// session's but with no driver behind it.
|
/// session's but with no driver behind it.
|
||||||
pub struct Subagent {
|
pub struct Subagent {
|
||||||
dir: PathBuf,
|
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>,
|
transcript: Mutex<Transcript>,
|
||||||
events: broadcast::Sender<SeqEvent>,
|
events: broadcast::Sender<SeqEvent>,
|
||||||
/// Mirrors the transcript's last `Status` event, kept live rather than
|
/// Mirrors the transcript's last `Status` event, kept live rather than
|
||||||
@@ -100,13 +96,6 @@ impl Subagent {
|
|||||||
*self.status.lock().unwrap() != SessionStatus::Exited
|
*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) {
|
fn append(&self, event: Event) {
|
||||||
let mut transcript = self.transcript.lock().unwrap();
|
let mut transcript = self.transcript.lock().unwrap();
|
||||||
match transcript.append(event, super::now()) {
|
match transcript.append(event, super::now()) {
|
||||||
@@ -211,7 +200,6 @@ impl Subagents {
|
|||||||
let (events, _) = broadcast::channel(EVENT_BUFFER);
|
let (events, _) = broadcast::channel(EVENT_BUFFER);
|
||||||
Ok(Arc::new(Subagent {
|
Ok(Arc::new(Subagent {
|
||||||
dir,
|
dir,
|
||||||
title: meta.title.clone(),
|
|
||||||
transcript: Mutex::new(transcript),
|
transcript: Mutex::new(transcript),
|
||||||
events,
|
events,
|
||||||
status: Mutex::new(status),
|
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`
|
/// 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
|
/// for an id that is not a subagent's at all -- a backgrounded command's
|
||||||
/// tool call reaches here with the same shape.
|
/// tool call reaches here with the same shape.
|
||||||
|
|||||||
Reference in new issue
Block a user