feat: optimize subagent list children query

This commit is contained in:
imccyu
2026-08-01 14:30:36 +08:00
committed by Tianyi Cui
parent 5d56019d22
commit 8c9cd4c15d
28 changed files with 261 additions and 86 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/runtime/README.md
README.md: 0f5b35b6fa4a92f083d5e20c354c19eaae1d5b70
README.zh.md: 67cdf6479ef2ba7156b7babb7249b51f8640b134
README.md: e4b8e1777ad3a01144dfe9da0a81f83e0f4e2b6a
README.zh.md: 5aa53ce1ec680579b44f0b2859104a1eda7471d3

View File

@@ -56,7 +56,7 @@ Each resident `Session` owns a `modelSelection` snapshot containing the current
## Addressed subagent conversations
`SessionListState.subagentsByParent` carries direct durable catalogs and `currentAddress` records the catalog-derived `{parentSessionId, childSessionId}` for the selected child. Only that recorded address selects subagent transport: lineage alone remains insufficient because ordinary forks also have `parentId`. An addressed Session loads and reconnects through `subagent.history`, sends through `subagent.prompt`, never calls ordinary cancel, and persists its address with the selected session across refresh and repeated ordinary selection of that same child. The list also projects the header's coarse `origin: 'subagent'` classification for navigation filtering; the recorded address, not `origin`, remains transport authority. Catalog reads are single-flight; `host/session-status` flips a listed child's coarse activity in place, while `host/session-added` causes one debounced refetch only while that parent catalog is open. Parent availability propagates into `ConversationSnapshot.subagent` so presentation can replace the composer with a read-only explanation without activating the parent.
`SessionListState.subagentsByParent` carries direct durable catalogs and `currentAddress` records the catalog-derived `{parentSessionId, childSessionId}` for the selected child. Only that recorded address selects subagent transport: lineage alone remains insufficient because ordinary forks also have `parentId`. An addressed Session loads and reconnects through `subagent.history`, sends through `subagent.prompt`, never calls ordinary cancel, and persists its address with the selected session across refresh and repeated ordinary selection of that same child. The list also projects the header's coarse `origin: 'subagent'` classification for navigation filtering; the recorded address, not `origin`, remains transport authority. Catalog reads are single-flight; `host/session-status` flips a listed child's coarse activity in place, while an origin-classified `host/session-added` immediately marks any loaded direct parent row `hasChildren: true` and causes one debounced refetch when that parent is selected or its catalog is open. Parent availability propagates into `ConversationSnapshot.subagent` so presentation can replace the composer with a read-only explanation without activating the parent.
## Model Experience

View File

@@ -56,7 +56,7 @@ Session 对象会在事件 wire 边界依据生产方的完整字段契约,验
## 已寻址的 subagent 对话
`SessionListState.subagentsByParent` 携带直接持久化目录,`currentAddress` 则记录所选 child 从目录得到的 `{parentSessionId, childSessionId}`。只有这份已记录地址能选择 subagent 传输;单凭谱系仍然不足,因为普通 fork 同样具有 `parentId`。已寻址的 Session 通过 `subagent.history` 加载和重连,通过 `subagent.prompt` 发送,绝不调用普通取消,并在刷新期间及通过普通选择路径重复选择同一 child 时,把地址与所选会话一同持久化。列表还会投影 header 的粗粒度 `origin: 'subagent'` 分类供导航过滤;传输的权威依据仍是已记录地址,而不是 `origin`。目录读取为 single-flight;`host/session-status` 就地翻转已列 child 的粗粒度活动状态,`host/session-added` 则只在对应 parent 目录打开时触发一次去抖动的重拉。parent 可用性会传播到 `ConversationSnapshot.subagent`,使呈现层可以把编辑器替换为只读说明,而不激活 parent。
`SessionListState.subagentsByParent` 携带直接持久化目录,`currentAddress` 则记录所选 child 从目录得到的 `{parentSessionId, childSessionId}`。只有这份已记录地址能选择 subagent 传输;单凭谱系仍然不足,因为普通 fork 同样具有 `parentId`。已寻址的 Session 通过 `subagent.history` 加载和重连,通过 `subagent.prompt` 发送,绝不调用普通取消,并在刷新期间及通过普通选择路径重复选择同一 child 时,把地址与所选会话一同持久化。列表还会投影 header 的粗粒度 `origin: 'subagent'` 分类供导航过滤;传输的权威依据仍是已记录地址,而不是 `origin`。目录读取为 single-flight;`host/session-status` 就地翻转已列 child 的粗粒度活动状态,按 origin 分类的 `host/session-added` 则会立即把任何已加载的直接 parent 行标记为 `hasChildren: true`,并在该 parent 被选中或其目录打开时触发一次去抖动的重拉。parent 可用性会传播到 `ConversationSnapshot.subagent`,使呈现层可以把编辑器替换为只读说明,而不激活 parent。
## 模型体验

View File

