mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
fix(web): synchronize subagent navigation state
This commit is contained in:
@@ -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: 1c709f78bc5aaad5cd03959f7fa9f077dc20d8d3
|
||||
2026-07-27-web-subagent-conversations.zh.md: 07c70ce06c4d4f9b5e2d07852d83e26ab9b06369
|
||||
2026-07-27-web-subagent-conversations.md: 34acb1410cf6316bca2980ed012046ffab9623f6
|
||||
2026-07-27-web-subagent-conversations.zh.md: 5dcd7025c5cd03fed34266834795de1f2b630648
|
||||
|
||||
@@ -10,7 +10,7 @@ Session-backed subagents have durable identities, persisted transcripts, and a d
|
||||
|
||||
The browser must preserve the [continuable subagent contract](../../implemented/feature/2026-07-28-continuable-subagent-conversations.md): a continuable child has at most one process-local Activation, accepts later work only through the exact live direct parent, and uses the Agent inbox as its sole FIFO. Viewing history must not create an Activation. Once an inbox message is accepted, the HTTP caller neither owns its execution nor gains a cancellation handle.
|
||||
|
||||
The UI must also preserve the [durable catalog](../../implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md). The catalog contains both one-shot and continuable children, retains per-child diagnostics, and reports only the live-preferred activity snapshot `running` or `inactive`. Activity is not a durable outcome or a promise that continuation will succeed.
|
||||
The UI must also preserve the membership, modes, and diagnostics of the [durable catalog](../../implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md). The shared service reports live-preferred corpus activity, while the Web projection replaces it with the exact child Agent driver's `running` or `inactive` state. Neither activity is a durable outcome or a promise that continuation will succeed.
|
||||
|
||||
## Decision
|
||||
|
||||
@@ -30,18 +30,18 @@ The Figma [subagent list](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Ha
|
||||
|
||||
| Design intent | Shipped contract |
|
||||
| --- | --- |
|
||||
| The session header opens a compact child list. | The action shows every direct catalog entry in service order, including disabled diagnostics. |
|
||||
| The session header opens a compact child list. | The trigger aggregates the complete subagent-only descendant lineage; the tree 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 row carries a one-level `hasChildren` snapshot; disclosure still 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 reserves known direct-descendant rows immediately, then 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. 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.
|
||||
The header action is absent only after a complete empty direct-catalog response. Its trigger counts every known session-summary descendant reached through an uninterrupted `origin: 'subagent'` lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. Every healthy direct-catalog 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. While an expanded direct catalog is loading, known lineage reserves one disabled loading row per direct descendant without recursively fetching descendant catalogs. The tree then 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. A `host/session-added` frame for a direct subagent immediately flips any loaded parent row to `hasChildren: true`, and that positive hint survives an older in-flight catalog response; 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.
|
||||
`running` means the exact child Agent driver is draining work at the Host sampling boundary; `inactive` means that driver is idle or absent. The UI does not translate either value into success, failure, cancellation, completeness, or resumability. `subagent.list` supplies the current driver-status baseline, `host/session-status` updates known activity in place, request-local replay prevents an older in-flight list response from overwriting a newer transition, and `host/session-removed` returns a known row to `inactive`; reconnect reads a fresh baseline. A `host/session-added` frame for a direct subagent immediately flips any loaded parent row to `hasChildren: true`, and that positive hint survives an older in-flight catalog response; 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, and live mux reconciliation reuse the ordinary conversation machinery. Breadcrumbs follow parent links only through `origin: 'subagent'` rows, include the first ordinary owner, and keep ordinary forks single-level. Forking an addressed subagent creates an ordinary fork with direct source lineage and attaches it to the nearest workspace-owning ancestor. The catalog is an ARIA tree with lazy ArrowRight/ArrowLeft disclosure, linear ArrowUp/ArrowDown navigation, Home/End, Escape, and focus restoration.
|
||||
Selecting a row records its exact address before opening the resident client `Session`. History pagination, event folding, tool render intents, titles, and live mux reconciliation reuse the ordinary conversation machinery. Breadcrumbs use catalog labels, follow parent links only through `origin: 'subagent'` rows, include the first ordinary owner, and keep ordinary forks single-level. Forking an addressed subagent creates an ordinary fork with direct source lineage and attaches it to the nearest workspace-owning ancestor. The catalog is an ARIA tree with lazy ArrowRight/ArrowLeft disclosure, linear ArrowUp/ArrowDown navigation, Home/End, Escape, and focus restoration.
|
||||
|
||||
A one-shot row always replaces the composer with copy explaining that the execution record is read-only. A continuable row does so only while `parentAvailable` is false. When enabled, its Send action admits another FIFO turn even if the child is currently running; it never becomes Stop. Prompt failures retain the draft through the ordinary error behavior.
|
||||
|
||||
@@ -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 with each healthy row's boolean `hasChildren` snapshot, 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, replaces each healthy row's corpus activity with whether its exact Agent driver is running, 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`.
|
||||
|
||||
@@ -101,14 +101,14 @@ The shipped Web composition mounts SQLite session query beside JSONL persistence
|
||||
|
||||
- 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, 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.
|
||||
- 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 including in-flight response replay and detach fallback, subagent-parent expandability flips, and membership refresh.
|
||||
- jsdom tests pin the aggregate descendant count and activity, known loading-row shape, 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 pins the three-descendant trigger and aggregate running transition, expands without activation, opens persisted history, admits a human FIFO follow-up, reconciles child mux events, and proves one-shot history remains read-only.
|
||||
- Navigation tests pin subagent-only breadcrumbs, workspace placement for forks created from subagents, and `origin: 'subagent'` sidebar filtering without hiding ordinary forks.
|
||||
|
||||
## Consequences
|
||||
|
||||
- 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.
|
||||
- Catalog reads may rescan persisted lineage and each direct candidate's descriptor log, but expandability reuses only descendant headers already present in that trace; the Web activity baseline adds one Agent-registry lookup per healthy row and then 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.
|
||||
|
||||
@@ -10,7 +10,7 @@ Status: implemented
|
||||
|
||||
浏览器必须遵守[可继续 subagent 契约](../../implemented/feature/2026-07-28-continuable-subagent-conversations.md):一个可继续 child 在进程内最多只能有一项 Activation,只能通过确切的存活直接 parent 接受后续工作,并将 agent inbox 用作唯一的 FIFO。查看历史不得创建 Activation。inbox 消息一经接受,HTTP 调用方既不拥有其执行过程,也不会获得取消句柄。
|
||||
|
||||
UI 还必须遵守[持久化目录](../../implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)。目录同时包含 one-shot 与可继续 child,保留每个 child 的 diagnostic,并且只报告采用实时优先规则的 `running` 或 `inactive` 活动快照。活动状态既不是持久化结果,也不承诺继续执行会成功。
|
||||
UI 还必须保留[持久化目录](../../implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)的成员、mode 与 diagnostic。共享服务报告采用实时优先规则的语料活动状态,而 Web 投影会将其替换为确切 child Agent driver 的 `running` 或 `inactive` 状态。这两种活动状态都不是持久化结果,也不承诺继续执行会成功。
|
||||
|
||||
## 决策
|
||||
|
||||
@@ -30,18 +30,18 @@ Figma 中的 [subagent 列表](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5
|
||||
|
||||
| 设计意图 | 已交付契约 |
|
||||
| --- | --- |
|
||||
| 会话页头可打开紧凑的 child 列表。 | 该操作按服务顺序显示每个直接目录条目,包括已禁用的 diagnostic。 |
|
||||
| 会话页头可打开紧凑的 child 列表。 | 触发器会汇总仅含 subagent 的完整后代谱系;树按服务顺序显示每个直接目录条目,包括已禁用的 diagnostic。 |
|
||||
| 选择一行会复用对话 UI。 | 已寻址历史绝不激活 child;只有 parent 存活的可继续行才保留普通输入框。 |
|
||||
| 嵌套 agent 会逐层展开。 | 每行携带一层 `hasChildren` 快照;展开时仍只加载该行的直接目录,并保留其自身的 parent 地址。 |
|
||||
| 嵌套 agent 会逐层展开。 | 每行携带一层 `hasChildren` 快照;展开时会立即预留已知直接后代行,随后仍只加载该行的直接目录,并保留其自身的 parent 地址。 |
|
||||
| 条目显示 label、状态与相对时间,同时避免侧边栏条目重复。 | mode 与 `running`/`inactive` 活动状态会同时以文字和视觉呈现;可选 title 与时间来自摘要。`SessionHeader.origin` 会移除重复的导航条目,但不授予任何功能权限。 |
|
||||
|
||||
## 产品契约
|
||||
|
||||
页头操作的计数包含健康的 `kind: 'child'` 条目,不包含 diagnostic。只有在完整响应为空后,才不显示该操作。每个健康行都携带读取时的 `hasChildren` 提示,该值只根据持久化 `origin: 'subagent'` 的直接谱系 header 派生;正常的健康与 diagnostic subagent 候选都会携带该标记,而普通 fork 不会。该预查不读取任何后代事件日志,展开后仍以描述符支撑的目录为权威依据。UI 会在交互前就省略已知叶子节点的展开控件;该提示不承诺 child 会一直是叶子。树会呈现可继续与 one-shot 行;one-shot 的可选 label 缺失时,回退到其会话 id。损坏、不受支持或不可用的候选仍以禁用的 diagnostic 行显示。
|
||||
只有在完整的直接目录响应为空后,才不显示页头操作。其触发器会统计经不间断的 `origin: 'subagent'` 谱系可达的每个已知会话摘要后代,在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。每个健康的直接目录行都携带读取时的 `hasChildren` 提示,该值只根据持久化 `origin: 'subagent'` 的直接谱系 header 派生;正常的健康与 diagnostic subagent 候选都会携带该标记,而普通 fork 不会。该预查不读取任何后代事件日志,展开后仍以描述符支撑的目录为权威依据。UI 会在交互前就省略已知叶子节点的展开控件;该提示不承诺 child 会一直是叶子。已展开的直接目录加载期间,已知谱系会为每个直接后代预留一行禁用的加载行,而不会递归获取后代目录。随后树会呈现可继续与 one-shot 行;one-shot 的可选 label 缺失时,回退到其会话 id。损坏、不受支持或不可用的候选仍以禁用的 diagnostic 行显示。
|
||||
|
||||
`running` 表示逻辑 child 记录存活于会话语料库中;`inactive` 表示它只存在于持久化存储中。UI 不会把任一值解释为成功、失败、取消、完成状态或可恢复性。`host/session-status` 会就地更新已知活动状态。直接 subagent 的 `host/session-added` 帧会立即把任何已加载的 parent 行翻转为 `hasChildren: true`,并使这项正向提示不被更早发起但尚未完成的目录响应覆盖;受影响分支打开期间,成员、label、mode、diagnostic 与权威快照仍需要通过去抖动的 `subagent.list` 刷新来更新。消息投递时仍以提示词响应为权威依据。
|
||||
`running` 表示在 Host 采样边界,确切 child Agent driver 正在处理工作;`inactive` 表示该 driver 空闲或不存在。UI 不会把任一值解释为成功、失败、取消、完成状态或可恢复性。`subagent.list` 提供当前 driver 状态基线,`host/session-status` 会就地更新已知活动状态,请求内回放会阻止更早发起但尚未完成的列表响应覆盖较新的状态转换,`host/session-removed` 则会使已知行恢复为 `inactive`;重连时会读取新的基线。直接 subagent 的 `host/session-added` 帧会立即把任何已加载的 parent 行翻转为 `hasChildren: true`,并使这项正向提示不被更早发起但尚未完成的目录响应覆盖;受影响分支打开期间,成员、label、mode、diagnostic 与权威快照仍需要通过去抖动的 `subagent.list` 刷新来更新。消息投递时仍以提示词响应为权威依据。
|
||||
|
||||
选择一行后,系统会先记录其确切地址,再打开常驻客户端 `Session`。历史分页、事件 fold、工具渲染意图、title 与实时 mux 归并都会复用普通对话机制。面包屑导航只会沿 `origin: 'subagent'` 行的父链接逐级回溯,包含第一个普通 owner,并让普通 fork 保持单层。从已寻址 subagent 创建 fork 时,会生成具有直接源谱系的普通 fork,并将其附加到最近拥有 Workspace 的祖先。目录是一棵 ARIA 树,支持懒加载式 ArrowRight/ArrowLeft 展开与折叠、线性 ArrowUp/ArrowDown 导航、Home/End、Escape 以及焦点恢复。
|
||||
选择一行后,系统会先记录其确切地址,再打开常驻客户端 `Session`。历史分页、事件 fold、工具渲染意图、title 与实时 mux 归并都会复用普通对话机制。面包屑导航使用目录 label,只会沿 `origin: 'subagent'` 行的父链接逐级回溯,包含第一个普通 owner,并让普通 fork 保持单层。从已寻址 subagent 创建 fork 时,会生成具有直接源谱系的普通 fork,并将其附加到最近拥有 Workspace 的祖先。目录是一棵 ARIA 树,支持懒加载式 ArrowRight/ArrowLeft 展开与折叠、线性 ArrowUp/ArrowDown 导航、Home/End、Escape 以及焦点恢复。
|
||||
|
||||
one-shot 行始终会用文案替代输入框,说明执行记录为只读。可继续行仅在 `parentAvailable` 为 false 时如此。启用后,即使 child 正在运行,其 Send 操作也会准入另一个 FIFO 轮次,绝不会变成 Stop。提示词失败会通过普通错误行为保留草稿。
|
||||
|
||||
@@ -51,7 +51,7 @@ one-shot 行始终会用文案替代输入框,说明执行记录为只读。
|
||||
|
||||
`@deepseek-ai/dsh-host-apiproxy` 拥有浏览器安全的 `subagents` 域:
|
||||
|
||||
- `subagent.list` 接受 `parentSessionId`,调用 `ctx.subagents.listChildren(parentSessionId, signal)`,返回完整有序的条目以及每个健康行的布尔 `hasChildren` 快照,并说明当前能否从 `ctx.agents` 解析出确切 parent。
|
||||
- `subagent.list` 接受 `parentSessionId`,调用 `ctx.subagents.listChildren(parentSessionId, signal)`,返回完整有序的条目以及每个健康行的布尔 `hasChildren` 快照,把每个健康行的语料活动状态替换为其确切 Agent driver 是否正在运行,并说明当前能否从 `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`。
|
||||
|
||||
@@ -101,14 +101,14 @@ one-shot 行始终会用文案替代输入框,说明执行记录为只读。
|
||||
|
||||
- 宿主协议测试固定 schema(包括必需的布尔可展开性)、id 回显、mode 校验、非激活式历史、确切 parent 强制要求、FIFO 准入回执、取消与脱敏后的失败映射。
|
||||
- 通用 Host 测试固定在不发布 Agent 的情况下读取已附加与冷态历史及执行 fork、冷态投影归并、按描述符/origin/运行时 owner 拒绝、拒绝显式 id 接纳,以及直接队列控制栅栏。
|
||||
- 客户端对象测试固定已保留与已恢复的地址、one-shot 只读拒绝、历史路由、可继续提示词路由、已寻址对话不提供取消、屏蔽绑定到 agent 的模型控件、实时活动状态翻转、subagent parent 可展开性翻转与成员刷新。
|
||||
- jsdom 测试固定混合 mode 行、点击前的叶子展开控件、diagnostic、后代懒加载展开、直接 parent 地址、键盘行为与两种只读原因。
|
||||
- 无密钥的组装 Web 快照包含一个 inactive 的可继续 child、一个 inactive 的 one-shot sibling 和一个持久化 grandchild;它会在不激活的情况下展开、打开持久化历史、准入一条用户 FIFO 后续消息、归并 child mux 事件,并证明 one-shot 历史仍然只读。
|
||||
- 客户端对象测试固定已保留与已恢复的地址、one-shot 只读拒绝、历史路由、可继续提示词路由、已寻址对话不提供取消、屏蔽绑定到 agent 的模型控件、实时活动状态翻转(包括在途响应回放与 detach 回退)、subagent parent 可展开性翻转与成员刷新。
|
||||
- jsdom 测试固定后代聚合计数与活动状态、已知加载行的形态、混合 mode 行、点击前的叶子展开控件、diagnostic、后代懒加载展开、直接 parent 地址、键盘行为与两种只读原因。
|
||||
- 无密钥的组装 Web 快照包含一个 inactive 的可继续 child、一个 inactive 的 one-shot sibling 和一个持久化 grandchild;它会固定触发器显示三个后代及聚合 `running` 状态转换,在不激活的情况下展开、打开持久化历史、准入一条用户 FIFO 后续消息、归并 child mux 事件,并证明 one-shot 历史仍然只读。
|
||||
- 导航测试固定仅含 subagent 的面包屑导航、从 subagent 创建 fork 时的 Workspace 归属,以及 `origin: 'subagent'` 侧边栏过滤,同时不隐藏普通 fork。
|
||||
|
||||
## 后果
|
||||
|
||||
- 目录读取可能重新扫描持久化谱系与每个直接候选的描述符日志,但可展开性只复用该追踪中已有的后代 header;因此活动状态使用现有实时帧,而成员刷新保持去抖动和单次并发。
|
||||
- 目录读取可能重新扫描持久化谱系与每个直接候选的描述符日志,但可展开性只复用该追踪中已有的后代 header;Web 活动基线会为每个健康行增加一次 Agent 注册表查找,随后使用现有实时帧,而成员刷新保持去抖动和单次并发。
|
||||
- parent 可用性、child 活动状态与 `hasChildren` 都是快照。列出之后,发布、dispose、其他发送方或其他进程都可能抢先改变状态;类型化提示词失败仍属预期行为。
|
||||
- child 可能在历史获取与 mux 订阅之间发布,因此现有序号归并也涵盖从冷态转为存活的已寻址路径。
|
||||
- 持久化 origin 会为 child header 与列表投影添加一个有意保持弱约束的产品分类字段;它不能变成授权捷径。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
- treeitem "workspace 2 sessions" [expanded]:
|
||||
- img
|
||||
- text: workspace 2 sessions
|
||||
- treeitem "Explain event sourcing in one now" [selected]
|
||||
- treeitem "Explain event sourcing in one (1) now" [selected]
|
||||
- treeitem "Ask a research subagent to now"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
- tree "子代理会话":
|
||||
- treeitem "event-sourcing researcher 可继续 · 当前未运行" [expanded] [level=1]:
|
||||
- treeitem "event-sourcing researcher Explain event sourcing in one · 可继续 · 当前未运行 刚刚" [expanded] [level=1]:
|
||||
- button "收起 event-sourcing researcher 的下级子代理":
|
||||
- img
|
||||
- text: event-sourcing researcher 可继续 · 当前未运行
|
||||
- text: event-sourcing researcher Explain event sourcing in one · 可继续 · 当前未运行 刚刚
|
||||
- group:
|
||||
- treeitem "example editor 可继续 · 当前未运行" [level=2]
|
||||
- treeitem "event-sourcing reviewer 一次性 · 当前未运行" [level=1]
|
||||
- treeitem "example editor 可继续 · 当前未运行 刚刚" [level=2]
|
||||
- treeitem "event-sourcing reviewer 一次性 · 当前未运行 刚刚" [level=1]
|
||||
|
||||
@@ -11,7 +11,8 @@ import {
|
||||
import type {} from '@deepseek-ai/dsh-agent'
|
||||
import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
|
||||
import {
|
||||
captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole,
|
||||
acknowledgeReloadConnectionLoss, captureStableAria, compareOrRefreshGolden,
|
||||
launchWebScaffold, watchConsole,
|
||||
webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { connectFreshWorkspace, saveFailureShot } from './support.ts'
|
||||
@@ -73,6 +74,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
|
||||
scaffold = await launchWebScaffold({
|
||||
replayFixture: BASE_FIXTURE,
|
||||
replayChildFixtures: [childFixturePath],
|
||||
paceMs: 25,
|
||||
})
|
||||
browser = await chromium.launch()
|
||||
page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
|
||||
@@ -209,7 +211,20 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
|
||||
label: NESTED_LABEL, activity: 'inactive', hasChildren: false,
|
||||
},
|
||||
])
|
||||
await page.getByRole('button', { name: '2 个子代理' }).waitFor({ timeout: 15_000 })
|
||||
// These two cold fixtures were authored after the page's initial
|
||||
// session.list and intentionally emitted no session-added frame. Reload
|
||||
// to exercise the restart baseline that discovers their full lineage.
|
||||
const warningStart = tripwire.warnings.length
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
const catalogButton = page.getByRole('button', { name: /个子代理/ })
|
||||
await catalogButton.waitFor({ timeout: 15_000 })
|
||||
await catalogButton.click()
|
||||
const catalogTree = page.getByRole('tree', { name: '子代理会话' })
|
||||
await catalogTree.getByRole('treeitem').nth(1).waitFor({ timeout: 15_000 })
|
||||
await catalogTree.press('Escape')
|
||||
await page.getByRole('button', { name: '3 个子代理' }).waitFor({ timeout: 15_000 })
|
||||
acknowledgeReloadConnectionLoss(tripwire, warningStart)
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -226,7 +241,7 @@ 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()
|
||||
await page.getByRole('button', { name: '3 个子代理' }).click()
|
||||
expect(await page.getByRole('button', {
|
||||
name: `展开 ${ONE_SHOT_LABEL} 的下级子代理`,
|
||||
}).count()).toBe(0)
|
||||
@@ -245,7 +260,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
|
||||
|
||||
it('opens the completed child from persistence without activating it', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-open'))
|
||||
await page.getByRole('button', { name: '2 个子代理' }).click()
|
||||
await page.getByRole('button', { name: '3 个子代理' }).click()
|
||||
await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
|
||||
await expect.poll(
|
||||
() => page.getByText(INITIAL_PROMPT, { exact: true }).count(),
|
||||
@@ -281,7 +296,26 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
|
||||
const input = page.getByRole('textbox', { name: 'Message the agent' })
|
||||
await input.fill(FOLLOWUP)
|
||||
await input.press('Enter')
|
||||
await expect.poll(
|
||||
() => scaffold.ctx.agents.get(childId)?.status,
|
||||
{ timeout: 10_000 },
|
||||
).toBe('running')
|
||||
const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' })
|
||||
await hierarchy.getByRole('button').first().click()
|
||||
const runningTrigger = page.getByRole('button', { name: '3 个子代理,正在运行' })
|
||||
await runningTrigger.waitFor({ timeout: 10_000 })
|
||||
expect(await runningTrigger.locator('[data-state="ongoing"]').count()).toBe(1)
|
||||
await runningTrigger.click()
|
||||
await page.getByRole('treeitem', {
|
||||
name: new RegExp(`${LABEL}.*正在运行`),
|
||||
}).waitFor({ timeout: 10_000 })
|
||||
await ended
|
||||
await page.getByRole('treeitem', {
|
||||
name: new RegExp(`${LABEL}.*当前未运行`),
|
||||
}).waitFor({ timeout: 10_000 })
|
||||
expect(await page.getByRole('button', { name: '3 个子代理' })
|
||||
.locator('[data-state="ongoing"]').count()).toBe(0)
|
||||
await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
|
||||
await expect.poll(() => page.getByText(FOLLOWUP, { exact: true }).count(), { timeout: 10_000 }).toBe(1)
|
||||
await expect.poll(() => scaffold.ctx.agents.get(childId), { timeout: 10_000 }).toBeUndefined()
|
||||
expect(await page.getByRole('button', { name: 'Stop generating' }).count()).toBe(0)
|
||||
@@ -318,7 +352,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
|
||||
.getByRole('treeitem')
|
||||
.last()
|
||||
await parentSession.click()
|
||||
await page.getByRole('button', { name: '2 个子代理' }).click()
|
||||
await page.getByRole('button', { name: '3 个子代理' }).click()
|
||||
await page.getByRole('treeitem', { name: new RegExp(ONE_SHOT_LABEL) }).click()
|
||||
await page.getByText('一次性任务不支持后续消息,可在这里查看完整执行记录。').waitFor()
|
||||
expect(scaffold.ctx.agents.get(oneShotId)).toBeUndefined()
|
||||
@@ -329,7 +363,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
|
||||
await page.getByRole('tree', { name: 'Sessions' })
|
||||
.getByRole('treeitem', { name: /Ask a research subagent to/ })
|
||||
.click()
|
||||
await page.getByRole('button', { name: '2 个子代理' }).click()
|
||||
await page.getByRole('button', { name: '3 个子代理' }).click()
|
||||
await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
|
||||
await page.getByRole('textbox', { name: 'Message the agent' }).waitFor()
|
||||
const forkResponse = page.waitForResponse(response =>
|
||||
@@ -355,7 +389,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-post-fork-followup'))
|
||||
const sessions = page.getByRole('tree', { name: 'Sessions' })
|
||||
await sessions.getByRole('treeitem', { name: /Ask a research subagent to/ }).click()
|
||||
await page.getByRole('button', { name: '2 个子代理' }).click()
|
||||
await page.getByRole('button', { name: '3 个子代理' }).click()
|
||||
await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
|
||||
await page.locator('textarea:enabled').first().waitFor()
|
||||
expect(scaffold.ctx.agents.get(childId)).toBeUndefined()
|
||||
@@ -372,7 +406,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
|
||||
await expect.poll(() => scaffold.ctx.agents.get(forkId)).not.toBeUndefined()
|
||||
|
||||
await sessions.getByRole('treeitem', { name: /Ask a research subagent to/ }).click()
|
||||
await page.getByRole('button', { name: '2 个子代理' }).click()
|
||||
await page.getByRole('button', { name: '3 个子代理' }).click()
|
||||
await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
|
||||
const input = page.locator('textarea:enabled').first()
|
||||
await input.waitFor()
|
||||
|
||||
@@ -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: e4b8e1777ad3a01144dfe9da0a81f83e0f4e2b6a
|
||||
README.zh.md: 5aa53ce1ec680579b44f0b2859104a1eda7471d3
|
||||
README.md: f956be22384a42e9ed30e8aa5f25fe8173cc9f9c
|
||||
README.zh.md: 49449c51d89d957b5bd39798c9167607f72a5c3a
|
||||
|
||||
@@ -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 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.
|
||||
`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; the Host baseline and `host/session-status` both derive activity from child Agent driver status, and status frames received during a read are replayed over its response. 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
|
||||
|
||||
|
||||
@@ -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 的粗粒度活动状态,按 origin 分类的 `host/session-added` 则会立即把任何已加载的直接 parent 行标记为 `hasChildren: true`,并在该 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 基线与 `host/session-status` 都根据 child Agent driver 状态推导活动状态,读取期间收到的状态帧会在该读取的响应之上回放。按 origin 分类的 `host/session-added` 会立即把任何已加载的直接 parent 行标记为 `hasChildren: true`,并在该 parent 被选中或其目录打开时触发一次去抖动的重拉。parent 可用性会传播到 `ConversationSnapshot.subagent`,使呈现层可以把编辑器替换为只读说明,而不激活 parent。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ export interface SubagentCatalogSnapshot extends SubagentCatalog {
|
||||
interface CatalogInflight {
|
||||
readonly promise: Promise<void>
|
||||
readonly expandableRows: Set<SessionId>
|
||||
readonly activityRows: Map<SessionId, 'running' | 'inactive'>
|
||||
}
|
||||
|
||||
type SessionListMutation =
|
||||
@@ -288,6 +289,7 @@ export class SessionManager {
|
||||
if (existing !== undefined) return existing.promise
|
||||
const previous = this.catalogs.get(parentSessionId)
|
||||
const expandableRows = new Set<SessionId>()
|
||||
const activityRows = new Map<SessionId, 'running' | 'inactive'>()
|
||||
this.catalogs.set(parentSessionId, {
|
||||
entries: previous?.entries ?? [],
|
||||
parentAvailable: previous?.parentAvailable ?? false,
|
||||
@@ -301,7 +303,7 @@ export class SessionManager {
|
||||
if (result.ok) {
|
||||
this.catalogs.set(parentSessionId, {
|
||||
...result.value,
|
||||
entries: this.withExpandableRows(result.value.entries, expandableRows),
|
||||
entries: this.withCatalogMutations(result.value.entries, expandableRows, activityRows),
|
||||
state: 'ready',
|
||||
error: null,
|
||||
})
|
||||
@@ -311,7 +313,9 @@ export class SessionManager {
|
||||
}
|
||||
} else {
|
||||
this.catalogs.set(parentSessionId, {
|
||||
entries: this.withExpandableRows(previous?.entries ?? [], expandableRows),
|
||||
entries: this.withCatalogMutations(
|
||||
previous?.entries ?? [], expandableRows, activityRows,
|
||||
),
|
||||
parentAvailable: previous?.parentAvailable ?? false,
|
||||
state: 'error',
|
||||
error: result.error,
|
||||
@@ -320,7 +324,9 @@ export class SessionManager {
|
||||
} catch (error: unknown) {
|
||||
const folded = transportError<never>(error)
|
||||
this.catalogs.set(parentSessionId, {
|
||||
entries: this.withExpandableRows(previous?.entries ?? [], expandableRows),
|
||||
entries: this.withCatalogMutations(
|
||||
previous?.entries ?? [], expandableRows, activityRows,
|
||||
),
|
||||
parentAvailable: previous?.parentAvailable ?? false,
|
||||
state: 'error',
|
||||
error: folded.ok ? null : folded.error,
|
||||
@@ -330,7 +336,7 @@ export class SessionManager {
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
})()
|
||||
this.catalogInflight.set(parentSessionId, { promise: operation, expandableRows })
|
||||
this.catalogInflight.set(parentSessionId, { promise: operation, expandableRows, activityRows })
|
||||
return operation
|
||||
}
|
||||
|
||||
@@ -651,19 +657,22 @@ export class SessionManager {
|
||||
return
|
||||
}
|
||||
case 'host/session-removed': {
|
||||
this.recordMutation({ kind: 'remove', sessionId: frame.sessionId })
|
||||
if (this.addresses.has(frame.sessionId)) {
|
||||
const summary = this.summaries.find(candidate => candidate.sessionId === frame.sessionId)
|
||||
const durableSubagent = summary?.origin === 'subagent' || this.addresses.has(frame.sessionId)
|
||||
this.recordMutation(durableSubagent
|
||||
? { kind: 'status', sessionId: frame.sessionId, running: false }
|
||||
: { kind: 'remove', sessionId: frame.sessionId })
|
||||
this.updateCatalogActivity(frame.sessionId, false)
|
||||
if (durableSubagent) {
|
||||
// An Activation detaching is not durable child deletion:
|
||||
// keep the addressed conversation usable and return its catalog row
|
||||
// to the inactive state.
|
||||
// keep its lineage and conversation while returning it to idle.
|
||||
this.sessions.get(frame.sessionId)?.handleRunning(false)
|
||||
this.updateCatalogActivity(frame.sessionId, false)
|
||||
} else {
|
||||
this.sessions.get(frame.sessionId)?.handleRemoved()
|
||||
}
|
||||
this.pendingBuffers.delete(frame.sessionId) // a removed session's buffered frames must not replay on a future instantiation
|
||||
this.waitingApprovals.delete(frame.sessionId) // a removed session cannot wait on anyone
|
||||
this.projectionStores.delete(frame.sessionId) // removed sessions drop their projection rows with the instance
|
||||
if (!durableSubagent) this.projectionStores.delete(frame.sessionId)
|
||||
return
|
||||
}
|
||||
case 'host/session-status': {
|
||||
@@ -725,11 +734,14 @@ export class SessionManager {
|
||||
this.catalogDebounce.set(parentSessionId, timer)
|
||||
}
|
||||
|
||||
/** Flip a listed child's coarse activity in place from the shared Host frame. */
|
||||
/** Apply one Agent-driver transition to loaded and in-flight catalogs. */
|
||||
private updateCatalogActivity(childSessionId: SessionId, running: boolean): void {
|
||||
const activity = running ? 'running' as const : 'inactive' as const
|
||||
for (const inflight of this.catalogInflight.values()) {
|
||||
inflight.activityRows.set(childSessionId, activity)
|
||||
}
|
||||
let changed = false
|
||||
for (const [parentSessionId, catalog] of this.catalogs) {
|
||||
const activity = running ? 'running' as const : 'inactive' as const
|
||||
if (!catalog.entries.some(entry =>
|
||||
entry.kind === 'child' && entry.id === childSessionId && entry.activity !== activity)) continue
|
||||
const entries = catalog.entries.map((entry) => {
|
||||
@@ -764,14 +776,22 @@ export class SessionManager {
|
||||
if (changed) this.notifier.markDirty()
|
||||
}
|
||||
|
||||
/** Fold request-local positive row mutations into one catalog result before publication. */
|
||||
private withExpandableRows(
|
||||
/** Fold request-local row mutations into one catalog result before publication. */
|
||||
private withCatalogMutations(
|
||||
entries: SubagentCatalog['entries'],
|
||||
expandableRows: ReadonlySet<SessionId>,
|
||||
activityRows: ReadonlyMap<SessionId, 'running' | 'inactive'>,
|
||||
): SubagentCatalog['entries'] {
|
||||
return entries.map(entry => entry.kind === 'child' && expandableRows.has(entry.id)
|
||||
? { ...entry, hasChildren: true }
|
||||
: entry)
|
||||
return entries.map((entry) => {
|
||||
if (entry.kind !== 'child') return entry
|
||||
const activity = activityRows.get(entry.id)
|
||||
if (!expandableRows.has(entry.id) && activity === undefined) return entry
|
||||
return {
|
||||
...entry,
|
||||
...expandableRows.has(entry.id) ? { hasChildren: true } : {},
|
||||
...activity === undefined ? {} : { activity },
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private buildListSnapshot(): SessionListSnapshot {
|
||||
|
||||
@@ -625,13 +625,15 @@ export class SessionsService implements ISessions {
|
||||
while (address !== undefined && !seen.has(address.childSessionId)) {
|
||||
const childId = address.childSessionId
|
||||
seen.add(childId)
|
||||
if (byId[childId] === undefined) {
|
||||
const child = subagentsByParent[address.parentSessionId]?.entries
|
||||
.find(entry => entry.kind === 'child' && entry.id === childId)
|
||||
if (child?.kind !== 'child') break
|
||||
const child = subagentsByParent[address.parentSessionId]?.entries
|
||||
.find(entry => entry.kind === 'child' && entry.id === childId)
|
||||
if (child?.kind !== 'child') break
|
||||
const displayTitle = child.label ?? childId
|
||||
const summary = byId[childId]
|
||||
if (summary === undefined) {
|
||||
byId[childId] = {
|
||||
id: childId,
|
||||
displayTitle: child.label ?? childId,
|
||||
displayTitle,
|
||||
parentId: address.parentSessionId,
|
||||
origin: 'subagent',
|
||||
running: child.activity === 'running',
|
||||
@@ -639,8 +641,11 @@ export class SessionsService implements ISessions {
|
||||
blank: false,
|
||||
updatedAt: 0,
|
||||
}
|
||||
} else if (summary.displayTitle !== displayTitle) {
|
||||
byId[childId] = { ...summary, displayTitle }
|
||||
}
|
||||
if (byId[address.parentSessionId] !== undefined) break
|
||||
const parent = byId[address.parentSessionId]
|
||||
if (parent !== undefined && parent.origin !== 'subagent') break
|
||||
address = this.manager.navigationAddress(address.parentSessionId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,6 +341,9 @@ describe('subagent catalogs', () => {
|
||||
rpcId: 'child-detached' as never,
|
||||
payload: { type: 'host/session-removed', sessionId: S2 },
|
||||
})
|
||||
expect(manager.getListSnapshot().items.find(item => item.sessionId === S2)).toMatchObject({
|
||||
origin: 'subagent', parentSessionId: S1, running: false,
|
||||
})
|
||||
expect(manager.get(S2).getSnapshot()).toMatchObject({
|
||||
removed: false,
|
||||
subagent: {
|
||||
@@ -467,6 +470,65 @@ describe('subagent catalogs', () => {
|
||||
{ kind: 'child', id: S1, hasChildren: false },
|
||||
])
|
||||
})
|
||||
|
||||
it('replays status frames over an older in-flight catalog response', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const root = 'fk-root' as SessionId
|
||||
const response = deferred<Awaited<ReturnType<FakeApiClient['onSubagentList']>>>()
|
||||
api.onSubagentList = () => response.promise
|
||||
const manager = new SessionManager(api)
|
||||
const refresh = manager.refreshSubagents(root)
|
||||
|
||||
manager.handleHostEnvelope({
|
||||
rpcId: 'child-stopped' as never,
|
||||
payload: { type: 'host/session-status', sessionId: S1, running: false },
|
||||
})
|
||||
manager.handleHostEnvelope({
|
||||
rpcId: 'child-started' as never,
|
||||
payload: { type: 'host/session-status', sessionId: S2, running: true },
|
||||
})
|
||||
response.resolve(ok({
|
||||
entries: [
|
||||
{
|
||||
kind: 'child', id: S1, mode: 'continuable', label: 'stopped',
|
||||
activity: 'running', hasChildren: false,
|
||||
},
|
||||
{
|
||||
kind: 'child', id: S2, mode: 'continuable', label: 'started',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
},
|
||||
] as never[],
|
||||
parentAvailable: true,
|
||||
}))
|
||||
await refresh
|
||||
|
||||
expect(manager.getListSnapshot().subagentsByParent[root]?.entries).toMatchObject([
|
||||
{ kind: 'child', id: S1, activity: 'inactive' },
|
||||
{ kind: 'child', id: S2, activity: 'running' },
|
||||
])
|
||||
})
|
||||
|
||||
it('marks a detached catalog child inactive without requiring a selected address', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onSubagentList = () => Promise.resolve(ok({
|
||||
entries: [{
|
||||
kind: 'child', id: S2, mode: 'continuable', label: 'worker',
|
||||
activity: 'running', hasChildren: false,
|
||||
}] as never[],
|
||||
parentAvailable: true,
|
||||
}))
|
||||
const manager = new SessionManager(api)
|
||||
await manager.refreshSubagents(S1)
|
||||
|
||||
manager.handleHostEnvelope({
|
||||
rpcId: 'child-detached' as never,
|
||||
payload: { type: 'host/session-removed', sessionId: S2 },
|
||||
})
|
||||
|
||||
expect(manager.getListSnapshot().subagentsByParent[S1]?.entries).toMatchObject([
|
||||
{ kind: 'child', id: S2, activity: 'inactive' },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('remaining branches', () => {
|
||||
|
||||
@@ -362,6 +362,45 @@ describe('slot-store scope prune hook', () => {
|
||||
})
|
||||
|
||||
describe('catalog-addressed navigation', () => {
|
||||
it('uses catalog labels for a listed addressed route', async () => {
|
||||
const b = bench()
|
||||
b.api.onSubagentList = (payload) => {
|
||||
const { parentSessionId } = payload as { parentSessionId: SessionId }
|
||||
if (parentSessionId === sid('root')) {
|
||||
return Promise.resolve(ok({
|
||||
entries: [{
|
||||
kind: 'child', id: sid('child'), mode: 'continuable', label: 'Child',
|
||||
activity: 'inactive', hasChildren: true,
|
||||
}] as never[],
|
||||
parentAvailable: true,
|
||||
}))
|
||||
}
|
||||
if (parentSessionId === sid('child')) {
|
||||
return Promise.resolve(ok({
|
||||
entries: [{
|
||||
kind: 'child', id: sid('grandchild'), mode: 'continuable', label: 'Grandchild',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
}] as never[],
|
||||
parentAvailable: false,
|
||||
}))
|
||||
}
|
||||
return Promise.resolve(ok({ entries: [], parentAvailable: false }))
|
||||
}
|
||||
await feedList(b, [
|
||||
{ id: 'root' },
|
||||
{ id: 'child', cwd: '/summary-child', parentId: 'root', origin: 'subagent' },
|
||||
{ id: 'grandchild', cwd: '/summary-grandchild', parentId: 'child', origin: 'subagent' },
|
||||
])
|
||||
await b.svc.refreshSubagents(sid('root'))
|
||||
await b.svc.refreshSubagents(sid('child'))
|
||||
b.svc.openSubagent({
|
||||
parentSessionId: sid('child'), childSessionId: sid('grandchild'), mode: 'continuable',
|
||||
})
|
||||
|
||||
expect(b.svc.list.getSnapshot().byId[sid('child')]?.displayTitle).toBe('Child')
|
||||
expect(b.svc.list.getSnapshot().byId[sid('grandchild')]?.displayTitle).toBe('Grandchild')
|
||||
})
|
||||
|
||||
it('projects a directly opened descendant route without retaining ancestor scopes or addresses', async () => {
|
||||
const b = bench()
|
||||
b.api.onSubagentList = (payload) => {
|
||||
|
||||
@@ -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: 561fc68c6c002a7542303f8f95e5bd325cfc673e
|
||||
README.zh.md: 7b80919a5b270eaca754ffccb99db04fb614882d
|
||||
README.md: f6b3fa2e9cdf1479a739e0b4eab15a5423e878e4
|
||||
README.zh.md: fdfba385e9188cd42bd973b6f32bc01fe8d004f2
|
||||
|
||||
@@ -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. 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.
|
||||
The header action reads `subagentsByParent` and session summaries through the standard `useSessions` hook. After a non-empty direct catalog arrives, its trigger counts the complete subagent-only descendant lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. The compact tree remains direct-catalog authoritative: 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, while 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 immediately reserves one disabled loading row per known direct descendant, then lazily replaces them with that child's authoritative catalog. Every visible branch is reported 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).
|
||||
|
||||
|
||||
@@ -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。损坏、不受支持或不可用的行仍保持可读但禁用。每个健康行的 `hasChildren` 提示会在交互前决定是否显示展开控件,因此已知叶子节点从不显示箭头;展开分支时仍会懒加载该 child 的权威直接目录,并向运行时报告每个可见分支,使成员帧只在树正被消费的位置触发去抖动刷新。选择任意深度的条目都会使用该行的确切地址 `{parentSessionId, childSessionId, mode}` 调用 `SessionsService.openSubagent()`。组件局部状态负责树的可见性、已展开分支与键盘焦点。ArrowRight/ArrowLeft 展开和折叠分支;ArrowUp/ArrowDown、Home、End 与 Escape 用于导航或关闭树;关闭后焦点返回触发器。样式只使用 token。
|
||||
页头操作通过标准 `useSessions` 钩子读取 `subagentsByParent` 与会话摘要。非空直接目录到达后,其触发器会统计仅含 subagent 的完整后代谱系,在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。紧凑树仍以直接目录为权威依据:可继续和 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)规定。
|
||||
|
||||
|
||||
@@ -17,6 +17,17 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.count {
|
||||
margin: 0 5px;
|
||||
}
|
||||
|
||||
.activitySlot {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.trigger:hover,
|
||||
.trigger:focus-visible {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
@@ -111,6 +122,10 @@
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.loadingRow {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.disclosure,
|
||||
.disclosureSpace {
|
||||
flex: none;
|
||||
|
||||
@@ -68,15 +68,78 @@ function relativeTime(updatedAt: number | undefined, now: number): string | unde
|
||||
return `${Math.floor(diff / (365 * day))}年`
|
||||
}
|
||||
|
||||
/** Aggregate the complete subagent-only descendant subtree from flat summaries. */
|
||||
function summarizeDescendants(
|
||||
sessionId: SessionId,
|
||||
summaries: Readonly<Record<SessionId, SessionSummary>>,
|
||||
): { count: number; running: boolean } {
|
||||
let count = 0
|
||||
let running = false
|
||||
for (const summary of Object.values(summaries)) {
|
||||
if (summary.origin !== 'subagent') continue
|
||||
const seen = new Set<SessionId>()
|
||||
let current: SessionSummary | undefined = summary
|
||||
while (current?.origin === 'subagent' && current.parentId !== undefined
|
||||
&& !seen.has(current.id)) {
|
||||
seen.add(current.id)
|
||||
if (current.parentId === sessionId) {
|
||||
count += 1
|
||||
running ||= summary.running
|
||||
break
|
||||
}
|
||||
current = summaries[current.parentId]
|
||||
}
|
||||
}
|
||||
return { count, running }
|
||||
}
|
||||
|
||||
/** Render the known direct-child shape while its authoritative catalog hydrates. */
|
||||
function CatalogLoadingRows({
|
||||
parentSessionId,
|
||||
summaries,
|
||||
level,
|
||||
}: {
|
||||
parentSessionId: SessionId
|
||||
summaries: Readonly<Record<SessionId, SessionSummary>>
|
||||
level: number
|
||||
}) {
|
||||
const children = Object.values(summaries).filter(summary => (
|
||||
summary.origin === 'subagent' && summary.parentId === parentSessionId
|
||||
))
|
||||
if (children.length === 0) return <div className={css.notice}>正在加载子代理…</div>
|
||||
return children.map(summary => (
|
||||
<div key={summary.id} className={css.node}>
|
||||
<div
|
||||
role="treeitem"
|
||||
aria-disabled="true"
|
||||
aria-level={level}
|
||||
aria-label="正在加载子代理"
|
||||
className={`${css.row} ${css.disabled} ${css.loadingRow}`}
|
||||
>
|
||||
<span className={css.disclosureSpace} />
|
||||
<StateDot state={summary.running ? 'ongoing' : 'done'} />
|
||||
<span className={css.content}>
|
||||
<span className={css.label}>正在加载子代理…</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
|
||||
/** Render one catalog level and recurse only through explicitly expanded rows. */
|
||||
function CatalogRows({
|
||||
parentSessionId, catalog, catalogs, summaries, expanded, level, now,
|
||||
openChild, refresh, toggleBranch, closeCatalog,
|
||||
}: CatalogRowsProps) {
|
||||
const emptyLoading = catalog.state === 'loading' && catalog.entries.length === 0
|
||||
return (
|
||||
<>
|
||||
{catalog.state === 'loading' && catalog.entries.length === 0 && (
|
||||
<div className={css.notice}>正在加载子代理…</div>
|
||||
{emptyLoading && (
|
||||
<CatalogLoadingRows
|
||||
parentSessionId={parentSessionId}
|
||||
summaries={summaries}
|
||||
level={level}
|
||||
/>
|
||||
)}
|
||||
{catalog.state === 'error' && (
|
||||
<div className={css.error}>
|
||||
@@ -118,7 +181,8 @@ function CatalogRows({
|
||||
const childCatalog = catalogs[entry.id]
|
||||
const isExpanded = expanded.has(entry.id)
|
||||
const knownLeaf = !entry.hasChildren
|
||||
const emptyLoading = childCatalog?.state === 'loading' && childCatalog.entries.length === 0
|
||||
const childLoading = childCatalog === undefined
|
||||
|| (childCatalog.state === 'loading' && childCatalog.entries.length === 0)
|
||||
const summary = summaries[entry.id]
|
||||
const label = entry.label ?? entry.id
|
||||
const mode = entry.mode === 'one-shot' ? '一次性' : '可继续'
|
||||
@@ -186,25 +250,35 @@ function CatalogRows({
|
||||
{time !== undefined && <span className={css.time}>{time}</span>}
|
||||
</div>
|
||||
</div>
|
||||
{isExpanded && childCatalog !== undefined && !knownLeaf && (
|
||||
{isExpanded && !knownLeaf && (
|
||||
<div
|
||||
role="group"
|
||||
className={css.children}
|
||||
aria-busy={emptyLoading || undefined}
|
||||
aria-busy={childLoading || undefined}
|
||||
>
|
||||
<CatalogRows
|
||||
parentSessionId={entry.id}
|
||||
catalog={childCatalog}
|
||||
catalogs={catalogs}
|
||||
summaries={summaries}
|
||||
expanded={expanded}
|
||||
level={level + 1}
|
||||
now={now}
|
||||
openChild={openChild}
|
||||
refresh={refresh}
|
||||
toggleBranch={toggleBranch}
|
||||
closeCatalog={closeCatalog}
|
||||
/>
|
||||
{childCatalog === undefined
|
||||
? (
|
||||
<CatalogLoadingRows
|
||||
parentSessionId={entry.id}
|
||||
summaries={summaries}
|
||||
level={level + 1}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<CatalogRows
|
||||
parentSessionId={entry.id}
|
||||
catalog={childCatalog}
|
||||
catalogs={catalogs}
|
||||
summaries={summaries}
|
||||
expanded={expanded}
|
||||
level={level + 1}
|
||||
now={now}
|
||||
openChild={openChild}
|
||||
refresh={refresh}
|
||||
toggleBranch={toggleBranch}
|
||||
closeCatalog={closeCatalog}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -233,6 +307,10 @@ export function SubagentCatalogAction({
|
||||
const setCatalogOpenRef = useRef(setCatalogOpen)
|
||||
setCatalogOpenRef.current = setCatalogOpen
|
||||
const healthy = catalog?.entries.filter(entry => entry.kind === 'child') ?? []
|
||||
const descendants = summarizeDescendants(sessionId, summaries)
|
||||
// The catalog can arrive before the session-list baseline; never undercount
|
||||
// the already-visible direct rows during that short bootstrap window.
|
||||
const descendantCount = Math.max(healthy.length, descendants.count)
|
||||
|
||||
const observeCatalog = (parentSessionId: SessionId, next: boolean): void => {
|
||||
if (next) observedCatalogs.current.add(parentSessionId)
|
||||
@@ -341,6 +419,7 @@ export function SubagentCatalogAction({
|
||||
className={css.trigger}
|
||||
aria-haspopup="tree"
|
||||
aria-expanded={open}
|
||||
aria-label={`${descendantCount} 个子代理${descendants.running ? ',正在运行' : ''}`}
|
||||
onClick={() => { changeOpen(!open) }}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'ArrowDown') return
|
||||
@@ -349,7 +428,10 @@ export function SubagentCatalogAction({
|
||||
queueMicrotask(() => { focusAt(0) })
|
||||
}}
|
||||
>
|
||||
<span>{healthy.length} 个子代理</span>
|
||||
<span className={css.activitySlot}>
|
||||
{descendants.running && <StateDot state="ongoing" />}
|
||||
</span>
|
||||
<span className={css.count}>{descendantCount} 个子代理</span>
|
||||
<IconChevronDownOutline14 className={open ? css.triggerOpen : undefined} />
|
||||
</button>
|
||||
{open && (
|
||||
|
||||
@@ -84,6 +84,54 @@ function summary(id: SessionId, updatedAt: number): SessionSummary {
|
||||
}
|
||||
|
||||
describe('SubagentCatalogAction', () => {
|
||||
it('aggregates live descendant activity onto the closed trigger', () => {
|
||||
const summaries: Record<SessionId, SessionSummary> = {
|
||||
[CHILD]: {
|
||||
...summary(CHILD, Date.now()),
|
||||
parentId: PARENT,
|
||||
origin: 'subagent',
|
||||
},
|
||||
[GRANDCHILD]: {
|
||||
...summary(GRANDCHILD, Date.now()),
|
||||
parentId: CHILD,
|
||||
origin: 'subagent',
|
||||
running: true,
|
||||
},
|
||||
['child-2' as SessionId]: {
|
||||
...summary('child-2' as SessionId, Date.now()),
|
||||
parentId: PARENT,
|
||||
origin: 'subagent',
|
||||
},
|
||||
}
|
||||
const view = render(<SubagentCatalogAction {...props(catalog(), {}, summaries)} />)
|
||||
|
||||
const trigger = screen.getByRole('button', { name: '3 个子代理,正在运行' })
|
||||
expect(trigger.querySelector('[data-state="ongoing"]')).not.toBeNull()
|
||||
|
||||
view.rerender(<SubagentCatalogAction {...props(catalog(), {}, {
|
||||
...summaries,
|
||||
[GRANDCHILD]: { ...summaries[GRANDCHILD]!, running: false },
|
||||
})} />)
|
||||
expect(screen.getByRole('button', { name: '3 个子代理' })
|
||||
.querySelector('[data-state="ongoing"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not aggregate subagents reached through an ordinary fork', () => {
|
||||
const fork = 'fork' as SessionId
|
||||
const forkChild = 'fork-child' as SessionId
|
||||
render(<SubagentCatalogAction {...props(catalog(), {}, {
|
||||
[CHILD]: { ...summary(CHILD, 1), parentId: PARENT, origin: 'subagent' },
|
||||
['child-2' as SessionId]: {
|
||||
...summary('child-2' as SessionId, 1), parentId: PARENT, origin: 'subagent',
|
||||
},
|
||||
[fork]: { ...summary(fork, 1), parentId: PARENT },
|
||||
[forkChild]: { ...summary(forkChild, 1), parentId: fork, origin: 'subagent', running: true },
|
||||
})} />)
|
||||
|
||||
const trigger = screen.getByRole('button', { name: '2 个子代理' })
|
||||
expect(trigger.querySelector('[data-state="ongoing"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders healthy counts, stable rows, diagnostics, and catalog-addressed navigation', () => {
|
||||
const input = props(catalog())
|
||||
render(<SubagentCatalogAction {...input} />)
|
||||
@@ -237,29 +285,54 @@ describe('SubagentCatalogAction', () => {
|
||||
expect(input.setCatalogOpen).toHaveBeenCalledWith(CHILD, false)
|
||||
})
|
||||
|
||||
it('shows initial descendant loading status without drawing its branch', () => {
|
||||
const loading = props(catalog(), {
|
||||
[CHILD]: catalog({ entries: [], state: 'loading' }),
|
||||
})
|
||||
const view = render(<SubagentCatalogAction {...loading} />)
|
||||
it('shows known descendant rows while their catalog loads', () => {
|
||||
const secondGrandchild = 'grandchild-2' as SessionId
|
||||
const summaries = {
|
||||
[GRANDCHILD]: {
|
||||
...summary(GRANDCHILD, 1), parentId: CHILD, origin: 'subagent' as const,
|
||||
},
|
||||
[secondGrandchild]: {
|
||||
...summary(secondGrandchild, 1), parentId: CHILD, origin: 'subagent' as const,
|
||||
running: true,
|
||||
},
|
||||
}
|
||||
const deferred = props(catalog(), {}, summaries)
|
||||
const view = render(<SubagentCatalogAction {...deferred} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /2 个子代理/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '展开 worker 的下级子代理' }))
|
||||
|
||||
expect(loading.setCatalogOpen).toHaveBeenCalledWith(CHILD, true)
|
||||
expect(deferred.setCatalogOpen).toHaveBeenCalledWith(CHILD, true)
|
||||
expect(screen.getByRole('group').getAttribute('aria-busy')).toBe('true')
|
||||
expect(screen.getByText('正在加载子代理…')).toBeTruthy()
|
||||
const loadingRows = screen.getAllByRole('treeitem', { name: '正在加载子代理' })
|
||||
expect(loadingRows).toHaveLength(2)
|
||||
expect(loadingRows.every(row => row.getAttribute('aria-level') === '2')).toBe(true)
|
||||
expect(loadingRows[1]?.querySelector('[data-state="ongoing"]')).not.toBeNull()
|
||||
|
||||
const loading = props(catalog(), {
|
||||
[CHILD]: catalog({ entries: [], state: 'loading' }),
|
||||
}, summaries)
|
||||
view.rerender(<SubagentCatalogAction {...loading} />)
|
||||
expect(screen.getAllByRole('treeitem', { name: '正在加载子代理' })).toHaveLength(2)
|
||||
|
||||
const ready = props(catalog(), {
|
||||
[CHILD]: catalog({
|
||||
entries: [{
|
||||
kind: 'child', id: GRANDCHILD, mode: 'continuable',
|
||||
label: 'indexer', activity: 'inactive', hasChildren: false,
|
||||
}],
|
||||
entries: [
|
||||
{
|
||||
kind: 'child', id: GRANDCHILD, mode: 'continuable',
|
||||
label: 'indexer', activity: 'inactive', hasChildren: false,
|
||||
},
|
||||
{
|
||||
kind: 'child', id: secondGrandchild, mode: 'one-shot',
|
||||
label: 'critic', activity: 'running', hasChildren: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
}, summaries)
|
||||
view.rerender(<SubagentCatalogAction {...ready} />)
|
||||
expect(screen.getByRole('group').getAttribute('aria-busy')).toBeNull()
|
||||
expect(screen.getByRole('treeitem', { name: /indexer/ })).toBeTruthy()
|
||||
expect(screen.getByRole('treeitem', { name: /critic/ })).toBeTruthy()
|
||||
expect(screen.queryByRole('treeitem', { name: '正在加载子代理' })).toBeNull()
|
||||
})
|
||||
|
||||
it('uses ArrowRight and ArrowLeft for branch disclosure', async () => {
|
||||
|
||||
@@ -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: c4e99c4240d2e728840ab67a29552ee39726fe62
|
||||
README.zh.md: c3166ede6babb885ac43b2fef91c55a3b843b122
|
||||
README.md: 933b5f6167263545b3bef5ca9fb8f8b945e86ef9
|
||||
README.zh.md: f1f1106dbd0c50889eca6f6ae52fbb29d1c4c03f
|
||||
|
||||
@@ -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 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).
|
||||
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, replaces corpus activity with the exact child Agent driver's running state, and includes 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)
|
||||
|
||||
|
||||
@@ -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` 投影包含 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)。
|
||||
`subagent.*` 领域通过 `{parentSessionId, childSessionId}` 寻址直接 child。`subagent.list` 从 `ctx.subagents.listChildren` 投影包含 one-shot 与可继续条目的完整持久化目录、每个健康行基于 origin 分类的 `hasChildren` 提示,并把语料活动状态替换为确切 child Agent driver 的运行状态,同时提供确切 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` + 根路径)
|
||||
|
||||
|
||||
@@ -1880,7 +1880,12 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
try {
|
||||
const entries = await ctx.subagents.listChildren(request.payload.parentSessionId, signal)
|
||||
return ok(request, {
|
||||
entries,
|
||||
entries: entries.map(entry => entry.kind === 'child'
|
||||
? {
|
||||
...entry,
|
||||
activity: ctx.agents.get(entry.id)?.status === 'running' ? 'running' : 'inactive',
|
||||
}
|
||||
: entry),
|
||||
parentAvailable: ctx.agents.get(request.payload.parentSessionId) !== undefined,
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
|
||||
@@ -15,6 +15,7 @@ export type SubagentListEntry =
|
||||
| {
|
||||
kind: 'child'
|
||||
id: SessionId
|
||||
/** Whether the child Agent driver is running at the Host sampling boundary. */
|
||||
activity: 'running' | 'inactive'
|
||||
/** Whether a direct descendant has durable `origin: 'subagent'`. */
|
||||
hasChildren: boolean
|
||||
|
||||
@@ -17,6 +17,7 @@ function request<P>(payload: P): RpcRequest<P> {
|
||||
|
||||
function bench(options: {
|
||||
parentLive?: boolean
|
||||
childStatus?: 'idle' | 'running'
|
||||
entries?: object[]
|
||||
followupError?: Error
|
||||
listError?: Error
|
||||
@@ -24,8 +25,14 @@ function bench(options: {
|
||||
historyParent?: SessionId
|
||||
} = {}) {
|
||||
const parent = { id: PARENT }
|
||||
const getAgent = vi.fn((id: SessionId) =>
|
||||
options.parentLive !== false && id === PARENT ? parent : undefined)
|
||||
const child = options.childStatus === undefined
|
||||
? undefined
|
||||
: { id: CHILD, status: options.childStatus }
|
||||
const getAgent = vi.fn((id: SessionId) => {
|
||||
if (options.parentLive !== false && id === PARENT) return parent
|
||||
if (id === CHILD) return child
|
||||
return undefined
|
||||
})
|
||||
const listChildren = vi.fn(() => options.listError === undefined
|
||||
? Promise.resolve(options.entries ?? [
|
||||
{
|
||||
@@ -92,6 +99,19 @@ describe('subagent gateway', () => {
|
||||
expect(listChildren).toHaveBeenCalledWith(PARENT, undefined)
|
||||
})
|
||||
|
||||
it('derives catalog activity from the live child Agent rather than Session residency', async () => {
|
||||
const residentIdle = bench({ childStatus: 'idle', entries: [{
|
||||
kind: 'child', id: CHILD, mode: 'continuable', label: 'worker',
|
||||
activity: 'running', hasChildren: false,
|
||||
}] })
|
||||
expect((await residentIdle.api.subagents.list(request({ parentSessionId: PARENT }))).result)
|
||||
.toMatchObject({ ok: true, value: { entries: [{ activity: 'inactive' }] } })
|
||||
|
||||
const running = bench({ childStatus: 'running' })
|
||||
expect((await running.api.subagents.list(request({ parentSessionId: PARENT }))).result)
|
||||
.toMatchObject({ ok: true, value: { entries: [{ activity: 'running' }] } })
|
||||
})
|
||||
|
||||
it('reads a healthy direct child without looking up or activating any Agent', async () => {
|
||||
const { api, getAgent, readSession } = bench()
|
||||
const response = await api.subagents.history(request({
|
||||
|
||||
Reference in New Issue
Block a user