Show a session's subagents as subcards, each with a read-only transcript

A subagent is a second transcript owned by a session, in the same event
model, with no process and no controls. The claude translator routes lines
carrying parent_tool_use_id to a per-subagent translator and transcript
under <session>/subagents/<tool_use_id>; three routes expose the list, a
transcript page and the SSE stream. Echo grows /subagent [n] as the rig.

On the phone a card with subagents ends in a chevron expander, collapsed by
default, opening to outlined subcards styled like dev-updater's components;
a subcard opens SessionScreen in read-only form, addressed through
TranscriptAddress so paging, cache and stream are shared.

Design in SUBAGENTS.md; choices awaiting review in DECISIONS.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Fable 5.1 committed 2026-09-05 13:41:15 -04:00
1 parent eff5c8b0c0
commit 9fa09b0af1
21 files changed
+1786 -165

No files matched your search

+4
View File
@@ -61,6 +61,10 @@ Module-by-module intent is in PLAN.md's "Backend layout".
projects version-locked to the commit this repo pins. What deliberately did projects version-locked to the commit this repo pins. What deliberately did
**not** move is the API surface and the config *schema*: routes, drivers, **not** move is the API surface and the config *schema*: routes, drivers,
sessions and setups are what makes this project itself. sessions and setups are what makes this project itself.
- `SUBAGENTS.md` — a session's subagents as transcripts of their own
(`server/src/session/subagent.rs`, the subcards in `SessionListScreen.kt`
and the read-only form of `SessionScreen.kt`); `DECISIONS.md` holds the
choices made there that are still awaiting review.
- `EXPLORER.md` — the file explorer's design (`server/src/files.rs` and - `EXPLORER.md` — the file explorer's design (`server/src/files.rs` and
`FilesScreen.kt` / `FileViewer.kt` / `FileEditor.kt`). `FilesScreen.kt` / `FileViewer.kt` / `FileEditor.kt`).
- `TRANSCRIPT_CACHE.md` — the phone's copy of what it has been sent. Read it - `TRANSCRIPT_CACHE.md` — the phone's copy of what it has been sent. Read it
+44
View File
@@ -0,0 +1,44 @@
# Decisions awaiting review
Choices made while working autonomously, for Bryan to keep or change. Each
says what was picked and why; the detail is in the design doc it names.
Delete an entry once it has been looked at.
## Subagent views (2026-09-05, `SUBAGENTS.md`)
Made on my own judgement, limited blast radius:
1. **A subagent is a transcript, not a session.** It has no process,
controls or settings; it is addressed as `/sessions/{id}/subagents/{sub}`
and stored under the session's directory, so deleting the session takes
it. Alternative rejected: registering it as a session of its own, which
would give it a card in the main list and a driver that can do nothing.
2. **Read-only view is the session screen minus its controls**, rather than
a second, simpler transcript screen. Keeps paging, caching, selection
and rendering in one place. Cost: a `readOnly` mode threaded through
`SessionScreen`.
3. **The list only carries a count.** Each session row says how many
subagents it has; their titles and statuses are fetched when the card is
expanded. Keeps `GET /sessions` from reading every subagent transcript.
Consequence: an expanded card's statuses refresh with the list, not live.
4. **Expanded/collapsed is remembered per session on the phone**, not on
the server. Collapsed by default, per the transcript convention that new
things arrive collapsed.
5. **Subagents of imported sessions are not shown.** The import path still
skips `isSidechain` records; the CLI's own `subagents/agent-*.jsonl` files
are not read. Only subagents run while this backend was watching exist.
6. **Echo grows `/subagent [n]`** as the test rig, so nothing here needs a
paid turn to exercise.
Deferred, because they reach further than this feature:
- **Live status on the list.** Whether the session list should follow a
stream at all (it refreshes on demand today) decides whether subagent
status can ever be live there. Not changed.
- **Nested subagents.** A subagent's own Task calls are shown as tool calls
in its transcript and are not given transcripts of their own. Supporting
that is the same mechanism one level down, but the UI would need nested
expanders.
- **The subagent status row says "context unknown".** Nothing measures a
subagent's context; the row could leave it out rather than admit it.
+14
View File
@@ -644,6 +644,20 @@ 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.
### Subagents (2026-09-05)
**A subagent is a second transcript owned by a session, in the same event
model, with no process and no controls of its own.** Full design and wire
shape in `SUBAGENTS.md`, kept separate because the app half is being built
against it in parallel and it is the shared contract between the two. The
one-paragraph reason: a session's Task-tool helpers already speak the common
event model on the parent's own stdout (each line carrying
`parent_tool_use_id`), so giving each one its own small transcript — same
file format, same paging routes, same SSE stream, reused by addressing rather
than by copying — costs a routing step in the translator and a registry
(`session/subagent.rs`) rather than a second session type with a driver, a
process and a config entry it does not need.
### HTTP surface ### HTTP surface
**`routes.rs`'s module doc comment is the table.** REST for actions, one SSE **`routes.rs`'s module doc comment is the table.** REST for actions, one SSE
+123
View File
@@ -0,0 +1,123 @@
# Subagents
A session's subagents -- the helpers a Claude Code session starts through its
Task tool -- each get a transcript of their own, listed under the session's
card and readable in the same transcript view the session has. Designed
2026-09-05; the decisions Bryan has not yet reviewed are in `DECISIONS.md`.
## What a subagent is here
**A subagent is a second transcript owned by a session, in the same event
model, with no process and no controls.** It is not a session: it cannot be
messaged, stopped or started, and it has no setup, model or usage of its
own. Everything it shares with a session -- the transcript file format, the
paging routes, the SSE stream, the phone's cache and rendering -- is reused
by addressing, not by copying.
The CLI reports a subagent's messages on the parent's own stream-json
output, each carrying `parent_tool_use_id` = the id of the Task `tool_use`
that started it. Before this the translator dropped those lines
(`subagent_events_are_not_duplicated_into_the_transcript`); now it routes
them to that subagent's own translator and transcript. The parent's
transcript still shows only the Task call itself.
## Storage
Under the session directory:
```
<session>/subagents/<tool_use_id>/meta.json {title, created}
<session>/subagents/<tool_use_id>/transcript.jsonl same SeqEvent lines as the session's
```
The id is the Task tool_use id (`toolu_…`), which is unique, stable across a
backend restart, and already the key everything on the parent side uses.
Only ids matching `[A-Za-z0-9_-]+` are ever created or looked up, since the
id becomes a path.
The transcript's sequence numbers are its own, starting at 1. `Transcript`,
`read_window`, `catch_up` and `read_after` work on it unchanged.
Its path out: deleting the session deletes its directory, subagents included.
There is no separate delete.
## Lifecycle, as events in the subagent's transcript
1. Created on the first child line for an unseen parent id (or, when the
parent Task call was seen, at that call). First lines written:
`Status Running`, then `UserMessage { text: <the Task's prompt> }` when
the prompt is known -- it genuinely is the subagent's first user turn.
2. Every child line is translated by that subagent's own `Translator`
(one per subagent: tool ids are unique but streaming deltas are by
content-block index, and parallel subagents interleave).
3. When the parent's `tool_result` for the Task id arrives, the parent gets
its `ToolEnd` as before, and the subagent gets `Status Exited`.
4. When the parent session's process exits (`Status Exited` on the
session), every subagent still `Running` gets `Status Exited` too: its
process was the parent's.
A subagent that was mid-flight when the backend restarted keeps working:
the registry reopens the existing transcript on the next child line, and
the file continues its sequence. If its Task call finished while the backend
was down nothing ever closes it -- its last status stays `Running`, which
the list reports as **unknown** rather than as running (see the wire shape).
Title: the Task call's `description` input, then ` (<subagent_type>)` when
one is given; falling back to the tool's name when the child arrives before
(or without) the parent call being seen.
## Server layout
- `session/subagent.rs` -- the registry: `Subagents` (per session, in
`Shared`), `Subagent` (its `Transcript` behind a mutex plus a
`broadcast::Sender<SeqEvent>`), `record(id, event)`, `start(id, title,
prompt)`, `finish(id)`, `finish_all()`, `list()` from disk. Drivers get an
`Arc<Subagents>` beside their `EventSink`; llama ignores it.
- `session/claude/translate.rs` -- routes child lines by parent id, holds
one child `Translator` per subagent, remembers pending Task calls'
description/prompt/subagent_type.
- `session/echo.rs` -- `/subagent [n]`: the test rig. Starts *n* (default 1)
subagents at once, each named "helper k". Each writes the prompt as its
user message, streams a few words of text, runs one `Bash` tool call, then
finishes about three seconds after starting, and the parent's Task calls
end when their subagent does. Three seconds so the running state can be
seen on the phone.
- `routes.rs` -- three routes, in the doc table.
## Wire shape
```
GET /sessions/{id} SessionInfo gains `subagents: N` (count, 0 when none)
GET /sessions same field on each row
GET /sessions/{id}/subagents [{id, title, status, created, lastActivity}], oldest first
GET /sessions/{id}/subagents/{sub}/transcript exactly the session transcript's query and answer
GET /sessions/{id}/subagents/{sub}/events?after=N exactly the session events stream
```
`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
phone words these as *running*, *finished* and *unknown* on the subcard.
The count on `SessionInfo` is a directory listing, so the list stays cheap.
The per-subagent status is only read when the list route is asked for.
## Phone
- `SessionSummary.subagents: Int`. A card with a non-zero count ends in an
expander row -- a full-width `Chevron(Pointing.Down)` row that flips to
`Pointing.Up` -- collapsed by default. Expanding fetches
`/sessions/{id}/subagents` and draws one `OutlinedCard` per subagent,
indented inside the session card, the way dev-updater draws a project's
components: title, then the status word and a relative time. The
expansion state is per session id and survives a refresh of the list.
- Tapping a subcard opens `Screen.Subagent`, which is `SessionScreen` in
**read-only** form: the same transcript, paging, cache, selection,
images and status row, with the composer, the process button, the model
picker, the files button, the settings cog and the usage bar left out.
The header shows the subagent's title with the session's title beneath
it. Back returns to the list.
- Addressing: `fetchTranscript`, `EventStream`, `TranscriptSource` and the
cache take a transcript address rather than a session id --
`sessions/{id}` or `sessions/{id}/subagents/{sub}` -- so the cache nests a
subagent's copy under its session's and the same code serves both.
@@ -223,6 +223,14 @@ data class SessionSummary(
val usageProvider: String?, val usageProvider: String?,
val status: String, val status: String,
val lastActivity: Double, val lastActivity: Double,
/**
* How many subagents this session has, however their own status now reads.
*
* A directory listing on the server rather than a status read per subagent, so the list stays
* cheap; the per-subagent state is only fetched when the card is expanded. Zero on a server
* that predates subagents, so this app still opens against one.
*/
val subagents: Int,
) )
private fun parseSession(session: JSONObject) = private fun parseSession(session: JSONObject) =
@@ -252,6 +260,7 @@ private fun parseSession(session: JSONObject) =
usageProvider = session.optString("usageProvider").ifEmpty { null }, usageProvider = session.optString("usageProvider").ifEmpty { null },
status = session.getString("status"), status = session.getString("status"),
lastActivity = session.getDouble("lastActivity"), lastActivity = session.getDouble("lastActivity"),
subagents = session.optInt("subagents", 0),
) )
fun fetchSessions(settings: ServerSettings): List<SessionSummary> = fun fetchSessions(settings: ServerSettings): List<SessionSummary> =
@@ -267,6 +276,35 @@ fun fetchSessions(settings: ServerSettings): List<SessionSummary> =
fun fetchSession(settings: ServerSettings, sessionId: String): SessionSummary = fun fetchSession(settings: ServerSettings, sessionId: String): SessionSummary =
requestFromServer(settings, "/sessions/$sessionId") { parseSession(it.jsonObject()) } requestFromServer(settings, "/sessions/$sessionId") { parseSession(it.jsonObject()) }
/**
* One row of `GET /sessions/{id}/subagents`, oldest first.
*
* A subagent is a second transcript owned by a session -- no process, no controls of its own -- so
* this carries only what a card needs to draw and to open it; see SUBAGENTS.md. [status] is
* "running", "exited" or "unknown": a subagent whose session is not itself running cannot be
* running, and the list says so rather than reporting a state that cannot hold.
*/
data class SubagentSummary(
val id: String,
val title: String,
val status: String,
val created: Double,
val lastActivity: Double,
)
fun fetchSubagents(settings: ServerSettings, sessionId: String): List<SubagentSummary> =
requestFromServer(settings, "/sessions/$sessionId/subagents") {
it.jsonObjects { row ->
SubagentSummary(
id = row.getString("id"),
title = row.getString("title"),
status = row.getString("status"),
created = row.getDouble("created"),
lastActivity = row.getDouble("lastActivity"),
)
}
}
// What the server offers, so the spawn screen has no hardcoded lists: a setup added to the server's // What the server offers, so the spawn screen has no hardcoded lists: a setup added to the server's
// config.ron appears here with no app rebuild. // config.ron appears here with no app rebuild.
// //
@@ -968,7 +1006,7 @@ fun startImport(
*/ */
fun fetchTranscript( fun fetchTranscript(
settings: ServerSettings, settings: ServerSettings,
sessionId: String, address: TranscriptAddress,
before: Long? = null, before: Long? = null,
limit: Int = 80, limit: Int = 80,
// Count [limit] in rows, not events, joining a reply's streamed deltas into one -- so a page of // Count [limit] in rows, not events, joining a reply's streamed deltas into one -- so a page of
@@ -987,7 +1025,7 @@ fun fetchTranscript(
if (coalesce) append("&coalesce=true") if (coalesce) append("&coalesce=true")
if (after != null) append("&after=").append(after) if (after != null) append("&after=").append(after)
} }
return requestFromServer(settings, "/sessions/$sessionId/transcript$query") { connection -> return requestFromServer(settings, "/${address.urlPath}/transcript$query") { connection ->
val body = JSONArray(connection.inputStream.bufferedReader().readText()) val body = JSONArray(connection.inputStream.bufferedReader().readText())
// The text as well as the event: the transcript cache stores the one and the fold needs the // The text as well as the event: the transcript cache stores the one and the fold needs the
// other, and they have to be the same line. // other, and they have to be the same line.
@@ -1,7 +1,9 @@
package com.example.aiapp package com.example.aiapp
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
@@ -34,7 +36,23 @@ import kotlinx.coroutines.withContext
* session, spawning one, and settings. * session, spawning one, and settings.
*/ */
private sealed class Screen { private sealed class Screen {
data object Main : Screen() /**
* The session list, with a subagent's own transcript over it when [subagent] is set.
*
* A layer on this screen rather than a screen of its own, for the same reason [Session.files]
* is: [SessionListScreen] owns which cards are expanded and what each expansion fetched, kept
* in `remember`, and a subagent is opened from a card's expander. As a sibling `Screen` it was
* disposed and recreated on every return, which lost that state -- an expanded card collapsed
* itself the moment its own subagent's view was closed.
*/
data class Main(val subagent: SubagentTarget? = null) : Screen()
/**
* One subagent's own transcript, read-only. See [SessionScreen]'s `subagent` parameter and
* SUBAGENTS.md's "Phone". Closing it returns to [Main] under it, not to [Session]: a subagent
* is opened from the session list's card rather than from inside the session it belongs to.
*/
data class SubagentTarget(val summary: SessionSummary, val subagent: SubagentSummary)
/** /**
* One session, with the file explorer over it when [files] is set. * One session, with the file explorer over it when [files] is set.
@@ -81,7 +99,7 @@ fun AppRoot(
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var settings by remember(settingsVersion) { mutableStateOf(loadServerSettings(context)) } var settings by remember(settingsVersion) { mutableStateOf(loadServerSettings(context)) }
var screen by remember { mutableStateOf<Screen>(Screen.Main) } var screen by remember { mutableStateOf<Screen>(Screen.Main()) }
// A notification tap this could not follow, and why. Null both before one is asked for and // A notification tap this could not follow, and why. Null both before one is asked for and
// after one succeeds, since success is a screen rather than a message. // after one succeeds, since success is a screen rather than a message.
var failedOpen by remember { mutableStateOf<FailedOpen?>(null) } var failedOpen by remember { mutableStateOf<FailedOpen?>(null) }
@@ -96,7 +114,7 @@ fun AppRoot(
share = shareRequest share = shareRequest
// A session already open takes it. Otherwise the list is where the choice is made, // A session already open takes it. Otherwise the list is where the choice is made,
// whatever screen was showing: Spawn and Settings have nowhere to put a file. // whatever screen was showing: Spawn and Settings have nowhere to put a file.
if (screen !is Screen.Session) screen = Screen.Main if (screen !is Screen.Session) screen = Screen.Main()
} }
} }
@@ -123,7 +141,7 @@ fun AppRoot(
existing = null, existing = null,
onSaved = { saved -> onSaved = { saved ->
settings = saved settings = saved
screen = Screen.Main screen = Screen.Main()
}, },
onBack = null, onBack = null,
) )
@@ -136,7 +154,7 @@ fun AppRoot(
// shows, so it always refetches. // shows, so it always refetches.
val goToMain = { val goToMain = {
reloadToken++ reloadToken++
screen = Screen.Main screen = Screen.Main()
} }
if (screen !is Screen.Main) { if (screen !is Screen.Main) {
BackHandler(onBack = goToMain) BackHandler(onBack = goToMain)
@@ -185,6 +203,9 @@ fun AppRoot(
reloadToken = reloadToken, reloadToken = reloadToken,
share = share, share = share,
onOpen = { screen = Screen.Session(it) }, onOpen = { screen = Screen.Session(it) },
onOpenSubagent = { summary, subagent ->
screen = here.copy(subagent = Screen.SubagentTarget(summary, subagent))
},
onSpawn = { screen = Screen.Spawn }, onSpawn = { screen = Screen.Spawn },
onImported = { imported -> onImported = { imported ->
reloadToken++ reloadToken++
@@ -192,6 +213,27 @@ fun AppRoot(
}, },
onSettings = { screen = Screen.Settings }, onSettings = { screen = Screen.Settings },
) )
// Its own back handler is registered after MainScreen's, so it is the one the
// platform asks first while a subagent is open -- the same rule the files
// explorer's handler follows over its session, below.
here.subagent?.let { target ->
BackHandler { screen = here.copy(subagent = null) }
// Its own opaque background: this screen was always the sole content under
// the theme's own Surface before, so it never had to paint one -- stacked over
// the list here, the space between its own cards let the list underneath show
// through without this. The same fix FilesScreen needed over its session.
Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
key(target.summary.id, target.subagent.id) {
SessionScreen(
settings = current,
summary = target.summary,
onBack = { screen = here.copy(subagent = null) },
onFiles = {},
subagent = target.subagent,
)
}
}
}
} }
is Screen.Session -> is Screen.Session ->
// Keyed on the id, because a different session is a different screen rather than this // Keyed on the id, because a different session is a different screen rather than this
@@ -14,7 +14,7 @@ private const val RESET_EVENT = "reset"
* mean. [close] from any thread ends it, and the caller owns reconnecting -- with the last seq it * mean. [close] from any thread ends it, and the caller owns reconnecting -- with the last seq it
* saw as the new cursor. * saw as the new cursor.
*/ */
class EventStream(settings: ServerSettings, private val sessionId: String) { class EventStream(settings: ServerSettings, private val address: TranscriptAddress) {
private val stream = Sse(settings) private val stream = Sse(settings)
fun close() = stream.close() fun close() = stream.close()
@@ -35,7 +35,7 @@ class EventStream(settings: ServerSettings, private val sessionId: String) {
// one and the screen folds the other, and they have to be the same line. // one and the screen folds the other, and they have to be the same line.
onEvent: (raw: String, event: SeqEvent) -> Unit, onEvent: (raw: String, event: SeqEvent) -> Unit,
) { ) {
stream.run("/sessions/$sessionId/events?after=$after", onOpen) { name, data -> stream.run("/${address.urlPath}/events?after=$after", onOpen) { name, data ->
// A named frame carries no payload and a data frame has no name. // A named frame carries no payload and a data frame has no name.
if (name == RESET_EVENT) onReset() if (name == RESET_EVENT) onReset()
else if (data.isNotEmpty()) onEvent(data, parseSeqEvent(data)) else if (data.isNotEmpty()) onEvent(data, parseSeqEvent(data))
@@ -48,6 +48,8 @@ fun MainScreen(
/** What another app shared in and no session has taken yet; see [ShareRequest]. */ /** What another app shared in and no session has taken yet; see [ShareRequest]. */
share: ShareRequest? = null, share: ShareRequest? = null,
onOpen: (SessionSummary) -> Unit, onOpen: (SessionSummary) -> Unit,
/** Opens one session's subagent, from the expander under its card. */
onOpenSubagent: (SessionSummary, SubagentSummary) -> Unit,
onSpawn: () -> Unit, onSpawn: () -> Unit,
onImported: (SessionSummary) -> Unit, onImported: (SessionSummary) -> Unit,
onSettings: () -> Unit, onSettings: () -> Unit,
@@ -139,6 +141,7 @@ fun MainScreen(
settings = settings, settings = settings,
reloadToken = token, reloadToken = token,
onOpen = onOpen, onOpen = onOpen,
onOpenSubagent = onOpenSubagent,
onSpawn = onSpawn, onSpawn = onSpawn,
) )
MainTab.Import -> MainTab.Import ->
@@ -1,7 +1,9 @@
package com.example.aiapp package com.example.aiapp
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -17,6 +19,7 @@ import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@@ -30,6 +33,8 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -47,12 +52,40 @@ fun SessionListScreen(
settings: ServerSettings, settings: ServerSettings,
reloadToken: Int, reloadToken: Int,
onOpen: (SessionSummary) -> Unit, onOpen: (SessionSummary) -> Unit,
/** Opens one session's subagent, from the expander under its card. */
onOpenSubagent: (SessionSummary, SubagentSummary) -> Unit,
onSpawn: () -> Unit, onSpawn: () -> Unit,
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var listState by remember { mutableStateOf<LoadState<List<SessionSummary>>>(LoadState.Loading) } var listState by remember { mutableStateOf<LoadState<List<SessionSummary>>>(LoadState.Loading) }
var confirmingDelete by remember { mutableStateOf<SessionSummary?>(null) } var confirmingDelete by remember { mutableStateOf<SessionSummary?>(null) }
// Which session cards are expanded to show their subagents, and what each expansion fetched.
// Ids rather than a flag on the row for the same reason `deleting` is: the rows are rebuilt
// from
// whatever the server last said, and this belongs to the reader's own choice, which survives a
// refresh.
var expandedSessions by remember { mutableStateOf(setOf<String>()) }
var subagentLoads by remember {
mutableStateOf(mapOf<String, LoadState<List<SubagentSummary>>>())
}
fun loadSubagents(sessionId: String) {
subagentLoads = subagentLoads + (sessionId to LoadState.Loading)
scope.launch {
subagentLoads =
subagentLoads +
(sessionId to
try {
LoadState.Loaded(
withContext(Dispatchers.IO) { fetchSubagents(settings, sessionId) }
)
} catch (e: ApiException) {
LoadState.failed(e)
})
}
}
// Failures that belong to one session rather than to the list, keyed by its id and shown on its // Failures that belong to one session rather than to the list, keyed by its id and shown on its
// own card. The two scopes are decided by whether the server answered: it answered and refused, // own card. The two scopes are decided by whether the server answered: it answered and refused,
// so this says nothing about the other rows. // so this says nothing about the other rows.
@@ -84,6 +117,14 @@ fun SessionListScreen(
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
transcriptCache.retainOnly(loaded.value.map { it.id }.toSet()) transcriptCache.retainOnly(loaded.value.map { it.id }.toSet())
} }
// A session gone from this answer cannot still be expanded, and an expanded one
// that is still here asks again -- its subagents may have changed since the
// last
// fetch.
val ids = loaded.value.map { it.id }.toSet()
expandedSessions = expandedSessions intersect ids
subagentLoads = subagentLoads.filterKeys { it in ids }
expandedSessions.forEach(::loadSubagents)
loaded loaded
} catch (e: ApiException) { } catch (e: ApiException) {
LoadState.failed(e) LoadState.failed(e)
@@ -127,6 +168,17 @@ fun SessionListScreen(
deleting = session.id in deleting, deleting = session.id in deleting,
onOpen = { onOpen(session) }, onOpen = { onOpen(session) },
onLongPress = { confirmingDelete = session }, onLongPress = { confirmingDelete = session },
expanded = session.id in expandedSessions,
subagents = subagentLoads[session.id],
onToggleSubagents = {
if (session.id in expandedSessions) {
expandedSessions = expandedSessions - session.id
} else {
expandedSessions = expandedSessions + session.id
loadSubagents(session.id)
}
},
onOpenSubagent = { subagent -> onOpenSubagent(session, subagent) },
) )
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
} }
@@ -225,7 +277,7 @@ fun SessionListScreen(
deleteSession(settings, session.id, alsoDeleteForeign) deleteSession(settings, session.id, alsoDeleteForeign)
// After it succeeded, not before: a refused delete leaves the // After it succeeded, not before: a refused delete leaves the
// session exactly as it was, and its transcript with it. // session exactly as it was, and its transcript with it.
transcriptCache.session(session.id).purge() transcriptCache.session(TranscriptAddress(session.id)).purge()
} }
// Only this row, and only what changed. Refetching the list instead // Only this row, and only what changed. Refetching the list instead
// put every other session back through loading and handed the // put every other session back through loading and handed the
@@ -276,6 +328,12 @@ private fun SessionCard(
deleting: Boolean, deleting: Boolean,
onOpen: () -> Unit, onOpen: () -> Unit,
onLongPress: () -> Unit, onLongPress: () -> Unit,
/** Whether the expander below is open. Collapsed by default; see [SessionListScreen]. */
expanded: Boolean,
/** What the expander's own fetch answered, or null before it has been asked. */
subagents: LoadState<List<SubagentSummary>>?,
onToggleSubagents: () -> Unit,
onOpenSubagent: (SubagentSummary) -> Unit,
) { ) {
BusyItem(label = if (deleting) "deleting" else null) { BusyItem(label = if (deleting) "deleting" else null) {
Card( Card(
@@ -332,10 +390,99 @@ private fun SessionCard(
color = MaterialTheme.colorScheme.error, color = MaterialTheme.colorScheme.error,
) )
} }
// Nothing at all for a card with no subagents: a disabled expander here would be
// noise on every ordinary session's card. Its own row at the bottom rather than
// beside the title or the machine line, so opening it never displaces text that was
// already on screen -- see UI_RULES on a control not displacing the text beside it.
if (session.subagents > 0) {
Spacer(Modifier.height(8.dp))
Row(
horizontalArrangement = Arrangement.Center,
modifier =
Modifier.fillMaxWidth()
.clickable(enabled = !deleting, onClick = onToggleSubagents)
.semantics {
contentDescription =
if (expanded) "Collapse subagents" else "Expand subagents"
},
) {
Chevron(if (expanded) Pointing.Up else Pointing.Down)
}
if (expanded) {
Spacer(Modifier.height(4.dp))
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
when (subagents) {
null,
is LoadState.Loading ->
CircularProgressIndicator(
modifier = Modifier.width(20.dp).height(20.dp),
strokeWidth = 2.dp,
)
is LoadState.Error ->
// Said here rather than left silent: a fetch that failed and an
// expander that simply found nothing must not look the same --
// see UI_RULES on designing the unknown state first.
Text(
subagents.message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
is LoadState.Loaded ->
subagents.value.forEach { subagent ->
SubagentCard(
subagent,
onClick = { onOpenSubagent(subagent) },
)
} }
} }
} }
} }
}
}
}
}
}
/**
* One subagent, indented inside its session's card -- the way dev-updater draws a project's
* components (`ComponentCard`, `UpdaterScreen.kt`): an outlined card, not the session card's own
* filled one, so the nesting reads as one step rather than as another session.
*/
@Composable
private fun SubagentCard(subagent: SubagentSummary, onClick: () -> Unit) {
OutlinedCard(Modifier.fillMaxWidth().clickable(onClick = onClick)) {
Column(Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
Text(subagent.title, style = MaterialTheme.typography.titleSmall)
Spacer(Modifier.height(2.dp))
Row(modifier = Modifier.fillMaxWidth()) {
Text(
subagentStatusLabel(subagent.status),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
Text(
relativeTime(subagent.lastActivity),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
/**
* The subcard's word for a subagent's status -- see SUBAGENTS.md's "Wire shape". Its own function
* rather than a branch inside [StatusText], because a subagent's three states are not that
* composable's five: "exited" reads as "finished" here, since its process was always its parent's
* and never something of its own to have merely stopped.
*/
private fun subagentStatusLabel(status: String) =
when (status) {
"running" -> "running"
"exited" -> "finished"
else -> "unknown"
}
@Composable @Composable
fun StatusText(status: String) { fun StatusText(status: String) {
@@ -216,16 +216,33 @@ fun SessionScreen(
share: ShareRequest? = null, share: ShareRequest? = null,
/** Said once [share] has been attached here, so it is not attached again. */ /** Said once [share] has been attached here, so it is not attached again. */
onShareTaken: () -> Unit = {}, onShareTaken: () -> Unit = {},
/**
* Draws this screen read-only, on a subagent's own transcript instead of the session's.
*
* A subagent has no process and no controls of its own -- see SUBAGENTS.md's "Phone" -- so
* every gate below keyed on this switches off the composer, the files button, the settings cog,
* the usage bar and notifications, while everything that draws a transcript (paging, cache,
* selection, images, the status row, stream reconnects) is reused unchanged, pointed at
* [address] instead of the session's own.
*/
subagent: SubagentSummary? = null,
) { ) {
DebugStats.count("session screen recomposed") DebugStats.count("session screen recomposed")
val isSubagent = subagent != null
val address = TranscriptAddress(summary.id, subagent?.id)
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val topEdgeHeld = remember { TopEdgeHold() } val topEdgeHeld = remember { TopEdgeHold() }
var items by remember { mutableStateOf(listOf<TranscriptItem>()) } var items by remember { mutableStateOf(listOf<TranscriptItem>()) }
var status by remember { mutableStateOf(summary.status) } var status by remember { mutableStateOf(subagent?.status ?: summary.status) }
// Seeded from the row this screen was opened from, so a conversation already under way says how // Seeded from the row this screen was opened from, so a conversation already under way says how
// much it is holding before any turn happens here. Null is "nobody has measured it", which is a // much it is holding before any turn happens here. Null is "nobody has measured it", which is a
// different answer from an empty context and is drawn differently. // different answer from an empty context and is drawn differently.
var contextTokens by remember(summary.id) { mutableStateOf(summary.contextTokens) } //
// A subagent has no context measurement of its own, so it always starts unmeasured rather than
// borrowing the parent session's figure -- see UI_RULES on not showing an inferred value as one
// that was measured.
var contextTokens by
remember(address) { mutableStateOf(if (isSubagent) null else summary.contextTokens) }
// When the current compaction started. The moment comes off the `compacting` status event // When the current compaction started. The moment comes off the `compacting` status event
// itself -- the server timestamps every transcript line -- rather than off this device noticing // itself -- the server timestamps every transcript line -- rather than off this device noticing
// one, which is what makes it survive leaving the session and reopening it. // one, which is what makes it survive leaving the session and reopening it.
@@ -241,7 +258,13 @@ fun SessionScreen(
val context = LocalContext.current val context = LocalContext.current
// Seeded from what was left in the box last time and written back on every keystroke, so // Seeded from what was left in the box last time and written back on every keystroke, so
// leaving the screen does not throw away a half-typed message. See `Drafts.kt`. // leaving the screen does not throw away a half-typed message. See `Drafts.kt`.
var input by remember(summary.id) { mutableStateOf(atEnd(loadDraft(context, summary.id))) } //
// A subagent has no box to type into, so it never touches a draft at all -- not this session's,
// which is what reading one keyed only by `summary.id` would do here.
var input by
remember(summary.id) {
mutableStateOf(if (isSubagent) atEnd("") else atEnd(loadDraft(context, summary.id)))
}
// A model the reader has chosen and not yet confirmed. See [ModelSwitchWarning]: switching // A model the reader has chosen and not yet confirmed. See [ModelSwitchWarning]: switching
// makes the session re-read the whole conversation. // makes the session re-read the whole conversation.
var pendingModel by remember { mutableStateOf<String?>(null) } var pendingModel by remember { mutableStateOf<String?>(null) }
@@ -294,27 +317,26 @@ fun SessionScreen(
// Reload throws away what it was reading from. // Reload throws away what it was reading from.
val cache = remember(settings) { TranscriptCache(cacheRoot(context, settings)) } val cache = remember(settings) { TranscriptCache(cacheRoot(context, settings)) }
val source = val source =
remember(summary.id, epoch) { remember(address, epoch) { TranscriptSource(settings, address, cache.session(address)) }
TranscriptSource(settings, summary.id, cache.session(summary.id))
}
// Whether the cached tail has been shown to still be the server's own line. Nothing is resumed // Whether the cached tail has been shown to still be the server's own line. Nothing is resumed
// from a cached cursor until it has, and a probe that could not be made leaves this false for // from a cached cursor until it has, and a probe that could not be made leaves this false for
// the stream loop to try again. // the stream loop to try again.
var probePassed by remember(summary.id, epoch) { mutableStateOf(false) } var probePassed by remember(address, epoch) { mutableStateOf(false) }
// Whether the opening effect is still settling that question. It draws the cached rows and // Whether the opening effect is still settling that question. It draws the cached rows and
// lifts [ready] before the answer arrives, which is the point of the cache -- so the stream // lifts [ready] before the answer arrives, which is the point of the cache -- so the stream
// below waits for this rather than for `ready`, or it asks the same question twice. // below waits for this rather than for `ready`, or it asks the same question twice.
var probing by remember(summary.id, epoch) { mutableStateOf(true) } var probing by remember(address, epoch) { mutableStateOf(true) }
// The oldest sequence number loaded, and whether there is more behind it. Paging backwards is // The oldest sequence number loaded, and whether there is more behind it. Paging backwards is
// what keeps opening a long session cheap. // what keeps opening a long session cheap.
var oldestSeq by remember { mutableLongStateOf(0L) } var oldestSeq by remember { mutableLongStateOf(0L) }
// Where this session was last being read, from this device's own store. Read once, because the // Where this transcript was last being read, from this device's own store, keyed by the address
// answer stops being interesting the moment the list is on screen. // rather than the session id so a subagent's saved position cannot collide with its session's.
val savedAnchor = remember(summary.id, epoch) { loadScrollAnchor(context, summary.id) } // Read once, because the answer stops being interesting the moment the list is on screen.
val savedAnchor = remember(address, epoch) { loadScrollAnchor(context, address.cachePath) }
// Whether the saved position is still being put back. Nothing is drawn while it is: opening at // Whether the saved position is still being put back. Nothing is drawn while it is: opening at
// the newest end and then travelling to the anchor is exactly the journey a reader must never // the newest end and then travelling to the anchor is exactly the journey a reader must never
// see. // see.
var restoring by remember(summary.id, epoch) { mutableStateOf(savedAnchor != null) } var restoring by remember(address, epoch) { mutableStateOf(savedAnchor != null) }
// Messages the server has taken and the session has not read yet, by the id that will resolve // Messages the server has taken and the session has not read yet, by the id that will resolve
// them. From the event stream rather than from what this screen sent, so they survive leaving // them. From the event stream rather than from what this screen sent, so they survive leaving
// the session -- and a message sent from another device is drawn waiting on this one too. // the session -- and a message sent from another device is drawn waiting on this one too.
@@ -327,11 +349,11 @@ fun SessionScreen(
var loadingHistory by remember { mutableStateOf(false) } var loadingHistory by remember { mutableStateOf(false) }
var ready by remember { mutableStateOf(false) } var ready by remember { mutableStateOf(false) }
// Replies parsed ahead of the rows that draw them; see [ParsedReplies]. // Replies parsed ahead of the rows that draw them; see [ParsedReplies].
val replies = remember(summary.id) { ParsedReplies() } val replies = remember(address) { ParsedReplies() }
// Keyed like everything else describing one session's transcript. `rememberLazyListState` saves // Keyed like everything else describing one transcript. `rememberLazyListState` saves through
// through `rememberSaveable`, and this screen restores by its own anchor instead -- two // `rememberSaveable`, and this screen restores by its own anchor instead -- two restores would
// restores would fight over the first frame. // fight over the first frame.
val listState = remember(summary.id) { LazyListState() } val listState = remember(address) { LazyListState() }
// Whether the newest message is on screen right now. The list is reversed, so the newest end is // Whether the newest message is on screen right now. The list is reversed, so the newest end is
// the scrolling start: nothing behind you is exactly being at the bottom. Asked of the scroll // the scrolling start: nothing behind you is exactly being at the bottom. Asked of the scroll
// state rather than of item indices, because a zero-height first item makes an index ambiguous. // state rather than of item indices, because a zero-height first item makes an index ambiguous.
@@ -637,7 +659,7 @@ fun SessionScreen(
// ended and carries live events only. The window comes from this phone's own copy when there is // ended and carries live events only. The window comes from this phone's own copy when there is
// one, and then costs a single request to check that the server's transcript is still the one // one, and then costs a single request to check that the server's transcript is still the one
// it came from. See TRANSCRIPT_CACHE.md. // it came from. See TRANSCRIPT_CACHE.md.
LaunchedEffect(summary.id, epoch) { LaunchedEffect(address, epoch) {
/** /**
* One opening window onto the screen, whichever side it came from. * One opening window onto the screen, whichever side it came from.
* *
@@ -667,11 +689,16 @@ fun SessionScreen(
// A replay is as old as the last visit; the row this screen was opened from was // A replay is as old as the last visit; the row this screen was opened from was
// fetched moments ago. So the transcript comes from the cache and everything that // fetched moments ago. So the transcript comes from the cache and everything that
// is not the transcript comes from the summary -- otherwise a session that finished // is not the transcript comes from the summary -- otherwise a session that finished
// an hour ago opens saying "working" until the stream connects. // an hour ago opens saying "working" until the stream connects. A subagent's status
status = summary.status // comes from its own summary, never the parent session's: they are two different
// things running or not, and the parent's model and permission mode do not apply to
// it at all.
status = subagent?.status ?: summary.status
if (!isSubagent) {
model = summary.model model = summary.model
permissionMode = summary.permissionMode ?: "auto" permissionMode = summary.permissionMode ?: "auto"
if (summary.status != "compacting") compactingSince = null }
if (status != "compacting") compactingSince = null
// Nothing to put back, so these rows are the screen and the probe can return under // Nothing to put back, so these rows are the screen and the probe can return under
// them. A restore still has history to fetch and is gated below. // them. A restore still has history to fetch and is gated below.
if (savedAnchor == null) ready = true if (savedAnchor == null) ready = true
@@ -798,7 +825,7 @@ fun SessionScreen(
// at the top on their return. Switching apps is a choice somebody made, not a fault to report. // at the top on their return. Switching apps is a choice somebody made, not a fault to report.
// Stopping the stream deliberately makes the drop a close rather than an error, and resuming // Stopping the stream deliberately makes the drop a close rather than an error, and resuming
// reconnects from the same cursor. // reconnects from the same cursor.
LaunchedEffect(summary.id, ready, epoch, lifecycleOwner) { LaunchedEffect(address, ready, epoch, lifecycleOwner) {
if (!ready) return@LaunchedEffect if (!ready) return@LaunchedEffect
// The opening effect draws cached rows and lifts `ready` *before* it has checked that the // The opening effect draws cached rows and lifts `ready` *before* it has checked that the
// cursor under them is still the server's, so `ready` is no longer the whole gate. Without // cursor under them is still the server's, so `ready` is no longer the whole gate. Without
@@ -868,10 +895,14 @@ fun SessionScreen(
// The screen going away entirely, which the lifecycle scope above does not cover: a composable // The screen going away entirely, which the lifecycle scope above does not cover: a composable
// can leave the composition while the activity stays started. Keyed on the epoch as well, so // can leave the composition while the activity stays started. Keyed on the epoch as well, so
// Reload's replacement source is the one a later disposal closes. // Reload's replacement source is the one a later disposal closes.
DisposableEffect(summary.id, epoch) { onDispose { source.close() } } DisposableEffect(address, epoch) { onDispose { source.close() } }
// Nothing gets announced about the session somebody is reading; see NotificationService. // Nothing gets announced about the session somebody is reading; see NotificationService.
// RESUMED rather than STARTED because "looking at it" means the foreground. // RESUMED rather than STARTED because "looking at it" means the foreground.
//
// Not for a subagent: it has no notifications of its own, and it is not the session this would
// otherwise mark as being read.
if (!isSubagent) {
LaunchedEffect(summary.id, lifecycleOwner) { LaunchedEffect(summary.id, lifecycleOwner) {
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) { lifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) {
NotificationService.showing(context, summary.id) NotificationService.showing(context, summary.id)
@@ -882,6 +913,7 @@ fun SessionScreen(
} }
} }
} }
}
// Back at the newest end, so the backlog [apply] held can land. Everything at once rather than // Back at the newest end, so the backlog [apply] held can land. Everything at once rather than
// paced out: they are at the bottom, which is the one place the list is allowed to follow new // paced out: they are at the bottom, which is the one place the list is allowed to follow new
@@ -924,7 +956,7 @@ fun SessionScreen(
val (index, offset, awayFromNewest) = settled val (index, offset, awayFromNewest) = settled
saveScrollAnchor( saveScrollAnchor(
context, context,
summary.id, address.cachePath,
// Nothing to restore at the newest end, which is where a session with no anchor // Nothing to restore at the newest end, which is where a session with no anchor
// opens anyway. One *before* the index, because item zero is the "below" slot. // opens anyway. One *before* the index, because item zero is the "below" slot.
if (!awayFromNewest) null if (!awayFromNewest) null
@@ -947,7 +979,7 @@ fun SessionScreen(
// //
// There is no correction beside this one. Following the newest message is not an effect: the // There is no correction beside this one. Following the newest message is not an effect: the
// list is reversed, so an arriving message extends the end the viewport is pinned to. // list is reversed, so an arriving message extends the end the viewport is pinned to.
val unitSizes = remember(summary.id) { HashMap<Any, Int>() } val unitSizes = remember(address) { HashMap<Any, Int>() }
LaunchedEffect(listState, moreHistory) { LaunchedEffect(listState, moreHistory) {
snapshotFlow { listState.layoutInfo } snapshotFlow { listState.layoutInfo }
.collect { info -> .collect { info ->
@@ -983,6 +1015,8 @@ fun SessionScreen(
} }
} }
// Only for the model picker, which a subagent does not have.
if (!isSubagent) {
LaunchedEffect(summary.setupName, summary.provider) { LaunchedEffect(summary.setupName, summary.provider) {
offeredModels = offeredModels =
try { try {
@@ -995,10 +1029,12 @@ fun SessionScreen(
.orEmpty() .orEmpty()
} }
} catch (_: Exception) { } catch (_: Exception) {
// Not worth reporting: the picker simply has nothing to offer, which is visible. // Not worth reporting: the picker simply has nothing to offer, which is
// visible.
emptyList() emptyList()
} }
} }
}
/** /**
* Asks the server to take back a message the session has not read yet. * Asks the server to take back a message the session has not read yet.
@@ -1148,8 +1184,9 @@ fun SessionScreen(
} }
// One poll for the machines' limits, read by everything on this screen that reports them. // One poll for the machines' limits, read by everything on this screen that reports them.
val usageFeed = rememberUsageFeed(settings) // Nothing meters a subagent -- it has no account of its own -- so it never starts this poll.
val usage = usageFeed.forSession(summary) val usageFeed = if (isSubagent) null else rememberUsageFeed(settings)
val usage = usageFeed?.forSession(summary) ?: SessionUsage.NotMetered
RecordFrames() RecordFrames()
var usageOpen by remember { mutableStateOf(false) } var usageOpen by remember { mutableStateOf(false) }
var settingsOpen by remember { mutableStateOf(false) } var settingsOpen by remember { mutableStateOf(false) }
@@ -1236,20 +1273,35 @@ fun SessionScreen(
// A ring's worth, which is what the arrow already keeps on its other three sides. // A ring's worth, which is what the arrow already keeps on its other three sides.
Spacer(Modifier.width(GLYPH_BUTTON_MARGIN)) Spacer(Modifier.width(GLYPH_BUTTON_MARGIN))
Column(Modifier.weight(1f)) { Column(Modifier.weight(1f)) {
// A subagent's own title, with the session's beneath it in a smaller style --
// the header says whose conversation this is as well as what it is. Otherwise
// just the session's title, as before.
if (subagent != null) {
Text(subagent.title, style = MaterialTheme.typography.titleMedium)
Text(
title,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
Text(title, style = MaterialTheme.typography.titleMedium) Text(title, style = MaterialTheme.typography.titleMedium)
// Machine first, then what runs on it -- the same order and the same wording // Machine first, then what runs on it -- the same order and the same
// everywhere this pair appears, so it reads as one fact rather than two // wording everywhere this pair appears, so it reads as one fact rather than
// sentences with different grammar. // two sentences with different grammar.
// //
// No model. The picker in the footer already shows what this session is set to, // No model. The picker in the footer already shows what this session is set
// and showing it twice means two things to keep in step -- they disagreed for a // to, and showing it twice means two things to keep in step -- they
// moment on every model change. // disagreed for a moment on every model change.
Text( Text(
"${summary.setupName} · ${summary.provider}", "${summary.setupName} · ${summary.provider}",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
}
// None of this is a subagent's: it has no files of its own to browse, no settings,
// and nothing meters it -- see SUBAGENTS.md's "Phone".
//
// Beside the provider it reports on, which is the line directly to its left. Its // Beside the provider it reports on, which is the line directly to its left. Its
// real home is this provider's settings, which do not exist yet. A session on a // real home is this provider's settings, which do not exist yet. A session on a
// provider with no such service gets an honest "unavailable" rather than a hidden // provider with no such service gets an honest "unavailable" rather than a hidden
@@ -1264,6 +1316,7 @@ fun SessionScreen(
// Usage, files, settings -- widest scope first, narrowing to the right, so the cog // Usage, files, settings -- widest scope first, narrowing to the right, so the cog
// stays at the end where every other screen keeps it. Asked for in this order by // stays at the end where every other screen keeps it. Asked for in this order by
// Iris on 2026-09-03. // Iris on 2026-09-03.
if (!isSubagent) {
Row { Row {
GlyphButton( GlyphButton(
USAGE_GLYPH, USAGE_GLYPH,
@@ -1281,25 +1334,29 @@ fun SessionScreen(
FilesTarget( FilesTarget(
setup = summary.setup, setup = summary.setup,
setupName = summary.setupName, setupName = summary.setupName,
// Where this session works, and the machine's own home when it // Where this session works, and the machine's own home when
// was never given a directory -- resolved there rather than // it was never given a directory -- resolved there rather
// guessed at here, since this app does not know that home. // than guessed at here, since this app does not know that
// home.
start = summary.cwd?.takeIf { it.isNotBlank() } ?: "~", start = summary.cwd?.takeIf { it.isNotBlank() } ?: "~",
) )
) )
}, },
) )
// What it opens is about this session, so it sits at the end of the session's // What it opens is about this session, so it sits at the end of the
// own row. A cog and not a word because there will be more, and a bar of words // session's own row. A cog and not a word because there will be more, and a
// has nowhere to put it. // bar of words has nowhere to put it.
GlyphButton(SETTINGS_GLYPH, "Session settings", { settingsOpen = true }) GlyphButton(SETTINGS_GLYPH, "Session settings", { settingsOpen = true })
} }
} }
}
// Under the header, above everything the session itself says: it is a fact about the // Under the header, above everything the session itself says: it is a fact about the
// machine rather than a turn in the conversation, and it is the number that decides // machine rather than a turn in the conversation, and it is the number that decides
// whether to keep going. // whether to keep going. Nothing meters a subagent.
if (!isSubagent) {
SessionUsageBar(usage) SessionUsageBar(usage)
}
(streamError ?: actionError)?.let { message -> (streamError ?: actionError)?.let { message ->
Text( Text(
@@ -1644,24 +1701,34 @@ fun SessionScreen(
) )
} }
// Kept for a subagent -- see SUBAGENTS.md's "Phone" -- with the wording that turns
// "exited" into "finished" for one, since it has no process to leave running or stop.
SessionStatusRow( SessionStatusRow(
status = status, status = status,
compactingFor = compactingFor, compactingFor = compactingFor,
contextTokens = contextTokens, contextTokens = contextTokens,
subagent = isSubagent,
) )
// Between the transcript and the box: above what is being typed, so the list does not // Everything from here down is the composer: a subagent cannot be messaged, so none of
// cover the thing the command is about, and below everything that explains it. // it applies -- see SUBAGENTS.md's "Phone".
if (!isSubagent) {
// Between the transcript and the box: above what is being typed, so the list does
// not cover the thing the command is about, and below everything that explains it.
CommandSuggestions( CommandSuggestions(
// Nothing to suggest about a suggestion that was just taken. `/compact` is a whole // Nothing to suggest about a suggestion that was just taken. `/compact` is a
// command *and* a prefix of itself, so picking it left the list standing there with // whole command *and* a prefix of itself, so picking it left the list standing
// the one row already chosen. Held by what was picked rather than by a flag, so // there with the one row already chosen. Held by what was picked rather than by
// typing anything else brings the list back without a second thing to reset. // a flag, so typing anything else brings the list back without a second thing
commands = if (input.text == picked) emptyList() else suggestedCommands(input.text), // to
// reset.
commands =
if (input.text == picked) emptyList() else suggestedCommands(input.text),
onPick = { command -> onPick = { command ->
// At the end of what was inserted, which is where the reader carries on typing: // At the end of what was inserted, which is where the reader carries on
// a command with an argument is put in the box half-written, and a cursor left // typing: a command with an argument is put in the box half-written, and a
// at the front makes the next keystroke the first character of "/rename". // cursor left at the front makes the next keystroke the first character of
// "/rename".
input = atEnd(command.typed()) input = atEnd(command.typed())
picked = command.typed() picked = command.typed()
}, },
@@ -1670,11 +1737,14 @@ fun SessionScreen(
// Always enabled -- a send while the session is running becomes a steering message // Always enabled -- a send while the session is running becomes a steering message
// injected at the next tool boundary, which is the point of the whole app. // injected at the next tool boundary, which is the point of the whole app.
// //
// The field gets a row of its own, above the buttons: sharing one put the full width // The field gets a row of its own, above the buttons: sharing one put the full
// behind three controls, so the thing being typed into was the narrowest on the row. // width
// behind three controls, so the thing being typed into was the narrowest on the
// row.
Column(Modifier.fillMaxWidth().padding(8.dp)) { Column(Modifier.fillMaxWidth().padding(8.dp)) {
// Directly above the box they will be sent from, so what is attached is visible // Directly above the box they will be sent from, so what is attached is visible
// rather than counted: the "+2" on the button below said how many and never which. // rather than counted: the "+2" on the button below said how many and never
// which.
PendingAttachments( PendingAttachments(
settings = settings, settings = settings,
sessionId = summary.id, sessionId = summary.id,
@@ -1688,8 +1758,8 @@ fun SessionScreen(
saveDraft(context, summary.id, it.text) saveDraft(context, summary.id, it.text)
}, },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
// No longer "(+image)": the images are on screen above this, and a placeholder // No longer "(+image)": the images are on screen above this, and a
// saying so said it in words beside the thing itself. // placeholder saying so said it in words beside the thing itself.
placeholder = { Text("Message") }, placeholder = { Text("Message") },
maxLines = 4, maxLines = 4,
) )
@@ -1697,17 +1767,19 @@ fun SessionScreen(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
// Photo or file, asked here rather than by two buttons: the row is full, and // Photo or file, asked here rather than by two buttons: the row is full,
// and
// attaching is one action whichever picker answers it. // attaching is one action whichever picker answers it.
var attaching by remember { mutableStateOf(false) } var attaching by remember { mutableStateOf(false) }
Box { Box {
// Just "+". The count it used to carry was standing in for showing them. // Just "+". The count it used to carry was standing in for showing
// them.
BubbleButton(onClick = { attaching = true }) { Text("+") } BubbleButton(onClick = { attaching = true }) { Text("+") }
DropdownMenu( DropdownMenu(
expanded = attaching, expanded = attaching,
onDismissRequest = { attaching = false }, onDismissRequest = { attaching = false },
// See PickerButton: without this the menu opens a status bar's height // See PickerButton: without this the menu opens a status bar's
// away from the button in an edge-to-edge activity. // height away from the button in an edge-to-edge activity.
properties = PopupProperties(clippingEnabled = false), properties = PopupProperties(clippingEnabled = false),
shape = BubbleMenuShape, shape = BubbleMenuShape,
) { ) {
@@ -1731,11 +1803,11 @@ fun SessionScreen(
) )
} }
} }
// The settings share what is left after the actions have taken what they need. // The settings share what is left after the actions have taken what they
// A Row hands out intrinsic widths in order and clips whatever runs past the // need. A Row hands out intrinsic widths in order and clips whatever runs
// edge, so with these laid out first the arrival of Stop pushed Send off the // past the edge, so with these laid out first the arrival of Stop pushed
// screen entirely -- the app's central control, gone at the moment it is most // Send off the screen entirely -- the app's central control, gone at the
// in use. // moment it is most in use.
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
@@ -1743,16 +1815,18 @@ fun SessionScreen(
if (offeredModels.isNotEmpty()) { if (offeredModels.isNotEmpty()) {
PickerButton( PickerButton(
current = modelLabel(model), current = modelLabel(model),
// What the machine offers, plus the state a session is in when it // What the machine offers, plus the state a session is in when
// has chosen none of them. The button has always been able to say // it has chosen none of them. The button has always been able
// "default"; until this the list could not, so leaving it was a // to
// one-way trip. // say "default"; until this the list could not, so leaving it
// was a one-way trip.
options = listOf(DEFAULT_MODEL) + offeredModels, options = listOf(DEFAULT_MODEL) + offeredModels,
// Not set here. The button follows what the session reports it is // Not set here. The button follows what the session reports it
// set to, which arrives a moment later and is sometimes a different // is set to, which arrives a moment later and is sometimes a
// answer -- a name the CLI resolved, or no change at all on a // different answer -- a name the CLI resolved, or no change at
// provider whose model is fixed. Asked about first, unless there is // all on a provider whose model is fixed. Asked about first,
// nothing to lose by it -- see [ModelSwitchWarning]. // unless there is nothing to lose by it -- see
// [ModelSwitchWarning].
onPick = { chosen -> onPick = { chosen ->
if ( if (
modelLabel(chosen) == modelLabel(model) || modelLabel(chosen) == modelLabel(model) ||
@@ -1773,14 +1847,16 @@ fun SessionScreen(
}, },
) )
} }
// The same filled shape as the button beside it, not an outlined one: these are // The same filled shape as the button beside it, not an outlined one: these
// two things you can do about the session, and weighting one as secondary said // are two things you can do about the session, and weighting one as
// they were a primary action and its qualifier. What separates them is the // secondary said they were a primary action and its qualifier. What
// colour and the mark, which is what they mean. // separates them is the colour and the mark, which is what they mean.
// //
// Always here, rather than arriving with the turn as it used to. A control that // Always here, rather than arriving with the turn as it used to. A control
// comes and goes makes its own presence the signal, and a button always in the // that comes and goes makes its own presence the signal, and a button
// same place also cannot push Send off the end of the row by turning up. // always
// in the same place also cannot push Send off the end of the row by turning
// up.
val process = val process =
when { when {
running -> ProcessAction.Pause running -> ProcessAction.Pause
@@ -1800,18 +1876,21 @@ fun SessionScreen(
Glyph( Glyph(
process.glyph, process.glyph,
colour = LocalContentColor.current, colour = LocalContentColor.current,
modifier = Modifier.semantics { contentDescription = process.label }, modifier =
Modifier.semantics { contentDescription = process.label },
) )
} }
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
// The paper plane, with a clock on it while a turn is in flight: sending then // The paper plane, with a clock on it while a turn is in flight: sending
// queues the message for the next tool boundary rather than starting a turn of // then queues the message for the next tool boundary rather than starting a
// its own, and the two have to be told apart at a glance. The label says the // turn of its own, and the two have to be told apart at a glance. The label
// same thing to a screen reader. // says the same thing to a screen reader.
// //
// Disabled while there is nothing to send, rather than pressable and silent: // Disabled while there is nothing to send, rather than pressable and
// `send` has always returned early on an empty composer, so the button promised // silent:
// something it would not do. Disabled and not hidden, for the reason above. // `send` has always returned early on an empty composer, so the button
// promised something it would not do. Disabled and not hidden, for the
// reason above.
Button( Button(
onClick = { send() }, onClick = { send() },
enabled = input.text.isNotBlank() || pendingAttachments.isNotEmpty(), enabled = input.text.isNotBlank() || pendingAttachments.isNotEmpty(),
@@ -1828,12 +1907,13 @@ fun SessionScreen(
} }
} }
} }
}
// Beside the other two dialogs, and outside the list for the same reason as them: what is open // Beside the other two dialogs, and outside the list for the same reason as them: what is open
// is the screen's business rather than any row's. See [SessionImageViewer]. // is the screen's business rather than any row's. See [SessionImageViewer].
fullImage?.let { ref -> SessionImageViewer(settings, summary.id, ref) { fullImage = null } } fullImage?.let { ref -> SessionImageViewer(settings, summary.id, ref) { fullImage = null } }
if (usageOpen) { if (usageOpen) {
UsageDialog(feed = usageFeed, onDismiss = { usageOpen = false }) usageFeed?.let { UsageDialog(feed = it, onDismiss = { usageOpen = false }) }
} }
if (settingsOpen) { if (settingsOpen) {
// Measured when the dialog opens rather than kept up to date: what the reader is being told // Measured when the dialog opens rather than kept up to date: what the reader is being told
@@ -2125,6 +2205,13 @@ private fun SessionStatusRow(
/** Context the session is holding, or null where nothing has measured it. */ /** Context the session is holding, or null where nothing has measured it. */
contextTokens: Long?, contextTokens: Long?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
/**
* Whether this row is for a subagent rather than a session, which changes only one word:
* "exited" reads as "finished" there too, the same as the subagent list's own card -- a
* subagent's process was always its parent's, so "exited" would read as a fault rather than the
* ordinary way one of these ends.
*/
subagent: Boolean = false,
) { ) {
DebugStats.count("status row recomposed") DebugStats.count("status row recomposed")
Row( Row(
@@ -2181,7 +2268,7 @@ private fun SessionStatusRow(
Text( Text(
when (status) { when (status) {
"idle" -> "idle" "idle" -> "idle"
"exited" -> "exited" "exited" -> if (subagent) "finished" else "exited"
"awaitingInput" -> "your turn" "awaitingInput" -> "your turn"
"unknown" -> "can't tell" "unknown" -> "can't tell"
else -> status else -> status
@@ -0,0 +1,27 @@
package com.example.aiapp
/**
* Where one transcript lives: a session's own, or one of its subagents'.
*
* The single mechanism [fetchTranscript], [EventStream], [TranscriptSource] and
* [TranscriptCache.session] all take, rather than each growing its own branch between a session and
* a subagent -- see SUBAGENTS.md's "Phone" and "Wire shape". A caller that has only a session id
* builds one with the one-argument constructor; a subagent's screen supplies both ids.
*/
data class TranscriptAddress(val sessionId: String, val subagentId: String? = null) {
/** The URL segment naming this transcript, before `/transcript` or `/events`. */
val urlPath: String
get() =
if (subagentId == null) "sessions/$sessionId"
else "sessions/$sessionId/subagents/$subagentId"
/**
* Where this transcript's cache lives on the phone, relative to the cache root.
*
* A subagent's nests under its session's directory rather than sitting beside it, so deleting a
* session's cache directory takes its subagents' with it -- the same one-way door the server's
* own storage describes.
*/
val cachePath: String
get() = if (subagentId == null) sessionId else "$sessionId/subagents/$subagentId"
}
@@ -35,8 +35,15 @@ class TranscriptCache(
private val root: File, private val root: File,
private val warn: (String) -> Unit = { Log.w("ai-app", it) }, private val warn: (String) -> Unit = { Log.w("ai-app", it) },
) { ) {
/** The cache for one session, whether or not anything has been stored for it yet. */ /**
fun session(id: String): SessionCache = SessionCache(File(root, id), warn) * The cache for one transcript, whether or not anything has been stored for it yet.
*
* A subagent's [TranscriptAddress.cachePath] nests it under its session's directory, so
* deleting the session (below) takes its subagents' caches with it -- there is no separate
* purge for one.
*/
fun session(address: TranscriptAddress): SessionCache =
SessionCache(File(root, address.cachePath), warn)
/** /**
* Deletes every session directory not in [ids], called after a successful list fetch. The path * Deletes every session directory not in [ids], called after a successful list fetch. The path
@@ -18,7 +18,7 @@ import java.util.concurrent.atomic.AtomicReference
*/ */
class TranscriptSource( class TranscriptSource(
private val settings: ServerSettings, private val settings: ServerSettings,
private val sessionId: String, private val address: TranscriptAddress,
val cache: SessionCache, val cache: SessionCache,
) { ) {
private val stream = AtomicReference<EventStream?>(null) private val stream = AtomicReference<EventStream?>(null)
@@ -65,7 +65,7 @@ class TranscriptSource(
val tail = cache.tail() ?: return false val tail = cache.tail() ?: return false
// `before = seq + 1` is the newest event with seq <= the cursor, which is the event *at* // `before = seq + 1` is the newest event with seq <= the cursor, which is the event *at*
// the cursor when the server still has one there. // the cursor when the server still has one there.
val answer = fetchTranscript(settings, sessionId, before = tail.seq + 1, limit = 1) val answer = fetchTranscript(settings, address, before = tail.seq + 1, limit = 1)
val matches = val matches =
answer.size == 1 && answer.size == 1 &&
try { try {
@@ -83,7 +83,7 @@ class TranscriptSource(
*/ */
suspend fun fetchOpening(): List<SeqEvent> { suspend fun fetchOpening(): List<SeqEvent> {
DebugStats.count("transcript page from server") DebugStats.count("transcript page from server")
val page = fetchTranscript(settings, sessionId, limit = OPENING_WINDOW) val page = fetchTranscript(settings, address, limit = OPENING_WINDOW)
page.forEach { (line, entry) -> cache.append(line, entry.seq) } page.forEach { (line, entry) -> cache.append(line, entry.seq) }
cache.flush() cache.flush()
return page.map { it.second } return page.map { it.second }
@@ -108,7 +108,7 @@ class TranscriptSource(
val page = val page =
fetchTranscript( fetchTranscript(
settings, settings,
sessionId, address,
before = before, before = before,
limit = limit, limit = limit,
coalesce = coalesce, coalesce = coalesce,
@@ -131,7 +131,7 @@ class TranscriptSource(
* well lose. * well lose.
*/ */
fun follow(after: Long, onOpen: () -> Unit, onReset: () -> Unit, onEvent: (SeqEvent) -> Unit) { fun follow(after: Long, onOpen: () -> Unit, onReset: () -> Unit, onEvent: (SeqEvent) -> Unit) {
val opened = EventStream(settings, sessionId) val opened = EventStream(settings, address)
stream.getAndSet(opened)?.close() stream.getAndSet(opened)?.close()
try { try {
opened.run(after, onOpen, onReset) { raw, entry -> opened.run(after, onOpen, onReset) { raw, entry ->
@@ -23,7 +23,7 @@ class TranscriptCacheTest {
private fun cache() = TranscriptCache(File(temp, "v1/host_8443")) { said += it } private fun cache() = TranscriptCache(File(temp, "v1/host_8443")) { said += it }
private fun session(id: String = "s") = cache().session(id) private fun session(id: String = "s") = cache().session(TranscriptAddress(id))
private fun line(seq: Long, type: String = "toolStart") = private fun line(seq: Long, type: String = "toolStart") =
"""{"seq":$seq,"ts":1.5,"type":"$type","id":"x"}""" """{"seq":$seq,"ts":1.5,"type":"$type","id":"x"}"""
+120 -13
View File
@@ -29,6 +29,12 @@
//! GET /sessions/{id}/transcript a page of history: ?before=N (newest when absent), //! GET /sessions/{id}/transcript a page of history: ?before=N (newest when absent),
//! ?limit=N, ?coalesce=true to count rows not deltas, //! ?limit=N, ?coalesce=true to count rows not deltas,
//! ?after=N to floor it at what the caller already holds //! ?after=N to floor it at what the caller already holds
//! GET /sessions/{id}/subagents [{id, title, status, created, lastActivity}], oldest
//! first -- see SUBAGENTS.md
//! GET /sessions/{id}/subagents/{sub}/transcript exactly the transcript route above,
//! against that subagent's own transcript
//! GET /sessions/{id}/subagents/{sub}/events?after=N exactly the events route above,
//! against that subagent's own stream
//! POST /sessions/{id}/message {text, attachmentIds?} //! POST /sessions/{id}/message {text, attachmentIds?}
//! (starts the process first if it has exited) //! (starts the process first if it has exited)
//! POST /sessions/{id}/unqueue {messageId} -- take back one not read yet //! POST /sessions/{id}/unqueue {messageId} -- take back one not read yet
@@ -99,6 +105,7 @@ use tokio_stream::wrappers::{BroadcastStream, ReceiverStream};
use crate::session::driver::{SessionCommand, Unqueued}; use crate::session::driver::{SessionCommand, Unqueued};
use crate::session::pending::Operation; use crate::session::pending::Operation;
use crate::session::subagent::{Subagent, SubagentInfo};
use crate::session::transcript::{CATCH_UP_LIMIT, CatchUp, SeqEvent, catch_up}; use crate::session::transcript::{CATCH_UP_LIMIT, CatchUp, SeqEvent, catch_up};
use crate::session::{LiveSession, SessionInfo, SessionManager, SpawnSpec}; use crate::session::{LiveSession, SessionInfo, SessionManager, SpawnSpec};
@@ -130,6 +137,15 @@ pub fn router(manager: Arc<SessionManager>) -> Router {
.route("/sessions/{id}", get(read_session).delete(delete_session)) .route("/sessions/{id}", get(read_session).delete(delete_session))
.route("/sessions/{id}/events", get(events)) .route("/sessions/{id}/events", get(events))
.route("/sessions/{id}/transcript", get(transcript)) .route("/sessions/{id}/transcript", get(transcript))
.route("/sessions/{id}/subagents", get(list_subagents))
.route(
"/sessions/{id}/subagents/{sub}/transcript",
get(subagent_transcript),
)
.route(
"/sessions/{id}/subagents/{sub}/events",
get(subagent_events),
)
.route("/sessions/{id}/message", post(message)) .route("/sessions/{id}/message", post(message))
.route("/sessions/{id}/unqueue", post(unqueue)) .route("/sessions/{id}/unqueue", post(unqueue))
.route("/sessions/{id}/answer", post(answer)) .route("/sessions/{id}/answer", post(answer))
@@ -209,6 +225,18 @@ fn lookup(manager: &SessionManager, id: &str) -> Result<Arc<LiveSession>, ApiErr
.ok_or_else(|| ApiError::NotFound(format!("no session {id}"))) .ok_or_else(|| ApiError::NotFound(format!("no session {id}")))
} }
/// A session's subagent by id -- the second half of the lookup every
/// `/sessions/{id}/subagents/{sub}/...` route needs. `Arc` because reopening
/// one from disk (a subagent this process has not touched yet) inserts it
/// into the registry, and a route holding a borrow across that would be
/// holding the registry's lock the whole request.
fn lookup_subagent(session: &LiveSession, sub: &str) -> Result<Arc<Subagent>, ApiError> {
session
.subagents()
.get(sub)
.ok_or_else(|| ApiError::NotFound(format!("no subagent {sub}")))
}
async fn list_sessions(State(manager): State<Arc<SessionManager>>) -> axum::Json<Vec<SessionInfo>> { async fn list_sessions(State(manager): State<Arc<SessionManager>>) -> axum::Json<Vec<SessionInfo>> {
axum::Json(manager.sessions()) axum::Json(manager.sessions())
} }
@@ -1747,8 +1775,36 @@ async fn transcript(
Query(query): Query<TranscriptQuery>, Query(query): Query<TranscriptQuery>,
) -> Result<axum::Json<Vec<crate::session::transcript::SeqEvent>>, ApiError> { ) -> Result<axum::Json<Vec<crate::session::transcript::SeqEvent>>, ApiError> {
let session = lookup(&manager, &id)?; let session = lookup(&manager, &id)?;
transcript_page(session.transcript_path(), &id, query)
}
/// Exactly [`transcript`]'s route and answer, against one subagent's own
/// transcript instead of its session's -- see `SUBAGENTS.md`'s wire shape.
async fn subagent_transcript(
State(manager): State<Arc<SessionManager>>,
UrlPath((id, sub)): UrlPath<(String, String)>,
Query(query): Query<TranscriptQuery>,
) -> Result<axum::Json<Vec<crate::session::transcript::SeqEvent>>, ApiError> {
let session = lookup(&manager, &id)?;
let subagent = lookup_subagent(&session, &sub)?;
transcript_page(
&subagent.transcript_path(),
&format!("{id}/subagents/{sub}"),
query,
)
}
/// A page of history at `path`, newest first to open with -- the one
/// implementation [`transcript`] and [`subagent_transcript`] share, since a
/// subagent's transcript is read exactly the way a session's is. `label` is
/// only for the debug line below.
fn transcript_page(
path: &Path,
label: &str,
query: TranscriptQuery,
) -> Result<axum::Json<Vec<crate::session::transcript::SeqEvent>>, ApiError> {
let events = crate::session::transcript::read_window( let events = crate::session::transcript::read_window(
session.transcript_path(), path,
query.before, query.before,
query.after, query.after,
query.limit, query.limit,
@@ -1760,7 +1816,7 @@ async fn transcript(
// for events and draws rows, and the ratio between them is a property of // for events and draws rows, and the ratio between them is a property of
// the conversation. `RUST_LOG=ai_server=debug`. // the conversation. `RUST_LOG=ai_server=debug`.
tracing::debug!( tracing::debug!(
session = %id, session = %label,
before = ?query.before, before = ?query.before,
after = ?query.after, after = ?query.after,
limit = query.limit, limit = query.limit,
@@ -1778,23 +1834,74 @@ async fn events(
headers: HeaderMap, headers: HeaderMap,
) -> Result<Sse<impl tokio_stream::Stream<Item = Result<SseEvent, Infallible>>>, ApiError> { ) -> Result<Sse<impl tokio_stream::Stream<Item = Result<SseEvent, Infallible>>>, ApiError> {
let session = lookup(&manager, &id)?; let session = lookup(&manager, &id)?;
let cursor = headers let cursor = cursor_of(&headers, query.after);
.get("last-event-id")
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse().ok())
.unwrap_or(query.after);
// Subscribe before reading the file so nothing can land in the gap // Subscribe before reading the file so nothing can land in the gap
// between replay and live; overlap is deduplicated by seq. // between replay and live; overlap is deduplicated by seq.
let live = session.subscribe(); let live = session.subscribe();
let (tx, stream) = mpsc::channel(64); Ok(sse_stream(
tokio::spawn(stream_session(
session.transcript_path().to_path_buf(), session.transcript_path().to_path_buf(),
cursor, cursor,
live, live,
tx, ))
)); }
Ok(Sse::new(ReceiverStream::new(stream).map(Ok)).keep_alive(KeepAlive::default()))
/// Exactly [`events`]'s route and answer, against one subagent's own stream
/// instead of its session's -- see `SUBAGENTS.md`'s wire shape.
async fn subagent_events(
State(manager): State<Arc<SessionManager>>,
UrlPath((id, sub)): UrlPath<(String, String)>,
Query(query): Query<EventsQuery>,
headers: HeaderMap,
) -> Result<Sse<impl tokio_stream::Stream<Item = Result<SseEvent, Infallible>>>, ApiError> {
let session = lookup(&manager, &id)?;
let subagent = lookup_subagent(&session, &sub)?;
let cursor = cursor_of(&headers, query.after);
let live = subagent.subscribe();
Ok(sse_stream(subagent.transcript_path(), cursor, live))
}
/// The cursor an SSE reconnect resumes from: the native `Last-Event-ID`
/// takes precedence over the query parameter, same cursor either way.
fn cursor_of(headers: &HeaderMap, query_after: u64) -> u64 {
headers
.get("last-event-id")
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse().ok())
.unwrap_or(query_after)
}
/// Spawns the backlog-then-live task and wraps it as the response, the one
/// piece [`events`] and [`subagent_events`] share.
fn sse_stream(
transcript: PathBuf,
cursor: u64,
live: broadcast::Receiver<SeqEvent>,
) -> Sse<impl tokio_stream::Stream<Item = Result<SseEvent, Infallible>>> {
let (tx, stream) = mpsc::channel(64);
tokio::spawn(stream_session(transcript, cursor, live, tx));
Sse::new(ReceiverStream::new(stream).map(Ok)).keep_alive(KeepAlive::default())
}
/// `GET /sessions/{id}/subagents`: every subagent this session has started,
/// oldest first, with a status read from its own transcript -- see
/// `SUBAGENTS.md`'s wire shape. A subagent whose last status is `Running` is
/// reported `Unknown` instead when the session itself is not running: its
/// process was the session's, and a session with none has nothing left to
/// ask.
async fn list_subagents(
State(manager): State<Arc<SessionManager>>,
UrlPath(id): UrlPath<String>,
) -> Result<axum::Json<Vec<SubagentInfo>>, ApiError> {
let session = lookup(&manager, &id)?;
// Anything but `Exited` or `Unknown` has a process behind it, which is
// what decides whether a subagent still reading `Running` from its own
// transcript can be believed -- see `SUBAGENTS.md`'s wire shape.
let running = !matches!(
session.status(),
crate::session::driver::SessionStatus::Exited
| crate::session::driver::SessionStatus::Unknown
);
Ok(axum::Json(session.subagents().list(running)))
} }
/// Every session's attention-wanting moments, on one stream. /// Every session's attention-wanting moments, on one stream.
+22 -5
View File
@@ -59,6 +59,7 @@ use tokio::sync::mpsc;
use super::driver::{AttachmentRef, Driver, Event, EventSink, SessionStatus, Unqueued}; use super::driver::{AttachmentRef, Driver, Event, EventSink, SessionStatus, Unqueued};
use super::process; use super::process;
use super::subagent::Subagents;
use super::transport::{Launch, Streams, Transport}; use super::transport::{Launch, Streams, Transport};
use crate::config::{ProviderConfig, SessionConfig}; use crate::config::{ProviderConfig, SessionConfig};
use translate::{AnswerOutcome, Setting, Translator, starts_a_model_call}; use translate::{AnswerOutcome, Setting, Translator, starts_a_model_call};
@@ -213,8 +214,12 @@ impl ClaudeDriver {
transport: &Transport, transport: &Transport,
session_dir: &Path, session_dir: &Path,
sink: EventSink, sink: EventSink,
subagents: Arc<Subagents>,
) -> Result<Self> { ) -> Result<Self> {
let state = Arc::new(Mutex::new(Translator::new(session_dir.to_path_buf()))); let state = Arc::new(Mutex::new(Translator::new(
session_dir.to_path_buf(),
subagents,
)));
let queue = Arc::new(Mutex::new(Queue::default())); let queue = Arc::new(Mutex::new(Queue::default()));
let reading = Arc::new(AtomicBool::new(true)); let reading = Arc::new(AtomicBool::new(true));
@@ -1169,7 +1174,10 @@ mod tests {
/// this" and "the transcript records that". /// this" and "the transcript records that".
fn events_from_lines(lines: &[&str]) -> Vec<Event> { fn events_from_lines(lines: &[&str]) -> Vec<Event> {
let dir = tempfile::tempdir().expect("temp dir"); let dir = tempfile::tempdir().expect("temp dir");
let state = Arc::new(Mutex::new(Translator::new(dir.path().to_path_buf()))); let state = Arc::new(Mutex::new(Translator::new(
dir.path().to_path_buf(),
Arc::new(Subagents::new(dir.path().to_path_buf())),
)));
let queue = Arc::new(Mutex::new(Queue::default())); let queue = Arc::new(Mutex::new(Queue::default()));
let (sink, mut out) = mpsc::unbounded_channel::<Event>(); let (sink, mut out) = mpsc::unbounded_channel::<Event>();
for line in lines { for line in lines {
@@ -1193,7 +1201,10 @@ mod tests {
interject: impl FnOnce(&Arc<Mutex<Queue>>), interject: impl FnOnce(&Arc<Mutex<Queue>>),
) -> Vec<Event> { ) -> Vec<Event> {
let dir = tempfile::tempdir().expect("temp dir"); let dir = tempfile::tempdir().expect("temp dir");
let state = Arc::new(Mutex::new(Translator::new(dir.path().to_path_buf()))); let state = Arc::new(Mutex::new(Translator::new(
dir.path().to_path_buf(),
Arc::new(Subagents::new(dir.path().to_path_buf())),
)));
let queue = Arc::new(Mutex::new(Queue::default())); let queue = Arc::new(Mutex::new(Queue::default()));
let (sink, mut out) = mpsc::unbounded_channel::<Event>(); let (sink, mut out) = mpsc::unbounded_channel::<Event>();
let mut interject = Some(interject); let mut interject = Some(interject);
@@ -1487,7 +1498,10 @@ mod tests {
// doing. // doing.
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let (sink, mut received) = mpsc::unbounded_channel(); let (sink, mut received) = mpsc::unbounded_channel();
let state = Arc::new(Mutex::new(Translator::new(dir.path().to_path_buf()))); let state = Arc::new(Mutex::new(Translator::new(
dir.path().to_path_buf(),
Arc::new(Subagents::new(dir.path().to_path_buf())),
)));
let queue = Arc::new(Mutex::new(Queue::default())); let queue = Arc::new(Mutex::new(Queue::default()));
let text = r#"{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"working"}},"parent_tool_use_id":null}"#; let text = r#"{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"working"}},"parent_tool_use_id":null}"#;
@@ -1532,7 +1546,10 @@ mod tests {
// session back to work. // session back to work.
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let (sink, mut received) = mpsc::unbounded_channel(); let (sink, mut received) = mpsc::unbounded_channel();
let state = Arc::new(Mutex::new(Translator::new(dir.path().to_path_buf()))); let state = Arc::new(Mutex::new(Translator::new(
dir.path().to_path_buf(),
Arc::new(Subagents::new(dir.path().to_path_buf())),
)));
let queue = Arc::new(Mutex::new(Queue::default())); let queue = Arc::new(Mutex::new(Queue::default()));
queue.lock().unwrap().close(&sink, "the session ended"); queue.lock().unwrap().close(&sink, "the session ended");
+267 -35
View File
@@ -11,10 +11,12 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use serde_json::{Value, json}; use serde_json::{Value, json};
use super::super::driver::{Event, QuestionOption, SessionStatus, context_tokens}; use super::super::driver::{Event, QuestionOption, SessionStatus, context_tokens};
use super::super::subagent::Subagents;
/// Whether this line is the CLI opening a fresh model call. /// Whether this line is the CLI opening a fresh model call.
/// ///
@@ -92,10 +94,20 @@ pub(super) struct Translator {
/// reports none rather than repeating the previous turn's. /// reports none rather than repeating the previous turn's.
context: Option<u64>, context: Option<u64>,
session_dir: PathBuf, session_dir: PathBuf,
/// This session's subagents, shared with every child translator below --
/// see `SUBAGENTS.md`. One registry per session, so a subagent started
/// through this translator or any of its children lands in the same
/// place a route reads it back from.
subagents: Arc<Subagents>,
/// One translator per subagent id, holding *its* streaming and
/// tool-tracking state -- separate from the parent's because tool ids
/// are unique but a `stream_event`'s content-block index is not, and
/// parallel subagents interleave their deltas on one stdout.
children: HashMap<String, Arc<Mutex<Translator>>>,
} }
impl Translator { impl Translator {
pub(super) fn new(session_dir: PathBuf) -> Self { pub(super) fn new(session_dir: PathBuf, subagents: Arc<Subagents>) -> Self {
Self { Self {
session_id: None, session_id: None,
pending: HashMap::new(), pending: HashMap::new(),
@@ -103,6 +115,8 @@ impl Translator {
interrupting: false, interrupting: false,
context: None, context: None,
session_dir, session_dir,
subagents,
children: HashMap::new(),
} }
} }
@@ -122,13 +136,54 @@ impl Translator {
pub(super) fn translate(&mut self, message: &Value) -> Vec<Event> { pub(super) fn translate(&mut self, message: &Value) -> Vec<Event> {
// Events from subagents (Task tool internals) carry a // Events from subagents (Task tool internals) carry a
// parent_tool_use_id; the transcript shows the Task tool's own // parent_tool_use_id; the transcript shows the Task tool's own
// start/end instead of every nested step. // start/end instead of every nested step. Routed into that
if message // subagent's own transcript rather than dropped -- see
.get("parent_tool_use_id") // `SUBAGENTS.md`.
.is_some_and(|id| !id.is_null()) if let Some(parent_id) = message.get("parent_tool_use_id").and_then(Value::as_str) {
{ return self.translate_child(parent_id, message);
}
self.dispatch(message)
}
/// A line belonging to a subagent rather than to this translator's own
/// session. Always returns nothing to the *caller*: everything it
/// produces goes into the subagent's own transcript instead.
fn translate_child(&mut self, id: &str, message: &Value) -> Vec<Event> {
match self.subagents.get(id) {
Some(subagent) if !subagent.is_open() => {
// The Task call already ended (or this line is stale from a
// resumed conversation) -- see `SUBAGENTS.md`'s lifecycle.
tracing::debug!("dropping a line for subagent {id}, which has already finished");
return Vec::new(); return Vec::new();
} }
Some(_) => {}
None => {
// Nobody has heard of this id yet: the Task call itself
// either has not been seen or never will be. Started here
// with the best title available -- the tool name of this
// first line -- since SUBAGENTS.md's real title only
// arrives with the Task call.
self.subagents.start(id, &fallback_title(message), None);
}
}
let child = self
.children
.entry(id.to_string())
.or_insert_with(|| {
Arc::new(Mutex::new(Translator::new(
self.session_dir.clone(),
Arc::clone(&self.subagents),
)))
})
.clone();
let events = child.lock().unwrap().dispatch(message);
for event in events {
self.subagents.record(id, event);
}
Vec::new()
}
fn dispatch(&mut self, message: &Value) -> Vec<Event> {
match message.get("type").and_then(Value::as_str) { match message.get("type").and_then(Value::as_str) {
Some("system") => self.translate_system(message), Some("system") => self.translate_system(message),
// The CLI's own announcement that `/clear` took effect, sent just // The CLI's own announcement that `/clear` took effect, sent just
@@ -379,22 +434,47 @@ impl Translator {
content content
.iter() .iter()
.filter(|block| block.get("type").and_then(Value::as_str) == Some("tool_use")) .filter(|block| block.get("type").and_then(Value::as_str) == Some("tool_use"))
.map(|block| Event::ToolStart { .map(|block| {
id: block let id = block
.get("id") .get("id")
.and_then(Value::as_str) .and_then(Value::as_str)
.unwrap_or_default() .unwrap_or_default()
.to_string(), .to_string();
tool: block let tool = block
.get("name") .get("name")
.and_then(Value::as_str) .and_then(Value::as_str)
.unwrap_or_default() .unwrap_or_default()
.to_string(), .to_string();
input: block.get("input").cloned().unwrap_or(Value::Null), let input = block.get("input").cloned().unwrap_or(Value::Null);
// A subagent this call is about to start -- see
// `SUBAGENTS.md`'s lifecycle #1. The parent's own transcript
// still shows only the Task call itself, below.
if tool == "Task" || tool == "Agent" {
self.start_subagent_from_task(&id, &input);
}
Event::ToolStart { id, tool, input }
}) })
.collect() .collect()
} }
/// Starts the subagent a Task call names, with the title and prompt
/// SUBAGENTS.md describes: the call's `description`, then
/// `(<subagent_type>)` when one is given, falling back to the tool's own
/// name when there is no description to build one from.
fn start_subagent_from_task(&self, id: &str, input: &Value) {
let description = text_field(input, "description");
let subagent_type = text_field(input, "subagent_type");
let prompt = input.get("prompt").and_then(Value::as_str);
let title = match (description, subagent_type) {
(Some(description), Some(subagent_type)) => {
format!("{description} ({subagent_type})")
}
(Some(description), None) => description,
(None, _) => "Task".to_string(),
};
self.subagents.start(id, &title, prompt);
}
fn translate_control_request(&mut self, message: &Value) -> Vec<Event> { fn translate_control_request(&mut self, message: &Value) -> Vec<Event> {
let request = &message["request"]; let request = &message["request"];
if request.get("subtype").and_then(Value::as_str) != Some("can_use_tool") { if request.get("subtype").and_then(Value::as_str) != Some("can_use_tool") {
@@ -598,11 +678,33 @@ impl Translator {
id: about.clone(), id: about.clone(),
output: texts.join("\n"), output: texts.join("\n"),
}); });
// A no-op unless `about` is a subagent's own id -- see
// `SUBAGENTS.md`'s lifecycle #3: the parent gets this `ToolEnd`
// like any other tool result, and the subagent it names (if it
// names one) gets its `Status::Exited`.
self.subagents.finish(&about);
} }
events events
} }
} }
/// The title to start a subagent under when its own first line arrives
/// before (or without) its Task call ever being seen: the tool name of that
/// first line, which is the only thing known about it yet. `"subagent"` for
/// a line this cannot even find a tool name in, such as one that opens with
/// something other than a tool call.
fn fallback_title(message: &Value) -> String {
message["message"]["content"]
.as_array()
.into_iter()
.flatten()
.find(|block| block.get("type").and_then(Value::as_str) == Some("tool_use"))
.and_then(|block| block.get("name"))
.and_then(Value::as_str)
.unwrap_or("subagent")
.to_string()
}
/// Whether a failed turn failed because the account is out of quota, and when /// Whether a failed turn failed because the account is out of quota, and when
/// the CLI said the limit lifts. /// the CLI said the limit lifts.
/// ///
@@ -696,10 +798,18 @@ mod tests {
.collect() .collect()
} }
/// A fresh, empty subagent registry over the same temp dir a test's
/// translator writes into -- every test here is about the parent's own
/// events, so what a registry does with a subagent is `subagent.rs`'s
/// tests to make, not these.
fn test_subagents(dir: &tempfile::TempDir) -> Arc<Subagents> {
Arc::new(Subagents::new(dir.path().to_path_buf()))
}
#[test] #[test]
fn captures_the_resume_token_and_the_settings_from_init() { fn captures_the_resume_token_and_the_settings_from_init() {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf()); let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
let events = translate_lines( let events = translate_lines(
&mut translator, &mut translator,
&[ &[
@@ -721,7 +831,7 @@ mod tests {
#[test] #[test]
fn a_setting_is_reported_when_the_cli_accepts_it_and_not_before() { fn a_setting_is_reported_when_the_cli_accepts_it_and_not_before() {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf()); let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
// What `set_model` does: remember, send, and say nothing yet. // What `set_model` does: remember, send, and say nothing yet.
translator.expect_setting("req-a".to_string(), Setting::Model("sonnet".to_string())); translator.expect_setting("req-a".to_string(), Setting::Model("sonnet".to_string()));
@@ -798,7 +908,7 @@ mod tests {
// The line it sends just after answering `set_permission_mode`, which is // The line it sends just after answering `set_permission_mode`, which is
// also how a mode changed from the terminal arrives. // also how a mode changed from the terminal arrives.
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf()); let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
let events = translate_lines( let events = translate_lines(
&mut translator, &mut translator,
&[ &[
@@ -818,7 +928,7 @@ mod tests {
fn streams_text_deltas_and_skips_the_consolidated_copy() { fn streams_text_deltas_and_skips_the_consolidated_copy() {
// Real lines (trimmed) from the 2.1.237 probe. // Real lines (trimmed) from the 2.1.237 probe.
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf()); let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
let events = translate_lines( let events = translate_lines(
&mut translator, &mut translator,
&[ &[
@@ -838,7 +948,7 @@ mod tests {
#[test] #[test]
fn tool_use_and_result_become_tool_events() { fn tool_use_and_result_become_tool_events() {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf()); let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
let events = translate_lines( let events = translate_lines(
&mut translator, &mut translator,
&[ &[
@@ -865,7 +975,7 @@ mod tests {
#[test] #[test]
fn subagent_events_are_not_duplicated_into_the_transcript() { fn subagent_events_are_not_duplicated_into_the_transcript() {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf()); let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
let events = translate_lines( let events = translate_lines(
&mut translator, &mut translator,
&[ &[
@@ -875,10 +985,132 @@ mod tests {
assert!(events.is_empty()); assert!(events.is_empty());
} }
/// A child line does not just vanish from the parent -- it lands in its
/// own subagent's transcript, with that transcript's own sequence
/// numbers, starting at 1 like any other.
#[test]
fn a_child_line_lands_in_its_own_subagents_transcript() {
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_c1","name":"Bash","input":{"command":"echo hi"}}]},"parent_tool_use_id":"toolu_parent"}"#,
],
);
let subagent = subagents.get("toolu_parent").expect("subagent started");
let lines = crate::session::transcript::read_after(&subagent.transcript_path(), 0)
.expect("read subagent transcript");
assert_eq!(lines[0].seq, 1);
assert_eq!(
lines[0].event,
Event::Status {
state: SessionStatus::Running
}
);
assert!(
lines.iter().any(
|entry| matches!(&entry.event, Event::ToolStart { tool, .. } if tool == "Bash")
)
);
}
/// The title and prompt shown for a subagent come from the Task call
/// that started it, not from anything guessed at its first line.
#[test]
fn the_subagent_takes_its_title_and_prompt_from_the_task_call() {
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_task","name":"Task","input":{"description":"Investigate the bug","prompt":"Find why X fails","subagent_type":"general-purpose"}}]},"parent_tool_use_id":null}"#,
],
);
let rows = subagents.list(true);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].title, "Investigate the bug (general-purpose)");
let subagent = subagents.get(&rows[0].id).expect("subagent");
let lines = crate::session::transcript::read_after(&subagent.transcript_path(), 0)
.expect("read subagent transcript");
assert!(lines.iter().any(
|entry| matches!(&entry.event, Event::UserMessage { text, .. } if text == "Find why X fails")
));
}
/// The parent's `tool_result` for the Task id is what ends the
/// subagent -- SUBAGENTS.md's lifecycle #3 -- and nothing else does.
#[test]
fn the_parents_tool_result_finishes_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_task2","name":"Task","input":{"description":"helper"}}]},"parent_tool_use_id":null}"#,
],
);
let subagent = subagents.get("toolu_task2").expect("subagent started");
assert!(subagent.is_open());
translate_lines(
&mut translator,
&[
r#"{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_task2","content":"done","is_error":false}]},"parent_tool_use_id":null}"#,
],
);
assert!(!subagent.is_open());
}
/// Two subagents running at once keep two separate transcripts: tool ids
/// are unique but a `stream_event`'s content-block index is not, so
/// sharing translation state between them would cross their streams.
#[test]
fn two_parallel_subagents_keep_separate_transcripts() {
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_a","name":"Bash","input":{}}]},"parent_tool_use_id":"toolu_task_a"}"#,
r#"{"type":"assistant","message":{"content":[{"type":"tool_use","id":"toolu_b","name":"Read","input":{}}]},"parent_tool_use_id":"toolu_task_b"}"#,
],
);
let a = subagents.get("toolu_task_a").expect("subagent a");
let b = subagents.get("toolu_task_b").expect("subagent b");
let a_events = crate::session::transcript::read_after(&a.transcript_path(), 0)
.expect("read a's transcript");
let b_events = crate::session::transcript::read_after(&b.transcript_path(), 0)
.expect("read b's transcript");
assert!(
a_events.iter().any(
|entry| matches!(&entry.event, Event::ToolStart { tool, .. } if tool == "Bash")
)
);
assert!(
b_events.iter().any(
|entry| matches!(&entry.event, Event::ToolStart { tool, .. } if tool == "Read")
)
);
assert!(
!a_events.iter().any(
|entry| matches!(&entry.event, Event::ToolStart { tool, .. } if tool == "Read")
)
);
assert!(
!b_events.iter().any(
|entry| matches!(&entry.event, Event::ToolStart { tool, .. } if tool == "Bash")
)
);
}
#[test] #[test]
fn a_permission_request_becomes_an_allow_deny_question() { fn a_permission_request_becomes_an_allow_deny_question() {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf()); let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
let events = translate_lines( let events = translate_lines(
&mut translator, &mut translator,
&[ &[
@@ -927,7 +1159,7 @@ mod tests {
#[test] #[test]
fn denying_a_permission_sends_deny() { fn denying_a_permission_sends_deny() {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf()); let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
translate_lines( translate_lines(
&mut translator, &mut translator,
&[ &[
@@ -945,7 +1177,7 @@ mod tests {
// The real 2.1.237 shape, verified live: answers go back inside // The real 2.1.237 shape, verified live: answers go back inside
// updatedInput, keyed by the question text. // updatedInput, keyed by the question text.
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf()); let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
let events = translate_lines( let events = translate_lines(
&mut translator, &mut translator,
&[ &[
@@ -997,7 +1229,7 @@ mod tests {
// in the event: a phone that had to read this dialect's tool input to // in the event: a phone that had to read this dialect's tool input to
// find them would be the only place that knew how. // find them would be the only place that knew how.
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf()); let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
let events = translate_lines( let events = translate_lines(
&mut translator, &mut translator,
&[ &[
@@ -1045,7 +1277,7 @@ mod tests {
#[test] #[test]
fn images_in_tool_results_are_saved_and_referenced() { fn images_in_tool_results_are_saved_and_referenced() {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf()); let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
// A 1x1 PNG, the smallest real payload worth round-tripping. // A 1x1 PNG, the smallest real payload worth round-tripping.
let png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="; let png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
let line = format!( let line = format!(
@@ -1074,7 +1306,7 @@ mod tests {
#[test] #[test]
fn a_turn_result_reports_usage_and_returns_to_idle() { fn a_turn_result_reports_usage_and_returns_to_idle() {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf()); let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
let events = translate_lines( let events = translate_lines(
&mut translator, &mut translator,
&[ &[
@@ -1109,7 +1341,7 @@ mod tests {
#[test] #[test]
fn a_turn_started_by_another_agent_records_who_and_what() { fn a_turn_started_by_another_agent_records_who_and_what() {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf()); let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
let events = translate_lines( let events = translate_lines(
&mut translator, &mut translator,
&[ &[
@@ -1143,7 +1375,7 @@ mod tests {
#[test] #[test]
fn an_ordinary_turn_carries_no_peer_note() { fn an_ordinary_turn_carries_no_peer_note() {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf()); let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
let events = translate_lines( let events = translate_lines(
&mut translator, &mut translator,
&[ &[
@@ -1168,7 +1400,7 @@ mod tests {
#[test] #[test]
fn the_context_is_what_the_last_message_held_not_the_turn_added_up() { fn the_context_is_what_the_last_message_held_not_the_turn_added_up() {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf()); let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
let events = translate_lines( let events = translate_lines(
&mut translator, &mut translator,
&[ &[
@@ -1208,7 +1440,7 @@ mod tests {
// Note the snake_case keys -- the CLI's transcript file writes the same // Note the snake_case keys -- the CLI's transcript file writes the same
// records in camelCase. // records in camelCase.
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf()); let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
let events = translate_lines( let events = translate_lines(
&mut translator, &mut translator,
&[ &[
@@ -1238,7 +1470,7 @@ mod tests {
#[test] #[test]
fn a_failed_compaction_says_why_and_leaves_the_turn_running() { fn a_failed_compaction_says_why_and_leaves_the_turn_running() {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf()); let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
let events = translate_lines( let events = translate_lines(
&mut translator, &mut translator,
&[ &[
@@ -1265,7 +1497,7 @@ mod tests {
#[test] #[test]
fn a_boundary_without_counts_says_so_rather_than_inventing_them() { fn a_boundary_without_counts_says_so_rather_than_inventing_them() {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf()); let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
let events = translate_lines( let events = translate_lines(
&mut translator, &mut translator,
&[ &[
@@ -1285,7 +1517,7 @@ mod tests {
#[test] #[test]
fn an_error_result_surfaces_the_message() { fn an_error_result_surfaces_the_message() {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf()); let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
let events = translate_lines( let events = translate_lines(
&mut translator, &mut translator,
&[ &[
@@ -1315,7 +1547,7 @@ mod tests {
#[test] #[test]
fn a_turn_stopped_by_the_usage_limit_says_so_and_carries_the_reset() { fn a_turn_stopped_by_the_usage_limit_says_so_and_carries_the_reset() {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf()); let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
let events = translate_lines( let events = translate_lines(
&mut translator, &mut translator,
&[ &[
@@ -1333,7 +1565,7 @@ mod tests {
#[test] #[test]
fn a_limit_the_cli_gave_no_reset_for_is_reported_without_one() { fn a_limit_the_cli_gave_no_reset_for_is_reported_without_one() {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf()); let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
let events = translate_lines( let events = translate_lines(
&mut translator, &mut translator,
&[ &[
@@ -1366,7 +1598,7 @@ mod tests {
#[test] #[test]
fn a_turn_stopped_on_purpose_is_not_an_error() { fn a_turn_stopped_on_purpose_is_not_an_error() {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf()); let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
let stopped_result = r#"{"type":"result","subtype":"error_during_execution","is_error":true,"result":"Interrupted by user","usage":{}}"#; let stopped_result = r#"{"type":"result","subtype":"error_during_execution","is_error":true,"result":"Interrupted by user","usage":{}}"#;
translator.expect_interrupt(); translator.expect_interrupt();
@@ -1398,7 +1630,7 @@ mod tests {
#[test] #[test]
fn replayed_and_synthetic_user_text_is_skipped() { fn replayed_and_synthetic_user_text_is_skipped() {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let mut translator = Translator::new(dir.path().to_path_buf()); let mut translator = Translator::new(dir.path().to_path_buf(), test_subagents(&dir));
let events = translate_lines( let events = translate_lines(
&mut translator, &mut translator,
&[ &[
+110 -1
View File
@@ -48,6 +48,10 @@
//! and height the app draws, in one session, which is what a scrolling //! and height the app draws, in one session, which is what a scrolling
//! problem needs in order to be reproduced twice the same way. //! problem needs in order to be reproduced twice the same way.
//! - `/table [columns]` -- a markdown table with cells too long for one line. //! - `/table [columns]` -- a markdown table with cells too long for one line.
//! - `/subagent [n]` -- n subagents at once (default 1), each named
//! "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`.
//! //!
//! `/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
@@ -62,6 +66,7 @@ use std::time::Duration;
use super::driver::{ use super::driver::{
AttachmentRef, Driver, Event, EventSink, QuestionOption, SessionStatus, Unqueued, AttachmentRef, Driver, Event, EventSink, QuestionOption, SessionStatus, Unqueued,
}; };
use super::subagent::Subagents;
/// Delay between streamed deltas -- long enough that streaming is visibly /// Delay between streamed deltas -- long enough that streaming is visibly
/// streaming, short enough that tests waiting on a full turn stay fast. /// streaming, short enough that tests waiting on a full turn stay fast.
@@ -108,6 +113,10 @@ pub struct EchoDriver {
/// says it recovered, and a clear leaves it unmeasured. What is real is /// says it recovered, and a clear leaves it unmeasured. What is real is
/// which way the numbers move. /// which way the numbers move.
context: Arc<AtomicU64>, context: Arc<AtomicU64>,
/// This session's subagents -- see `SUBAGENTS.md`. `/subagent` is the
/// test rig for the same registry the claude driver routes real Task
/// calls into.
subagents: Arc<Subagents>,
} }
impl EchoDriver { impl EchoDriver {
@@ -384,6 +393,55 @@ impl EchoDriver {
return; return;
} }
// `n` subagents at once, each with its own transcript in the
// registry a real Task call routes into -- see `SUBAGENTS.md`. The
// parent's own Task calls end when their subagent does, three
// seconds later, which is long enough to see the running state on
// the phone before it finishes.
if let Some(rest) = text.strip_prefix("/subagent") {
let n = rest.trim().parse::<usize>().unwrap_or(1).clamp(1, 8);
if announce {
self.emit(Event::MessageTaken {
id: None,
text: text.clone(),
attachments,
});
}
self.emit(Event::Status {
state: SessionStatus::Running,
});
let sink = self.sink.clone();
let subagents = Arc::clone(&self.subagents);
tokio::spawn(async move {
let mut helpers = Vec::new();
for k in 1..=n {
let id = format!("echo-subagent-{k}-{}", super::random_hex());
let title = format!("helper {k}");
let prompt = format!(
"You are helper {k} of {n}. Say a few words, run a command, then stop."
);
let _ = sink.send(Event::ToolStart {
id: id.clone(),
tool: "Task".to_string(),
input: serde_json::json!({
"description": title,
"prompt": prompt,
"subagent_type": "general-purpose",
}),
});
subagents.start(&id, &title, Some(&prompt));
helpers.push((id, sink.clone(), Arc::clone(&subagents)));
}
for (id, sink, subagents) in helpers {
tokio::spawn(run_helper(id, sink, subagents));
}
let _ = sink.send(Event::Status {
state: SessionStatus::Idle,
});
});
return;
}
// The same word the real CLI takes, so a phone drives both the same way. // The same word the real CLI takes, so a phone drives both the same way.
// `Driver::compact` is what the manager's route calls; this is the typed // `Driver::compact` is what the manager's route calls; this is the typed
// path onto it. // path onto it.
@@ -679,7 +737,12 @@ impl EchoDriver {
}); });
} }
pub fn new(sink: EventSink, session_dir: PathBuf, usage: crate::usage::Fixture) -> Self { pub fn new(
sink: EventSink,
session_dir: PathBuf,
usage: crate::usage::Fixture,
subagents: Arc<Subagents>,
) -> Self {
let driver = Self { let driver = Self {
sink, sink,
pending_questions: Mutex::new(Vec::new()), pending_questions: Mutex::new(Vec::new()),
@@ -688,6 +751,7 @@ impl EchoDriver {
queued: Arc::new(Mutex::new(Vec::new())), queued: Arc::new(Mutex::new(Vec::new())),
session_dir, session_dir,
usage, usage,
subagents,
}; };
driver.emit(Event::Status { driver.emit(Event::Status {
state: SessionStatus::Idle, state: SessionStatus::Idle,
@@ -809,6 +873,51 @@ async fn write_beat(sink: &EventSink, session_dir: &Path, beat: usize) {
tokio::time::sleep(Duration::from_millis(120)).await; tokio::time::sleep(Duration::from_millis(120)).await;
} }
/// One `/subagent` helper: a few streamed words, one Bash call, then
/// `Status::Exited` about three seconds after it started -- long enough that
/// 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>) {
let start = tokio::time::Instant::now();
for word in "Working on it now.".split_inclusive(' ') {
subagents.record(
&id,
Event::AssistantText {
delta: word.to_string(),
},
);
tokio::time::sleep(DELTA_DELAY).await;
}
let tool_id = format!("{id}-bash");
subagents.record(
&id,
Event::ToolStart {
id: tool_id.clone(),
tool: "Bash".to_string(),
input: serde_json::json!({ "command": "echo helper done" }),
},
);
tokio::time::sleep(DELTA_DELAY).await;
subagents.record(
&id,
Event::ToolEnd {
id: tool_id,
output: "helper done".to_string(),
},
);
let target = Duration::from_secs(3);
let elapsed = start.elapsed();
if elapsed < target {
tokio::time::sleep(target - elapsed).await;
}
subagents.finish(&id);
let _ = sink.send(Event::ToolEnd {
id,
output: "subagent finished".to_string(),
});
}
/// A message written during a turn and waiting for it to end: the id of the /// A message written during a turn and waiting for it to end: the id of the
/// `MessageQueued` that announced it, what it said, and what was attached. All /// `MessageQueued` that announced it, what it said, and what was attached. All
/// three, because all three are what the `MessageTaken` at the other end owes. /// three, because all three are what the `MessageTaken` at the other end owes.
+5
View File
@@ -77,6 +77,7 @@ impl LlamaDriver {
/// in a different currency: two servers holding the same model is twice the /// in a different currency: two servers holding the same model is twice the
/// memory, and the second would bind a different port while the phone kept /// memory, and the second would bind a different port while the phone kept
/// talking to the first. /// talking to the first.
#[allow(clippy::too_many_arguments)]
pub fn launch( pub fn launch(
meta: &SessionConfig, meta: &SessionConfig,
provider: &ProviderConfig, provider: &ProviderConfig,
@@ -85,6 +86,10 @@ impl LlamaDriver {
transcript: &Path, transcript: &Path,
session_dir: &Path, session_dir: &Path,
sink: EventSink, sink: EventSink,
// llama.cpp has no notion of a Task call, so this is accepted only
// to keep one shape across every driver's launch -- see
// `SUBAGENTS.md`'s "Server layout".
_subagents: Arc<super::subagent::Subagents>,
) -> Result<Self> { ) -> Result<Self> {
let model = meta.model.as_deref().context( let model = meta.model.as_deref().context(
"a llama.cpp session needs a model -- one of the downloaded ones, by its key", "a llama.cpp session needs a model -- one of the downloaded ones, by its key",
+127 -2
View File
@@ -15,6 +15,7 @@ pub mod import;
pub mod llama; pub mod llama;
pub mod pending; pub mod pending;
pub mod process; pub mod process;
pub mod subagent;
pub mod transcript; pub mod transcript;
pub mod transport; pub mod transport;
@@ -37,6 +38,7 @@ use driver::{
}; };
use echo::EchoDriver; use echo::EchoDriver;
use llama::LlamaDriver; use llama::LlamaDriver;
use subagent::Subagents;
use transcript::{SeqEvent, Transcript}; use transcript::{SeqEvent, Transcript};
use transport::Transport; use transport::Transport;
@@ -265,6 +267,11 @@ pub struct SessionInfo {
pub status: SessionStatus, pub status: SessionStatus,
pub last_activity: f64, pub last_activity: f64,
pub created: f64, pub created: f64,
/// How many subagents this session has started, from a directory
/// listing rather than reading each one's status -- see
/// `GET /sessions/{id}/subagents` for that. 0 when it has none, not
/// absent: every session can say this without asking anything.
pub subagents: usize,
} }
/// What is running a session at this moment, and `None` when nothing is. /// What is running a session at this moment, and `None` when nothing is.
@@ -293,6 +300,10 @@ pub struct LiveSession {
events: broadcast::Sender<SeqEvent>, events: broadcast::Sender<SeqEvent>,
transcript_path: PathBuf, transcript_path: PathBuf,
shared: Arc<Shared>, shared: Arc<Shared>,
/// This session's subagents -- see `SUBAGENTS.md`. Built once at launch
/// and handed to whichever driver replaces it across a stop/start, so a
/// subagent started before a Stop is still there to read after a Start.
subagents: Arc<Subagents>,
} }
/// Commands waiting for the session to be between turns. /// Commands waiting for the session to be between turns.
@@ -502,6 +513,19 @@ impl LiveSession {
&self.transcript_path &self.transcript_path
} }
pub fn subagents(&self) -> &Arc<Subagents> {
&self.subagents
}
/// What this session is doing right now, as the pump last recorded it --
/// the same word `SessionInfo::status` reports. Read here rather than
/// only through `SessionManager::sessions` for
/// `GET /sessions/{id}/subagents`, which needs exactly this and nothing
/// else `SessionInfo` carries.
pub fn status(&self) -> SessionStatus {
*self.shared.status.lock().unwrap()
}
/// The session's directory (attachments in, produced files out live in /// The session's directory (attachments in, produced files out live in
/// `attachments/` and `files/` under it). /// `attachments/` and `files/` under it).
pub fn dir(&self) -> &Path { pub fn dir(&self) -> &Path {
@@ -578,6 +602,7 @@ impl LiveSession {
status: *self.shared.status.lock().unwrap(), status: *self.shared.status.lock().unwrap(),
last_activity: *self.shared.last_activity.lock().unwrap(), last_activity: *self.shared.last_activity.lock().unwrap(),
created: self.meta.created, created: self.meta.created,
subagents: subagent::count(self.dir()),
} }
} }
} }
@@ -1065,6 +1090,7 @@ impl SessionManager {
status: status_of_unlaunched(&self.data_dir.join(&meta.id)), status: status_of_unlaunched(&self.data_dir.join(&meta.id)),
last_activity: meta.created, last_activity: meta.created,
created: meta.created, created: meta.created,
subagents: subagent::count(&self.data_dir.join(&meta.id)),
}, },
}) })
.collect() .collect()
@@ -1828,6 +1854,7 @@ impl SessionManager {
session.dir(), session.dir(),
session.transcript_path(), session.transcript_path(),
&session.sink, &session.sink,
session.subagents(),
)?); )?);
} }
// Nothing is live for this one -- a session whose launch failed // Nothing is live for this one -- a session whose launch failed
@@ -2289,6 +2316,11 @@ fn launch(
let (sink, source) = mpsc::unbounded_channel(); let (sink, source) = mpsc::unbounded_channel();
let (events, _) = broadcast::channel(EVENT_BUFFER); let (events, _) = broadcast::channel(EVENT_BUFFER);
// Built once per session, here, rather than per driver: a subagent
// started before a Stop has to still be there to read after a Start,
// and only `launch` runs once across that boundary -- `start_if_exited`
// replaces the driver alone.
let subagents = Arc::new(subagent::Subagents::new(dir.clone()));
let shared = Arc::new(Shared { let shared = Arc::new(Shared {
// What it was last known to be doing, not an assumption. A driver // What it was last known to be doing, not an assumption. A driver
// that has something to say corrects this within its first poll. // that has something to say corrects this within its first poll.
@@ -2350,7 +2382,18 @@ fn launch(
let driver = Arc::new(Mutex::new( let driver = Arc::new(Mutex::new(
driving driving
.then(|| make_driver(&meta, setup, provider, env, &dir, &transcript_path, &sink)) .then(|| {
make_driver(
&meta,
setup,
provider,
env,
&dir,
&transcript_path,
&sink,
&subagents,
)
})
.transpose()?, .transpose()?,
)); ));
@@ -2368,6 +2411,7 @@ fn launch(
events.clone(), events.clone(),
Arc::clone(&commands), Arc::clone(&commands),
announce, announce,
Arc::clone(&subagents),
)); ));
Ok(Arc::new(LiveSession { Ok(Arc::new(LiveSession {
@@ -2378,6 +2422,7 @@ fn launch(
events, events,
transcript_path, transcript_path,
shared, shared,
subagents,
})) }))
} }
@@ -2388,6 +2433,7 @@ fn launch(
/// what [`SessionManager::start_session`] builds. That path replaces the /// what [`SessionManager::start_session`] builds. That path replaces the
/// driver and nothing else, so it has to construct one the same way rather /// driver and nothing else, so it has to construct one the same way rather
/// than becoming a second answer to "what runs this". /// than becoming a second answer to "what runs this".
#[allow(clippy::too_many_arguments)]
fn make_driver( fn make_driver(
meta: &SessionConfig, meta: &SessionConfig,
setup: &SetupConfig, setup: &SetupConfig,
@@ -2396,13 +2442,17 @@ fn make_driver(
dir: &Path, dir: &Path,
transcript_path: &Path, transcript_path: &Path,
sink: &EventSink, sink: &EventSink,
subagents: &Arc<Subagents>,
) -> Result<Arc<dyn Driver>> { ) -> Result<Arc<dyn Driver>> {
Ok(match provider.kind { Ok(match provider.kind {
DriverKind::Echo => Arc::new(EchoDriver::new( DriverKind::Echo => Arc::new(EchoDriver::new(
sink.clone(), sink.clone(),
dir.to_path_buf(), dir.to_path_buf(),
env.usage.clone(), env.usage.clone(),
Arc::clone(subagents),
)), )),
// llama.cpp has no notion of a Task call, so it takes the registry
// and never touches it -- see `SUBAGENTS.md`'s "Server layout".
DriverKind::LlamaCpp => Arc::new(LlamaDriver::launch( DriverKind::LlamaCpp => Arc::new(LlamaDriver::launch(
meta, meta,
provider, provider,
@@ -2411,6 +2461,7 @@ fn make_driver(
transcript_path, transcript_path,
dir, dir,
sink.clone(), sink.clone(),
Arc::clone(subagents),
)?), )?),
DriverKind::ClaudeCli => Arc::new(ClaudeDriver::launch( DriverKind::ClaudeCli => Arc::new(ClaudeDriver::launch(
meta, meta,
@@ -2418,6 +2469,7 @@ fn make_driver(
&Transport::for_setup(setup), &Transport::for_setup(setup),
dir, dir,
sink.clone(), sink.clone(),
Arc::clone(subagents),
)?), )?),
}) })
} }
@@ -2482,6 +2534,7 @@ fn notification_for(
} }
} }
#[allow(clippy::too_many_arguments)]
async fn pump( async fn pump(
id: String, id: String,
mut transcript: Transcript, mut transcript: Transcript,
@@ -2490,6 +2543,7 @@ async fn pump(
events: broadcast::Sender<SeqEvent>, events: broadcast::Sender<SeqEvent>,
commands: Arc<Commands>, commands: Arc<Commands>,
announce: Announcements, announce: Announcements,
subagents: Arc<Subagents>,
) { ) {
// Messages the session has been given and not started reading, which is // Messages the session has been given and not started reading, which is
// what makes a turn ending not the same thing as the work ending. // what makes a turn ending not the same thing as the work ending.
@@ -2611,7 +2665,12 @@ async fn pump(
} => commands.take_one(), } => commands.take_one(),
Event::Status { Event::Status {
state: SessionStatus::Exited, state: SessionStatus::Exited,
} => commands.abandon("this session's process has exited"), } => {
commands.abandon("this session's process has exited");
// The process behind every open subagent was this
// session's own -- see `SUBAGENTS.md`'s lifecycle #4.
subagents.finish_all();
}
// The two ends of a message's wait. A `UserMessage` with // The two ends of a message's wait. A `UserMessage` with
// no id never waited -- it was sent between turns, and // no id never waited -- it was sent between turns, and
// counting it would take the total below zero. // counting it would take the total below zero.
@@ -2752,6 +2811,7 @@ mod tests {
sink.clone(), sink.clone(),
dir.path().to_path_buf(), dir.path().to_path_buf(),
crate::usage::Fixture::new(), crate::usage::Fixture::new(),
Arc::new(subagent::Subagents::new(dir.path().to_path_buf())),
))))), ))))),
sink, sink,
waiting: Mutex::new(VecDeque::new()), waiting: Mutex::new(VecDeque::new()),
@@ -4399,4 +4459,69 @@ mod tests {
let seen = collect_turn(&mut rx).await; let seen = collect_turn(&mut rx).await;
assert!(seen.first().expect("events").seq > last_seq); assert!(seen.first().expect("events").seq > last_seq);
} }
/// `/subagent 2` is the test rig for `SUBAGENTS.md`'s whole feature:
/// each helper gets its own transcript with its prompt as its first
/// user message, `SessionInfo::subagents` counts them from the
/// directory, and each finishes on its own a few seconds later.
#[tokio::test]
async fn subagent_helpers_get_their_own_transcripts_and_finish() {
let dir = tempfile::tempdir().expect("tempdir");
let config_path = dir.path().join("config.ron");
let data_dir = dir.path().join("sessions");
seed_echo_only(&config_path);
let manager = SessionManager::new(config_path, data_dir.clone(), data_dir.join("models"))
.expect("manager");
let info = manager.spawn_session(echo_spec()).expect("spawn");
let session = manager.session(&info.id).expect("live");
session.send_message("/subagent 2".to_string(), Vec::new());
// Both helpers exist as soon as their Task calls go out, well before
// either finishes.
let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
loop {
if session.subagents().list(true).len() == 2 {
break;
}
assert!(
tokio::time::Instant::now() < deadline,
"both helpers should have started by now"
);
tokio::time::sleep(Duration::from_millis(20)).await;
}
let rows = session.subagents().list(true);
let mut titles: Vec<&str> = rows.iter().map(|row| row.title.as_str()).collect();
titles.sort_unstable();
assert_eq!(titles, ["helper 1", "helper 2"]);
assert!(rows.iter().all(|row| row.status == SessionStatus::Running));
assert_eq!(manager.sessions()[0].subagents, 2);
// Each subagent's own transcript opens with its prompt.
let first = session.subagents().get(&rows[0].id).expect("subagent");
let events =
transcript::read_after(&first.transcript_path(), 0).expect("read subagent transcript");
assert!(
events
.iter()
.any(|entry| matches!(&entry.event, Event::UserMessage { text, .. } if text.contains("helper")))
);
// Each finishes on its own about three seconds after it started.
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
loop {
if session
.subagents()
.list(true)
.iter()
.all(|row| row.status == SessionStatus::Exited)
{
break;
}
assert!(
tokio::time::Instant::now() < deadline,
"both helpers should have finished by now"
);
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
} }
+490
View File
@@ -0,0 +1,490 @@
//! A session's subagents -- see `SUBAGENTS.md`.
//!
//! **A subagent is a second transcript owned by a session, in the same event
//! model, with no process and no controls.** It shares the transcript file
//! format, the paging routes, and the SSE stream with a session by
//! addressing, not by copying: `Transcript`, `read_window` and `catch_up`
//! work on a subagent's file unchanged.
//!
//! Storage is `<session dir>/subagents/<id>/{meta.json,transcript.jsonl}`,
//! where `<id>` is the Task tool_use id that started it -- unique, stable
//! across a backend restart, and already the key the parent side uses. Only
//! ids matching [`is_subagent_id`] are ever turned into a path.
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use tokio::sync::broadcast;
use super::driver::{Event, SessionStatus};
use super::transcript::{SeqEvent, Transcript};
/// Fan-out buffer for one subagent's SSE subscribers. Smaller than a
/// session's: a subagent's whole conversation is usually a handful of tool
/// calls, not an hours-long session.
const EVENT_BUFFER: usize = 64;
/// Whether `id` is safe to become a path segment under a session's
/// `subagents/` directory. Mirrors `import::is_session_id`'s reasoning: the
/// id arrives as a value inside JSON the CLI sent, and it becomes a
/// directory name, so a `/` or `..` in it must never be trusted.
fn is_subagent_id(id: &str) -> bool {
!id.is_empty()
&& id.len() <= 200
&& id
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
}
/// What a subagent's directory holds beside its transcript. Small and
/// separate from `Subagent` itself because this is exactly what survives a
/// backend restart on disk, and nothing else does.
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Meta {
title: String,
/// Epoch seconds. Absent from `SubagentInfo`'s sort key deliberately:
/// `list` sorts by this rather than by directory order, which a
/// filesystem does not promise.
created: f64,
}
/// One row of `GET /sessions/{id}/subagents`.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SubagentInfo {
pub id: String,
pub title: String,
pub status: SessionStatus,
pub created: f64,
pub last_activity: f64,
}
/// One subagent: its own transcript and broadcast, same shape as a
/// session's but with no driver behind it.
pub struct Subagent {
dir: PathBuf,
transcript: Mutex<Transcript>,
events: broadcast::Sender<SeqEvent>,
/// Mirrors the transcript's last `Status` event, kept live rather than
/// read back from `Transcript::last_status` -- that answers "as of
/// opening" (see its own doc comment) and never moves for an append made
/// through *this* object, which is every append a live subagent ever
/// makes. Without this, `finish` immediately after `start` in the same
/// process read the file's stale opening status and reported itself
/// still open.
status: Mutex<SessionStatus>,
}
impl Subagent {
pub fn transcript_path(&self) -> PathBuf {
self.dir.join("transcript.jsonl")
}
pub fn subscribe(&self) -> broadcast::Receiver<SeqEvent> {
self.events.subscribe()
}
/// Whether this subagent's last recorded status is not `Exited` --
/// what decides whether a further child line still belongs in its
/// transcript. See `SUBAGENTS.md`'s lifecycle: "a child line whose
/// subagent finished already... is ignored".
pub fn is_open(&self) -> bool {
*self.status.lock().unwrap() != SessionStatus::Exited
}
fn append(&self, event: Event) {
let mut transcript = self.transcript.lock().unwrap();
match transcript.append(event, super::now()) {
Ok(entry) => {
if let Event::Status { state } = &entry.event {
*self.status.lock().unwrap() = *state;
}
// No subscribers is fine; the transcript already has it,
// same as a session's pump.
let _ = self.events.send(entry);
}
Err(err) => tracing::error!("subagent transcript append failed: {err:#}"),
}
}
}
/// Every subagent one session has started, keyed by the Task tool_use id
/// that names it.
///
/// Lives beside a session's driver rather than inside it: a claude driver
/// holds an `Arc` to this and routes child lines into it; echo uses it for
/// its `/subagent` rig; llama ignores it, since it has no notion of a Task
/// call. One instance per live session, built at launch and handed to
/// whichever driver replaces it across a stop/start.
pub struct Subagents {
/// The session's own directory; subagents live under `<dir>/subagents`.
dir: PathBuf,
live: Mutex<HashMap<String, Arc<Subagent>>>,
}
impl Subagents {
pub fn new(session_dir: PathBuf) -> Self {
Self {
dir: session_dir,
live: Mutex::new(HashMap::new()),
}
}
fn subagents_dir(&self) -> PathBuf {
self.dir.join("subagents")
}
/// Opens the subagent named `id`, creating it if this is the first
/// anyone has heard of it -- on disk as well as in memory, so a
/// subagent from before a backend restart is reopened rather than
/// recreated. `title`/`prompt` are used only at creation: reopening an
/// existing one keeps its original title and never repeats the prompt
/// into its transcript a second time.
fn open_or_create(&self, id: &str, title: &str, prompt: Option<&str>) -> Result<Arc<Subagent>> {
let dir = self.subagents_dir().join(id);
let meta_path = dir.join("meta.json");
let existed = meta_path.is_file();
let meta = if existed {
let text = fs::read_to_string(&meta_path)
.with_context(|| format!("read {}", meta_path.display()))?;
serde_json::from_str::<Meta>(&text).context("parse subagent meta")?
} else {
wg_app_link::private::create_dir(&dir)?;
let meta = Meta {
title: title.to_string(),
created: super::now(),
};
wg_app_link::private::write_file(
&meta_path,
serde_json::to_string(&meta)
.context("serialize subagent meta")?
.as_bytes(),
)?;
meta
};
let mut transcript = Transcript::open(&dir.join("transcript.jsonl"))?;
if !existed {
// First lines, in order: the subagent is running the moment it
// exists, and its prompt -- when known -- is genuinely its first
// user turn. Written once, here, so a reopen never repeats them.
transcript.append(
Event::Status {
state: SessionStatus::Running,
},
meta.created,
)?;
if let Some(prompt) = prompt {
transcript.append(
Event::UserMessage {
id: None,
text: prompt.to_string(),
attachments: Vec::new(),
},
meta.created,
)?;
}
}
// A freshly created subagent is running by construction (its only
// lines so far are `Status::Running` and maybe its prompt); a
// reopened one takes whatever the file last said, since this
// `Transcript` has not been appended to yet in this process.
let status = if existed {
transcript.last_status().unwrap_or(SessionStatus::Running)
} else {
SessionStatus::Running
};
let (events, _) = broadcast::channel(EVENT_BUFFER);
Ok(Arc::new(Subagent {
dir,
transcript: Mutex::new(transcript),
events,
status: Mutex::new(status),
}))
}
/// Starts a subagent unless one is already known by this id -- see
/// `SUBAGENTS.md`'s lifecycle: created at the Task call or at the first
/// child line, whichever comes first, and never twice. A bad id is
/// refused rather than turned into a path.
pub fn start(&self, id: &str, title: &str, prompt: Option<&str>) {
if !is_subagent_id(id) {
tracing::debug!("refusing to start a subagent with a bad id {id:?}");
return;
}
let mut live = self.live.lock().unwrap();
if live.contains_key(id) {
return;
}
match self.open_or_create(id, title, prompt) {
Ok(subagent) => {
live.insert(id.to_string(), subagent);
}
Err(err) => tracing::error!("couldn't start subagent {id}: {err:#}"),
}
}
/// The subagent named `id`, reopening it from disk on first use in this
/// process if one is there. `None` for an id nothing has ever started --
/// deliberately not created here, since a route or a routing decision is
/// not the Task call that is supposed to be the only way one begins.
pub fn get(&self, id: &str) -> Option<Arc<Subagent>> {
if !is_subagent_id(id) {
return None;
}
if let Some(existing) = self.live.lock().unwrap().get(id).cloned() {
return Some(existing);
}
if !self.subagents_dir().join(id).join("meta.json").is_file() {
return None;
}
// Title and prompt are ignored: the directory already exists, so
// `open_or_create` reads its own meta rather than using either.
match self.open_or_create(id, "", None) {
Ok(subagent) => {
self.live
.lock()
.unwrap()
.insert(id.to_string(), Arc::clone(&subagent));
Some(subagent)
}
Err(err) => {
tracing::error!("couldn't reopen subagent {id}: {err:#}");
None
}
}
}
/// 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
/// at.
pub fn record(&self, id: &str, event: Event) {
match self.live.lock().unwrap().get(id).cloned() {
Some(subagent) => subagent.append(event),
None => tracing::debug!("dropping an event for unknown subagent {id}"),
}
}
/// The parent's `tool_result` for this Task id arrived: the subagent's
/// own `Status::Exited`. A no-op for an id that is not a subagent's, so
/// callers can call this for every `tool_result` without first checking
/// whether it belongs to one.
pub fn finish(&self, id: &str) {
if let Some(subagent) = self.live.lock().unwrap().get(id).cloned()
&& subagent.is_open()
{
subagent.append(Event::Status {
state: SessionStatus::Exited,
});
}
}
/// The parent session's process is gone, so nothing still open here has
/// a process behind it either -- see `SUBAGENTS.md`'s lifecycle #4.
pub fn finish_all(&self) {
let subagents: Vec<Arc<Subagent>> = self.live.lock().unwrap().values().cloned().collect();
for subagent in subagents {
if subagent.is_open() {
subagent.append(Event::Status {
state: SessionStatus::Exited,
});
}
}
}
/// Every subagent under this session's directory, oldest first --
/// `GET /sessions/{id}/subagents`. Read straight from disk rather than
/// from `live`, so a subagent from before this process started (or one
/// this run has not yet touched) still shows up; one file read per
/// subagent, which is fine at the handful a session usually has.
///
/// `session_running` is what turns a subagent whose last status is
/// `Running` into `Unknown`: its process was the session's, and the
/// session has none.
pub fn list(&self, session_running: bool) -> Vec<SubagentInfo> {
let mut rows: Vec<SubagentInfo> = match fs::read_dir(self.subagents_dir()) {
Ok(entries) => entries
.filter_map(Result::ok)
.filter_map(|entry| info_of(&entry.path(), session_running))
.collect(),
// No directory is no subagents, not a fault worth reporting.
Err(_) => Vec::new(),
};
rows.sort_by(|a, b| {
a.created
.partial_cmp(&b.created)
.unwrap_or(std::cmp::Ordering::Equal)
});
rows
}
}
fn info_of(subagent_dir: &Path, session_running: bool) -> Option<SubagentInfo> {
let id = subagent_dir.file_name()?.to_str()?.to_string();
let meta_path = subagent_dir.join("meta.json");
let text = fs::read_to_string(&meta_path).ok()?;
let meta: Meta = serde_json::from_str(&text).ok()?;
let transcript = Transcript::open(&subagent_dir.join("transcript.jsonl")).ok()?;
// The subagent's first line is always `Status::Running`, written before
// this directory is discoverable at all, so `None` here is not a state a
// reader can actually observe -- but it is not this function's place to
// invent one, so a status this build does not expect to see falls back
// to the word the lifecycle promises it started in.
let last_status = transcript.last_status().unwrap_or(SessionStatus::Running);
let status = if last_status == SessionStatus::Running && !session_running {
SessionStatus::Unknown
} else {
last_status
};
Some(SubagentInfo {
id,
title: meta.title,
status,
created: meta.created,
last_activity: transcript.last_activity().unwrap_or(meta.created),
})
}
/// How many subagents a session has, for `SessionInfo::subagents`: a
/// directory listing, so the session list stays cheap and only the
/// dedicated route pays for reading a status out of each one.
pub fn count(session_dir: &Path) -> usize {
fs::read_dir(session_dir.join("subagents"))
.map(|entries| entries.filter_map(Result::ok).count())
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_bad_id_is_refused_rather_than_turned_into_a_path() {
let dir = tempfile::tempdir().expect("tempdir");
let subagents = Subagents::new(dir.path().to_path_buf());
subagents.start("../../etc", "escape", None);
assert!(subagents.get("../../etc").is_none());
assert!(!dir.path().join("subagents").exists());
}
#[test]
fn starting_twice_keeps_the_first_title_and_does_not_repeat_the_prompt() {
let dir = tempfile::tempdir().expect("tempdir");
let subagents = Subagents::new(dir.path().to_path_buf());
subagents.start("toolu_1", "first title", Some("do the thing"));
subagents.start("toolu_1", "second title", Some("do the thing"));
let rows = subagents.list(true);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].title, "first title");
let events = crate::session::transcript::read_after(
&subagents.get("toolu_1").unwrap().transcript_path(),
0,
)
.expect("read");
assert_eq!(
events
.iter()
.filter(|e| matches!(e.event, Event::UserMessage { .. }))
.count(),
1
);
}
#[test]
fn a_reopened_subagent_continues_its_own_transcript() {
let dir = tempfile::tempdir().expect("tempdir");
{
let subagents = Subagents::new(dir.path().to_path_buf());
subagents.start("toolu_2", "helper", Some("go"));
subagents.record(
"toolu_2",
Event::AssistantText {
delta: "working".to_string(),
},
);
}
// A fresh registry, the way a backend restart builds one.
let subagents = Subagents::new(dir.path().to_path_buf());
let subagent = subagents.get("toolu_2").expect("reopened");
assert!(subagent.is_open());
subagents.record(
"toolu_2",
Event::AssistantText {
delta: " more".to_string(),
},
);
let events =
crate::session::transcript::read_after(&subagent.transcript_path(), 0).expect("read");
// Status, UserMessage, two AssistantText deltas, seq continuing.
assert_eq!(events.len(), 4);
assert_eq!(events.last().unwrap().seq, 4);
}
#[test]
fn finishing_appends_exited_and_further_lines_are_droppable() {
let dir = tempfile::tempdir().expect("tempdir");
let subagents = Subagents::new(dir.path().to_path_buf());
subagents.start("toolu_3", "helper", None);
subagents.finish("toolu_3");
let subagent = subagents.get("toolu_3").unwrap();
assert!(!subagent.is_open());
// On disk too, not only in the live cache `is_open` reads.
assert_eq!(
Transcript::open(&subagent.transcript_path())
.expect("reopen")
.last_status(),
Some(SessionStatus::Exited)
);
// Finishing an id that was never a subagent is a no-op, not a panic.
subagents.finish("never-started");
}
#[test]
fn finish_all_closes_only_what_is_still_open() {
let dir = tempfile::tempdir().expect("tempdir");
let subagents = Subagents::new(dir.path().to_path_buf());
subagents.start("toolu_4", "one", None);
subagents.start("toolu_5", "two", None);
subagents.finish("toolu_4");
subagents.finish_all();
let rows = subagents.list(false);
assert_eq!(rows.len(), 2);
for row in rows {
assert_eq!(row.status, SessionStatus::Exited);
}
}
#[test]
fn a_subagent_still_running_when_the_session_is_not_reports_unknown() {
let dir = tempfile::tempdir().expect("tempdir");
let subagents = Subagents::new(dir.path().to_path_buf());
subagents.start("toolu_6", "helper", None);
assert_eq!(subagents.list(true)[0].status, SessionStatus::Running);
assert_eq!(subagents.list(false)[0].status, SessionStatus::Unknown);
}
#[test]
fn list_is_oldest_first_and_the_count_matches_the_directory() {
let dir = tempfile::tempdir().expect("tempdir");
let subagents = Subagents::new(dir.path().to_path_buf());
assert_eq!(count(dir.path()), 0);
subagents.start("toolu_a", "a", None);
std::thread::sleep(std::time::Duration::from_millis(2));
subagents.start("toolu_b", "b", None);
let rows = subagents.list(true);
assert_eq!(
rows.iter().map(|r| r.id.as_str()).collect::<Vec<_>>(),
["toolu_a", "toolu_b"]
);
assert_eq!(count(dir.path()), 2);
}
}