diff --git a/AGENTS.md b/AGENTS.md index 9279365..58cc91e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 **not** move is the API surface and the config *schema*: routes, drivers, 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 `FilesScreen.kt` / `FileViewer.kt` / `FileEditor.kt`). - `TRANSCRIPT_CACHE.md` — the phone's copy of what it has been sent. Read it diff --git a/DECISIONS.md b/DECISIONS.md new file mode 100644 index 0000000..65ff286 --- /dev/null +++ b/DECISIONS.md @@ -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. diff --git a/PLAN.md b/PLAN.md index 3f1a0a0..c28c664 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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 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 **`routes.rs`'s module doc comment is the table.** REST for actions, one SSE diff --git a/SUBAGENTS.md b/SUBAGENTS.md new file mode 100644 index 0000000..b173aa7 --- /dev/null +++ b/SUBAGENTS.md @@ -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: + +``` +/subagents//meta.json {title, created} +/subagents//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: }` 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 ` ()` 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`), `record(id, event)`, `start(id, title, + prompt)`, `finish(id)`, `finish_all()`, `list()` from disk. Drivers get an + `Arc` 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. diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt index d64df02..832f89b 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Api.kt @@ -223,6 +223,14 @@ data class SessionSummary( val usageProvider: String?, val status: String, 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) = @@ -252,6 +260,7 @@ private fun parseSession(session: JSONObject) = usageProvider = session.optString("usageProvider").ifEmpty { null }, status = session.getString("status"), lastActivity = session.getDouble("lastActivity"), + subagents = session.optInt("subagents", 0), ) fun fetchSessions(settings: ServerSettings): List = @@ -267,6 +276,35 @@ fun fetchSessions(settings: ServerSettings): List = fun fetchSession(settings: ServerSettings, sessionId: String): SessionSummary = 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 = + 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 // config.ron appears here with no app rebuild. // @@ -968,7 +1006,7 @@ fun startImport( */ fun fetchTranscript( settings: ServerSettings, - sessionId: String, + address: TranscriptAddress, before: Long? = null, limit: Int = 80, // 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 (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()) // 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. diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt index c1b000a..c8efd06 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/AppRoot.kt @@ -1,7 +1,9 @@ package com.example.aiapp import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.material3.AlertDialog @@ -34,7 +36,23 @@ import kotlinx.coroutines.withContext * session, spawning one, and settings. */ 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. @@ -81,7 +99,7 @@ fun AppRoot( val context = LocalContext.current val scope = rememberCoroutineScope() var settings by remember(settingsVersion) { mutableStateOf(loadServerSettings(context)) } - var screen by remember { mutableStateOf(Screen.Main) } + var screen by remember { mutableStateOf(Screen.Main()) } // 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. var failedOpen by remember { mutableStateOf(null) } @@ -96,7 +114,7 @@ fun AppRoot( share = shareRequest // 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. - if (screen !is Screen.Session) screen = Screen.Main + if (screen !is Screen.Session) screen = Screen.Main() } } @@ -123,7 +141,7 @@ fun AppRoot( existing = null, onSaved = { saved -> settings = saved - screen = Screen.Main + screen = Screen.Main() }, onBack = null, ) @@ -136,7 +154,7 @@ fun AppRoot( // shows, so it always refetches. val goToMain = { reloadToken++ - screen = Screen.Main + screen = Screen.Main() } if (screen !is Screen.Main) { BackHandler(onBack = goToMain) @@ -185,6 +203,9 @@ fun AppRoot( reloadToken = reloadToken, share = share, onOpen = { screen = Screen.Session(it) }, + onOpenSubagent = { summary, subagent -> + screen = here.copy(subagent = Screen.SubagentTarget(summary, subagent)) + }, onSpawn = { screen = Screen.Spawn }, onImported = { imported -> reloadToken++ @@ -192,6 +213,27 @@ fun AppRoot( }, 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 -> // Keyed on the id, because a different session is a different screen rather than this diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/EventStream.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/EventStream.kt index 748b3fc..6f30cb2 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/EventStream.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/EventStream.kt @@ -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 * 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) 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. 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. if (name == RESET_EVENT) onReset() else if (data.isNotEmpty()) onEvent(data, parseSeqEvent(data)) diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/MainScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/MainScreen.kt index f18ef22..4252ac4 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/MainScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/MainScreen.kt @@ -48,6 +48,8 @@ fun MainScreen( /** What another app shared in and no session has taken yet; see [ShareRequest]. */ share: ShareRequest? = null, onOpen: (SessionSummary) -> Unit, + /** Opens one session's subagent, from the expander under its card. */ + onOpenSubagent: (SessionSummary, SubagentSummary) -> Unit, onSpawn: () -> Unit, onImported: (SessionSummary) -> Unit, onSettings: () -> Unit, @@ -139,6 +141,7 @@ fun MainScreen( settings = settings, reloadToken = token, onOpen = onOpen, + onOpenSubagent = onOpenSubagent, onSpawn = onSpawn, ) MainTab.Import -> diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt index 1c3cc2b..425d26b 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionListScreen.kt @@ -1,7 +1,9 @@ package com.example.aiapp import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -17,6 +19,7 @@ import androidx.compose.material3.Card import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -30,6 +33,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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 kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -47,12 +52,40 @@ fun SessionListScreen( settings: ServerSettings, reloadToken: Int, onOpen: (SessionSummary) -> Unit, + /** Opens one session's subagent, from the expander under its card. */ + onOpenSubagent: (SessionSummary, SubagentSummary) -> Unit, onSpawn: () -> Unit, ) { val scope = rememberCoroutineScope() var listState by remember { mutableStateOf>>(LoadState.Loading) } var confirmingDelete by remember { mutableStateOf(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()) } + var subagentLoads by remember { + mutableStateOf(mapOf>>()) + } + + 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 // own card. The two scopes are decided by whether the server answered: it answered and refused, // so this says nothing about the other rows. @@ -84,6 +117,14 @@ fun SessionListScreen( withContext(Dispatchers.IO) { 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 } catch (e: ApiException) { LoadState.failed(e) @@ -127,6 +168,17 @@ fun SessionListScreen( deleting = session.id in deleting, onOpen = { onOpen(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)) } @@ -225,7 +277,7 @@ fun SessionListScreen( deleteSession(settings, session.id, alsoDeleteForeign) // After it succeeded, not before: a refused delete leaves the // 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 // put every other session back through loading and handed the @@ -276,6 +328,12 @@ private fun SessionCard( deleting: Boolean, onOpen: () -> 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>?, + onToggleSubagents: () -> Unit, + onOpenSubagent: (SubagentSummary) -> Unit, ) { BusyItem(label = if (deleting) "deleting" else null) { Card( @@ -332,11 +390,100 @@ private fun SessionCard( 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 fun StatusText(status: String) { val (label, color) = diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt index 64e031c..2b54b6f 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -216,16 +216,33 @@ fun SessionScreen( share: ShareRequest? = null, /** Said once [share] has been attached here, so it is not attached again. */ 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") + val isSubagent = subagent != null + val address = TranscriptAddress(summary.id, subagent?.id) val scope = rememberCoroutineScope() val topEdgeHeld = remember { TopEdgeHold() } var items by remember { mutableStateOf(listOf()) } - 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 // 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. - 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 // 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. @@ -241,7 +258,13 @@ fun SessionScreen( val context = LocalContext.current // 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`. - 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 // makes the session re-read the whole conversation. var pendingModel by remember { mutableStateOf(null) } @@ -294,27 +317,26 @@ fun SessionScreen( // Reload throws away what it was reading from. val cache = remember(settings) { TranscriptCache(cacheRoot(context, settings)) } val source = - remember(summary.id, epoch) { - TranscriptSource(settings, summary.id, cache.session(summary.id)) - } + remember(address, epoch) { TranscriptSource(settings, address, cache.session(address)) } // 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 // 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 // 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. - 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 // what keeps opening a long session cheap. var oldestSeq by remember { mutableLongStateOf(0L) } - // Where this session was last being read, from this device's own store. Read once, because the - // answer stops being interesting the moment the list is on screen. - val savedAnchor = remember(summary.id, epoch) { loadScrollAnchor(context, summary.id) } + // Where this transcript was last being read, from this device's own store, keyed by the address + // rather than the session id so a subagent's saved position cannot collide with its session's. + // 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 // the newest end and then travelling to the anchor is exactly the journey a reader must never // 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 // 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. @@ -327,11 +349,11 @@ fun SessionScreen( var loadingHistory by remember { mutableStateOf(false) } var ready by remember { mutableStateOf(false) } // Replies parsed ahead of the rows that draw them; see [ParsedReplies]. - val replies = remember(summary.id) { ParsedReplies() } - // Keyed like everything else describing one session's transcript. `rememberLazyListState` saves - // through `rememberSaveable`, and this screen restores by its own anchor instead -- two - // restores would fight over the first frame. - val listState = remember(summary.id) { LazyListState() } + val replies = remember(address) { ParsedReplies() } + // Keyed like everything else describing one transcript. `rememberLazyListState` saves through + // `rememberSaveable`, and this screen restores by its own anchor instead -- two restores would + // fight over the first frame. + val listState = remember(address) { LazyListState() } // 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 // 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 // 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. - LaunchedEffect(summary.id, epoch) { + LaunchedEffect(address, epoch) { /** * 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 // 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 - // an hour ago opens saying "working" until the stream connects. - status = summary.status - model = summary.model - permissionMode = summary.permissionMode ?: "auto" - if (summary.status != "compacting") compactingSince = null + // an hour ago opens saying "working" until the stream connects. A subagent's 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 + permissionMode = summary.permissionMode ?: "auto" + } + if (status != "compacting") compactingSince = null // 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. 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. // Stopping the stream deliberately makes the drop a close rather than an error, and resuming // reconnects from the same cursor. - LaunchedEffect(summary.id, ready, epoch, lifecycleOwner) { + LaunchedEffect(address, ready, epoch, lifecycleOwner) { if (!ready) return@LaunchedEffect // 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 @@ -868,17 +895,22 @@ fun SessionScreen( // 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 // 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. // RESUMED rather than STARTED because "looking at it" means the foreground. - LaunchedEffect(summary.id, lifecycleOwner) { - lifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) { - NotificationService.showing(context, summary.id) - try { - awaitCancellation() - } finally { - NotificationService.stoppedShowing(summary.id) + // + // 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) { + lifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) { + NotificationService.showing(context, summary.id) + try { + awaitCancellation() + } finally { + NotificationService.stoppedShowing(summary.id) + } } } } @@ -924,7 +956,7 @@ fun SessionScreen( val (index, offset, awayFromNewest) = settled saveScrollAnchor( context, - summary.id, + address.cachePath, // 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. 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 // list is reversed, so an arriving message extends the end the viewport is pinned to. - val unitSizes = remember(summary.id) { HashMap() } + val unitSizes = remember(address) { HashMap() } LaunchedEffect(listState, moreHistory) { snapshotFlow { listState.layoutInfo } .collect { info -> @@ -983,21 +1015,25 @@ fun SessionScreen( } } - LaunchedEffect(summary.setupName, summary.provider) { - offeredModels = - try { - withContext(Dispatchers.IO) { - fetchSetups(settings) - .firstOrNull { it.name == summary.setupName } - ?.providers - ?.firstOrNull { it.name == summary.provider } - ?.models - .orEmpty() + // Only for the model picker, which a subagent does not have. + if (!isSubagent) { + LaunchedEffect(summary.setupName, summary.provider) { + offeredModels = + try { + withContext(Dispatchers.IO) { + fetchSetups(settings) + .firstOrNull { it.name == summary.setupName } + ?.providers + ?.firstOrNull { it.name == summary.provider } + ?.models + .orEmpty() + } + } catch (_: Exception) { + // Not worth reporting: the picker simply has nothing to offer, which is + // visible. + emptyList() } - } catch (_: Exception) { - // Not worth reporting: the picker simply has nothing to offer, which is visible. - emptyList() - } + } } /** @@ -1148,8 +1184,9 @@ fun SessionScreen( } // One poll for the machines' limits, read by everything on this screen that reports them. - val usageFeed = rememberUsageFeed(settings) - val usage = usageFeed.forSession(summary) + // Nothing meters a subagent -- it has no account of its own -- so it never starts this poll. + val usageFeed = if (isSubagent) null else rememberUsageFeed(settings) + val usage = usageFeed?.forSession(summary) ?: SessionUsage.NotMetered RecordFrames() var usageOpen 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. Spacer(Modifier.width(GLYPH_BUTTON_MARGIN)) Column(Modifier.weight(1f)) { - Text(title, style = MaterialTheme.typography.titleMedium) - // Machine first, then what runs on it -- the same order and the same wording - // everywhere this pair appears, so it reads as one fact rather than two - // sentences with different grammar. - // - // No model. The picker in the footer already shows what this session is set to, - // and showing it twice means two things to keep in step -- they disagreed for a - // moment on every model change. - Text( - "${summary.setupName} · ${summary.provider}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + // 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) + // Machine first, then what runs on it -- the same order and the same + // wording everywhere this pair appears, so it reads as one fact rather than + // two sentences with different grammar. + // + // No model. The picker in the footer already shows what this session is set + // to, and showing it twice means two things to keep in step -- they + // disagreed for a moment on every model change. + Text( + "${summary.setupName} · ${summary.provider}", + style = MaterialTheme.typography.bodySmall, + 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 // 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 @@ -1264,42 +1316,47 @@ fun SessionScreen( // 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 // Iris on 2026-09-03. - Row { - GlyphButton( - USAGE_GLYPH, - "Usage", - { usageOpen = true }, - colour = usageGlyphColour(usage), - ) - // The machine's files, which is where the answer to "what did it actually - // change" is. It opens *over* this screen rather than replacing it. - GlyphButton( - FOLDER_GLYPH, - "Files", - onClick = { - onFiles( - FilesTarget( - setup = summary.setup, - setupName = summary.setupName, - // Where this session works, and the machine's own home when it - // was never given a directory -- resolved there rather than - // guessed at here, since this app does not know that home. - start = summary.cwd?.takeIf { it.isNotBlank() } ?: "~", + if (!isSubagent) { + Row { + GlyphButton( + USAGE_GLYPH, + "Usage", + { usageOpen = true }, + colour = usageGlyphColour(usage), + ) + // The machine's files, which is where the answer to "what did it actually + // change" is. It opens *over* this screen rather than replacing it. + GlyphButton( + FOLDER_GLYPH, + "Files", + onClick = { + onFiles( + FilesTarget( + setup = summary.setup, + setupName = summary.setupName, + // Where this session works, and the machine's own home when + // it was never given a directory -- resolved there rather + // than guessed at here, since this app does not know that + // home. + start = summary.cwd?.takeIf { it.isNotBlank() } ?: "~", + ) ) - ) - }, - ) - // What it opens is about this session, so it sits at the end of the session's - // own row. A cog and not a word because there will be more, and a bar of words - // has nowhere to put it. - GlyphButton(SETTINGS_GLYPH, "Session settings", { settingsOpen = true }) + }, + ) + // What it opens is about this session, so it sits at the end of the + // session's own row. A cog and not a word because there will be more, and a + // bar of words has nowhere to put it. + GlyphButton(SETTINGS_GLYPH, "Session settings", { settingsOpen = true }) + } } } // 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 - // whether to keep going. - SessionUsageBar(usage) + // whether to keep going. Nothing meters a subagent. + if (!isSubagent) { + SessionUsageBar(usage) + } (streamError ?: actionError)?.let { message -> Text( @@ -1644,185 +1701,208 @@ 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( status = status, compactingFor = compactingFor, contextTokens = contextTokens, + subagent = 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( - // Nothing to suggest about a suggestion that was just taken. `/compact` is a whole - // command *and* a prefix of itself, so picking it left the list standing there with - // the one row already chosen. Held by what was picked rather than by a flag, so - // typing anything else brings the list back without a second thing to reset. - commands = if (input.text == picked) emptyList() else suggestedCommands(input.text), - onPick = { command -> - // At the end of what was inserted, which is where the reader carries on typing: - // a command with an argument is put in the box half-written, and a cursor left - // at the front makes the next keystroke the first character of "/rename". - input = atEnd(command.typed()) - picked = command.typed() - }, - ) - - // 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. - // - // The field gets a row of its own, above the buttons: sharing one put the full width - // behind three controls, so the thing being typed into was the narrowest on the row. - Column(Modifier.fillMaxWidth().padding(8.dp)) { - // 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. - PendingAttachments( - settings = settings, - sessionId = summary.id, - refs = pendingAttachments, - onRemove = { pendingAttachments = pendingAttachments - it }, - ) - OutlinedTextField( - value = input, - onValueChange = { - input = it - saveDraft(context, summary.id, it.text) + // Everything from here down is the composer: a subagent cannot be messaged, so none of + // 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( + // Nothing to suggest about a suggestion that was just taken. `/compact` is a + // whole command *and* a prefix of itself, so picking it left the list standing + // there with the one row already chosen. Held by what was picked rather than by + // a flag, so typing anything else brings the list back without a second thing + // to + // reset. + commands = + if (input.text == picked) emptyList() else suggestedCommands(input.text), + onPick = { command -> + // At the end of what was inserted, which is where the reader carries on + // typing: a command with an argument is put in the box half-written, and a + // cursor left at the front makes the next keystroke the first character of + // "/rename". + input = atEnd(command.typed()) + picked = command.typed() }, - modifier = Modifier.fillMaxWidth(), - // No longer "(+image)": the images are on screen above this, and a placeholder - // saying so said it in words beside the thing itself. - placeholder = { Text("Message") }, - maxLines = 4, ) - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), - ) { - // Photo or file, asked here rather than by two buttons: the row is full, and - // attaching is one action whichever picker answers it. - var attaching by remember { mutableStateOf(false) } - Box { - // Just "+". The count it used to carry was standing in for showing them. - BubbleButton(onClick = { attaching = true }) { Text("+") } - DropdownMenu( - expanded = attaching, - onDismissRequest = { attaching = false }, - // See PickerButton: without this the menu opens a status bar's height - // away from the button in an edge-to-edge activity. - properties = PopupProperties(clippingEnabled = false), - shape = BubbleMenuShape, - ) { - DropdownMenuItem( - text = { Text("Photo") }, - onClick = { - attaching = false - pickImage.launch( - PickVisualMediaRequest( - ActivityResultContracts.PickVisualMedia.ImageOnly - ) - ) - }, - ) - DropdownMenuItem( - text = { Text("File") }, - onClick = { - attaching = false - pickFile.launch(arrayOf("*/*")) - }, - ) - } - } - // The settings share what is left after the actions have taken what they need. - // A Row hands out intrinsic widths in order and clips whatever runs past the - // edge, so with these laid out first the arrival of Stop pushed Send off the - // screen entirely -- the app's central control, gone at the moment it is most - // in use. + + // 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. + // + // The field gets a row of its own, above the buttons: sharing one put the full + // width + // behind three controls, so the thing being typed into was the narrowest on the + // row. + Column(Modifier.fillMaxWidth().padding(8.dp)) { + // 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. + PendingAttachments( + settings = settings, + sessionId = summary.id, + refs = pendingAttachments, + onRemove = { pendingAttachments = pendingAttachments - it }, + ) + OutlinedTextField( + value = input, + onValueChange = { + input = it + saveDraft(context, summary.id, it.text) + }, + modifier = Modifier.fillMaxWidth(), + // No longer "(+image)": the images are on screen above this, and a + // placeholder saying so said it in words beside the thing itself. + placeholder = { Text("Message") }, + maxLines = 4, + ) Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.weight(1f), + modifier = Modifier.fillMaxWidth(), ) { - if (offeredModels.isNotEmpty()) { + // Photo or file, asked here rather than by two buttons: the row is full, + // and + // attaching is one action whichever picker answers it. + var attaching by remember { mutableStateOf(false) } + Box { + // Just "+". The count it used to carry was standing in for showing + // them. + BubbleButton(onClick = { attaching = true }) { Text("+") } + DropdownMenu( + expanded = attaching, + onDismissRequest = { attaching = false }, + // See PickerButton: without this the menu opens a status bar's + // height away from the button in an edge-to-edge activity. + properties = PopupProperties(clippingEnabled = false), + shape = BubbleMenuShape, + ) { + DropdownMenuItem( + text = { Text("Photo") }, + onClick = { + attaching = false + pickImage.launch( + PickVisualMediaRequest( + ActivityResultContracts.PickVisualMedia.ImageOnly + ) + ) + }, + ) + DropdownMenuItem( + text = { Text("File") }, + onClick = { + attaching = false + pickFile.launch(arrayOf("*/*")) + }, + ) + } + } + // The settings share what is left after the actions have taken what they + // need. A Row hands out intrinsic widths in order and clips whatever runs + // past the edge, so with these laid out first the arrival of Stop pushed + // Send off the screen entirely -- the app's central control, gone at the + // moment it is most in use. + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.weight(1f), + ) { + if (offeredModels.isNotEmpty()) { + PickerButton( + current = modelLabel(model), + // What the machine offers, plus the state a session is in when + // it has chosen none of them. The button has always been able + // to + // say "default"; until this the list could not, so leaving it + // was a one-way trip. + options = listOf(DEFAULT_MODEL) + offeredModels, + // Not set here. The button follows what the session reports it + // is set to, which arrives a moment later and is sometimes a + // different answer -- a name the CLI resolved, or no change at + // all on a provider whose model is fixed. Asked about first, + // unless there is nothing to lose by it -- see + // [ModelSwitchWarning]. + onPick = { chosen -> + if ( + modelLabel(chosen) == modelLabel(model) || + !worthWarningAbout(status, contextTokens, items) + ) { + act { setSessionModel(settings, summary.id, chosen) } + } else { + pendingModel = chosen + } + }, + ) + } PickerButton( - current = modelLabel(model), - // What the machine offers, plus the state a session is in when it - // has chosen none of them. The button has always been able to say - // "default"; until this the list could not, so leaving it was a - // one-way trip. - options = listOf(DEFAULT_MODEL) + offeredModels, - // Not set here. The button follows what the session reports it is - // set to, which arrives a moment later and is sometimes a different - // answer -- a name the CLI resolved, or no change at all on a - // provider whose model is fixed. Asked about first, unless there is - // nothing to lose by it -- see [ModelSwitchWarning]. + current = permissionMode, + options = PERMISSION_MODES, onPick = { chosen -> - if ( - modelLabel(chosen) == modelLabel(model) || - !worthWarningAbout(status, contextTokens, items) - ) { - act { setSessionModel(settings, summary.id, chosen) } - } else { - pendingModel = chosen - } + act { setSessionPermissionMode(settings, summary.id, chosen) } }, ) } - PickerButton( - current = permissionMode, - options = PERMISSION_MODES, - onPick = { chosen -> - act { setSessionPermissionMode(settings, summary.id, chosen) } - }, - ) - } - // The same filled shape as the button beside it, not an outlined one: these are - // two things you can do about the session, and weighting one as secondary said - // they were a primary action and its qualifier. What 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 - // comes and goes makes its own presence the signal, and a button always in the - // same place also cannot push Send off the end of the row by turning up. - val process = - when { - running -> ProcessAction.Pause - status == "exited" -> ProcessAction.Start - else -> ProcessAction.Stop - } - Button( - onClick = { - processInFlight = true - act(onDone = { processInFlight = false }) { - process.perform(settings, summary.id) + // The same filled shape as the button beside it, not an outlined one: these + // are two things you can do about the session, and weighting one as + // secondary said they were a primary action and its qualifier. What + // 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 comes and goes makes its own presence the signal, and a button + // always + // in the same place also cannot push Send off the end of the row by turning + // up. + val process = + when { + running -> ProcessAction.Pause + status == "exited" -> ProcessAction.Start + else -> ProcessAction.Stop } - }, - enabled = !processInFlight, - colors = actionButtonColors(process.colour()), - ) { - Glyph( - process.glyph, - colour = LocalContentColor.current, - modifier = Modifier.semantics { contentDescription = process.label }, - ) - } - Spacer(Modifier.width(8.dp)) - // The paper plane, with a clock on it while a turn is in flight: sending then - // queues the message for the next tool boundary rather than starting a turn of - // its own, and the two have to be told apart at a glance. The label says the - // same thing to a screen reader. - // - // Disabled while there is nothing to send, rather than pressable and silent: - // `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( - onClick = { send() }, - enabled = input.text.isNotBlank() || pendingAttachments.isNotEmpty(), - colors = actionButtonColors(if (running) queueColor else sendColor), - ) { - Glyph( - if (running) QUEUE_GLYPH else SEND_GLYPH, - colour = LocalContentColor.current, - modifier = - Modifier.semantics { contentDescription = sendLabel(running) }, - ) + Button( + onClick = { + processInFlight = true + act(onDone = { processInFlight = false }) { + process.perform(settings, summary.id) + } + }, + enabled = !processInFlight, + colors = actionButtonColors(process.colour()), + ) { + Glyph( + process.glyph, + colour = LocalContentColor.current, + modifier = + Modifier.semantics { contentDescription = process.label }, + ) + } + Spacer(Modifier.width(8.dp)) + // The paper plane, with a clock on it while a turn is in flight: sending + // then queues the message for the next tool boundary rather than starting a + // turn of its own, and the two have to be told apart at a glance. The label + // says the same thing to a screen reader. + // + // Disabled while there is nothing to send, rather than pressable and + // silent: + // `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( + onClick = { send() }, + enabled = input.text.isNotBlank() || pendingAttachments.isNotEmpty(), + colors = actionButtonColors(if (running) queueColor else sendColor), + ) { + Glyph( + if (running) QUEUE_GLYPH else SEND_GLYPH, + colour = LocalContentColor.current, + modifier = + Modifier.semantics { contentDescription = sendLabel(running) }, + ) + } } } } @@ -1833,7 +1913,7 @@ fun SessionScreen( // is the screen's business rather than any row's. See [SessionImageViewer]. fullImage?.let { ref -> SessionImageViewer(settings, summary.id, ref) { fullImage = null } } if (usageOpen) { - UsageDialog(feed = usageFeed, onDismiss = { usageOpen = false }) + usageFeed?.let { UsageDialog(feed = it, onDismiss = { usageOpen = false }) } } if (settingsOpen) { // 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. */ contextTokens: Long?, 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") Row( @@ -2181,7 +2268,7 @@ private fun SessionStatusRow( Text( when (status) { "idle" -> "idle" - "exited" -> "exited" + "exited" -> if (subagent) "finished" else "exited" "awaitingInput" -> "your turn" "unknown" -> "can't tell" else -> status diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptAddress.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptAddress.kt new file mode 100644 index 0000000..ae2a97f --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptAddress.kt @@ -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" +} diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptCache.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptCache.kt index 606628e..d489f3a 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptCache.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptCache.kt @@ -35,8 +35,15 @@ class TranscriptCache( private val root: File, 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 diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptSource.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptSource.kt index 8a7bdcd..10c7c32 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptSource.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptSource.kt @@ -18,7 +18,7 @@ import java.util.concurrent.atomic.AtomicReference */ class TranscriptSource( private val settings: ServerSettings, - private val sessionId: String, + private val address: TranscriptAddress, val cache: SessionCache, ) { private val stream = AtomicReference(null) @@ -65,7 +65,7 @@ class TranscriptSource( val tail = cache.tail() ?: return false // `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. - val answer = fetchTranscript(settings, sessionId, before = tail.seq + 1, limit = 1) + val answer = fetchTranscript(settings, address, before = tail.seq + 1, limit = 1) val matches = answer.size == 1 && try { @@ -83,7 +83,7 @@ class TranscriptSource( */ suspend fun fetchOpening(): List { 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) } cache.flush() return page.map { it.second } @@ -108,7 +108,7 @@ class TranscriptSource( val page = fetchTranscript( settings, - sessionId, + address, before = before, limit = limit, coalesce = coalesce, @@ -131,7 +131,7 @@ class TranscriptSource( * well lose. */ 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() try { opened.run(after, onOpen, onReset) { raw, entry -> diff --git a/app/androidApp/src/test/kotlin/com/example/aiapp/TranscriptCacheTest.kt b/app/androidApp/src/test/kotlin/com/example/aiapp/TranscriptCacheTest.kt index 04b4db9..e25341f 100644 --- a/app/androidApp/src/test/kotlin/com/example/aiapp/TranscriptCacheTest.kt +++ b/app/androidApp/src/test/kotlin/com/example/aiapp/TranscriptCacheTest.kt @@ -23,7 +23,7 @@ class TranscriptCacheTest { 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") = """{"seq":$seq,"ts":1.5,"type":"$type","id":"x"}""" diff --git a/server/src/routes.rs b/server/src/routes.rs index b42ff11..aac707e 100644 --- a/server/src/routes.rs +++ b/server/src/routes.rs @@ -29,6 +29,12 @@ //! GET /sessions/{id}/transcript a page of history: ?before=N (newest when absent), //! ?limit=N, ?coalesce=true to count rows not deltas, //! ?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?} //! (starts the process first if it has exited) //! 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::pending::Operation; +use crate::session::subagent::{Subagent, SubagentInfo}; use crate::session::transcript::{CATCH_UP_LIMIT, CatchUp, SeqEvent, catch_up}; use crate::session::{LiveSession, SessionInfo, SessionManager, SpawnSpec}; @@ -130,6 +137,15 @@ pub fn router(manager: Arc) -> Router { .route("/sessions/{id}", get(read_session).delete(delete_session)) .route("/sessions/{id}/events", get(events)) .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}/unqueue", post(unqueue)) .route("/sessions/{id}/answer", post(answer)) @@ -209,6 +225,18 @@ fn lookup(manager: &SessionManager, id: &str) -> Result, ApiErr .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, ApiError> { + session + .subagents() + .get(sub) + .ok_or_else(|| ApiError::NotFound(format!("no subagent {sub}"))) +} + async fn list_sessions(State(manager): State>) -> axum::Json> { axum::Json(manager.sessions()) } @@ -1747,8 +1775,36 @@ async fn transcript( Query(query): Query, ) -> Result>, ApiError> { 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>, + UrlPath((id, sub)): UrlPath<(String, String)>, + Query(query): Query, +) -> Result>, 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>, ApiError> { let events = crate::session::transcript::read_window( - session.transcript_path(), + path, query.before, query.after, query.limit, @@ -1760,7 +1816,7 @@ async fn transcript( // for events and draws rows, and the ratio between them is a property of // the conversation. `RUST_LOG=ai_server=debug`. tracing::debug!( - session = %id, + session = %label, before = ?query.before, after = ?query.after, limit = query.limit, @@ -1778,23 +1834,74 @@ async fn events( headers: HeaderMap, ) -> Result>>, ApiError> { let session = lookup(&manager, &id)?; - let cursor = headers - .get("last-event-id") - .and_then(|value| value.to_str().ok()) - .and_then(|value| value.parse().ok()) - .unwrap_or(query.after); - + let cursor = cursor_of(&headers, query.after); // Subscribe before reading the file so nothing can land in the gap // between replay and live; overlap is deduplicated by seq. let live = session.subscribe(); - let (tx, stream) = mpsc::channel(64); - tokio::spawn(stream_session( + Ok(sse_stream( session.transcript_path().to_path_buf(), cursor, 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>, + UrlPath((id, sub)): UrlPath<(String, String)>, + Query(query): Query, + headers: HeaderMap, +) -> Result>>, 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, +) -> Sse>> { + 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>, + UrlPath(id): UrlPath, +) -> Result>, 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. diff --git a/server/src/session/claude.rs b/server/src/session/claude.rs index 69c4d54..ac88f6b 100644 --- a/server/src/session/claude.rs +++ b/server/src/session/claude.rs @@ -59,6 +59,7 @@ use tokio::sync::mpsc; use super::driver::{AttachmentRef, Driver, Event, EventSink, SessionStatus, Unqueued}; use super::process; +use super::subagent::Subagents; use super::transport::{Launch, Streams, Transport}; use crate::config::{ProviderConfig, SessionConfig}; use translate::{AnswerOutcome, Setting, Translator, starts_a_model_call}; @@ -213,8 +214,12 @@ impl ClaudeDriver { transport: &Transport, session_dir: &Path, sink: EventSink, + subagents: Arc, ) -> Result { - 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 reading = Arc::new(AtomicBool::new(true)); @@ -1169,7 +1174,10 @@ mod tests { /// this" and "the transcript records that". fn events_from_lines(lines: &[&str]) -> Vec { 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 (sink, mut out) = mpsc::unbounded_channel::(); for line in lines { @@ -1193,7 +1201,10 @@ mod tests { interject: impl FnOnce(&Arc>), ) -> Vec { 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 (sink, mut out) = mpsc::unbounded_channel::(); let mut interject = Some(interject); @@ -1487,7 +1498,10 @@ mod tests { // doing. let dir = tempfile::tempdir().expect("tempdir"); 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 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. let dir = tempfile::tempdir().expect("tempdir"); 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())); queue.lock().unwrap().close(&sink, "the session ended"); diff --git a/server/src/session/claude/translate.rs b/server/src/session/claude/translate.rs index 39c1650..6a4bd09 100644 --- a/server/src/session/claude/translate.rs +++ b/server/src/session/claude/translate.rs @@ -11,10 +11,12 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; use serde_json::{Value, json}; 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. /// @@ -92,10 +94,20 @@ pub(super) struct Translator { /// reports none rather than repeating the previous turn's. context: Option, 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, + /// 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>>, } impl Translator { - pub(super) fn new(session_dir: PathBuf) -> Self { + pub(super) fn new(session_dir: PathBuf, subagents: Arc) -> Self { Self { session_id: None, pending: HashMap::new(), @@ -103,6 +115,8 @@ impl Translator { interrupting: false, context: None, session_dir, + subagents, + children: HashMap::new(), } } @@ -122,13 +136,54 @@ impl Translator { pub(super) fn translate(&mut self, message: &Value) -> Vec { // Events from subagents (Task tool internals) carry a // parent_tool_use_id; the transcript shows the Task tool's own - // start/end instead of every nested step. - if message - .get("parent_tool_use_id") - .is_some_and(|id| !id.is_null()) - { - return Vec::new(); + // start/end instead of every nested step. Routed into that + // subagent's own transcript rather than dropped -- see + // `SUBAGENTS.md`. + 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 { + 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(); + } + 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 { match message.get("type").and_then(Value::as_str) { Some("system") => self.translate_system(message), // The CLI's own announcement that `/clear` took effect, sent just @@ -379,22 +434,47 @@ impl Translator { content .iter() .filter(|block| block.get("type").and_then(Value::as_str) == Some("tool_use")) - .map(|block| Event::ToolStart { - id: block + .map(|block| { + let id = block .get("id") .and_then(Value::as_str) .unwrap_or_default() - .to_string(), - tool: block + .to_string(); + let tool = block .get("name") .and_then(Value::as_str) .unwrap_or_default() - .to_string(), - input: block.get("input").cloned().unwrap_or(Value::Null), + .to_string(); + 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() } + /// Starts the subagent a Task call names, with the title and prompt + /// SUBAGENTS.md describes: the call's `description`, then + /// `()` 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 { let request = &message["request"]; if request.get("subtype").and_then(Value::as_str) != Some("can_use_tool") { @@ -598,11 +678,33 @@ impl Translator { id: about.clone(), 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 } } +/// 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 /// the CLI said the limit lifts. /// @@ -696,10 +798,18 @@ mod tests { .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 { + Arc::new(Subagents::new(dir.path().to_path_buf())) + } + #[test] fn captures_the_resume_token_and_the_settings_from_init() { 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( &mut translator, &[ @@ -721,7 +831,7 @@ mod tests { #[test] fn a_setting_is_reported_when_the_cli_accepts_it_and_not_before() { 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. 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 // also how a mode changed from the terminal arrives. 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( &mut translator, &[ @@ -818,7 +928,7 @@ mod tests { fn streams_text_deltas_and_skips_the_consolidated_copy() { // Real lines (trimmed) from the 2.1.237 probe. 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( &mut translator, &[ @@ -838,7 +948,7 @@ mod tests { #[test] fn tool_use_and_result_become_tool_events() { 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( &mut translator, &[ @@ -865,7 +975,7 @@ mod tests { #[test] fn subagent_events_are_not_duplicated_into_the_transcript() { 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( &mut translator, &[ @@ -875,10 +985,132 @@ mod tests { 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] fn a_permission_request_becomes_an_allow_deny_question() { 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( &mut translator, &[ @@ -927,7 +1159,7 @@ mod tests { #[test] fn denying_a_permission_sends_deny() { 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( &mut translator, &[ @@ -945,7 +1177,7 @@ mod tests { // The real 2.1.237 shape, verified live: answers go back inside // updatedInput, keyed by the question text. 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( &mut translator, &[ @@ -997,7 +1229,7 @@ mod tests { // 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. 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( &mut translator, &[ @@ -1045,7 +1277,7 @@ mod tests { #[test] fn images_in_tool_results_are_saved_and_referenced() { 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. let png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="; let line = format!( @@ -1074,7 +1306,7 @@ mod tests { #[test] fn a_turn_result_reports_usage_and_returns_to_idle() { 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( &mut translator, &[ @@ -1109,7 +1341,7 @@ mod tests { #[test] fn a_turn_started_by_another_agent_records_who_and_what() { 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( &mut translator, &[ @@ -1143,7 +1375,7 @@ mod tests { #[test] fn an_ordinary_turn_carries_no_peer_note() { 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( &mut translator, &[ @@ -1168,7 +1400,7 @@ mod tests { #[test] fn the_context_is_what_the_last_message_held_not_the_turn_added_up() { 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( &mut translator, &[ @@ -1208,7 +1440,7 @@ mod tests { // Note the snake_case keys -- the CLI's transcript file writes the same // records in camelCase. 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( &mut translator, &[ @@ -1238,7 +1470,7 @@ mod tests { #[test] fn a_failed_compaction_says_why_and_leaves_the_turn_running() { 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( &mut translator, &[ @@ -1265,7 +1497,7 @@ mod tests { #[test] fn a_boundary_without_counts_says_so_rather_than_inventing_them() { 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( &mut translator, &[ @@ -1285,7 +1517,7 @@ mod tests { #[test] fn an_error_result_surfaces_the_message() { 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( &mut translator, &[ @@ -1315,7 +1547,7 @@ mod tests { #[test] fn a_turn_stopped_by_the_usage_limit_says_so_and_carries_the_reset() { 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( &mut translator, &[ @@ -1333,7 +1565,7 @@ mod tests { #[test] fn a_limit_the_cli_gave_no_reset_for_is_reported_without_one() { 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( &mut translator, &[ @@ -1366,7 +1598,7 @@ mod tests { #[test] fn a_turn_stopped_on_purpose_is_not_an_error() { 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":{}}"#; translator.expect_interrupt(); @@ -1398,7 +1630,7 @@ mod tests { #[test] fn replayed_and_synthetic_user_text_is_skipped() { 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( &mut translator, &[ diff --git a/server/src/session/echo.rs b/server/src/session/echo.rs index 20c2c3f..c899524 100644 --- a/server/src/session/echo.rs +++ b/server/src/session/echo.rs @@ -48,6 +48,10 @@ //! and height the app draws, in one session, which is what a scrolling //! problem needs in order to be reproduced twice the same way. //! - `/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 //! 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::{ AttachmentRef, Driver, Event, EventSink, QuestionOption, SessionStatus, Unqueued, }; +use super::subagent::Subagents; /// Delay between streamed deltas -- long enough that streaming is visibly /// 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 /// which way the numbers move. context: Arc, + /// 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, } impl EchoDriver { @@ -384,6 +393,55 @@ impl EchoDriver { 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::().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. // `Driver::compact` is what the manager's route calls; this is the typed // 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, + ) -> Self { let driver = Self { sink, pending_questions: Mutex::new(Vec::new()), @@ -688,6 +751,7 @@ impl EchoDriver { queued: Arc::new(Mutex::new(Vec::new())), session_dir, usage, + subagents, }; driver.emit(Event::Status { 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; } +/// 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) { + 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 /// `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. diff --git a/server/src/session/llama.rs b/server/src/session/llama.rs index c9ce819..e70e041 100644 --- a/server/src/session/llama.rs +++ b/server/src/session/llama.rs @@ -77,6 +77,7 @@ impl LlamaDriver { /// 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 /// talking to the first. + #[allow(clippy::too_many_arguments)] pub fn launch( meta: &SessionConfig, provider: &ProviderConfig, @@ -85,6 +86,10 @@ impl LlamaDriver { transcript: &Path, session_dir: &Path, 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, ) -> Result { let model = meta.model.as_deref().context( "a llama.cpp session needs a model -- one of the downloaded ones, by its key", diff --git a/server/src/session/mod.rs b/server/src/session/mod.rs index 463c658..8d1adc5 100644 --- a/server/src/session/mod.rs +++ b/server/src/session/mod.rs @@ -15,6 +15,7 @@ pub mod import; pub mod llama; pub mod pending; pub mod process; +pub mod subagent; pub mod transcript; pub mod transport; @@ -37,6 +38,7 @@ use driver::{ }; use echo::EchoDriver; use llama::LlamaDriver; +use subagent::Subagents; use transcript::{SeqEvent, Transcript}; use transport::Transport; @@ -265,6 +267,11 @@ pub struct SessionInfo { pub status: SessionStatus, pub last_activity: 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. @@ -293,6 +300,10 @@ pub struct LiveSession { events: broadcast::Sender, transcript_path: PathBuf, shared: Arc, + /// 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, } /// Commands waiting for the session to be between turns. @@ -502,6 +513,19 @@ impl LiveSession { &self.transcript_path } + pub fn subagents(&self) -> &Arc { + &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 /// `attachments/` and `files/` under it). pub fn dir(&self) -> &Path { @@ -578,6 +602,7 @@ impl LiveSession { status: *self.shared.status.lock().unwrap(), last_activity: *self.shared.last_activity.lock().unwrap(), 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)), last_activity: meta.created, created: meta.created, + subagents: subagent::count(&self.data_dir.join(&meta.id)), }, }) .collect() @@ -1828,6 +1854,7 @@ impl SessionManager { session.dir(), session.transcript_path(), &session.sink, + session.subagents(), )?); } // Nothing is live for this one -- a session whose launch failed @@ -2289,6 +2316,11 @@ fn launch( let (sink, source) = mpsc::unbounded_channel(); 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 { // What it was last known to be doing, not an assumption. A driver // that has something to say corrects this within its first poll. @@ -2350,7 +2382,18 @@ fn launch( let driver = Arc::new(Mutex::new( 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()?, )); @@ -2368,6 +2411,7 @@ fn launch( events.clone(), Arc::clone(&commands), announce, + Arc::clone(&subagents), )); Ok(Arc::new(LiveSession { @@ -2378,6 +2422,7 @@ fn launch( events, transcript_path, shared, + subagents, })) } @@ -2388,6 +2433,7 @@ fn launch( /// what [`SessionManager::start_session`] builds. That path replaces the /// driver and nothing else, so it has to construct one the same way rather /// than becoming a second answer to "what runs this". +#[allow(clippy::too_many_arguments)] fn make_driver( meta: &SessionConfig, setup: &SetupConfig, @@ -2396,13 +2442,17 @@ fn make_driver( dir: &Path, transcript_path: &Path, sink: &EventSink, + subagents: &Arc, ) -> Result> { Ok(match provider.kind { DriverKind::Echo => Arc::new(EchoDriver::new( sink.clone(), dir.to_path_buf(), 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( meta, provider, @@ -2411,6 +2461,7 @@ fn make_driver( transcript_path, dir, sink.clone(), + Arc::clone(subagents), )?), DriverKind::ClaudeCli => Arc::new(ClaudeDriver::launch( meta, @@ -2418,6 +2469,7 @@ fn make_driver( &Transport::for_setup(setup), dir, sink.clone(), + Arc::clone(subagents), )?), }) } @@ -2482,6 +2534,7 @@ fn notification_for( } } +#[allow(clippy::too_many_arguments)] async fn pump( id: String, mut transcript: Transcript, @@ -2490,6 +2543,7 @@ async fn pump( events: broadcast::Sender, commands: Arc, announce: Announcements, + subagents: Arc, ) { // 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. @@ -2611,7 +2665,12 @@ async fn pump( } => commands.take_one(), Event::Status { 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 // no id never waited -- it was sent between turns, and // counting it would take the total below zero. @@ -2752,6 +2811,7 @@ mod tests { sink.clone(), dir.path().to_path_buf(), crate::usage::Fixture::new(), + Arc::new(subagent::Subagents::new(dir.path().to_path_buf())), ))))), sink, waiting: Mutex::new(VecDeque::new()), @@ -4399,4 +4459,69 @@ mod tests { let seen = collect_turn(&mut rx).await; 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; + } + } } diff --git a/server/src/session/subagent.rs b/server/src/session/subagent.rs new file mode 100644 index 0000000..c825e9f --- /dev/null +++ b/server/src/session/subagent.rs @@ -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 `/subagents//{meta.json,transcript.jsonl}`, +//! where `` 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, + events: broadcast::Sender, + /// 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, +} + +impl Subagent { + pub fn transcript_path(&self) -> PathBuf { + self.dir.join("transcript.jsonl") + } + + pub fn subscribe(&self) -> broadcast::Receiver { + 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 `/subagents`. + dir: PathBuf, + live: Mutex>>, +} + +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> { + 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::(&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> { + 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> = 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 { + let mut rows: Vec = 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 { + 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::>(), + ["toolu_a", "toolu_b"] + ); + assert_eq!(count(dir.path()), 2); + } +}