@@ -616,6 +616,9 @@ export class SessionManager {
...(frame.cwd !== undefined ? { cwd: frame.cwd } : {}),
})
this.sessions.get(frame.sessionId)?.handleBlank(frame.blank)
if (frame.origin === 'subagent' && frame.parentSessionId !== undefined) {
this.markCatalogParentExpandable(frame.parentSessionId)
}
if (frame.parentSessionId !== undefined
&& (this.selected === frame.parentSessionId || this.openCatalogs.has(frame.parentSessionId))) {
this.scheduleCatalogRefresh(frame.parentSessionId)
@@ -714,6 +717,22 @@ export class SessionManager {
if (changed) this.notifier.markDirty()
}
/** Mark a loaded parent row expandable after one direct subagent publishes. */
private markCatalogParentExpandable(parentSessionId: SessionId): void {
let changed = false
for (const [catalogParentId, catalog] of this.catalogs) {
if (!catalog.entries.some(entry =>
entry.kind === 'child' && entry.id === parentSessionId && !entry.hasChildren)) continue
const entries = catalog.entries.map((entry) => {
if (entry.kind !== 'child' || entry.id !== parentSessionId || entry.hasChildren) return entry
return { ...entry, hasChildren: true }
})
changed = true
this.catalogs.set(catalogParentId, { ...catalog, entries })
}
if (changed) this.notifier.markDirty()
}
private buildListSnapshot(): SessionListSnapshot {
const merged: TitledSessionSummary[] = this.summaries.map((summary) => {
// List rows read the generic 'title' projection key (host-computed unit

View File

@@ -287,7 +287,8 @@ describe('subagent catalogs', () => {
] as never[] }))
api.onSubagentList = () => Promise.resolve(ok({
entries: [{
kind: 'child', id: S2, mode: 'continuable', label: 'worker', activity: 'running',
kind: 'child', id: S2, mode: 'continuable', label: 'worker',
activity: 'running', hasChildren: false,
}] as never[],
parentAvailable: true,
}))
@@ -385,6 +386,46 @@ describe('subagent catalogs', () => {
vi.useRealTimers()
}
})
it('marks a loaded parent row expandable only for a direct subagent publication', async () => {
const api = new FakeApiClient()
const root = 'fk-root' as SessionId
api.onSubagentList = () => Promise.resolve(ok({
entries: [
{
kind: 'child', id: S1, mode: 'continuable', label: 'parent',
activity: 'inactive', hasChildren: false,
},
{
kind: 'child', id: S2, mode: 'continuable', label: 'ordinary parent',
activity: 'inactive', hasChildren: false,
},
] as never[],
parentAvailable: true,
}))
const manager = new SessionManager(api)
await manager.refreshSubagents(root)
manager.handleHostEnvelope({
rpcId: 'nested-subagent' as never,
payload: {
type: 'host/session-added', sessionId: 'fk-grandchild' as SessionId,
parentSessionId: S1, origin: 'subagent', blank: false,
},
})
manager.handleHostEnvelope({
rpcId: 'ordinary-fork' as never,
payload: {
type: 'host/session-added', sessionId: 'fk-fork' as SessionId,
parentSessionId: S2, blank: false,
},
})
expect(manager.getListSnapshot().subagentsByParent[root]?.entries).toMatchObject([
{ kind: 'child', id: S1, hasChildren: true },
{ kind: 'child', id: S2, hasChildren: false },
])
})
})
describe('remaining branches', () => {

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-subagent/README.md
README.md: 7399681265a9905f12fa0cbaf621528fb86562a3
README.zh.md: f9a507336058920835745c3882fc3b9fe83c5ab0
README.md: 561fc68c6c002a7542303f8f95e5bd325cfc673e
README.zh.md: 7b80919a5b270eaca754ffccb99db04fb614882d

View File

@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
Web subagent feature owner: contributes the lazily expandable catalog tree to `conversation.session.header.actions`, reason-specific read-only replacements to the conversation composer chain, and the existing `@` reference source to `ctx.slash`.
The header action reads `subagentsByParent` and session summaries through the standard `useSessions` hook. After a non-empty catalog arrives it shows the healthy direct-child count and a compact tree in service order. Continuable and one-shot rows display mode plus `running`/`inactive` activity, an optional log-backed title, and session-summary activity time; an unlabeled one-shot row falls back to its session id. Corrupt, unsupported, or unavailable rows remain readable but disabled. Expanding a row lazily opens that child's direct catalog and reports every visible branch to the runtime so membership frames cause a debounced refresh only where the tree is being consumed. Selecting any depth calls `SessionsService.openSubagent()` with the row's exact `{parentSessionId, childSessionId, mode}` address. Component-local state owns tree visibility, expanded branches, and keyboard focus. ArrowRight/ArrowLeft expand and collapse branches; ArrowUp/ArrowDown, Home, End, and Escape navigate or close the tree; closing returns focus to the trigger. Styling uses tokens only.
The header action reads `subagentsByParent` and session summaries through the standard `useSessions` hook. After a non-empty catalog arrives it shows the healthy direct-child count and a compact tree in service order. Continuable and one-shot rows display mode plus `running`/`inactive` activity, an optional log-backed title, and session-summary activity time; an unlabeled one-shot row falls back to its session id. Corrupt, unsupported, or unavailable rows remain readable but disabled. Each healthy row's `hasChildren` hint determines disclosure before interaction, so known leaves never show an arrow; expanding a branch still lazily opens that child's authoritative direct catalog and reports every visible branch to the runtime so membership frames cause a debounced refresh only where the tree is being consumed. Selecting any depth calls `SessionsService.openSubagent()` with the row's exact `{parentSessionId, childSessionId, mode}` address. Component-local state owns tree visibility, expanded branches, and keyboard focus. ArrowRight/ArrowLeft expand and collapse branches; ArrowUp/ArrowDown, Home, End, and Escape navigate or close the tree; closing returns focus to the trigger. Styling uses tokens only.
A one-shot child always elects a read-only composer that identifies the transcript as a completed execution record. A continuable child does so only when its exact parent is unavailable, with copy explaining the recovery path. A continuable child with a live parent keeps the ordinary input chrome, whose Session routes through `subagent.prompt`; running input remains Send because every follow-up joins the child's FIFO inbox, and addressed sessions never expose Stop. This package never receives host context or calls a model-facing tool. The catalog and composer behavior are specified by the [Web subagent conversations Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md).

View File

@@ -4,7 +4,7 @@
Web subagent 功能 owner:向 `conversation.session.header.actions` 贡献可懒加载展开的目录树,向会话编辑器链贡献按原因区分的只读替代呈现,并保留注册到 `ctx.slash` 的既有 `@` 引用 source。
页头操作通过标准 `useSessions` 钩子读取 `subagentsByParent` 与会话摘要。非空目录到达后,它会显示健康的直接 child 数量,并按服务顺序显示一棵紧凑树。可继续和 one-shot 行会显示 mode、`running`/`inactive` 活动状态、由日志支撑的可选 title 与会话摘要中的活动时间;没有 label 的 one-shot 行会回退到其会话 id。损坏、不受支持或不可用的行仍保持可读但禁用。展开某一行时,会懒加载该 child 的直接目录,并向运行时报告每个可见分支,使成员帧只在树正被消费的位置触发去抖动刷新。选择任意深度的条目都会使用该行的确切地址 `{parentSessionId, childSessionId, mode}` 调用 `SessionsService.openSubagent()`。组件局部状态负责树的可见性、已展开分支与键盘焦点。ArrowRight/ArrowLeft 展开和折叠分支;ArrowUp/ArrowDown、Home、End 与 Escape 用于导航或关闭树;关闭后焦点返回触发器。样式只使用 token。
页头操作通过标准 `useSessions` 钩子读取 `subagentsByParent` 与会话摘要。非空目录到达后,它会显示健康的直接 child 数量,并按服务顺序显示一棵紧凑树。可继续和 one-shot 行会显示 mode、`running`/`inactive` 活动状态、由日志支撑的可选 title 与会话摘要中的活动时间;没有 label 的 one-shot 行会回退到其会话 id。损坏、不受支持或不可用的行仍保持可读但禁用。每个健康行的 `hasChildren` 提示会在交互前决定是否显示展开控件,因此已知叶子节点从不显示箭头;展开分支时仍会懒加载该 child 的权威直接目录,并向运行时报告每个可见分支,使成员帧只在树正被消费的位置触发去抖动刷新。选择任意深度的条目都会使用该行的确切地址 `{parentSessionId, childSessionId, mode}` 调用 `SessionsService.openSubagent()`。组件局部状态负责树的可见性、已展开分支与键盘焦点。ArrowRight/ArrowLeft 展开和折叠分支;ArrowUp/ArrowDown、Home、End 与 Escape 用于导航或关闭树;关闭后焦点返回触发器。样式只使用 token。
one-shot child 始终选用只读编辑器,并将 transcript(文本记录)说明为已完成的执行记录。可继续 child 仅在其确切 parent 不可用时选用只读编辑器,并以文案说明恢复路径。确切 parent 存活时,可继续 child 保留普通输入 chrome,其 Session 会通过 `subagent.prompt` 路由;child 运行期间,输入操作仍为 Send,因为每条后续消息都会进入 child 的 FIFO inbox,且已寻址会话绝不公开 Stop。本包绝不接收宿主 context,也不调用面向模型的工具。目录与编辑器行为由 [Web subagent 对话 Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md)规定。

View File

@@ -117,7 +117,7 @@ function CatalogRows({
const childCatalog = catalogs[entry.id]
const isExpanded = expanded.has(entry.id)
const knownLeaf = childCatalog?.state === 'ready' && childCatalog.entries.length === 0
const knownLeaf = !entry.hasChildren
const summary = summaries[entry.id]
const label = entry.label ?? entry.id
const mode = entry.mode === 'one-shot' ? '一次性' : '可继续'

View File

@@ -22,11 +22,12 @@ function catalog(over: Partial<SubagentCatalogSnapshot> = {}): SubagentCatalogSn
return {
entries: [
{
kind: 'child', id: CHILD, mode: 'continuable', label: 'worker', activity: 'running',
kind: 'child', id: CHILD, mode: 'continuable', label: 'worker',
activity: 'running', hasChildren: true,
},
{
kind: 'child', id: 'child-2' as SessionId, mode: 'one-shot',
label: 'reviewer', activity: 'inactive',
label: 'reviewer', activity: 'inactive', hasChildren: false,
},
{ kind: 'diagnostic', id: 'bad' as SessionId, reason: 'corrupt' },
],
@@ -95,6 +96,8 @@ describe('SubagentCatalogAction', () => {
expect(screen.getByText('一次性 · 当前未运行')).toBeTruthy()
const diagnostic = screen.getByRole('treeitem', { name: /会话记录损坏/ })
expect(diagnostic.getAttribute('aria-disabled')).toBe('true')
expect(screen.getByRole('button', { name: '展开 worker 的下级子代理' })).toBeTruthy()
expect(screen.queryByRole('button', { name: '展开 reviewer 的下级子代理' })).toBeNull()
fireEvent.click(screen.getByRole('treeitem', { name: /worker/ }))
expect(input.openChild).toHaveBeenCalledWith({
@@ -137,8 +140,14 @@ describe('SubagentCatalogAction', () => {
entries: [
{ kind: 'diagnostic', id: unsupported, reason: 'unsupported' },
{ kind: 'diagnostic', id: unavailable, reason: 'unavailable' },
{ kind: 'child', id: CHILD, mode: 'continuable', label: 'worker', activity: 'running' },
{ kind: 'child', id: unlabeled, mode: 'one-shot', activity: 'inactive' },
{
kind: 'child', id: CHILD, mode: 'continuable', label: 'worker',
activity: 'running', hasChildren: false,
},
{
kind: 'child', id: unlabeled, mode: 'one-shot',
activity: 'inactive', hasChildren: false,
},
],
}))
render(<SubagentCatalogAction {...input} />)
@@ -180,6 +189,7 @@ describe('SubagentCatalogAction', () => {
mode: 'continuable' as const,
label: id,
activity: 'inactive' as const,
hasChildren: false,
}))
const summaries = Object.fromEntries(rows.map(([id, updatedAt]) => [
id,
@@ -202,7 +212,7 @@ describe('SubagentCatalogAction', () => {
entries: [
{
kind: 'child', id: GRANDCHILD, mode: 'continuable',
label: 'indexer', activity: 'inactive',
label: 'indexer', activity: 'inactive', hasChildren: false,
},
],
})
@@ -232,7 +242,7 @@ describe('SubagentCatalogAction', () => {
[CHILD]: catalog({
entries: [{
kind: 'child', id: GRANDCHILD, mode: 'continuable',
label: 'indexer', activity: 'running',
label: 'indexer', activity: 'running', hasChildren: false,
}],
}),
})
@@ -254,7 +264,7 @@ describe('SubagentCatalogAction', () => {
entries: [
{
kind: 'child', id: GRANDCHILD, mode: 'continuable',
label: 'indexer', activity: 'running',
label: 'indexer', activity: 'running', hasChildren: true,
},
{ kind: 'diagnostic', id: 'nested-bad' as SessionId, reason: 'corrupt' },
],
@@ -328,7 +338,7 @@ describe('SubagentCatalogAction', () => {
[CHILD]: catalog({
entries: [{
kind: 'child', id: GRANDCHILD, mode: 'continuable',
label: 'indexer', activity: 'inactive',
label: 'indexer', activity: 'inactive', hasChildren: false,
}],
}),
})

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
README.md: 45e79aea55665882faeee14201a2c2dda6fe9199
README.zh.md: cb0cbe4fe53a3d328adbb57671ce6aa4a439470c
README.md: 5755850e77dbd247b8808d907d813aecf2a650aa
README.zh.md: 02048fdbf2eaca3458b34d508731dfc9df1e90d3

View File

@@ -38,7 +38,7 @@ The `command.*` and `skill.*` domains expose the host command registry and skill
The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preference `permission` and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, and the section's `revision`. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `permission` or `ui-onboarding` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads included (`settings.describe`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin.
The `subagent.*` domain addresses continuable direct children by `{parentSessionId, childSessionId}`. `subagent.list` projects the durable continuable catalog plus an exact-live-parent hint from `ctx.subagents.listChildren`, excluding one-shot children; `subagent.history` verifies a healthy direct-child entry and reads its persisted log through `ctx.sessionQuery` without resuming an Agent. `subagent.prompt` requires that exact live parent, delivers human content through `ctx.subagents.followup()` with the request `rpcId` as attribution, and returns the accepted inbox `messageId`. Typed errors preserve catalog diagnostics, parent availability, resumability, authorization, and not-delivered distinctions without exposing the model-hidden continuation descriptor. See the [Web subagent conversations Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md).
The `subagent.*` domain addresses direct children by `{parentSessionId, childSessionId}`. `subagent.list` projects the complete durable one-shot and continuable catalog from `ctx.subagents.listChildren`, including each healthy row's origin-classified `hasChildren` hint plus an exact-live-parent hint; `subagent.history` verifies a healthy direct-child entry and reads its persisted log through `ctx.sessionQuery` without resuming an Agent. `subagent.prompt` accepts only continuable addresses, requires that exact live parent, delivers human content through `ctx.subagents.followup()` with the request `rpcId` as attribution, and returns the accepted inbox `messageId`. Typed errors preserve catalog diagnostics, parent availability, resumability, authorization, and not-delivered distinctions without exposing the model-hidden continuation descriptor. See the [Web subagent conversations Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md).
## Carrier layer (`/client` + root)

View File

@@ -38,7 +38,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
`settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `permission` 与产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表,以及该分节的 `revision`。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;过期的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`permission` 或 `ui-onboarding` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取:`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。
`subagent.*` 领域通过 `{parentSessionId, childSessionId}` 寻址可继续的直接 child。`subagent.list` 从 `ctx.subagents.listChildren` 投影持久化的可继续目录及确切 parent 是否存活的提示,并排除 one-shot child;`subagent.history` 先验证健康的直接 child 条目,再通过 `ctx.sessionQuery` 读取其持久化日志,且不恢复 Agent。`subagent.prompt` 要求该确切 parent 已存活,通过 `ctx.subagents.followup()` 投递用户内容,以请求 `rpcId` 作为来源信息,并返回已接纳消息的 inbox `messageId`。类型化错误保留目录诊断、parent 可用性、可恢复性、授权和未投递等区别,同时不暴露对模型隐藏的继续执行描述符。见 [Web subagent 对话 Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md)。
`subagent.*` 领域通过 `{parentSessionId, childSessionId}` 寻址直接 child。`subagent.list` 从 `ctx.subagents.listChildren` 投影包含 one-shot 与可继续条目的完整持久化目录、每个健康行基于 origin 分类的 `hasChildren` 提示,以及确切 parent 是否存活的提示;`subagent.history` 先验证健康的直接 child 条目,再通过 `ctx.sessionQuery` 读取其持久化日志,且不恢复 Agent。`subagent.prompt` 只接受可继续地址,要求该确切 parent 已存活,通过 `ctx.subagents.followup()` 投递用户内容,以请求 `rpcId` 作为来源信息,并返回已接纳消息的 inbox `messageId`。类型化错误保留目录诊断、parent 可用性、可恢复性、授权和未投递等区别,同时不暴露对模型隐藏的继续执行描述符。见 [Web subagent 对话 Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md)。
## 载体层(`/client` + 根路径)

View File

@@ -16,6 +16,7 @@ export const subagentListEntrySchema = z.union([
id: sessionIdSchema,
mode: z.literal('one-shot'),
activity: z.union([z.literal('running'), z.literal('inactive')]),
hasChildren: z.boolean(),
label: z.string().optional(),
}),
z.object({
@@ -23,6 +24,7 @@ export const subagentListEntrySchema = z.union([
id: sessionIdSchema,
mode: z.literal('continuable'),
activity: z.union([z.literal('running'), z.literal('inactive')]),
hasChildren: z.boolean(),
label: z.string(),
}),
z.object({

View File

@@ -16,6 +16,8 @@ export type SubagentListEntry =
kind: 'child'
id: SessionId
activity: 'running' | 'inactive'
/** Whether a direct descendant has durable `origin: 'subagent'`. */
hasChildren: boolean
} & (
| {
mode: 'one-shot'

View File

@@ -28,7 +28,10 @@ function bench(options: {
options.parentLive !== false && id === PARENT ? parent : undefined)
const listChildren = vi.fn(() => options.listError === undefined
? Promise.resolve(options.entries ?? [
{ kind: 'child', id: CHILD, mode: 'continuable', label: 'worker', activity: 'inactive' },
{
kind: 'child', id: CHILD, mode: 'continuable', label: 'worker',
activity: 'inactive', hasChildren: false,
},
])
: Promise.reject(options.listError))
const followup = vi.fn((
@@ -63,8 +66,14 @@ function bench(options: {
describe('subagent gateway', () => {
it('lists the complete catalog and reports exact live-parent availability', async () => {
const { api, listChildren } = bench({ parentLive: false, entries: [
{ kind: 'child', id: CHILD, mode: 'continuable', label: 'worker', activity: 'inactive' },
{ kind: 'child', id: sid('one-shot'), mode: 'one-shot', activity: 'inactive' },
{
kind: 'child', id: CHILD, mode: 'continuable', label: 'worker',
activity: 'inactive', hasChildren: true,
},
{
kind: 'child', id: sid('one-shot'), mode: 'one-shot',
activity: 'inactive', hasChildren: false,
},
{ kind: 'diagnostic', id: sid('bad'), reason: 'corrupt' },
] })
const response = await api.subagents.list(request({ parentSessionId: PARENT }))
@@ -98,7 +107,8 @@ describe('subagent gateway', () => {
it('reads one-shot history and rejects an address with the wrong mode', async () => {
const oneShot = {
kind: 'child', id: CHILD, mode: 'one-shot', label: 'batch', activity: 'inactive',
kind: 'child', id: CHILD, mode: 'one-shot', label: 'batch',
activity: 'inactive', hasChildren: false,
}
const { api, readSession } = bench({ entries: [oneShot] })
expect((await api.subagents.history(request({

View File

@@ -292,13 +292,19 @@ describe('sessions domain schemas', () => {
describe('subagent domain schemas', () => {
it('validates the direct catalog and addressed history pair', () => {
const child = {
kind: 'child', id: 'c', mode: 'continuable', label: 'worker', activity: 'running',
kind: 'child', id: 'c', mode: 'continuable', label: 'worker',
activity: 'running', hasChildren: true,
}
const oneShot = {
kind: 'child', id: 'o', mode: 'one-shot', activity: 'inactive', hasChildren: false,
}
const oneShot = { kind: 'child', id: 'o', mode: 'one-shot', activity: 'inactive' }
const diagnostic = { kind: 'diagnostic', id: 'bad', reason: 'unsupported' }
expect(subagentListEntrySchema.parse(child)).toEqual(child)
expect(subagentListEntrySchema.parse(oneShot)).toEqual(oneShot)
expect(subagentListEntrySchema.parse(diagnostic)).toEqual(diagnostic)
expect(() => subagentListEntrySchema.parse({
kind: 'child', id: 'missing', mode: 'one-shot', activity: 'inactive',
})).toThrow()
expect(subagentListRequestSchema.parse({ parentSessionId: 'p' })).toEqual({ parentSessionId: 'p' })
expect(subagentListValueSchema.parse({
entries: [child, oneShot, diagnostic], parentAvailable: true,

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/subagent/subagent/README.md
README.md: 4776f45a2f4ba881c2bb8414876100dc84adc86b
README.zh.md: a40a12a4b386c91409711b8459a6c3b1f3f37cd0
README.md: 9aea27a0f150d90a41d9a7cb4cd422a75e6107fe
README.zh.md: 3f0b534deae53b8d5aff2765974050f26b931953

View File

@@ -35,7 +35,7 @@ Multiple providers may coexist under different names. This lets a deployment exp
| `reportFrom(child, content, { delivery, signal })` | Deliver one selected message from the exact live continuable child to its exact live direct parent and return the accepted stable `MessageId`. Quiet delivery injects context; waking delivery submits one later parent turn. |
| `registerContinuableSetup(contribution)` | Compose an optional deployment capability into each continuable child's unpublished scope, with immediate revocation from resident children. |
| `drainContinuableDescendants(parents)` | Close admission below exact live host-owned parent Agents, stop only their visible continuable descendants, await materializations admitted below those roots through publication or rollback, then release the selected forests child-first. The cutoff lasts until each exact parent leaves the registry; unrelated parent forests and manager-wide admission remain live. |
| `listChildren(parentSessionId, signal?)` | List direct session-backed subagents with their `one-shot`/`continuable` mode and `running`/`inactive` activity, plus per-child diagnostics, in stable trace order without loading or resuming them. Requires session query; it does not require `ctx.agents` or the continuation manager. |
| `listChildren(parentSessionId, signal?)` | List direct session-backed subagents with their `one-shot`/`continuable` mode, `running`/`inactive` activity, origin-classified one-level `hasChildren` hint, and per-child diagnostics, in stable trace order without loading or resuming them. Requires session query; it does not require `ctx.agents` or the continuation manager. |
`SubagentStartRequest.label` is an optional short durable display label for a session-backed one-shot child. Model-facing delegation supplies its existing `description`; lower-level callers need not invent presentation metadata. Continuable starts always carry their own required label. `signal` is required and is the canonical cancellation channel for a one-shot `start`. An abort before publication makes `start()` reject after rollback; an abort after publication cancels the returned run's remaining turn work without hiding its id. The request may also select a model, require structured output, cap delegation depth, restrict child tools, or set a child persona. For a continuable start or follow-up, the caller signal owns lookup, materialization, and admission only until inbox acceptance; afterward the manager owns the Activation independently, so later caller cancellation neither cancels the accepted turn nor disposes the child.
@@ -96,7 +96,7 @@ Continuable children do not create `SubagentRun` or Tasks. The continuation mana
## Collection model
The model-facing tool collects synchronously by default: it awaits the child result and disposes the run before returning. One-shot background delegation registers a plain Task in the tool, whose generic status, collection, and cancellation tools own later interaction, and persists its model-supplied `description` as the optional display label. Continuable background delegation calls `ctx.subagents.startContinuable()` and returns only the durable child id; the child owns its own turns from inbox acceptance, so there is no Task, no result promise, and no public subagent cancellation — a caller sends later work with the `send_message` follow-up tool, and the durable child Session remains the source of the child's detailed output. The continuation manager exists only while `ctx.agents` is available, and session persistence is resolved per continuation operation. Independently, `listChildren()` resolves session query and dynamically imports its optional runtime only when called, then interprets a read-only live-preferred scan of all descriptor-bearing direct children without consulting the continuation manager, Agent registrations, Activations, or providers. Service consumers such as a UI can retain both modes and choose a fallback for an unlabeled one-shot child; the model-facing `list_agents` tool projects only `continuable` entries and maps service activity to its existing `running`/`complete` vocabulary. The scan forwards the caller's signal to cancellable trace and exact-read operations, checks cancellation around the remaining event-list read, and reports every observed abort as `SubagentError` code `CANCELLED`. See the [background subagent tasks Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable background subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), the [durable catalog Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md), the [merged-service Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md), the [capability-seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), and `src/types.ts` for the complete contracts.
The model-facing tool collects synchronously by default: it awaits the child result and disposes the run before returning. One-shot background delegation registers a plain Task in the tool, whose generic status, collection, and cancellation tools own later interaction, and persists its model-supplied `description` as the optional display label. Continuable background delegation calls `ctx.subagents.startContinuable()` and returns only the durable child id; the child owns its own turns from inbox acceptance, so there is no Task, no result promise, and no public subagent cancellation — a caller sends later work with the `send_message` follow-up tool, and the durable child Session remains the source of the child's detailed output. The continuation manager exists only while `ctx.agents` is available, and session persistence is resolved per continuation operation. Independently, `listChildren()` resolves session query and dynamically imports its optional runtime only when called, then interprets a read-only live-preferred scan of all descriptor-bearing direct children without consulting the continuation manager, Agent registrations, Activations, or providers. Each healthy row derives its read-time `hasChildren` hint from traced direct-descendant headers carrying durable `origin: 'subagent'`; it does not read descendant event logs, and the descriptor-backed child catalog remains authoritative when expanded. Service consumers such as a UI can retain both modes and choose a fallback for an unlabeled one-shot child; the model-facing `list_agents` tool projects only `continuable` entries and maps service activity to its existing `running`/`complete` vocabulary. The scan forwards the caller's signal to cancellable trace and exact-read operations, checks cancellation around the remaining event-list read, and reports every observed abort as `SubagentError` code `CANCELLED`. See the [background subagent tasks Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable background subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), the [durable catalog Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md), the [merged-service Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md), the [capability-seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), and `src/types.ts` for the complete contracts.
Continuable Activations await a best-effort final session flush without treating listener participation as durability confirmation. One-shot runs retain best-effort session checkpointing, so a completed one-shot child is discoverable after disposal only when its session actually reached persistence; the service does not invent a catalog entry from Task history when that checkpoint is absent.

View File

@@ -35,7 +35,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
| `reportFrom(child, content, { delivery, signal })` | 从确切在线可继续 child 向其确切在线直接 parent 投递一条选中消息,并返回已接受的稳定 `MessageId`。静默投递会注入上下文;唤醒投递会提交一个后续 parent 轮次。 |
| `registerContinuableSetup(contribution)` | 把一项可选部署能力组合到每个可继续 child 尚未发布的作用域中,并支持从驻留 child 立即撤销。 |
| `drainContinuableDescendants(parents)` | 在由 host 确切拥有的在线 parent Agent 之下关闭准入,只停止其可见的可继续后代,等待在这些根之下已获准的物化过程完成发布或回滚,再按 child-first 顺序释放所选森林。该截止状态会持续到每个确切 parent 离开注册表;无关的 parent 森林和管理器全局准入保持在线。 |
| `listChildren(parentSessionId, signal?)` | 按稳定的追踪顺序列出由会话支撑的直接 subagent,包括其 `one-shot`/`continuable` 模式和 `running`/`inactive` 活动状态,以及逐 child diagnostic,且不会加载或恢复它们。要求会话查询;不要求 `ctx.agents` 或继续执行管理器。 |
| `listChildren(parentSessionId, signal?)` | 按稳定的追踪顺序列出由会话支撑的直接 subagent,包括其 `one-shot`/`continuable` 模式、`running`/`inactive` 活动状态、基于 origin 分类的一层 `hasChildren` 提示与逐 child diagnostic,且不会加载或恢复它们。要求会话查询;不要求 `ctx.agents` 或继续执行管理器。 |
`SubagentStartRequest.label` 是由会话支撑的一次性 child 所使用的可选简短持久化显示标签。面向模型的委派会提供其已有的 `description`;底层调用方无需凭空构造展示元数据。可继续启动始终携带自身的必填标签。`signal` 是必填项,也是一次性 `start` 的规范取消通道。发布前中止会使 `start()` 在回滚后拒绝;发布后中止会取消已返回 run 的剩余轮次工作,但不会隐藏其 id。请求还可以选择模型、要求结构化输出、限制委派深度、约束子 agent 工具或设置子 agent persona。对于可继续启动或后续操作,调用方信号只在 inbox 接受之前掌管查找、物化和准入;此后由管理器独立拥有 Activation,因此调用方后续取消既不会取消已接受的轮次,也不会 dispose 子 agent。
@@ -96,7 +96,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
## 收集模型
面向模型的工具默认同步收集:先等待子 agent 结果,再 dispose 运行,然后才返回。一次性后台委派会在工具中注册普通 Task,其通用状态、收集和取消工具负责后续交互,并将模型提供的 `description` 持久化为可选显示标签。可继续后台委派会调用 `ctx.subagents.startContinuable()`,只返回持久化子 agent id;子 agent 自 inbox 接受起就拥有自己的轮次,因此没有 Task、没有结果 promise,也没有公开的子 agent 取消操作——调用方通过 `send_message` 后续操作工具发送后续工作,而持久化子 agent Session 仍是子 agent 详细输出的来源。只有 `ctx.agents` 可用时,继续执行管理器才会存在,而会话持久化按每项继续执行操作解析。与此独立,`listChildren()` 只在被调用时解析会话查询并动态导入其可选运行时,然后解释对所有带描述符的直接 child 所作的只读、实时优先扫描,且不查询继续执行管理器、Agent 注册信息、Activation 或提供方。UI 等服务消费方可以保留两种模式,并为无标签的一次性 child 选择回退展示;面向模型的 `list_agents` 工具只投影 `continuable` 条目,并将服务活动状态映射到现有的 `running`/`complete` 词汇。扫描会把调用方的取消信号转发到可取消的追踪与精确读取操作,在其余事件列表读取的前后检查取消,并将每次检测到的中止报告为 `SubagentError` 错误码 `CANCELLED`。完整契约见[后台 subagent 任务 Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md)、[持久化目录 Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)、[服务合并 Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)、[能力 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)和 `src/types.ts`。
面向模型的工具默认同步收集:先等待子 agent 结果,再 dispose 运行,然后才返回。一次性后台委派会在工具中注册普通 Task,其通用状态、收集和取消工具负责后续交互,并将模型提供的 `description` 持久化为可选显示标签。可继续后台委派会调用 `ctx.subagents.startContinuable()`,只返回持久化子 agent id;子 agent 自 inbox 接受起就拥有自己的轮次,因此没有 Task、没有结果 promise,也没有公开的子 agent 取消操作——调用方通过 `send_message` 后续操作工具发送后续工作,而持久化子 agent Session 仍是子 agent 详细输出的来源。只有 `ctx.agents` 可用时,继续执行管理器才会存在,而会话持久化按每项继续执行操作解析。与此独立,`listChildren()` 只在被调用时解析会话查询并动态导入其可选运行时,然后解释对所有带描述符的直接 child 所作的只读、实时优先扫描,且不查询继续执行管理器、Agent 注册信息、Activation 或提供方。每个健康条目都会根据追踪结果中携带持久化 `origin: 'subagent'` 的直接后代 header 派生读取时的 `hasChildren` 提示;它不会读取后代事件日志,展开后仍以描述符支撑的 child 目录为权威依据。UI 等服务消费方可以保留两种模式,并为无标签的一次性 child 选择回退展示;面向模型的 `list_agents` 工具只投影 `continuable` 条目,并将服务活动状态映射到现有的 `running`/`complete` 词汇。扫描会把调用方的取消信号转发到可取消的追踪与精确读取操作,在其余事件列表读取的前后检查取消,并将每次检测到的中止报告为 `SubagentError` 错误码 `CANCELLED`。完整契约见[后台 subagent 任务 Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md)、[持久化目录 Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)、[服务合并 Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)、[能力 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)和 `src/types.ts`。
可继续 Activation 会等待 best-effort 的最终会话 flush,但不会把 listener 参与视为持久性确认。一次性运行保留尽力执行的会话检查点,因此已完成的一次性 child 只有在其会话确实进入持久化存储时,才可在 dispose 后继续被发现;如果该检查点缺失,服务不会根据 Task 历史虚构目录条目。

View File

@@ -22,9 +22,10 @@ type SessionQueryRuntime = Pick<
/**
* One entry of a {@link listChildren} result in trace candidate order. A valid
* descriptor produces a `child`, a per-child inspection failure produces a
* `diagnostic`, and a descriptor-less ordinary child is omitted. Diagnostics
* are transient query results, never session events or catalog state, and
* never expose model-hidden descriptor content.
* `diagnostic`, and a descriptor-less ordinary child is omitted. Healthy rows
* include a one-level, origin-classified descendant hint. Diagnostics are
* transient query results, never session events or catalog state, and never
* expose model-hidden descriptor content.
*/
export type SubagentListEntry =
| {
@@ -38,6 +39,8 @@ export type SubagentListEntry =
* delivery as an ownership conflict.
*/
readonly activity: 'running' | 'inactive'
/** Whether a direct descendant has durable `origin: 'subagent'`. */
readonly hasChildren: boolean
} & (
| {
/** A terminal one-shot child. */
@@ -100,7 +103,12 @@ export async function listChildren(
)
const entries: SubagentListEntry[] = []
for (const node of trace.descendants) {
const entry = await inspectChild(query, queryRuntime, parentSessionId, node.session, signal)
const hasChildren = node.descendants.some(
descendant => descendant.session.header.origin === 'subagent',
)
const entry = await inspectChild(
query, queryRuntime, parentSessionId, node.session, hasChildren, signal,
)
// Cancellation can race the inspection's last checkpoint or diagnostic
// mapping; do not return success or begin another candidate afterward.
assertListingNotCancelled(signal)
@@ -115,6 +123,7 @@ async function inspectChild(
queryRuntime: SessionQueryRuntime,
parentSessionId: SessionId,
candidate: SessionRecord,
hasChildren: boolean,
signal?: AbortSignal,
): Promise<SubagentListEntry | undefined> {
const childId = candidate.header.id
@@ -158,9 +167,13 @@ async function inspectChild(
mode: descriptor.mode,
...descriptor.label !== undefined ? { label: descriptor.label } : {},
activity,
hasChildren,
}
}
return { kind: 'child', id: childId, mode: descriptor.mode, label: descriptor.label, activity }
return {
kind: 'child', id: childId, mode: descriptor.mode, label: descriptor.label,
activity, hasChildren,
}
} catch (error: unknown) {
const reason = perChildDiagnosticReason(error, queryRuntime.SessionQueryError)
if (reason === undefined) throw error

View File

@@ -121,7 +121,10 @@ describe('SubagentService.listChildren', () => {
child.append('subagent/descriptor', descriptorPayload('query-only child'))
await expect(ctx.subagents.listChildren(parentId)).resolves.toEqual([
{ kind: 'child', id: childId, label: 'query-only child', mode: 'continuable', activity: 'running' },
{
kind: 'child', id: childId, label: 'query-only child', mode: 'continuable',
activity: 'running', hasChildren: false,
},
])
})
@@ -137,7 +140,10 @@ describe('SubagentService.listChildren', () => {
const childId = await startChild(ctx, parent, 'summarize the doc')
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries).toEqual([
{ kind: 'child', id: childId, label: 'summarize the doc', mode: 'continuable', activity: 'inactive' },
{
kind: 'child', id: childId, label: 'summarize the doc', mode: 'continuable',
activity: 'inactive', hasChildren: false,
},
])
})
@@ -160,6 +166,7 @@ describe('SubagentService.listChildren', () => {
id: oneShotId,
mode: 'one-shot',
activity: 'inactive',
hasChildren: false,
})
expect(entries).toContainEqual({
kind: 'child',
@@ -167,6 +174,7 @@ describe('SubagentService.listChildren', () => {
label: 'continuable child',
mode: 'continuable',
activity: 'inactive',
hasChildren: false,
})
})
@@ -188,7 +196,10 @@ describe('SubagentService.listChildren', () => {
}, childEvents(descriptorPayload('persisted parent case')))
const entries = await ctx.subagents.listChildren(coldParent)
expect(entries).toEqual([
{ kind: 'child', id: childId, label: 'persisted parent case', mode: 'continuable', activity: 'inactive' },
{
kind: 'child', id: childId, label: 'persisted parent case', mode: 'continuable',
activity: 'inactive', hasChildren: false,
},
])
})
@@ -227,10 +238,12 @@ describe('SubagentService.listChildren', () => {
live.append('subagent/descriptor', descriptorPayload('live child'))
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries).toContainEqual({
kind: 'child', id: settled, label: 'settled child', mode: 'continuable', activity: 'inactive',
kind: 'child', id: settled, label: 'settled child', mode: 'continuable',
activity: 'inactive', hasChildren: false,
})
expect(entries).toContainEqual({
kind: 'child', id: liveId, label: 'live child', mode: 'continuable', activity: 'running',
kind: 'child', id: liveId, label: 'live child', mode: 'continuable',
activity: 'running', hasChildren: false,
})
})
@@ -251,7 +264,8 @@ describe('SubagentService.listChildren', () => {
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries).toContainEqual({ kind: 'diagnostic', id: corrupt, reason: 'corrupt' })
expect(entries).toContainEqual({
kind: 'child', id: healthy, label: 'healthy sibling', mode: 'continuable', activity: 'inactive',
kind: 'child', id: healthy, label: 'healthy sibling', mode: 'continuable',
activity: 'inactive', hasChildren: false,
})
})
@@ -318,7 +332,10 @@ describe('SubagentService.listChildren', () => {
}))
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries).toEqual([
{ kind: 'child', id: foreign, label: 'orphan provider', mode: 'continuable', activity: 'inactive' },
{
kind: 'child', id: foreign, label: 'orphan provider', mode: 'continuable',
activity: 'inactive', hasChildren: false,
},
])
})
@@ -433,21 +450,73 @@ describe('SubagentService.listChildren', () => {
}, compactedEvents)
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries).toEqual([
{ kind: 'child', id: plain, label: 'twin child', mode: 'continuable', activity: 'inactive' },
{ kind: 'child', id: compacted, label: 'twin child', mode: 'continuable', activity: 'inactive' },
{
kind: 'child', id: plain, label: 'twin child', mode: 'continuable',
activity: 'inactive', hasChildren: false,
},
{
kind: 'child', id: compacted, label: 'twin child', mode: 'continuable',
activity: 'inactive', hasChildren: false,
},
])
})
it('excludes grandchildren: only direct descendants are candidates', async () => {
it('reports an origin-classified grandchild without reading its events', async () => {
const { ctx, parent } = await setup([textResponse('done')])
const childId = await startChild(ctx, parent, 'direct child')
await authorChild(ctx, '00000000-0000-4000-8000-0000000000cc', {
const grandchildId = await authorChild(ctx, '00000000-0000-4000-8000-0000000000cc', {
parentSession: childId,
origin: 'subagent',
}, childEvents(descriptorPayload('grandchild')))
const query = ctx.get('sessionQuery')!
const originalListEvents = query.listEvents.bind(query)
const inspected: SessionId[] = []
query.listEvents = (sessionId) => {
inspected.push(sessionId)
return originalListEvents(sessionId)
}
const entries = await ctx.subagents.listChildren(parent.id)
expect(entries).toEqual([
{ kind: 'child', id: childId, label: 'direct child', mode: 'continuable', activity: 'inactive' },
{
kind: 'child', id: childId, label: 'direct child', mode: 'continuable',
activity: 'inactive', hasChildren: true,
},
])
expect(inspected).toContain(childId)
expect(inspected).not.toContain(grandchildId)
})
it('does not count an ordinary grandchild without subagent origin', async () => {
const { ctx, parent } = await setup([textResponse('done')])
const childId = await startChild(ctx, parent, 'direct child')
await authorChild(ctx, '00000000-0000-4000-8000-0000000000f1', {
parentSession: childId,
}, [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } },
] as SessionEvent[])
await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([{
kind: 'child', id: childId, label: 'direct child', mode: 'continuable',
activity: 'inactive', hasChildren: false,
}])
})
it('counts an origin-classified diagnostic grandchild', async () => {
const { ctx, parent } = await setup([textResponse('done')])
const childId = await startChild(ctx, parent, 'direct child')
const diagnosticId = await authorChild(ctx, '00000000-0000-4000-8000-0000000000f2', {
parentSession: childId,
origin: 'subagent',
}, childEvents({ version: SUBAGENT_DESCRIPTOR_VERSION, mode: 'continuable', provider: 7 }))
await expect(ctx.subagents.listChildren(childId)).resolves.toEqual([
{ kind: 'diagnostic', id: diagnosticId, reason: 'corrupt' },
])
await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([{
kind: 'child', id: childId, label: 'direct child', mode: 'continuable',
activity: 'inactive', hasChildren: true,
}])
})
it('stops the scan at the between-candidates checkpoint when the signal aborts', async () => {

View File

@@ -102,6 +102,7 @@ describe('dsh-tool-subagent-control/list-agents', () => {
label: 'finished once',
mode: 'one-shot',
activity: 'inactive',
hasChildren: false,
},
{
kind: 'child',
@@ -109,6 +110,7 @@ describe('dsh-tool-subagent-control/list-agents', () => {
label: 'real child',
mode: 'continuable',
activity: 'inactive',
hasChildren: false,
},
{
kind: 'child',
@@ -116,6 +118,7 @@ describe('dsh-tool-subagent-control/list-agents', () => {
label: 'still working',
mode: 'continuable',
activity: 'running',
hasChildren: true,
},
{ kind: 'diagnostic', id: SessionId('broken-child'), reason: 'corrupt' },
]