diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml index 6c36c771ed..5a8c9b997f 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md -2026-07-27-web-subagent-conversations.md: d75bc1471815cb0da78cf078585022ca668ebb81 -2026-07-27-web-subagent-conversations.zh.md: 5cd2999cb6e8ad68bfeccd0f685dc6e4471b6091 +2026-07-27-web-subagent-conversations.md: 947f5a33fbb6db19a1e88bacecb557ae6d7b80f0 +2026-07-27-web-subagent-conversations.zh.md: 407e8d626846c71dec17ff0901b7169bd886c314 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md index d75bc14718..947f5a33fb 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md @@ -32,14 +32,14 @@ The Figma [subagent list](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Ha | --- | --- | | The session header opens a compact child list. | The action shows every direct catalog entry in service order, including disabled diagnostics. | | Selecting a row reuses the conversation UI. | Addressed history never activates the child; only a continuable row with a live parent retains the ordinary composer. | -| Nested agents expand progressively. | Each disclosure loads only that row's direct catalog and retains its own parent address. | +| Nested agents expand progressively. | Each row carries a one-level `hasChildren` snapshot; disclosure still loads only that row's direct catalog and retains its own parent address. | | Rows show labels, state, and relative time without duplicating sidebar rows. | Mode and `running`/`inactive` activity are textual as well as visual; optional title and time come from summaries. `SessionHeader.origin` removes duplicate navigation rows but grants no capability. | ## Product contract -The header action count includes healthy `kind: 'child'` entries and excludes diagnostics. It is absent only after a complete empty response. The tree presents continuable and one-shot rows, falling back to the session id when an optional one-shot label is absent. Corrupt, unsupported, and unavailable candidates remain visible as disabled diagnostic rows. +The header action count includes healthy `kind: 'child'` entries and excludes diagnostics. It is absent only after a complete empty response. Every healthy row carries a read-time `hasChildren` hint derived only from direct lineage headers with durable `origin: 'subagent'`; normal healthy and diagnostic subagent candidates carry that marker, while ordinary forks do not. This lookahead reads no descendant event log, and the descriptor-backed catalog loaded after disclosure remains authoritative. The UI omits disclosure for a known leaf before interaction; the hint does not promise that the child will remain a leaf. The tree presents continuable and one-shot rows, falling back to the session id when an optional one-shot label is absent. Corrupt, unsupported, and unavailable candidates remain visible as disabled diagnostic rows. -`running` means the logical child record is live in the session corpus; `inactive` means it exists only in persistence. The UI does not translate either value into success, failure, cancellation, completeness, or resumability. `host/session-status` updates known activity in place. Membership, labels, mode, and diagnostics still require a debounced `subagent.list` refresh while the affected branch is open. A prompt response remains delivery-time authority. +`running` means the logical child record is live in the session corpus; `inactive` means it exists only in persistence. The UI does not translate either value into success, failure, cancellation, completeness, or resumability. `host/session-status` updates known activity in place. A `host/session-added` frame for a direct subagent immediately flips any loaded parent row to `hasChildren: true`; membership, labels, mode, diagnostics, and the authoritative snapshot still require a debounced `subagent.list` refresh while the affected branch is open. A prompt response remains delivery-time authority. Selecting a row records its exact address before opening the resident client `Session`. History pagination, event folding, tool render intents, titles, breadcrumbs, and live mux reconciliation reuse the ordinary conversation machinery. The catalog is an ARIA tree with lazy ArrowRight/ArrowLeft disclosure, linear ArrowUp/ArrowDown navigation, Home/End, Escape, and focus restoration. @@ -51,7 +51,7 @@ Agent-bound auxiliary controls are unavailable in addressed child views. In part `@deepseek-ai/dsh-host-apiproxy` owns a browser-safe `subagents` domain: -- `subagent.list` takes `parentSessionId`, calls `ctx.subagents.listChildren(parentSessionId, signal)`, returns the complete ordered entries, and includes whether the exact parent currently resolves from `ctx.agents`. +- `subagent.list` takes `parentSessionId`, calls `ctx.subagents.listChildren(parentSessionId, signal)`, returns the complete ordered entries with each healthy row's boolean `hasChildren` snapshot, and includes whether the exact parent currently resolves from `ctx.agents`. - `subagent.history` takes the full mode-bearing address plus ordinary page arguments. It verifies the child and mode against the direct catalog, reads through `ctx.sessionQuery.readSession()`, rechecks direct lineage, and returns the ordinary raw-event, render-intent, pagination, and host-computed session-projection baseline without publishing an Agent. - `subagent.prompt` accepts only a `mode: 'continuable'` address and `ContentBlock[]`. It requires the exact live parent, revalidates the catalog address, calls `ctx.subagents.followup(parent, childId, content, { source, signal })`, and returns the accepted `MessageId`. @@ -69,7 +69,7 @@ The React-free runtime owns catalogs, single-flight refreshes, retained addresse Catalogs ride the standard `useSessions` snapshot. Component-local state owns menu visibility, expanded branches, and focus. `ui-conversation` declares the generic header-action list slot and dispatches the current conversation snapshot through its composer chain; it contains no subagent-specific takeover flag. `@deepseek-ai/dsh-client-ui-subagent` registers the catalog action and elects a reason-specific read-only composer from ordinary owner props. Components receive derived props and callbacks, never `ctx`. -Every in-process subagent child stamps `SessionHeader.origin: 'subagent'` before publication. Session list summaries and incremental Host frames project it so grouped and flat sidebars omit duplicate child rows while preserving ordinary forks. Descriptor mode and catalog verification remain the authority for navigation, continuation, and authorization. +Every in-process subagent child stamps `SessionHeader.origin: 'subagent'` before publication. Session list summaries and incremental Host frames project it so grouped and flat sidebars omit duplicate child rows while preserving ordinary forks. The same existing `host/session-added` frame marks a loaded direct parent row expandable without introducing a catalog event stream. Descriptor mode and catalog verification remain the authority for navigation, continuation, and authorization. The package's existing `@label` source remains separate plain-text model input. It does not resolve labels to addresses or acquire continuation semantics. @@ -93,23 +93,23 @@ The shipped Web composition mounts SQLite session query beside JSONL persistence **Infer mode or sidebar filtering from lineage.** Rejected because ordinary forks share `parentSession`. The descriptor-backed catalog owns mode; the separate `origin` marker is only a cheap navigation classifier. -**Build an eager recursive tree or dedicated catalog stream.** Rejected for the current scale. Lazy direct-child reads preserve ordering and diagnostics; existing Host frames update activity and trigger bounded membership refreshes. +**Build an eager recursive tree or dedicated catalog stream.** Rejected for the current scale. Header-only one-level expandability lookahead preserves pre-click stability without reading descendant events, while disclosure remains a lazy authoritative direct-child read; existing Host frames update activity, restore expandable parent rows, and trigger bounded membership refreshes. **Let a child remain independently interactive after its parent disappears.** Rejected because independent lifetime and user ownership require side-session semantics. ## Testing -- Host protocol tests pin schemas, id echoing, mode verification, non-activating history, exact-parent enforcement, FIFO admission receipts, cancellation, and sanitized failure mapping. +- Host protocol tests pin schemas including required boolean expandability, id echoing, mode verification, non-activating history, exact-parent enforcement, FIFO admission receipts, cancellation, and sanitized failure mapping. - Generic Host tests pin attached and cold history and forks without Agent publication, cold projection folding, descriptor/origin/runtime-owner denial, explicit-id adoption denial, and the direct queue-control fence. -- Client object tests pin retained and restored addresses, one-shot read-only rejection, history routing, continuable prompt routing, no addressed cancellation, suppression of Agent-bound model controls, live activity flips, and membership refresh. -- jsdom tests pin mixed-mode rows, diagnostics, lazy descendant disclosure, direct-parent addresses, keyboard behavior, and both read-only reasons. +- Client object tests pin retained and restored addresses, one-shot read-only rejection, history routing, continuable prompt routing, no addressed cancellation, suppression of Agent-bound model controls, live activity flips, subagent-parent expandability flips, and membership refresh. +- jsdom tests pin mixed-mode rows, pre-click leaf disclosure, diagnostics, lazy descendant disclosure, direct-parent addresses, keyboard behavior, and both read-only reasons. - The keyless assembled Web snapshot contains an inactive continuable child, an inactive one-shot sibling, and a persisted grandchild; it expands without activation, opens persisted history, admits a human FIFO follow-up, reconciles child mux events, and proves one-shot history remains read-only. - Sidebar tests pin `origin: 'subagent'` filtering without hiding ordinary forks. ## Consequences -- Catalog reads may rescan persisted lineage and descriptor logs, so activity uses existing live frames while membership refresh stays debounced and single-flight. -- Parent availability and child activity are process-local snapshots. Publication, disposal, another sender, or another process may win after listing; typed prompt failure remains expected. +- Catalog reads may rescan persisted lineage and each direct candidate's descriptor log, but expandability reuses only descendant headers already present in that trace; activity uses existing live frames while membership refresh stays debounced and single-flight. +- Parent availability, child activity, and `hasChildren` are snapshots. Publication, disposal, another sender, or another process may win after listing; typed prompt failure remains expected. - A child may publish between history fetch and mux subscription, so the existing sequence reconciliation also covers the cold-to-live addressed path. - Persisted origin adds one deliberately weak product-classification field to child headers and list projections; it cannot become an authorization shortcut. - The UI has no child cancellation, durable outcome, activation duration, deletion, or independently interactive offline mode, and its text must not imply those capabilities. diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md index 5cd2999cb6..407e8d6268 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md @@ -32,14 +32,14 @@ Figma 中的 [subagent 列表](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5 | --- | --- | | 会话页头可打开紧凑的 child 列表。 | 该操作按服务顺序显示每个直接目录条目,包括已禁用的 diagnostic。 | | 选择一行会复用对话 UI。 | 已寻址历史绝不激活 child;只有 parent 存活的可继续行才保留普通输入框。 | -| 嵌套 agent 会逐层展开。 | 每次展开只加载该行的直接目录,并保留其自身的 parent 地址。 | +| 嵌套 agent 会逐层展开。 | 每行携带一层 `hasChildren` 快照;展开时仍只加载该行的直接目录,并保留其自身的 parent 地址。 | | 条目显示 label、状态与相对时间,同时避免侧边栏条目重复。 | mode 与 `running`/`inactive` 活动状态会同时以文字和视觉呈现;可选 title 与时间来自摘要。`SessionHeader.origin` 会移除重复的导航条目,但不授予任何功能权限。 | ## 产品契约 -页头操作的计数包含健康的 `kind: 'child'` 条目,不包含 diagnostic。只有在完整响应为空后,才不显示该操作。树会呈现可继续与 one-shot 行;one-shot 的可选 label 缺失时,回退到其会话 id。损坏、不受支持或不可用的候选仍以禁用的 diagnostic 行显示。 +页头操作的计数包含健康的 `kind: 'child'` 条目,不包含 diagnostic。只有在完整响应为空后,才不显示该操作。每个健康行都携带读取时的 `hasChildren` 提示,该值只根据持久化 `origin: 'subagent'` 的直接谱系 header 派生;正常的健康与 diagnostic subagent 候选都会携带该标记,而普通 fork 不会。该预查不读取任何后代事件日志,展开后仍以描述符支撑的目录为权威依据。UI 会在交互前就省略已知叶子节点的展开控件;该提示不承诺 child 会一直是叶子。树会呈现可继续与 one-shot 行;one-shot 的可选 label 缺失时,回退到其会话 id。损坏、不受支持或不可用的候选仍以禁用的 diagnostic 行显示。 -`running` 表示逻辑 child 记录存活于会话语料库中;`inactive` 表示它只存在于持久化存储中。UI 不会把任一值解释为成功、失败、取消、完成状态或可恢复性。`host/session-status` 会就地更新已知活动状态。受影响分支打开期间,成员、label、mode 与 diagnostic 仍需要通过去抖动的 `subagent.list` 刷新来更新。消息投递时仍以提示词响应为权威依据。 +`running` 表示逻辑 child 记录存活于会话语料库中;`inactive` 表示它只存在于持久化存储中。UI 不会把任一值解释为成功、失败、取消、完成状态或可恢复性。`host/session-status` 会就地更新已知活动状态。直接 subagent 的 `host/session-added` 帧会立即把任何已加载的 parent 行翻转为 `hasChildren: true`;受影响分支打开期间,成员、label、mode、diagnostic 与权威快照仍需要通过去抖动的 `subagent.list` 刷新来更新。消息投递时仍以提示词响应为权威依据。 选择一行后,系统会先记录其确切地址,再打开常驻客户端 `Session`。历史分页、事件 fold、工具渲染意图、title、面包屑导航与实时 mux 归并都会复用普通对话机制。目录是一棵 ARIA 树,支持懒加载式 ArrowRight/ArrowLeft 展开与折叠、线性 ArrowUp/ArrowDown 导航、Home/End、Escape 以及焦点恢复。 @@ -51,7 +51,7 @@ one-shot 行始终会用文案替代输入框,说明执行记录为只读。 `@deepseek-ai/dsh-host-apiproxy` 拥有浏览器安全的 `subagents` 域: -- `subagent.list` 接受 `parentSessionId`,调用 `ctx.subagents.listChildren(parentSessionId, signal)`,返回完整有序的条目,并说明当前能否从 `ctx.agents` 解析出确切 parent。 +- `subagent.list` 接受 `parentSessionId`,调用 `ctx.subagents.listChildren(parentSessionId, signal)`,返回完整有序的条目以及每个健康行的布尔 `hasChildren` 快照,并说明当前能否从 `ctx.agents` 解析出确切 parent。 - `subagent.history` 接受包含 mode 的完整地址与普通页参数。它对照直接目录校验 child 与 mode,通过 `ctx.sessionQuery.readSession()` 读取,再次检查直接谱系,并在不发布 agent 的情况下返回普通原始事件、渲染意图、分页与由 Host 计算的会话投影基线。 - `subagent.prompt` 只接受 `mode: 'continuable'` 地址与 `ContentBlock[]`。它要求确切的存活 parent,重新校验目录地址,调用 `ctx.subagents.followup(parent, childId, content, { source, signal })`,并返回已接受的 `MessageId`。 @@ -69,7 +69,7 @@ one-shot 行始终会用文案替代输入框,说明执行记录为只读。 目录通过标准 `useSessions` 快照传递。组件局部状态负责菜单可见性、已展开分支与焦点。`ui-conversation` 声明通用页头操作列表 slot,并通过其编辑器链分发当前对话快照;其中没有 subagent 专用的接管标记。`@deepseek-ai/dsh-client-ui-subagent` 注册目录操作,并根据普通 owner props 选择按原因区分的只读编辑器。组件只接收派生 props 与回调,绝不接收 `ctx`。 -每个进程内 subagent child 都会在发布前写入 `SessionHeader.origin: 'subagent'`。会话列表摘要与增量 Host 帧会投影该字段,使分组和扁平侧边栏省略重复的 child 行,同时保留普通 fork。描述符 mode 与目录校验仍然是导航、继续执行和授权的权威依据。 +每个进程内 subagent child 都会在发布前写入 `SessionHeader.origin: 'subagent'`。会话列表摘要与增量 Host 帧会投影该字段,使分组和扁平侧边栏省略重复的 child 行,同时保留普通 fork。同一条现有的 `host/session-added` 帧还会把已加载的直接 parent 行标记为可展开,而无需引入目录事件流。描述符 mode 与目录校验仍然是导航、继续执行和授权的权威依据。 该包现有的 `@label` source 仍然是独立的面向模型纯文本输入。它不会将 label 解析为地址,也不会获得继续执行语义。 @@ -93,23 +93,23 @@ one-shot 行始终会用文案替代输入框,说明执行记录为只读。 **根据谱系推断 mode 或侧边栏过滤。** 不予采纳,因为普通 fork 共享 `parentSession`。由描述符支撑的目录负责提供 mode;单独的 `origin` 标记只是低成本的导航分类器。 -**构建预先加载的递归树或专用目录流。** 就当前规模而言不予采纳。懒加载式直接 child 读取会保留排序与 diagnostic;现有 Host 帧会更新活动状态,并触发有界的成员刷新。 +**构建预先加载的递归树或专用目录流。** 就当前规模而言不予采纳。只读 header 的一层可展开性预查会在不读取后代事件的情况下保证点击前的稳定性,而展开仍是懒加载式权威直接 child 读取;现有 Host 帧会更新活动状态、恢复 parent 行的可展开性,并触发有界的成员刷新。 **让 child 在 parent 消失后仍能独立交互。** 不予采纳,因为独立生命周期与用户所有权需要 side session 语义。 ## 测试 -- 宿主协议测试固定 schema、id 回显、mode 校验、非激活式历史、确切 parent 强制要求、FIFO 准入回执、取消与脱敏后的失败映射。 +- 宿主协议测试固定 schema(包括必需的布尔可展开性)、id 回显、mode 校验、非激活式历史、确切 parent 强制要求、FIFO 准入回执、取消与脱敏后的失败映射。 - 通用 Host 测试固定在不发布 Agent 的情况下读取已附加与冷态历史及执行 fork、冷态投影归并、按描述符/origin/运行时 owner 拒绝、拒绝显式 id 接纳,以及直接队列控制栅栏。 -- 客户端对象测试固定已保留与已恢复的地址、one-shot 只读拒绝、历史路由、可继续提示词路由、已寻址对话不提供取消、屏蔽绑定到 agent 的模型控件、实时活动状态翻转与成员刷新。 -- jsdom 测试固定混合 mode 行、diagnostic、后代懒加载展开、直接 parent 地址、键盘行为与两种只读原因。 +- 客户端对象测试固定已保留与已恢复的地址、one-shot 只读拒绝、历史路由、可继续提示词路由、已寻址对话不提供取消、屏蔽绑定到 agent 的模型控件、实时活动状态翻转、subagent parent 可展开性翻转与成员刷新。 +- jsdom 测试固定混合 mode 行、点击前的叶子展开控件、diagnostic、后代懒加载展开、直接 parent 地址、键盘行为与两种只读原因。 - 无密钥的组装 Web 快照包含一个 inactive 的可继续 child、一个 inactive 的 one-shot sibling 和一个持久化 grandchild;它会在不激活的情况下展开、打开持久化历史、准入一条用户 FIFO 后续消息、归并 child mux 事件,并证明 one-shot 历史仍然只读。 - 侧边栏测试固定 `origin: 'subagent'` 过滤,同时不隐藏普通 fork。 ## 后果 -- 目录读取可能重新扫描持久化谱系与描述符日志,因此活动状态使用现有实时帧,而成员刷新保持去抖动和单次并发。 -- parent 可用性与 child 活动状态都是进程局部快照。列出之后,发布、dispose、其他发送方或其他进程都可能抢先改变状态;类型化提示词失败仍属预期行为。 +- 目录读取可能重新扫描持久化谱系与每个直接候选的描述符日志,但可展开性只复用该追踪中已有的后代 header;因此活动状态使用现有实时帧,而成员刷新保持去抖动和单次并发。 +- parent 可用性、child 活动状态与 `hasChildren` 都是快照。列出之后,发布、dispose、其他发送方或其他进程都可能抢先改变状态;类型化提示词失败仍属预期行为。 - child 可能在历史获取与 mux 订阅之间发布,因此现有序号归并也涵盖从冷态转为存活的已寻址路径。 - 持久化 origin 会为 child header 与列表投影添加一个有意保持弱约束的产品分类字段;它不能变成授权捷径。 - UI 不提供 child 取消、持久化结果、激活耗时、删除或可独立交互的离线 mode,其文案不得暗示这些功能已经存在。 diff --git a/apps/web/tests/snapshots/subagent-conversation/tree.expected.md b/apps/web/tests/snapshots/subagent-conversation/tree.expected.md index 4db5fab3fd..c34e2aa45d 100644 --- a/apps/web/tests/snapshots/subagent-conversation/tree.expected.md +++ b/apps/web/tests/snapshots/subagent-conversation/tree.expected.md @@ -4,11 +4,5 @@ - img - text: event-sourcing researcher 可继续 · 当前未运行 - group: - - treeitem "example editor 可继续 · 当前未运行" [level=2]: - - button "展开 example editor 的下级子代理": - - img - - text: example editor 可继续 · 当前未运行 - - treeitem "event-sourcing reviewer 一次性 · 当前未运行" [level=1]: - - button "展开 event-sourcing reviewer 的下级子代理": - - img - - text: event-sourcing reviewer 一次性 · 当前未运行 + - treeitem "example editor 可继续 · 当前未运行" [level=2] + - treeitem "event-sourcing reviewer 一次性 · 当前未运行" [level=1] diff --git a/apps/web/tests/subagent-conversation.e2e.ts b/apps/web/tests/subagent-conversation.e2e.ts index bfa04a420d..840a5220b0 100644 --- a/apps/web/tests/subagent-conversation.e2e.ts +++ b/apps/web/tests/subagent-conversation.e2e.ts @@ -192,16 +192,19 @@ describe('web e2e: persisted subagent conversation and human continuation', () = expect(scaffold.ctx.agents.get(oneShotId)).toBeUndefined() expect(scaffold.ctx.agents.get(grandchildId)).toBeUndefined() await expect(scaffold.ctx.subagents.listChildren(parent.id)).resolves.toMatchObject([ - { kind: 'child', id: childId, mode: 'continuable', label: LABEL, activity: 'inactive' }, + { + kind: 'child', id: childId, mode: 'continuable', label: LABEL, + activity: 'inactive', hasChildren: true, + }, { kind: 'child', id: oneShotId, mode: 'one-shot', - label: ONE_SHOT_LABEL, activity: 'inactive', + label: ONE_SHOT_LABEL, activity: 'inactive', hasChildren: false, }, ]) await expect(scaffold.ctx.subagents.listChildren(childId)).resolves.toMatchObject([ { kind: 'child', id: grandchildId, mode: 'continuable', - label: NESTED_LABEL, activity: 'inactive', + label: NESTED_LABEL, activity: 'inactive', hasChildren: false, }, ]) await page.getByRole('button', { name: '2 个子代理' }).waitFor({ timeout: 15_000 }) @@ -222,6 +225,9 @@ describe('web e2e: persisted subagent conversation and human continuation', () = it('expands a persisted grandchild progressively without activating either level', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-tree')) await page.getByRole('button', { name: '2 个子代理' }).click() + expect(await page.getByRole('button', { + name: `展开 ${ONE_SHOT_LABEL} 的下级子代理`, + }).count()).toBe(0) await page.getByRole('button', { name: `展开 ${LABEL} 的下级子代理` }).click() await page.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) }).waitFor({ timeout: 15_000 }) expect(scaffold.ctx.agents.get(childId)).toBeUndefined() diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index 3224a5572d..2820d717f9 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/README.i18n.yaml @@ -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 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 0f5b35b6fa..e4b8e1777a 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -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 diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 67cdf6479e..5aa53ce1ec 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -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。 ## 模型体验 diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index 03087915ea..a077c6ac9e 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -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 diff --git a/packages/client/runtime/tests/manager.spec.ts b/packages/client/runtime/tests/manager.spec.ts index 9f6d3a5fa7..31874bcee6 100644 --- a/packages/client/runtime/tests/manager.spec.ts +++ b/packages/client/runtime/tests/manager.spec.ts @@ -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', () => { diff --git a/packages/client/ui-subagent/README.i18n.yaml b/packages/client/ui-subagent/README.i18n.yaml index e1fefc3e74..791aa84621 100644 --- a/packages/client/ui-subagent/README.i18n.yaml +++ b/packages/client/ui-subagent/README.i18n.yaml @@ -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 diff --git a/packages/client/ui-subagent/README.md b/packages/client/ui-subagent/README.md index 7399681265..561fc68c6c 100644 --- a/packages/client/ui-subagent/README.md +++ b/packages/client/ui-subagent/README.md @@ -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). diff --git a/packages/client/ui-subagent/README.zh.md b/packages/client/ui-subagent/README.zh.md index f9a5073360..7b80919a5b 100644 --- a/packages/client/ui-subagent/README.zh.md +++ b/packages/client/ui-subagent/README.zh.md @@ -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)规定。 diff --git a/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx b/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx index 70ef38e4ea..6c5ad91b9a 100644 --- a/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx +++ b/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx @@ -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' ? '一次性' : '可继续' diff --git a/packages/client/ui-subagent/tests/conversation-ui.spec.tsx b/packages/client/ui-subagent/tests/conversation-ui.spec.tsx index 90508f392d..88fceed8d0 100644 --- a/packages/client/ui-subagent/tests/conversation-ui.spec.tsx +++ b/packages/client/ui-subagent/tests/conversation-ui.spec.tsx @@ -22,11 +22,12 @@ function catalog(over: Partial = {}): 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() @@ -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, }], }), }) diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index d83623608e..fce1033f67 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -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 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 45e79aea55..5755850e77 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -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) diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index cb0cbe4fe5..02048fdbf2 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -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` + 根路径) diff --git a/packages/host/apiproxy/src/api/subagents.schema.ts b/packages/host/apiproxy/src/api/subagents.schema.ts index ae415d68cc..1987d568d3 100644 --- a/packages/host/apiproxy/src/api/subagents.schema.ts +++ b/packages/host/apiproxy/src/api/subagents.schema.ts @@ -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({ diff --git a/packages/host/apiproxy/src/api/subagents.ts b/packages/host/apiproxy/src/api/subagents.ts index 18abc0260f..35bcfcc9cc 100644 --- a/packages/host/apiproxy/src/api/subagents.ts +++ b/packages/host/apiproxy/src/api/subagents.ts @@ -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' diff --git a/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts b/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts index 5c00a1946e..b72057aeab 100644 --- a/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts @@ -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({ diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index ab402c6fff..34910acb9f 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -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, diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index 08df1f179c..f7043e2403 100644 --- a/packages/subagent/subagent/README.i18n.yaml +++ b/packages/subagent/subagent/README.i18n.yaml @@ -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 diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 4776f45a2f..9aea27a0f1 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -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. diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index a40a12a4b3..3f0b534dea 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -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 历史虚构目录条目。 diff --git a/packages/subagent/subagent/src/list-children.ts b/packages/subagent/subagent/src/list-children.ts index 0d62792a2b..35bb581500 100644 --- a/packages/subagent/subagent/src/list-children.ts +++ b/packages/subagent/subagent/src/list-children.ts @@ -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 { 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 diff --git a/packages/subagent/subagent/tests/list-children.spec.ts b/packages/subagent/subagent/tests/list-children.spec.ts index a2c4cd0090..232bb3c23c 100644 --- a/packages/subagent/subagent/tests/list-children.spec.ts +++ b/packages/subagent/subagent/tests/list-children.spec.ts @@ -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 () => { diff --git a/packages/subagent/tool-subagent-control/tests/list-agents.spec.ts b/packages/subagent/tool-subagent-control/tests/list-agents.spec.ts index 9494c441c5..9872f73456 100644 --- a/packages/subagent/tool-subagent-control/tests/list-agents.spec.ts +++ b/packages/subagent/tool-subagent-control/tests/list-agents.spec.ts @@ -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' }, ]