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