Show a session's subagents as subcards, each with a read-only transcript
A subagent is a second transcript owned by a session, in the same event model, with no process and no controls. The claude translator routes lines carrying parent_tool_use_id to a per-subagent translator and transcript under <session>/subagents/<tool_use_id>; three routes expose the list, a transcript page and the SSE stream. Echo grows /subagent [n] as the rig. On the phone a card with subagents ends in a chevron expander, collapsed by default, opening to outlined subcards styled like dev-updater's components; a subcard opens SessionScreen in read-only form, addressed through TranscriptAddress so paging, cache and stream are shared. Design in SUBAGENTS.md; choices awaiting review in DECISIONS.md. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
eff5c8b0c0
commit
9fa09b0af1
21 files changed
+1786
-165
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
# Subagents
|
||||
|
||||
A session's subagents -- the helpers a Claude Code session starts through its
|
||||
Task tool -- each get a transcript of their own, listed under the session's
|
||||
card and readable in the same transcript view the session has. Designed
|
||||
2026-09-05; the decisions Bryan has not yet reviewed are in `DECISIONS.md`.
|
||||
|
||||
## What a subagent is here
|
||||
|
||||
**A subagent is a second transcript owned by a session, in the same event
|
||||
model, with no process and no controls.** It is not a session: it cannot be
|
||||
messaged, stopped or started, and it has no setup, model or usage of its
|
||||
own. Everything it shares with a session -- the transcript file format, the
|
||||
paging routes, the SSE stream, the phone's cache and rendering -- is reused
|
||||
by addressing, not by copying.
|
||||
|
||||
The CLI reports a subagent's messages on the parent's own stream-json
|
||||
output, each carrying `parent_tool_use_id` = the id of the Task `tool_use`
|
||||
that started it. Before this the translator dropped those lines
|
||||
(`subagent_events_are_not_duplicated_into_the_transcript`); now it routes
|
||||
them to that subagent's own translator and transcript. The parent's
|
||||
transcript still shows only the Task call itself.
|
||||
|
||||
## Storage
|
||||
|
||||
Under the session directory:
|
||||
|
||||
```
|
||||
<session>/subagents/<tool_use_id>/meta.json {title, created}
|
||||
<session>/subagents/<tool_use_id>/transcript.jsonl same SeqEvent lines as the session's
|
||||
```
|
||||
|
||||
The id is the Task tool_use id (`toolu_…`), which is unique, stable across a
|
||||
backend restart, and already the key everything on the parent side uses.
|
||||
Only ids matching `[A-Za-z0-9_-]+` are ever created or looked up, since the
|
||||
id becomes a path.
|
||||
|
||||
The transcript's sequence numbers are its own, starting at 1. `Transcript`,
|
||||
`read_window`, `catch_up` and `read_after` work on it unchanged.
|
||||
|
||||
Its path out: deleting the session deletes its directory, subagents included.
|
||||
There is no separate delete.
|
||||
|
||||
## Lifecycle, as events in the subagent's transcript
|
||||
|
||||
1. Created on the first child line for an unseen parent id (or, when the
|
||||
parent Task call was seen, at that call). First lines written:
|
||||
`Status Running`, then `UserMessage { text: <the Task's prompt> }` when
|
||||
the prompt is known -- it genuinely is the subagent's first user turn.
|
||||
2. Every child line is translated by that subagent's own `Translator`
|
||||
(one per subagent: tool ids are unique but streaming deltas are by
|
||||
content-block index, and parallel subagents interleave).
|
||||
3. When the parent's `tool_result` for the Task id arrives, the parent gets
|
||||
its `ToolEnd` as before, and the subagent gets `Status Exited`.
|
||||
4. When the parent session's process exits (`Status Exited` on the
|
||||
session), every subagent still `Running` gets `Status Exited` too: its
|
||||
process was the parent's.
|
||||
|
||||
A subagent that was mid-flight when the backend restarted keeps working:
|
||||
the registry reopens the existing transcript on the next child line, and
|
||||
the file continues its sequence. If its Task call finished while the backend
|
||||
was down nothing ever closes it -- its last status stays `Running`, which
|
||||
the list reports as **unknown** rather than as running (see the wire shape).
|
||||
|
||||
Title: the Task call's `description` input, then ` (<subagent_type>)` when
|
||||
one is given; falling back to the tool's name when the child arrives before
|
||||
(or without) the parent call being seen.
|
||||
|
||||
## Server layout
|
||||
|
||||
- `session/subagent.rs` -- the registry: `Subagents` (per session, in
|
||||
`Shared`), `Subagent` (its `Transcript` behind a mutex plus a
|
||||
`broadcast::Sender<SeqEvent>`), `record(id, event)`, `start(id, title,
|
||||
prompt)`, `finish(id)`, `finish_all()`, `list()` from disk. Drivers get an
|
||||
`Arc<Subagents>` beside their `EventSink`; llama ignores it.
|
||||
- `session/claude/translate.rs` -- routes child lines by parent id, holds
|
||||
one child `Translator` per subagent, remembers pending Task calls'
|
||||
description/prompt/subagent_type.
|
||||
- `session/echo.rs` -- `/subagent [n]`: the test rig. Starts *n* (default 1)
|
||||
subagents at once, each named "helper k". Each writes the prompt as its
|
||||
user message, streams a few words of text, runs one `Bash` tool call, then
|
||||
finishes about three seconds after starting, and the parent's Task calls
|
||||
end when their subagent does. Three seconds so the running state can be
|
||||
seen on the phone.
|
||||
- `routes.rs` -- three routes, in the doc table.
|
||||
|
||||
## Wire shape
|
||||
|
||||
```
|
||||
GET /sessions/{id} SessionInfo gains `subagents: N` (count, 0 when none)
|
||||
GET /sessions same field on each row
|
||||
GET /sessions/{id}/subagents [{id, title, status, created, lastActivity}], oldest first
|
||||
GET /sessions/{id}/subagents/{sub}/transcript exactly the session transcript's query and answer
|
||||
GET /sessions/{id}/subagents/{sub}/events?after=N exactly the session events stream
|
||||
```
|
||||
|
||||
`status` is the transcript's last `Status` event, serialised like a session's
|
||||
(`running`, `exited`), except that a subagent whose session is not itself
|
||||
running cannot be running: the list answers `unknown` for that one. The
|
||||
phone words these as *running*, *finished* and *unknown* on the subcard.
|
||||
|
||||
The count on `SessionInfo` is a directory listing, so the list stays cheap.
|
||||
The per-subagent status is only read when the list route is asked for.
|
||||
|
||||
## Phone
|
||||
|
||||
- `SessionSummary.subagents: Int`. A card with a non-zero count ends in an
|
||||
expander row -- a full-width `Chevron(Pointing.Down)` row that flips to
|
||||
`Pointing.Up` -- collapsed by default. Expanding fetches
|
||||
`/sessions/{id}/subagents` and draws one `OutlinedCard` per subagent,
|
||||
indented inside the session card, the way dev-updater draws a project's
|
||||
components: title, then the status word and a relative time. The
|
||||
expansion state is per session id and survives a refresh of the list.
|
||||
- Tapping a subcard opens `Screen.Subagent`, which is `SessionScreen` in
|
||||
**read-only** form: the same transcript, paging, cache, selection,
|
||||
images and status row, with the composer, the process button, the model
|
||||
picker, the files button, the settings cog and the usage bar left out.
|
||||
The header shows the subagent's title with the session's title beneath
|
||||
it. Back returns to the list.
|
||||
- Addressing: `fetchTranscript`, `EventStream`, `TranscriptSource` and the
|
||||
cache take a transcript address rather than a session id --
|
||||
`sessions/{id}` or `sessions/{id}/subagents/{sub}` -- so the cache nests a
|
||||
subagent's copy under its session's and the same code serves both.
|
||||
@@ -223,6 +223,14 @@ data class SessionSummary(
|
||||
val usageProvider: String?,
|
||||
val 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<SessionSummary> =
|
||||
@@ -267,6 +276,35 @@ fun fetchSessions(settings: ServerSettings): List<SessionSummary> =
|
||||
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<SubagentSummary> =
|
||||
requestFromServer(settings, "/sessions/$sessionId/subagents") {
|
||||
it.jsonObjects { row ->
|
||||
SubagentSummary(
|
||||
id = row.getString("id"),
|
||||
title = row.getString("title"),
|
||||
status = row.getString("status"),
|
||||
created = row.getDouble("created"),
|
||||
lastActivity = row.getDouble("lastActivity"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// What the server offers, so the spawn screen has no hardcoded lists: a setup added to the server's
|
||||
// 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.
|
||||
|
||||
@@ -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>(Screen.Main) }
|
||||
var screen by remember { mutableStateOf<Screen>(Screen.Main()) }
|
||||
// A notification tap this could not follow, and why. Null both before one is asked for and
|
||||
// after one succeeds, since success is a screen rather than a message.
|
||||
var failedOpen by remember { mutableStateOf<FailedOpen?>(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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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<List<SessionSummary>>>(LoadState.Loading) }
|
||||
var confirmingDelete by remember { mutableStateOf<SessionSummary?>(null) }
|
||||
|
||||
// Which session cards are expanded to show their subagents, and what each expansion fetched.
|
||||
// Ids rather than a flag on the row for the same reason `deleting` is: the rows are rebuilt
|
||||
// from
|
||||
// whatever the server last said, and this belongs to the reader's own choice, which survives a
|
||||
// refresh.
|
||||
var expandedSessions by remember { mutableStateOf(setOf<String>()) }
|
||||
var subagentLoads by remember {
|
||||
mutableStateOf(mapOf<String, LoadState<List<SubagentSummary>>>())
|
||||
}
|
||||
|
||||
fun loadSubagents(sessionId: String) {
|
||||
subagentLoads = subagentLoads + (sessionId to LoadState.Loading)
|
||||
scope.launch {
|
||||
subagentLoads =
|
||||
subagentLoads +
|
||||
(sessionId to
|
||||
try {
|
||||
LoadState.Loaded(
|
||||
withContext(Dispatchers.IO) { fetchSubagents(settings, sessionId) }
|
||||
)
|
||||
} catch (e: ApiException) {
|
||||
LoadState.failed(e)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Failures that belong to one session rather than to the list, keyed by its id and shown on its
|
||||
// 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<List<SubagentSummary>>?,
|
||||
onToggleSubagents: () -> Unit,
|
||||
onOpenSubagent: (SubagentSummary) -> Unit,
|
||||
) {
|
||||
BusyItem(label = if (deleting) "deleting" else null) {
|
||||
Card(
|
||||
@@ -332,10 +390,99 @@ 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) {
|
||||
|
||||
@@ -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<TranscriptItem>()) }
|
||||
var status by remember { mutableStateOf(summary.status) }
|
||||
var status by remember { mutableStateOf(subagent?.status ?: summary.status) }
|
||||
// Seeded from the row this screen was opened from, so a conversation already under way says how
|
||||
// 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<String?>(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
|
||||
// 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 (summary.status != "compacting") compactingSince = null
|
||||
}
|
||||
if (status != "compacting") compactingSince = null
|
||||
// Nothing to put back, so these rows are the screen and the probe can return under
|
||||
// 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,10 +895,14 @@ 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.
|
||||
//
|
||||
// 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)
|
||||
@@ -882,6 +913,7 @@ fun SessionScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Back at the newest end, so the backlog [apply] held can land. Everything at once rather than
|
||||
// paced out: they are at the bottom, which is the one place the list is allowed to follow new
|
||||
@@ -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<Any, Int>() }
|
||||
val unitSizes = remember(address) { HashMap<Any, Int>() }
|
||||
LaunchedEffect(listState, moreHistory) {
|
||||
snapshotFlow { listState.layoutInfo }
|
||||
.collect { info ->
|
||||
@@ -983,6 +1015,8 @@ fun SessionScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Only for the model picker, which a subagent does not have.
|
||||
if (!isSubagent) {
|
||||
LaunchedEffect(summary.setupName, summary.provider) {
|
||||
offeredModels =
|
||||
try {
|
||||
@@ -995,10 +1029,12 @@ fun SessionScreen(
|
||||
.orEmpty()
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// Not worth reporting: the picker simply has nothing to offer, which is visible.
|
||||
// Not worth reporting: the picker simply has nothing to offer, which is
|
||||
// visible.
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the server to take back a message the session has not read yet.
|
||||
@@ -1148,8 +1184,9 @@ fun SessionScreen(
|
||||
}
|
||||
|
||||
// One poll for the machines' limits, read by everything on this screen that reports them.
|
||||
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)) {
|
||||
// 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.
|
||||
// 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.
|
||||
// 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,6 +1316,7 @@ 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.
|
||||
if (!isSubagent) {
|
||||
Row {
|
||||
GlyphButton(
|
||||
USAGE_GLYPH,
|
||||
@@ -1281,25 +1334,29 @@ fun SessionScreen(
|
||||
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.
|
||||
// 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.
|
||||
// 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.
|
||||
// whether to keep going. Nothing meters a subagent.
|
||||
if (!isSubagent) {
|
||||
SessionUsageBar(usage)
|
||||
}
|
||||
|
||||
(streamError ?: actionError)?.let { message ->
|
||||
Text(
|
||||
@@ -1644,24 +1701,34 @@ fun SessionScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// Kept for a subagent -- see SUBAGENTS.md's "Phone" -- with the wording that turns
|
||||
// "exited" into "finished" for one, since it has no process to leave running or stop.
|
||||
SessionStatusRow(
|
||||
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.
|
||||
// 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),
|
||||
// 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".
|
||||
// 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()
|
||||
},
|
||||
@@ -1670,11 +1737,14 @@ fun SessionScreen(
|
||||
// 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.
|
||||
// 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.
|
||||
// rather than counted: the "+2" on the button below said how many and never
|
||||
// which.
|
||||
PendingAttachments(
|
||||
settings = settings,
|
||||
sessionId = summary.id,
|
||||
@@ -1688,8 +1758,8 @@ fun SessionScreen(
|
||||
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.
|
||||
// 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,
|
||||
)
|
||||
@@ -1697,17 +1767,19 @@ fun SessionScreen(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
// Photo or file, asked here rather than by two buttons: the row is full, and
|
||||
// Photo or file, asked here rather than by two buttons: the row is full,
|
||||
// and
|
||||
// attaching is one action whichever picker answers it.
|
||||
var attaching by remember { mutableStateOf(false) }
|
||||
Box {
|
||||
// Just "+". The count it used to carry was standing in for showing them.
|
||||
// Just "+". The count it used to carry was standing in for showing
|
||||
// them.
|
||||
BubbleButton(onClick = { attaching = true }) { Text("+") }
|
||||
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.
|
||||
// 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,
|
||||
) {
|
||||
@@ -1731,11 +1803,11 @@ fun SessionScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
// 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.
|
||||
// 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),
|
||||
@@ -1743,16 +1815,18 @@ fun SessionScreen(
|
||||
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.
|
||||
// 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].
|
||||
// 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) ||
|
||||
@@ -1773,14 +1847,16 @@ fun SessionScreen(
|
||||
},
|
||||
)
|
||||
}
|
||||
// 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.
|
||||
// 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.
|
||||
// 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
|
||||
@@ -1800,18 +1876,21 @@ fun SessionScreen(
|
||||
Glyph(
|
||||
process.glyph,
|
||||
colour = LocalContentColor.current,
|
||||
modifier = Modifier.semantics { contentDescription = process.label },
|
||||
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.
|
||||
// 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.
|
||||
// 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(),
|
||||
@@ -1828,12 +1907,13 @@ fun SessionScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Beside the other two dialogs, and outside the list for the same reason as them: what is open
|
||||
// is the screen's business rather than any row's. See [SessionImageViewer].
|
||||
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
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.example.aiapp
|
||||
|
||||
/**
|
||||
* Where one transcript lives: a session's own, or one of its subagents'.
|
||||
*
|
||||
* The single mechanism [fetchTranscript], [EventStream], [TranscriptSource] and
|
||||
* [TranscriptCache.session] all take, rather than each growing its own branch between a session and
|
||||
* a subagent -- see SUBAGENTS.md's "Phone" and "Wire shape". A caller that has only a session id
|
||||
* builds one with the one-argument constructor; a subagent's screen supplies both ids.
|
||||
*/
|
||||
data class TranscriptAddress(val sessionId: String, val subagentId: String? = null) {
|
||||
/** The URL segment naming this transcript, before `/transcript` or `/events`. */
|
||||
val urlPath: String
|
||||
get() =
|
||||
if (subagentId == null) "sessions/$sessionId"
|
||||
else "sessions/$sessionId/subagents/$subagentId"
|
||||
|
||||
/**
|
||||
* Where this transcript's cache lives on the phone, relative to the cache root.
|
||||
*
|
||||
* A subagent's nests under its session's directory rather than sitting beside it, so deleting a
|
||||
* session's cache directory takes its subagents' with it -- the same one-way door the server's
|
||||
* own storage describes.
|
||||
*/
|
||||
val cachePath: String
|
||||
get() = if (subagentId == null) sessionId else "$sessionId/subagents/$subagentId"
|
||||
}
|
||||
@@ -35,8 +35,15 @@ class TranscriptCache(
|
||||
private val root: File,
|
||||
private val 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
|
||||
|
||||
@@ -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<EventStream?>(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<SeqEvent> {
|
||||
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 ->
|
||||
|
||||
@@ -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"}"""
|
||||
|
||||
+120
-13
@@ -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<SessionManager>) -> 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<Arc<LiveSession>, 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<Arc<Subagent>, ApiError> {
|
||||
session
|
||||
.subagents()
|
||||
.get(sub)
|
||||
.ok_or_else(|| ApiError::NotFound(format!("no subagent {sub}")))
|
||||
}
|
||||
|
||||
async fn list_sessions(State(manager): State<Arc<SessionManager>>) -> axum::Json<Vec<SessionInfo>> {
|
||||
axum::Json(manager.sessions())
|
||||
}
|
||||
@@ -1747,8 +1775,36 @@ async fn transcript(
|
||||
Query(query): Query<TranscriptQuery>,
|
||||
) -> Result<axum::Json<Vec<crate::session::transcript::SeqEvent>>, 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<Arc<SessionManager>>,
|
||||
UrlPath((id, sub)): UrlPath<(String, String)>,
|
||||
Query(query): Query<TranscriptQuery>,
|
||||
) -> Result<axum::Json<Vec<crate::session::transcript::SeqEvent>>, ApiError> {
|
||||
let session = lookup(&manager, &id)?;
|
||||
let subagent = lookup_subagent(&session, &sub)?;
|
||||
transcript_page(
|
||||
&subagent.transcript_path(),
|
||||
&format!("{id}/subagents/{sub}"),
|
||||
query,
|
||||
)
|
||||
}
|
||||
|
||||
/// A page of history at `path`, newest first to open with -- the one
|
||||
/// implementation [`transcript`] and [`subagent_transcript`] share, since a
|
||||
/// subagent's transcript is read exactly the way a session's is. `label` is
|
||||
/// only for the debug line below.
|
||||
fn transcript_page(
|
||||
path: &Path,
|
||||
label: &str,
|
||||
query: TranscriptQuery,
|
||||
) -> Result<axum::Json<Vec<crate::session::transcript::SeqEvent>>, ApiError> {
|
||||
let events = crate::session::transcript::read_window(
|
||||
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<Sse<impl tokio_stream::Stream<Item = Result<SseEvent, Infallible>>>, 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<Arc<SessionManager>>,
|
||||
UrlPath((id, sub)): UrlPath<(String, String)>,
|
||||
Query(query): Query<EventsQuery>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Sse<impl tokio_stream::Stream<Item = Result<SseEvent, Infallible>>>, ApiError> {
|
||||
let session = lookup(&manager, &id)?;
|
||||
let subagent = lookup_subagent(&session, &sub)?;
|
||||
let cursor = cursor_of(&headers, query.after);
|
||||
let live = subagent.subscribe();
|
||||
Ok(sse_stream(subagent.transcript_path(), cursor, live))
|
||||
}
|
||||
|
||||
/// The cursor an SSE reconnect resumes from: the native `Last-Event-ID`
|
||||
/// takes precedence over the query parameter, same cursor either way.
|
||||
fn cursor_of(headers: &HeaderMap, query_after: u64) -> u64 {
|
||||
headers
|
||||
.get("last-event-id")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| value.parse().ok())
|
||||
.unwrap_or(query_after)
|
||||
}
|
||||
|
||||
/// Spawns the backlog-then-live task and wraps it as the response, the one
|
||||
/// piece [`events`] and [`subagent_events`] share.
|
||||
fn sse_stream(
|
||||
transcript: PathBuf,
|
||||
cursor: u64,
|
||||
live: broadcast::Receiver<SeqEvent>,
|
||||
) -> Sse<impl tokio_stream::Stream<Item = Result<SseEvent, Infallible>>> {
|
||||
let (tx, stream) = mpsc::channel(64);
|
||||
tokio::spawn(stream_session(transcript, cursor, live, tx));
|
||||
Sse::new(ReceiverStream::new(stream).map(Ok)).keep_alive(KeepAlive::default())
|
||||
}
|
||||
|
||||
/// `GET /sessions/{id}/subagents`: every subagent this session has started,
|
||||
/// oldest first, with a status read from its own transcript -- see
|
||||
/// `SUBAGENTS.md`'s wire shape. A subagent whose last status is `Running` is
|
||||
/// reported `Unknown` instead when the session itself is not running: its
|
||||
/// process was the session's, and a session with none has nothing left to
|
||||
/// ask.
|
||||
async fn list_subagents(
|
||||
State(manager): State<Arc<SessionManager>>,
|
||||
UrlPath(id): UrlPath<String>,
|
||||
) -> Result<axum::Json<Vec<SubagentInfo>>, ApiError> {
|
||||
let session = lookup(&manager, &id)?;
|
||||
// Anything but `Exited` or `Unknown` has a process behind it, which is
|
||||
// what decides whether a subagent still reading `Running` from its own
|
||||
// transcript can be believed -- see `SUBAGENTS.md`'s wire shape.
|
||||
let running = !matches!(
|
||||
session.status(),
|
||||
crate::session::driver::SessionStatus::Exited
|
||||
| crate::session::driver::SessionStatus::Unknown
|
||||
);
|
||||
Ok(axum::Json(session.subagents().list(running)))
|
||||
}
|
||||
|
||||
/// Every session's attention-wanting moments, on one stream.
|
||||
|
||||
@@ -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<Subagents>,
|
||||
) -> Result<Self> {
|
||||
let state = Arc::new(Mutex::new(Translator::new(session_dir.to_path_buf())));
|
||||
let state = Arc::new(Mutex::new(Translator::new(
|
||||
session_dir.to_path_buf(),
|
||||
subagents,
|
||||
)));
|
||||
let queue = Arc::new(Mutex::new(Queue::default()));
|
||||
let 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<Event> {
|
||||
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::<Event>();
|
||||
for line in lines {
|
||||
@@ -1193,7 +1201,10 @@ mod tests {
|
||||
interject: impl FnOnce(&Arc<Mutex<Queue>>),
|
||||
) -> Vec<Event> {
|
||||
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::<Event>();
|
||||
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");
|
||||
|
||||
|
||||
@@ -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<u64>,
|
||||
session_dir: PathBuf,
|
||||
/// This session's subagents, shared with every child translator below --
|
||||
/// see `SUBAGENTS.md`. One registry per session, so a subagent started
|
||||
/// through this translator or any of its children lands in the same
|
||||
/// place a route reads it back from.
|
||||
subagents: Arc<Subagents>,
|
||||
/// One translator per subagent id, holding *its* streaming and
|
||||
/// tool-tracking state -- separate from the parent's because tool ids
|
||||
/// are unique but a `stream_event`'s content-block index is not, and
|
||||
/// parallel subagents interleave their deltas on one stdout.
|
||||
children: HashMap<String, Arc<Mutex<Translator>>>,
|
||||
}
|
||||
|
||||
impl Translator {
|
||||
pub(super) fn new(session_dir: PathBuf) -> Self {
|
||||
pub(super) fn new(session_dir: PathBuf, subagents: Arc<Subagents>) -> 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<Event> {
|
||||
// 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())
|
||||
{
|
||||
// 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<Event> {
|
||||
match self.subagents.get(id) {
|
||||
Some(subagent) if !subagent.is_open() => {
|
||||
// The Task call already ended (or this line is stale from a
|
||||
// resumed conversation) -- see `SUBAGENTS.md`'s lifecycle.
|
||||
tracing::debug!("dropping a line for subagent {id}, which has already finished");
|
||||
return Vec::new();
|
||||
}
|
||||
Some(_) => {}
|
||||
None => {
|
||||
// Nobody has heard of this id yet: the Task call itself
|
||||
// either has not been seen or never will be. Started here
|
||||
// with the best title available -- the tool name of this
|
||||
// first line -- since SUBAGENTS.md's real title only
|
||||
// arrives with the Task call.
|
||||
self.subagents.start(id, &fallback_title(message), None);
|
||||
}
|
||||
}
|
||||
let child = self
|
||||
.children
|
||||
.entry(id.to_string())
|
||||
.or_insert_with(|| {
|
||||
Arc::new(Mutex::new(Translator::new(
|
||||
self.session_dir.clone(),
|
||||
Arc::clone(&self.subagents),
|
||||
)))
|
||||
})
|
||||
.clone();
|
||||
let events = child.lock().unwrap().dispatch(message);
|
||||
for event in events {
|
||||
self.subagents.record(id, event);
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn dispatch(&mut self, message: &Value) -> Vec<Event> {
|
||||
match message.get("type").and_then(Value::as_str) {
|
||||
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
|
||||
/// `(<subagent_type>)` when one is given, falling back to the tool's own
|
||||
/// name when there is no description to build one from.
|
||||
fn start_subagent_from_task(&self, id: &str, input: &Value) {
|
||||
let description = text_field(input, "description");
|
||||
let subagent_type = text_field(input, "subagent_type");
|
||||
let prompt = input.get("prompt").and_then(Value::as_str);
|
||||
let title = match (description, subagent_type) {
|
||||
(Some(description), Some(subagent_type)) => {
|
||||
format!("{description} ({subagent_type})")
|
||||
}
|
||||
(Some(description), None) => description,
|
||||
(None, _) => "Task".to_string(),
|
||||
};
|
||||
self.subagents.start(id, &title, prompt);
|
||||
}
|
||||
|
||||
fn translate_control_request(&mut self, message: &Value) -> Vec<Event> {
|
||||
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<Subagents> {
|
||||
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,
|
||||
&[
|
||||
|
||||
+110
-1
@@ -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<AtomicU64>,
|
||||
/// This session's subagents -- see `SUBAGENTS.md`. `/subagent` is the
|
||||
/// test rig for the same registry the claude driver routes real Task
|
||||
/// calls into.
|
||||
subagents: Arc<Subagents>,
|
||||
}
|
||||
|
||||
impl EchoDriver {
|
||||
@@ -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::<usize>().unwrap_or(1).clamp(1, 8);
|
||||
if announce {
|
||||
self.emit(Event::MessageTaken {
|
||||
id: None,
|
||||
text: text.clone(),
|
||||
attachments,
|
||||
});
|
||||
}
|
||||
self.emit(Event::Status {
|
||||
state: SessionStatus::Running,
|
||||
});
|
||||
let sink = self.sink.clone();
|
||||
let subagents = Arc::clone(&self.subagents);
|
||||
tokio::spawn(async move {
|
||||
let mut helpers = Vec::new();
|
||||
for k in 1..=n {
|
||||
let id = format!("echo-subagent-{k}-{}", super::random_hex());
|
||||
let title = format!("helper {k}");
|
||||
let prompt = format!(
|
||||
"You are helper {k} of {n}. Say a few words, run a command, then stop."
|
||||
);
|
||||
let _ = sink.send(Event::ToolStart {
|
||||
id: id.clone(),
|
||||
tool: "Task".to_string(),
|
||||
input: serde_json::json!({
|
||||
"description": title,
|
||||
"prompt": prompt,
|
||||
"subagent_type": "general-purpose",
|
||||
}),
|
||||
});
|
||||
subagents.start(&id, &title, Some(&prompt));
|
||||
helpers.push((id, sink.clone(), Arc::clone(&subagents)));
|
||||
}
|
||||
for (id, sink, subagents) in helpers {
|
||||
tokio::spawn(run_helper(id, sink, subagents));
|
||||
}
|
||||
let _ = sink.send(Event::Status {
|
||||
state: SessionStatus::Idle,
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// The same word the real CLI takes, so a phone drives both the same way.
|
||||
// `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<Subagents>,
|
||||
) -> 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<Subagents>) {
|
||||
let start = tokio::time::Instant::now();
|
||||
for word in "Working on it now.".split_inclusive(' ') {
|
||||
subagents.record(
|
||||
&id,
|
||||
Event::AssistantText {
|
||||
delta: word.to_string(),
|
||||
},
|
||||
);
|
||||
tokio::time::sleep(DELTA_DELAY).await;
|
||||
}
|
||||
let tool_id = format!("{id}-bash");
|
||||
subagents.record(
|
||||
&id,
|
||||
Event::ToolStart {
|
||||
id: tool_id.clone(),
|
||||
tool: "Bash".to_string(),
|
||||
input: serde_json::json!({ "command": "echo helper done" }),
|
||||
},
|
||||
);
|
||||
tokio::time::sleep(DELTA_DELAY).await;
|
||||
subagents.record(
|
||||
&id,
|
||||
Event::ToolEnd {
|
||||
id: tool_id,
|
||||
output: "helper done".to_string(),
|
||||
},
|
||||
);
|
||||
let target = Duration::from_secs(3);
|
||||
let elapsed = start.elapsed();
|
||||
if elapsed < target {
|
||||
tokio::time::sleep(target - elapsed).await;
|
||||
}
|
||||
subagents.finish(&id);
|
||||
let _ = sink.send(Event::ToolEnd {
|
||||
id,
|
||||
output: "subagent finished".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
/// A message written during a turn and waiting for it to end: the id of the
|
||||
/// `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.
|
||||
|
||||
@@ -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<super::subagent::Subagents>,
|
||||
) -> Result<Self> {
|
||||
let model = meta.model.as_deref().context(
|
||||
"a llama.cpp session needs a model -- one of the downloaded ones, by its key",
|
||||
|
||||
+127
-2
@@ -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<SeqEvent>,
|
||||
transcript_path: PathBuf,
|
||||
shared: Arc<Shared>,
|
||||
/// This session's subagents -- see `SUBAGENTS.md`. Built once at launch
|
||||
/// and handed to whichever driver replaces it across a stop/start, so a
|
||||
/// subagent started before a Stop is still there to read after a Start.
|
||||
subagents: Arc<Subagents>,
|
||||
}
|
||||
|
||||
/// Commands waiting for the session to be between turns.
|
||||
@@ -502,6 +513,19 @@ impl LiveSession {
|
||||
&self.transcript_path
|
||||
}
|
||||
|
||||
pub fn subagents(&self) -> &Arc<Subagents> {
|
||||
&self.subagents
|
||||
}
|
||||
|
||||
/// What this session is doing right now, as the pump last recorded it --
|
||||
/// the same word `SessionInfo::status` reports. Read here rather than
|
||||
/// only through `SessionManager::sessions` for
|
||||
/// `GET /sessions/{id}/subagents`, which needs exactly this and nothing
|
||||
/// else `SessionInfo` carries.
|
||||
pub fn status(&self) -> SessionStatus {
|
||||
*self.shared.status.lock().unwrap()
|
||||
}
|
||||
|
||||
/// The session's directory (attachments in, produced files out live in
|
||||
/// `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<Subagents>,
|
||||
) -> Result<Arc<dyn Driver>> {
|
||||
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<SeqEvent>,
|
||||
commands: Arc<Commands>,
|
||||
announce: Announcements,
|
||||
subagents: Arc<Subagents>,
|
||||
) {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,490 @@
|
||||
//! A session's subagents -- see `SUBAGENTS.md`.
|
||||
//!
|
||||
//! **A subagent is a second transcript owned by a session, in the same event
|
||||
//! model, with no process and no controls.** It shares the transcript file
|
||||
//! format, the paging routes, and the SSE stream with a session by
|
||||
//! addressing, not by copying: `Transcript`, `read_window` and `catch_up`
|
||||
//! work on a subagent's file unchanged.
|
||||
//!
|
||||
//! Storage is `<session dir>/subagents/<id>/{meta.json,transcript.jsonl}`,
|
||||
//! where `<id>` is the Task tool_use id that started it -- unique, stable
|
||||
//! across a backend restart, and already the key the parent side uses. Only
|
||||
//! ids matching [`is_subagent_id`] are ever turned into a path.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
use super::driver::{Event, SessionStatus};
|
||||
use super::transcript::{SeqEvent, Transcript};
|
||||
|
||||
/// Fan-out buffer for one subagent's SSE subscribers. Smaller than a
|
||||
/// session's: a subagent's whole conversation is usually a handful of tool
|
||||
/// calls, not an hours-long session.
|
||||
const EVENT_BUFFER: usize = 64;
|
||||
|
||||
/// Whether `id` is safe to become a path segment under a session's
|
||||
/// `subagents/` directory. Mirrors `import::is_session_id`'s reasoning: the
|
||||
/// id arrives as a value inside JSON the CLI sent, and it becomes a
|
||||
/// directory name, so a `/` or `..` in it must never be trusted.
|
||||
fn is_subagent_id(id: &str) -> bool {
|
||||
!id.is_empty()
|
||||
&& id.len() <= 200
|
||||
&& id
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
|
||||
}
|
||||
|
||||
/// What a subagent's directory holds beside its transcript. Small and
|
||||
/// separate from `Subagent` itself because this is exactly what survives a
|
||||
/// backend restart on disk, and nothing else does.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct Meta {
|
||||
title: String,
|
||||
/// Epoch seconds. Absent from `SubagentInfo`'s sort key deliberately:
|
||||
/// `list` sorts by this rather than by directory order, which a
|
||||
/// filesystem does not promise.
|
||||
created: f64,
|
||||
}
|
||||
|
||||
/// One row of `GET /sessions/{id}/subagents`.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SubagentInfo {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub status: SessionStatus,
|
||||
pub created: f64,
|
||||
pub last_activity: f64,
|
||||
}
|
||||
|
||||
/// One subagent: its own transcript and broadcast, same shape as a
|
||||
/// session's but with no driver behind it.
|
||||
pub struct Subagent {
|
||||
dir: PathBuf,
|
||||
transcript: Mutex<Transcript>,
|
||||
events: broadcast::Sender<SeqEvent>,
|
||||
/// Mirrors the transcript's last `Status` event, kept live rather than
|
||||
/// read back from `Transcript::last_status` -- that answers "as of
|
||||
/// opening" (see its own doc comment) and never moves for an append made
|
||||
/// through *this* object, which is every append a live subagent ever
|
||||
/// makes. Without this, `finish` immediately after `start` in the same
|
||||
/// process read the file's stale opening status and reported itself
|
||||
/// still open.
|
||||
status: Mutex<SessionStatus>,
|
||||
}
|
||||
|
||||
impl Subagent {
|
||||
pub fn transcript_path(&self) -> PathBuf {
|
||||
self.dir.join("transcript.jsonl")
|
||||
}
|
||||
|
||||
pub fn subscribe(&self) -> broadcast::Receiver<SeqEvent> {
|
||||
self.events.subscribe()
|
||||
}
|
||||
|
||||
/// Whether this subagent's last recorded status is not `Exited` --
|
||||
/// what decides whether a further child line still belongs in its
|
||||
/// transcript. See `SUBAGENTS.md`'s lifecycle: "a child line whose
|
||||
/// subagent finished already... is ignored".
|
||||
pub fn is_open(&self) -> bool {
|
||||
*self.status.lock().unwrap() != SessionStatus::Exited
|
||||
}
|
||||
|
||||
fn append(&self, event: Event) {
|
||||
let mut transcript = self.transcript.lock().unwrap();
|
||||
match transcript.append(event, super::now()) {
|
||||
Ok(entry) => {
|
||||
if let Event::Status { state } = &entry.event {
|
||||
*self.status.lock().unwrap() = *state;
|
||||
}
|
||||
// No subscribers is fine; the transcript already has it,
|
||||
// same as a session's pump.
|
||||
let _ = self.events.send(entry);
|
||||
}
|
||||
Err(err) => tracing::error!("subagent transcript append failed: {err:#}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every subagent one session has started, keyed by the Task tool_use id
|
||||
/// that names it.
|
||||
///
|
||||
/// Lives beside a session's driver rather than inside it: a claude driver
|
||||
/// holds an `Arc` to this and routes child lines into it; echo uses it for
|
||||
/// its `/subagent` rig; llama ignores it, since it has no notion of a Task
|
||||
/// call. One instance per live session, built at launch and handed to
|
||||
/// whichever driver replaces it across a stop/start.
|
||||
pub struct Subagents {
|
||||
/// The session's own directory; subagents live under `<dir>/subagents`.
|
||||
dir: PathBuf,
|
||||
live: Mutex<HashMap<String, Arc<Subagent>>>,
|
||||
}
|
||||
|
||||
impl Subagents {
|
||||
pub fn new(session_dir: PathBuf) -> Self {
|
||||
Self {
|
||||
dir: session_dir,
|
||||
live: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn subagents_dir(&self) -> PathBuf {
|
||||
self.dir.join("subagents")
|
||||
}
|
||||
|
||||
/// Opens the subagent named `id`, creating it if this is the first
|
||||
/// anyone has heard of it -- on disk as well as in memory, so a
|
||||
/// subagent from before a backend restart is reopened rather than
|
||||
/// recreated. `title`/`prompt` are used only at creation: reopening an
|
||||
/// existing one keeps its original title and never repeats the prompt
|
||||
/// into its transcript a second time.
|
||||
fn open_or_create(&self, id: &str, title: &str, prompt: Option<&str>) -> Result<Arc<Subagent>> {
|
||||
let dir = self.subagents_dir().join(id);
|
||||
let meta_path = dir.join("meta.json");
|
||||
let existed = meta_path.is_file();
|
||||
let meta = if existed {
|
||||
let text = fs::read_to_string(&meta_path)
|
||||
.with_context(|| format!("read {}", meta_path.display()))?;
|
||||
serde_json::from_str::<Meta>(&text).context("parse subagent meta")?
|
||||
} else {
|
||||
wg_app_link::private::create_dir(&dir)?;
|
||||
let meta = Meta {
|
||||
title: title.to_string(),
|
||||
created: super::now(),
|
||||
};
|
||||
wg_app_link::private::write_file(
|
||||
&meta_path,
|
||||
serde_json::to_string(&meta)
|
||||
.context("serialize subagent meta")?
|
||||
.as_bytes(),
|
||||
)?;
|
||||
meta
|
||||
};
|
||||
let mut transcript = Transcript::open(&dir.join("transcript.jsonl"))?;
|
||||
if !existed {
|
||||
// First lines, in order: the subagent is running the moment it
|
||||
// exists, and its prompt -- when known -- is genuinely its first
|
||||
// user turn. Written once, here, so a reopen never repeats them.
|
||||
transcript.append(
|
||||
Event::Status {
|
||||
state: SessionStatus::Running,
|
||||
},
|
||||
meta.created,
|
||||
)?;
|
||||
if let Some(prompt) = prompt {
|
||||
transcript.append(
|
||||
Event::UserMessage {
|
||||
id: None,
|
||||
text: prompt.to_string(),
|
||||
attachments: Vec::new(),
|
||||
},
|
||||
meta.created,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
// A freshly created subagent is running by construction (its only
|
||||
// lines so far are `Status::Running` and maybe its prompt); a
|
||||
// reopened one takes whatever the file last said, since this
|
||||
// `Transcript` has not been appended to yet in this process.
|
||||
let status = if existed {
|
||||
transcript.last_status().unwrap_or(SessionStatus::Running)
|
||||
} else {
|
||||
SessionStatus::Running
|
||||
};
|
||||
let (events, _) = broadcast::channel(EVENT_BUFFER);
|
||||
Ok(Arc::new(Subagent {
|
||||
dir,
|
||||
transcript: Mutex::new(transcript),
|
||||
events,
|
||||
status: Mutex::new(status),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Starts a subagent unless one is already known by this id -- see
|
||||
/// `SUBAGENTS.md`'s lifecycle: created at the Task call or at the first
|
||||
/// child line, whichever comes first, and never twice. A bad id is
|
||||
/// refused rather than turned into a path.
|
||||
pub fn start(&self, id: &str, title: &str, prompt: Option<&str>) {
|
||||
if !is_subagent_id(id) {
|
||||
tracing::debug!("refusing to start a subagent with a bad id {id:?}");
|
||||
return;
|
||||
}
|
||||
let mut live = self.live.lock().unwrap();
|
||||
if live.contains_key(id) {
|
||||
return;
|
||||
}
|
||||
match self.open_or_create(id, title, prompt) {
|
||||
Ok(subagent) => {
|
||||
live.insert(id.to_string(), subagent);
|
||||
}
|
||||
Err(err) => tracing::error!("couldn't start subagent {id}: {err:#}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The subagent named `id`, reopening it from disk on first use in this
|
||||
/// process if one is there. `None` for an id nothing has ever started --
|
||||
/// deliberately not created here, since a route or a routing decision is
|
||||
/// not the Task call that is supposed to be the only way one begins.
|
||||
pub fn get(&self, id: &str) -> Option<Arc<Subagent>> {
|
||||
if !is_subagent_id(id) {
|
||||
return None;
|
||||
}
|
||||
if let Some(existing) = self.live.lock().unwrap().get(id).cloned() {
|
||||
return Some(existing);
|
||||
}
|
||||
if !self.subagents_dir().join(id).join("meta.json").is_file() {
|
||||
return None;
|
||||
}
|
||||
// Title and prompt are ignored: the directory already exists, so
|
||||
// `open_or_create` reads its own meta rather than using either.
|
||||
match self.open_or_create(id, "", None) {
|
||||
Ok(subagent) => {
|
||||
self.live
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(id.to_string(), Arc::clone(&subagent));
|
||||
Some(subagent)
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::error!("couldn't reopen subagent {id}: {err:#}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Appends one event to a subagent's own transcript. A no-op, with a
|
||||
/// debug log, for an id nothing was started under -- a child line for a
|
||||
/// subagent this registry never opened is dropped rather than guessed
|
||||
/// at.
|
||||
pub fn record(&self, id: &str, event: Event) {
|
||||
match self.live.lock().unwrap().get(id).cloned() {
|
||||
Some(subagent) => subagent.append(event),
|
||||
None => tracing::debug!("dropping an event for unknown subagent {id}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The parent's `tool_result` for this Task id arrived: the subagent's
|
||||
/// own `Status::Exited`. A no-op for an id that is not a subagent's, so
|
||||
/// callers can call this for every `tool_result` without first checking
|
||||
/// whether it belongs to one.
|
||||
pub fn finish(&self, id: &str) {
|
||||
if let Some(subagent) = self.live.lock().unwrap().get(id).cloned()
|
||||
&& subagent.is_open()
|
||||
{
|
||||
subagent.append(Event::Status {
|
||||
state: SessionStatus::Exited,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The parent session's process is gone, so nothing still open here has
|
||||
/// a process behind it either -- see `SUBAGENTS.md`'s lifecycle #4.
|
||||
pub fn finish_all(&self) {
|
||||
let subagents: Vec<Arc<Subagent>> = self.live.lock().unwrap().values().cloned().collect();
|
||||
for subagent in subagents {
|
||||
if subagent.is_open() {
|
||||
subagent.append(Event::Status {
|
||||
state: SessionStatus::Exited,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every subagent under this session's directory, oldest first --
|
||||
/// `GET /sessions/{id}/subagents`. Read straight from disk rather than
|
||||
/// from `live`, so a subagent from before this process started (or one
|
||||
/// this run has not yet touched) still shows up; one file read per
|
||||
/// subagent, which is fine at the handful a session usually has.
|
||||
///
|
||||
/// `session_running` is what turns a subagent whose last status is
|
||||
/// `Running` into `Unknown`: its process was the session's, and the
|
||||
/// session has none.
|
||||
pub fn list(&self, session_running: bool) -> Vec<SubagentInfo> {
|
||||
let mut rows: Vec<SubagentInfo> = match fs::read_dir(self.subagents_dir()) {
|
||||
Ok(entries) => entries
|
||||
.filter_map(Result::ok)
|
||||
.filter_map(|entry| info_of(&entry.path(), session_running))
|
||||
.collect(),
|
||||
// No directory is no subagents, not a fault worth reporting.
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
rows.sort_by(|a, b| {
|
||||
a.created
|
||||
.partial_cmp(&b.created)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
rows
|
||||
}
|
||||
}
|
||||
|
||||
fn info_of(subagent_dir: &Path, session_running: bool) -> Option<SubagentInfo> {
|
||||
let id = subagent_dir.file_name()?.to_str()?.to_string();
|
||||
let meta_path = subagent_dir.join("meta.json");
|
||||
let text = fs::read_to_string(&meta_path).ok()?;
|
||||
let meta: Meta = serde_json::from_str(&text).ok()?;
|
||||
let transcript = Transcript::open(&subagent_dir.join("transcript.jsonl")).ok()?;
|
||||
// The subagent's first line is always `Status::Running`, written before
|
||||
// this directory is discoverable at all, so `None` here is not a state a
|
||||
// reader can actually observe -- but it is not this function's place to
|
||||
// invent one, so a status this build does not expect to see falls back
|
||||
// to the word the lifecycle promises it started in.
|
||||
let last_status = transcript.last_status().unwrap_or(SessionStatus::Running);
|
||||
let status = if last_status == SessionStatus::Running && !session_running {
|
||||
SessionStatus::Unknown
|
||||
} else {
|
||||
last_status
|
||||
};
|
||||
Some(SubagentInfo {
|
||||
id,
|
||||
title: meta.title,
|
||||
status,
|
||||
created: meta.created,
|
||||
last_activity: transcript.last_activity().unwrap_or(meta.created),
|
||||
})
|
||||
}
|
||||
|
||||
/// How many subagents a session has, for `SessionInfo::subagents`: a
|
||||
/// directory listing, so the session list stays cheap and only the
|
||||
/// dedicated route pays for reading a status out of each one.
|
||||
pub fn count(session_dir: &Path) -> usize {
|
||||
fs::read_dir(session_dir.join("subagents"))
|
||||
.map(|entries| entries.filter_map(Result::ok).count())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_bad_id_is_refused_rather_than_turned_into_a_path() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let subagents = Subagents::new(dir.path().to_path_buf());
|
||||
subagents.start("../../etc", "escape", None);
|
||||
assert!(subagents.get("../../etc").is_none());
|
||||
assert!(!dir.path().join("subagents").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn starting_twice_keeps_the_first_title_and_does_not_repeat_the_prompt() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let subagents = Subagents::new(dir.path().to_path_buf());
|
||||
subagents.start("toolu_1", "first title", Some("do the thing"));
|
||||
subagents.start("toolu_1", "second title", Some("do the thing"));
|
||||
|
||||
let rows = subagents.list(true);
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0].title, "first title");
|
||||
|
||||
let events = crate::session::transcript::read_after(
|
||||
&subagents.get("toolu_1").unwrap().transcript_path(),
|
||||
0,
|
||||
)
|
||||
.expect("read");
|
||||
assert_eq!(
|
||||
events
|
||||
.iter()
|
||||
.filter(|e| matches!(e.event, Event::UserMessage { .. }))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reopened_subagent_continues_its_own_transcript() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
{
|
||||
let subagents = Subagents::new(dir.path().to_path_buf());
|
||||
subagents.start("toolu_2", "helper", Some("go"));
|
||||
subagents.record(
|
||||
"toolu_2",
|
||||
Event::AssistantText {
|
||||
delta: "working".to_string(),
|
||||
},
|
||||
);
|
||||
}
|
||||
// A fresh registry, the way a backend restart builds one.
|
||||
let subagents = Subagents::new(dir.path().to_path_buf());
|
||||
let subagent = subagents.get("toolu_2").expect("reopened");
|
||||
assert!(subagent.is_open());
|
||||
subagents.record(
|
||||
"toolu_2",
|
||||
Event::AssistantText {
|
||||
delta: " more".to_string(),
|
||||
},
|
||||
);
|
||||
let events =
|
||||
crate::session::transcript::read_after(&subagent.transcript_path(), 0).expect("read");
|
||||
// Status, UserMessage, two AssistantText deltas, seq continuing.
|
||||
assert_eq!(events.len(), 4);
|
||||
assert_eq!(events.last().unwrap().seq, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finishing_appends_exited_and_further_lines_are_droppable() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let subagents = Subagents::new(dir.path().to_path_buf());
|
||||
subagents.start("toolu_3", "helper", None);
|
||||
subagents.finish("toolu_3");
|
||||
let subagent = subagents.get("toolu_3").unwrap();
|
||||
assert!(!subagent.is_open());
|
||||
// On disk too, not only in the live cache `is_open` reads.
|
||||
assert_eq!(
|
||||
Transcript::open(&subagent.transcript_path())
|
||||
.expect("reopen")
|
||||
.last_status(),
|
||||
Some(SessionStatus::Exited)
|
||||
);
|
||||
|
||||
// Finishing an id that was never a subagent is a no-op, not a panic.
|
||||
subagents.finish("never-started");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finish_all_closes_only_what_is_still_open() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let subagents = Subagents::new(dir.path().to_path_buf());
|
||||
subagents.start("toolu_4", "one", None);
|
||||
subagents.start("toolu_5", "two", None);
|
||||
subagents.finish("toolu_4");
|
||||
subagents.finish_all();
|
||||
|
||||
let rows = subagents.list(false);
|
||||
assert_eq!(rows.len(), 2);
|
||||
for row in rows {
|
||||
assert_eq!(row.status, SessionStatus::Exited);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_subagent_still_running_when_the_session_is_not_reports_unknown() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let subagents = Subagents::new(dir.path().to_path_buf());
|
||||
subagents.start("toolu_6", "helper", None);
|
||||
|
||||
assert_eq!(subagents.list(true)[0].status, SessionStatus::Running);
|
||||
assert_eq!(subagents.list(false)[0].status, SessionStatus::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_is_oldest_first_and_the_count_matches_the_directory() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let subagents = Subagents::new(dir.path().to_path_buf());
|
||||
assert_eq!(count(dir.path()), 0);
|
||||
subagents.start("toolu_a", "a", None);
|
||||
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||
subagents.start("toolu_b", "b", None);
|
||||
let rows = subagents.list(true);
|
||||
assert_eq!(
|
||||
rows.iter().map(|r| r.id.as_str()).collect::<Vec<_>>(),
|
||||
["toolu_a", "toolu_b"]
|
||||
);
|
||||
assert_eq!(count(dir.path()), 2);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user