show subagent usage and active duration
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: 34acb1410cf6316bca2980ed012046ffab9623f6
|
||||
2026-07-27-web-subagent-conversations.zh.md: 5dcd7025c5cd03fed34266834795de1f2b630648
|
||||
2026-07-27-web-subagent-conversations.md: 6658bcc960ec691f3646f4ff08d8a065d3a8d70a
|
||||
2026-07-27-web-subagent-conversations.zh.md: 5e89c9a7a420687c53be961245de7adb99bf3c44
|
||||
@@ -33,7 +33,7 @@ The Figma [subagent list](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Ha
|
||||
| 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 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. |
|
||||
| Rows show labels, state, usage, and active duration without duplicating sidebar rows. | Mode and `running`/`inactive` activity are textual as well as visual; optional title, durable token usage, and exact active-turn duration come from the list's retained projection values. `SessionHeader.origin` removes duplicate navigation rows but grants no capability. |
|
||||
|
||||
## Product contract
|
||||
|
||||
@@ -41,6 +41,8 @@ The header action is absent only after a complete empty direct-catalog response.
|
||||
|
||||
`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.
|
||||
|
||||
Healthy rows reuse the standard session projections retained in the list mirror. The token figure sums the four disjoint `tokenUsage` buckets across the durable log. `subagentTiming` resets at every descriptor so an inherited fork seed cannot enter the child's total, accumulates completed `turn/start` → `turn/end` spans, and carries the current turn's `activeSince`. The menu formats whole seconds and advances its local clock only while a known descendant is running; an inactive row uses settled duration, or the summary's last activity to bound an interrupted open turn, so reopening the menu never restarts completed work. Token chunks do not change `subagentTiming` and therefore do not add a per-token list update path. Neither metric implies a durable outcome.
|
||||
|
||||
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.
|
||||
@@ -65,7 +67,7 @@ The adapter stays in `dsh-host-apiproxy`; `dsh-host-webserver` remains a carrier
|
||||
|
||||
## Client object layer and presentation
|
||||
|
||||
The React-free runtime owns catalogs, single-flight refreshes, retained addresses, availability hints, and transport selection. Re-selecting a known child retains its address so navigation cannot silently switch to ordinary session APIs. A missing intermediate breadcrumb address can be recovered from an already-loaded ancestor catalog, but it is not retained for transport and creates no scope until the user selects that breadcrumb. Restored navigation persists the full mode-bearing address.
|
||||
The React-free runtime owns catalogs, single-flight refreshes, retained addresses, availability hints, transport selection, and a reference-stable map of each list row's current projection values. Re-selecting a known child retains its address so navigation cannot silently switch to ordinary session APIs. A missing intermediate breadcrumb address can be recovered from an already-loaded ancestor catalog, but it is not retained for transport and creates no scope until the user selects that breadcrumb. Restored navigation persists the full mode-bearing address.
|
||||
|
||||
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`.
|
||||
|
||||
@@ -102,14 +104,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 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.
|
||||
- jsdom tests pin the aggregate descendant count and activity, token totals, second-precision running and frozen inactive durations, 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 with durable usage, an inactive one-shot sibling, and a persisted grandchild; it pins the three-descendant trigger, usage and timing rows, 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; 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.
|
||||
- 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 usage and duration reuse projection baselines and pushes with no per-row log read, and 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.
|
||||
- The UI has no child cancellation, durable outcome, Activation identity, deletion, or independently interactive offline mode, and its text must not imply those capabilities. Active-turn duration measures logged work rather than Activation residency.
|
||||
@@ -33,7 +33,7 @@ Figma 中的 [subagent 列表](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5
|
||||
| 会话页头可打开紧凑的 child 列表。 | 触发器会汇总仅含 subagent 的完整后代谱系;树按服务顺序显示每个直接目录条目,包括已禁用的 diagnostic。 |
|
||||
| 选择一行会复用对话 UI。 | 已寻址历史绝不激活 child;只有 parent 存活的可继续行才保留普通输入框。 |
|
||||
| 嵌套 agent 会逐层展开。 | 每行携带一层 `hasChildren` 快照;展开时会立即预留已知直接后代行,随后仍只加载该行的直接目录,并保留其自身的 parent 地址。 |
|
||||
| 条目显示 label、状态与相对时间,同时避免侧边栏条目重复。 | mode 与 `running`/`inactive` 活动状态会同时以文字和视觉呈现;可选 title 与时间来自摘要。`SessionHeader.origin` 会移除重复的导航条目,但不授予任何功能权限。 |
|
||||
| 条目显示 label、状态、用量与活跃耗时,同时避免侧边栏条目重复。 | mode 与 `running`/`inactive` 活动状态会同时以文字和视觉呈现;可选 title、持久化 token 用量与精确的活跃轮次耗时来自列表保留的投影值。`SessionHeader.origin` 会移除重复的导航条目,但不授予任何功能权限。 |
|
||||
|
||||
## 产品契约
|
||||
|
||||
@@ -41,6 +41,8 @@ Figma 中的 [subagent 列表](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5
|
||||
|
||||
`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` 刷新来更新。消息投递时仍以提示词响应为权威依据。
|
||||
|
||||
健康行会复用列表镜像中保留的标准会话投影。token 数值会汇总持久化日志中四个互不重叠的 `tokenUsage` 桶。`subagentTiming` 会在每个描述符处重置,使继承的 fork 种子不会计入 child 总量;它会累加已完成的 `turn/start` → `turn/end` 时段,并携带当前轮次的 `activeSince`。菜单会以整秒格式化时间,且仅在有已知后代处于运行状态时才推进其本地时钟;对 inactive 行,菜单使用已结算耗时,或以摘要的最后活动为被中断未结束轮次的上界,因此重新打开菜单绝不会让已完成工作重新计时。token 分片不会改变 `subagentTiming`,因此不会增加按 token 更新列表的路径。这两项指标都不蕴含持久化结果语义。
|
||||
|
||||
选择一行后,系统会先记录其确切地址,再打开常驻客户端 `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。提示词失败会通过普通错误行为保留草稿。
|
||||
@@ -65,7 +67,7 @@ one-shot 行始终会用文案替代输入框,说明执行记录为只读。
|
||||
|
||||
## 客户端对象层与呈现
|
||||
|
||||
不依赖 React 的运行时负责目录、单次并发刷新、保留的地址、可用性提示与传输选择。再次选择已知 child 时会保留其地址,避免导航静默切换到普通会话 API。缺失的中间面包屑地址可以从已加载的祖先目录恢复,但在用户选择该面包屑之前不会保留为传输地址,也不会创建 scope。恢复的导航会持久化包含 mode 的完整地址。
|
||||
不依赖 React 的运行时负责目录、单次并发刷新、保留的地址、可用性提示、传输选择,以及每个列表行当前投影值的引用稳定映射。再次选择已知 child 时会保留其地址,避免导航静默切换到普通会话 API。缺失的中间面包屑地址可以从已加载的祖先目录恢复,但在用户选择该面包屑之前不会保留为传输地址,也不会创建 scope。恢复的导航会持久化包含 mode 的完整地址。
|
||||
|
||||
目录通过标准 `useSessions` 快照传递。组件局部状态负责菜单可见性、已展开分支与焦点。`ui-conversation` 声明通用页头操作列表 slot,并通过其编辑器链分发当前对话快照;其中没有 subagent 专用的接管标记。`@deepseek-ai/dsh-client-ui-subagent` 注册目录操作,并根据普通 owner props 选择按原因区分的只读编辑器。组件只接收派生 props 与回调,绝不接收 `ctx`。
|
||||
|
||||
@@ -102,14 +104,14 @@ one-shot 行始终会用文案替代输入框,说明执行记录为只读。
|
||||
- 宿主协议测试固定 schema(包括必需的布尔可展开性)、id 回显、mode 校验、非激活式历史、确切 parent 强制要求、FIFO 准入回执、取消与脱敏后的失败映射。
|
||||
- 通用 Host 测试固定在不发布 Agent 的情况下读取已附加与冷态历史及执行 fork、冷态投影归并、按描述符/origin/运行时 owner 拒绝、拒绝显式 id 接纳,以及直接队列控制栅栏。
|
||||
- 客户端对象测试固定已保留与已恢复的地址、one-shot 只读拒绝、历史路由、可继续提示词路由、已寻址对话不提供取消、屏蔽绑定到 agent 的模型控件、实时活动状态翻转(包括在途响应回放与 detach 回退)、subagent parent 可展开性翻转与成员刷新。
|
||||
- jsdom 测试固定后代聚合计数与活动状态、已知加载行的形态、混合 mode 行、点击前的叶子展开控件、diagnostic、后代懒加载展开、直接 parent 地址、键盘行为与两种只读原因。
|
||||
- 无密钥的组装 Web 快照包含一个 inactive 的可继续 child、一个 inactive 的 one-shot sibling 和一个持久化 grandchild;它会固定触发器显示三个后代及聚合 `running` 状态转换,在不激活的情况下展开、打开持久化历史、准入一条用户 FIFO 后续消息、归并 child mux 事件,并证明 one-shot 历史仍然只读。
|
||||
- jsdom 测试固定后代聚合计数与活动状态、token 总量、精确到秒的运行中耗时与冻结后 inactive 耗时、已知加载行的形态、混合 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;Web 活动基线会为每个健康行增加一次 Agent 注册表查找,随后使用现有实时帧,而成员刷新保持去抖动和单次并发。
|
||||
- 目录读取可能重新扫描持久化谱系与每个直接候选的描述符日志,但可展开性只复用该追踪中已有的后代 header;Web 活动基线会为每个健康行增加一次 Agent 注册表查找,随后使用现有实时帧,而用量与耗时会复用投影基线和推送,无需按行读取日志,成员刷新则保持去抖动和单次并发。
|
||||
- parent 可用性、child 活动状态与 `hasChildren` 都是快照。列出之后,发布、dispose、其他发送方或其他进程都可能抢先改变状态;类型化提示词失败仍属预期行为。
|
||||
- child 可能在历史获取与 mux 订阅之间发布,因此现有序号归并也涵盖从冷态转为存活的已寻址路径。
|
||||
- 持久化 origin 会为 child header 与列表投影添加一个有意保持弱约束的产品分类字段;它不能变成授权捷径。
|
||||
- UI 不提供 child 取消、持久化结果、激活耗时、删除或可独立交互的离线 mode,其文案不得暗示这些功能已经存在。
|
||||
- UI 不提供 child 取消、持久化结果、Activation 身份、删除或可独立交互的离线 mode,其文案不得暗示这些功能已经存在。活跃轮次耗时度量的是已记录工作,而非 Activation 驻留时间。
|
||||
@@ -1,8 +1,8 @@
|
||||
- tree "子代理会话":
|
||||
- treeitem "event-sourcing researcher Explain event sourcing in one · 可继续 · 当前未运行 刚刚" [expanded] [level=1]:
|
||||
- treeitem "event-sourcing researcher Explain event sourcing in one · 可继续 · 当前未运行 7.9K tok · 2秒" [expanded] [level=1]:
|
||||
- button "收起 event-sourcing researcher 的下级子代理":
|
||||
- img
|
||||
- text: event-sourcing researcher Explain event sourcing in one · 可继续 · 当前未运行 刚刚
|
||||
- text: event-sourcing researcher Explain event sourcing in one · 可继续 · 当前未运行 7.9K tok · 2秒
|
||||
- group:
|
||||
- treeitem "example editor 可继续 · 当前未运行 刚刚" [level=2]
|
||||
- treeitem "event-sourcing reviewer 一次性 · 当前未运行 刚刚" [level=1]
|
||||
- treeitem "example editor 可继续 · 当前未运行 0 tok · 0秒" [level=2]
|
||||
- treeitem "event-sourcing reviewer 一次性 · 当前未运行 0 tok · 0秒" [level=1]
|
||||
@@ -149,6 +149,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
|
||||
data: { turn: 1, reason: { kind: 'completed' } },
|
||||
},
|
||||
] as SessionEvent[])
|
||||
await scaffold.ctx.sessionProjectionCache.coldSnapshot(oneShotId)
|
||||
grandchildId = sessionId('recorded-grandchild')
|
||||
const authoredAt = Date.now()
|
||||
await scaffold.ctx.sessionPersistence.create({
|
||||
@@ -192,6 +193,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
|
||||
data: { turn: 1, reason: { kind: 'completed' } },
|
||||
},
|
||||
] as SessionEvent[])
|
||||
await scaffold.ctx.sessionProjectionCache.coldSnapshot(grandchildId)
|
||||
expect(scaffold.ctx.agents.get(childId)).toBeUndefined()
|
||||
expect(scaffold.ctx.agents.get(oneShotId)).toBeUndefined()
|
||||
expect(scaffold.ctx.agents.get(grandchildId)).toBeUndefined()
|
||||
@@ -246,6 +248,10 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
|
||||
name: `展开 ${ONE_SHOT_LABEL} 的下级子代理`,
|
||||
}).count()).toBe(0)
|
||||
await page.getByRole('button', { name: `展开 ${LABEL} 的下级子代理` }).click()
|
||||
const childRow = page.getByRole('treeitem', { name: new RegExp(LABEL) })
|
||||
const childLabel = await childRow.getAttribute('aria-label')
|
||||
await page.waitForTimeout(1_100)
|
||||
expect(await childRow.getAttribute('aria-label')).toBe(childLabel)
|
||||
await page.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) }).waitFor({ timeout: 15_000 })
|
||||
expect(scaffold.ctx.agents.get(childId)).toBeUndefined()
|
||||
expect(scaffold.ctx.agents.get(grandchildId)).toBeUndefined()
|
||||
|
||||
@@ -794,7 +794,7 @@ A published child settled. Scope-filtered dispatch uses the same delegating pare
|
||||
|
||||
Types: [Scoped](../core-data-structures/scope.md) · [SubagentService](../core-data-structures/subagent.md)
|
||||
|
||||
Source: [`packages/subagent/subagent/src/index.ts:158`](../../packages/subagent/subagent/src/index.ts)
|
||||
Source: [`packages/subagent/subagent/src/index.ts:160`](../../packages/subagent/subagent/src/index.ts)
|
||||
|
||||
### `subagent/provider-added` — emit
|
||||
|
||||
@@ -811,7 +811,7 @@ A provider became resolvable in the registry.
|
||||
|
||||
Types: [SubagentProvider](../core-data-structures/subagent.md)
|
||||
|
||||
Source: [`packages/subagent/subagent/src/index.ts:132`](../../packages/subagent/subagent/src/index.ts)
|
||||
Source: [`packages/subagent/subagent/src/index.ts:134`](../../packages/subagent/subagent/src/index.ts)
|
||||
|
||||
### `subagent/provider-removed` — emit
|
||||
|
||||
@@ -826,7 +826,7 @@ A provider left the registry. Accepted runs remain holder-owned.
|
||||
'subagent/provider-removed'(name: string): void
|
||||
```
|
||||
|
||||
Source: [`packages/subagent/subagent/src/index.ts:138`](../../packages/subagent/subagent/src/index.ts)
|
||||
Source: [`packages/subagent/subagent/src/index.ts:140`](../../packages/subagent/subagent/src/index.ts)
|
||||
|
||||
### `subagent/start` — emit
|
||||
|
||||
@@ -848,7 +848,7 @@ A provider established a published child. For in-process providers, `ctx.agents.
|
||||
|
||||
Types: [Scoped](../core-data-structures/scope.md) · [SubagentService](../core-data-structures/subagent.md)
|
||||
|
||||
Source: [`packages/subagent/subagent/src/index.ts:149`](../../packages/subagent/subagent/src/index.ts)
|
||||
Source: [`packages/subagent/subagent/src/index.ts:151`](../../packages/subagent/subagent/src/index.ts)
|
||||
|
||||
## `system-prompt/*`
|
||||
|
||||
|
||||
@@ -2072,7 +2072,7 @@ async start(name: string, request: SubagentStartRequest): Promise<SubagentRun>
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [ContinuableSetupContribution](../core-data-structures/subagent.md) · [ContinuableStart](../core-data-structures/subagent.md) · [ContinuableStartSpec](../core-data-structures/subagent.md) · [MessageId](../core-data-structures/core.md) · [SessionId](../core-data-structures/core.md) · [SubagentFollowupOptions](../core-data-structures/subagent.md) · [SubagentListEntry](../core-data-structures/subagent.md) · [SubagentProvider](../core-data-structures/subagent.md) · [SubagentReportOptions](../core-data-structures/subagent.md) · [SubagentRun](../core-data-structures/subagent.md) · [SubagentStartRequest](../core-data-structures/subagent.md)
|
||||
|
||||
Source: [`packages/subagent/subagent/src/index.ts:163`](../../packages/subagent/subagent/src/index.ts)
|
||||
Source: [`packages/subagent/subagent/src/index.ts:165`](../../packages/subagent/subagent/src/index.ts)
|
||||
|
||||
## `ctx.subprocess` — `SubprocessService` (abstract seam)
|
||||
|
||||
|
||||
@@ -41,10 +41,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `settings/document-updated` | `emit` | [`packages/settings/settings/src/index.ts:150`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` |
|
||||
| `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:137`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) |
|
||||
| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:188`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | [`tui`](../packages/ui/tui) |
|
||||
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:158`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) |
|
||||
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:132`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:138`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:149`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) |
|
||||
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:160`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) |
|
||||
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:134`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:140`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:151`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) |
|
||||
| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) |
|
||||
| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
|
||||
| `telemetry/record` | `waterfall` | [`packages/telemetry/session-telemetry/src/index.ts:41`](../packages/telemetry/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/telemetry/session-telemetry) (`waterfall`) | - |
|
||||
|
||||
@@ -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: eca7db1f9b2d5c7e28fa86a363ca4408703b99df
|
||||
README.zh.md: 6a2e8c6085d06a9f04c1270e5976452b995a7e77
|
||||
README.md: 82f1bc95a6128245f88f01a0de0849494cb98359
|
||||
README.zh.md: f3aba75d18671fe377ec8305693ea5025d51e0de
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects and the Chat-facing list, scope, and event-window state; SessionHistoryService lazily owns independent raw-history ledgers for inspection consumers; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into the Session, Workspace, and activated history owners without routing inspection state through Session or SessionManager, and bridges the registry-invalidation frames to typed ctx events (`commands/changed`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`.
|
||||
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects and the Chat-facing list, scope, and event-window state; SessionHistoryService lazily owns independent raw-history ledgers for inspection consumers; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into the Session, Workspace, and activated history owners without routing inspection state through Session or SessionManager, and bridges the registry-invalidation frames to typed ctx events (`commands/changed`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions.
|
||||
|
||||
## Workspace and Session lists
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象以及 Chat 所需的列表、scope 和事件窗口状态;SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本;WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session、Workspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager,并把注册表失效帧桥接为类型化 ctx 事件(`commands/changed`、`settings/changed`、`credentials/changed`、`models/changed`),使各表面缓存无需触碰流即可重拉。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent(智能体)和 cwd);客户端不持有任何实体化之前的会话状态——agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。契约:api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。
|
||||
客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象以及 Chat 所需的列表、scope 和事件窗口状态;SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本;WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session、Workspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager,并把注册表失效帧桥接为类型化 ctx 事件(`commands/changed`、`settings/changed`、`credentials/changed`、`models/changed`),使各表面缓存无需触碰流即可重拉。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent(智能体)和 cwd);客户端不持有任何实体化之前的会话状态——agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。契约:api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。
|
||||
|
||||
## Workspace 与 Session 列表
|
||||
|
||||
|
||||
@@ -3,10 +3,13 @@
|
||||
// Orphaned lineage degrades to root level; cycles fail soft and emit as roots.
|
||||
|
||||
import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
|
||||
|
||||
/** Host list summary enriched with the latest mux-projected durable title. */
|
||||
export interface TitledSessionSummary extends SessionSummary {
|
||||
title?: string
|
||||
/** Current host-computed projection values for list consumers. */
|
||||
projectionValues?: Readonly<Partial<SessionProjectionMap>>
|
||||
}
|
||||
|
||||
/** One flattened session-list row (summary + lineage indent depth + live pending-approval bit). */
|
||||
@@ -21,6 +24,8 @@ export interface SessionListEntry {
|
||||
/** Coarse durable origin for navigation filtering; not a continuation capability. */
|
||||
origin?: 'subagent'
|
||||
cwd?: string
|
||||
/** Current host-computed projection values for list consumers. */
|
||||
projectionValues?: Readonly<Partial<SessionProjectionMap>>
|
||||
/** An approval question is pending on this session (mux-frame derived; the sidebar's amber dot). */
|
||||
waitingApproval: boolean
|
||||
/** Lineage indent depth: root = 0; the UI just multiplies by the indent width. */
|
||||
|
||||
@@ -798,10 +798,14 @@ export class SessionManager {
|
||||
const merged: TitledSessionSummary[] = this.summaries.map((summary) => {
|
||||
// List rows read the generic 'title' projection key (host-computed unit
|
||||
// value; the bespoke session/title frame is retired).
|
||||
const title = this.projectionStores.get(summary.sessionId)?.get('title')
|
||||
return typeof title === 'string' && title !== ''
|
||||
? { ...summary, title }
|
||||
: summary
|
||||
const projectionStore = this.projectionStores.get(summary.sessionId)
|
||||
const title = projectionStore?.get('title')
|
||||
const projectionValues = projectionStore?.values()
|
||||
return {
|
||||
...summary,
|
||||
...(typeof title === 'string' && title !== '' ? { title } : {}),
|
||||
...(projectionValues === undefined ? {} : { projectionValues }),
|
||||
}
|
||||
})
|
||||
const fresh = flattenLineage(merged, new Set(this.waitingApprovals.keys()))
|
||||
const items = fresh.map((entry) => {
|
||||
@@ -812,6 +816,7 @@ export class SessionManager {
|
||||
&& prev.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd
|
||||
&& prev.origin === entry.origin && prev.title === entry.title && prev.depth === entry.depth
|
||||
&& prev.waitingApproval === entry.waitingApproval
|
||||
&& prev.projectionValues === entry.projectionValues
|
||||
) return prev
|
||||
this.entryCache.set(entry.sessionId, entry)
|
||||
return entry
|
||||
|
||||
@@ -74,6 +74,7 @@ interface Channel {
|
||||
export class ProjectionValueStore {
|
||||
private readonly rows = new Map<string, Row>()
|
||||
private readonly channels = new Map<string, Channel>()
|
||||
private valuesCache: Readonly<Partial<SessionProjectionMap>> | undefined
|
||||
/** Coarse any-key channel (no snapshot cache to rebuild: reads hit rows directly). */
|
||||
private readonly anyNotifier = new Notifier(() => {})
|
||||
|
||||
@@ -98,6 +99,19 @@ export class ProjectionValueStore {
|
||||
return this.rows.get(key)?.value
|
||||
}
|
||||
|
||||
/**
|
||||
* Read every current projection value as one reference-stable snapshot.
|
||||
* @returns The same frozen value map until a row changes.
|
||||
*/
|
||||
values(): Readonly<Partial<SessionProjectionMap>> {
|
||||
if (this.valuesCache === undefined) {
|
||||
this.valuesCache = Object.freeze(Object.fromEntries(
|
||||
[...this.rows].map(([key, row]) => [key, row.value]),
|
||||
))
|
||||
}
|
||||
return this.valuesCache
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to any-key changes (microtask-batched) — the manager's list
|
||||
* rebuild channel.
|
||||
@@ -160,6 +174,7 @@ export class ProjectionValueStore {
|
||||
}
|
||||
|
||||
private changed(key: string): void {
|
||||
this.valuesCache = undefined
|
||||
this.channels.get(key)?.notifier.markDirty()
|
||||
this.anyNotifier.markDirty()
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import { SESSION_SEARCH_RESULT_LIMIT } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type {
|
||||
HostObservable, SessionMaybeProvideInfo, SessionProvideInfo,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
|
||||
import type { SnapshotStore } from '../contract/store.ts'
|
||||
import { createSnapshotStore } from '../contract/store.ts'
|
||||
import type { SessionFace } from '../contract/session.ts'
|
||||
@@ -57,6 +58,8 @@ export interface SessionSummary {
|
||||
*/
|
||||
blank: boolean
|
||||
updatedAt: number
|
||||
/** Current host-computed projection values retained by the object layer. */
|
||||
projectionValues?: Readonly<Partial<SessionProjectionMap>>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -613,6 +616,9 @@ export class SessionsService implements ISessions {
|
||||
waitingApproval: entry.waitingApproval,
|
||||
blank: entry.blank,
|
||||
updatedAt: entry.updatedAt,
|
||||
...(entry.projectionValues === undefined
|
||||
? {}
|
||||
: { projectionValues: entry.projectionValues }),
|
||||
...(entry.title !== undefined ? { title: entry.title } : {}),
|
||||
...(entry.cwd !== undefined ? { cwd: entry.cwd } : {}),
|
||||
...(entry.parentSessionId !== undefined ? { parentId: entry.parentSessionId } : {}),
|
||||
|
||||
@@ -86,6 +86,17 @@ describe('ProjectionValueStore semantics', () => {
|
||||
const store = new ProjectionValueStore()
|
||||
expect(store.faceOf('test/marks')).toBe(store.faceOf('test/marks'))
|
||||
})
|
||||
|
||||
it('publishes one reference-stable whole-value snapshot until a row changes', () => {
|
||||
const store = new ProjectionValueStore()
|
||||
const empty = store.values()
|
||||
expect(store.values()).toBe(empty)
|
||||
store.apply('test/marks', { marks: ['a'] }, 1)
|
||||
const populated = store.values()
|
||||
expect(populated).toEqual({ 'test/marks': { marks: ['a'] } })
|
||||
expect(populated).not.toBe(empty)
|
||||
expect(store.values()).toBe(populated)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Session tail-page seeding', () => {
|
||||
@@ -167,6 +178,36 @@ describe('manager frame routing', () => {
|
||||
expect(manager.getListSnapshot().items[0]?.title).toBeUndefined()
|
||||
})
|
||||
|
||||
it('projects every retained value into list rows with stable snapshot identity', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
api.onList = () => Promise.resolve(ok({
|
||||
items: [{
|
||||
sessionId: sid('s1'), updatedAt: 1, running: false, blank: false,
|
||||
projections: {
|
||||
asOfSeq: 2,
|
||||
values: { 'test/marks': { marks: ['baseline'] } },
|
||||
},
|
||||
}],
|
||||
}) as never)
|
||||
await manager.refreshList()
|
||||
const baseline = manager.getListSnapshot().items[0]?.projectionValues
|
||||
expect(baseline).toEqual({ 'test/marks': { marks: ['baseline'] } })
|
||||
expect(manager.getListSnapshot().items[0]?.projectionValues).toBe(baseline)
|
||||
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'p2' as never,
|
||||
payload: {
|
||||
type: 'session/projection', sessionId: sid('s1'), key: 'test/marks',
|
||||
value: { marks: ['live'] }, seq: 3,
|
||||
} as never,
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(manager.getListSnapshot().items[0]?.projectionValues)
|
||||
.toEqual({ 'test/marks': { marks: ['live'] } })
|
||||
expect(manager.getListSnapshot().items[0]?.projectionValues).not.toBe(baseline)
|
||||
})
|
||||
|
||||
it('drops the projection store with the removed session', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
|
||||
@@ -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: f6b3fa2e9cdf1479a739e0b4eab15a5423e878e4
|
||||
README.zh.md: fdfba385e9188cd42bd973b6f32bc01fe8d004f2
|
||||
README.md: 2d507dde49f3796f3bbd00c239a768cdfd8a561d
|
||||
README.zh.md: d0143858ff7eac1437fce381378ebcfad2187f2a
|
||||
@@ -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 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.
|
||||
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, total durable provider usage, and active-turn duration to the second; an unlabeled one-shot row falls back to its session id, while corrupt, unsupported, or unavailable rows remain readable but disabled. Token totals sum the four disjoint `tokenUsage` buckets. Duration sums completed `subagentTiming` turns, advances once per second only for an open turn on a running child, and freezes after the child becomes inactive; an interrupted open turn is bounded by the session summary's last activity. 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, keyboard focus, and the running-duration clock. 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).
|
||||
|
||||
@@ -30,5 +30,5 @@ Append-only. This package never edits earlier request tokens.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **The catalog has coarse activity only** — it cannot show durable outcome, elapsed time, Activation identity, or an authority-safe cancel button.
|
||||
- **The catalog has no durable outcome** — activity and timing do not distinguish completion, failure, or cancellation, and the UI exposes neither Activation identity nor an authority-safe cancel button.
|
||||
- **`@` references remain display-title text** — duplicate or renamed labels are ambiguous, so they intentionally do not acquire continuation semantics.
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
Web subagent 功能 owner:向 `conversation.session.header.actions` 贡献可懒加载展开的目录树,向会话编辑器链贡献按原因区分的只读替代呈现,并保留注册到 `ctx.slash` 的既有 `@` 引用 source。
|
||||
|
||||
页头操作通过标准 `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。
|
||||
页头操作通过标准 `useSessions` 钩子读取 `subagentsByParent` 与会话摘要。非空直接目录到达后,其触发器会统计仅含 subagent 的完整后代谱系,在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。紧凑树仍以直接目录为权威依据:可继续和 one-shot 行会显示 mode、`running`/`inactive` 活动状态、由日志支撑的可选 title、提供方的持久化总用量,以及精确到秒的活跃轮次耗时;没有 label 的 one-shot 行会回退到其会话 id,而损坏、不受支持或不可用的行仍保持可读但禁用。token 总量为四个互不重叠的 `tokenUsage` 桶之和。耗时会累加已完成的 `subagentTiming` 轮次,仅在运行中 child 存在未结束轮次时每秒递增一次,并在 child 变为 inactive 后冻结;被中断的未结束轮次以会话摘要中的最后活动为上界。每个健康行的 `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)规定。
|
||||
|
||||
@@ -30,5 +30,5 @@ one-shot child 始终选用只读编辑器,并将 transcript(文本记录)
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **目录只有粗粒度活动状态**:它不能显示持久化结果、耗时、Activation 身份或具备安全授权的取消按钮。
|
||||
- **目录没有持久化结果**:活动状态与计时无法区分完成、失败或取消,且 UI 既不公开 Activation 身份,也不公开具备安全授权的取消按钮。
|
||||
- **`@` 引用仍是显示标题文本**:重复或改名后的 label 会有歧义,因此它们刻意不获得继续执行语义。
|
||||
@@ -46,6 +46,8 @@
|
||||
"@deepseek-ai/dsh-client-ui-slash": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-subagent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-token-meter": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -55,6 +57,8 @@
|
||||
"@deepseek-ai/dsh-client-ui-slash": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-token-meter": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
|
||||
@@ -173,15 +173,17 @@
|
||||
}
|
||||
|
||||
.summary,
|
||||
.time {
|
||||
.metrics {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.time {
|
||||
.metrics {
|
||||
flex: none;
|
||||
margin-top: 16px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.children {
|
||||
|
||||
@@ -2,13 +2,16 @@ import {
|
||||
useEffect, useRef, useState, type KeyboardEvent, type MouseEvent,
|
||||
} from 'react'
|
||||
import type {
|
||||
SessionId, SessionListState, SessionSummary, SubagentAddress, SubagentCatalogSnapshot,
|
||||
SessionId, SessionListState, SessionProjectionMap, SessionSummary, SubagentAddress,
|
||||
SubagentCatalogSnapshot,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
IconChevronDownOutline14, IconChevronRightOutline14, IconRefreshOutline14, StateDot,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type {} from '@deepseek-ai/dsh-subagent/client'
|
||||
import type {} from '@deepseek-ai/dsh-token-meter/client'
|
||||
import css from './SubagentCatalogAction.module.css'
|
||||
|
||||
type CatalogEntry = SubagentCatalogSnapshot['entries'][number]
|
||||
@@ -53,19 +56,53 @@ function treeItems(root: HTMLDivElement | null): HTMLElement[] {
|
||||
: Array.from(root.querySelectorAll<HTMLElement>('[role="treeitem"]:not([aria-disabled="true"])'))
|
||||
}
|
||||
|
||||
/** Compact trailing activity time for a catalog row. */
|
||||
function relativeTime(updatedAt: number | undefined, now: number): string | undefined {
|
||||
if (updatedAt === undefined) return undefined
|
||||
const minute = 60_000
|
||||
const hour = 60 * minute
|
||||
const day = 24 * hour
|
||||
const diff = Math.max(0, now - updatedAt)
|
||||
if (diff < minute) return '刚刚'
|
||||
if (diff < hour) return `${Math.floor(diff / minute)}分钟`
|
||||
if (diff < day) return `${Math.floor(diff / hour)}小时`
|
||||
if (diff < 30 * day) return `${Math.floor(diff / day)}天`
|
||||
if (diff < 365 * day) return `${Math.floor(diff / (30 * day))}个月`
|
||||
return `${Math.floor(diff / (365 * day))}年`
|
||||
/** Compact token count shared in shape with the conversation stats strip. */
|
||||
function formatTokens(value: number): string {
|
||||
const scaled = (next: number): string => next >= 100
|
||||
? String(Math.round(next))
|
||||
: String(Math.round(next * 10) / 10)
|
||||
if (value < 1_000) return String(value)
|
||||
if (value < 1_000_000) return `${scaled(value / 1_000)}K`
|
||||
return `${scaled(value / 1_000_000)}M`
|
||||
}
|
||||
|
||||
/** Sum the four disjoint durable provider-usage buckets. */
|
||||
function tokenTotal(
|
||||
usage: SessionProjectionMap['tokenUsage'] | undefined,
|
||||
): number | undefined {
|
||||
return usage === undefined
|
||||
? undefined
|
||||
: usage.uncachedInputTokens + usage.outputTokens
|
||||
+ usage.cacheReadTokens + usage.cacheWriteTokens
|
||||
}
|
||||
|
||||
/** Exact whole-second active-turn duration for one catalog row. */
|
||||
function activityDuration(
|
||||
timing: SessionProjectionMap['subagentTiming'] | undefined,
|
||||
activity: 'running' | 'inactive',
|
||||
updatedAt: number | undefined,
|
||||
now: number,
|
||||
): number | undefined {
|
||||
if (timing === undefined) return undefined
|
||||
if (timing.activeSince === undefined) return timing.settledMs
|
||||
const end = activity === 'running' ? now : updatedAt ?? timing.activeSince
|
||||
return timing.settledMs + Math.max(0, end - timing.activeSince)
|
||||
}
|
||||
|
||||
/** Format a non-negative duration to seconds without dropping larger units. */
|
||||
function formatDuration(ms: number): string {
|
||||
const totalSeconds = Math.floor(Math.max(0, ms) / 1_000)
|
||||
const seconds = totalSeconds % 60
|
||||
const totalMinutes = Math.floor(totalSeconds / 60)
|
||||
const minutes = totalMinutes % 60
|
||||
const hours = Math.floor(totalMinutes / 60)
|
||||
if (hours > 0) {
|
||||
return `${hours}小时${String(minutes).padStart(2, '0')}分${String(seconds).padStart(2, '0')}秒`
|
||||
}
|
||||
if (totalMinutes > 0) {
|
||||
return `${totalMinutes}分${String(seconds).padStart(2, '0')}秒`
|
||||
}
|
||||
return `${seconds}秒`
|
||||
}
|
||||
|
||||
/** Aggregate the complete subagent-only descendant subtree from flat summaries. */
|
||||
@@ -190,7 +227,17 @@ function CatalogRows({
|
||||
const secondary = [summary?.title, mode, activity]
|
||||
.filter(value => value !== undefined)
|
||||
.join(' · ')
|
||||
const time = relativeTime(summary?.updatedAt, now)
|
||||
const totalTokens = tokenTotal(summary?.projectionValues?.tokenUsage)
|
||||
const durationMs = activityDuration(
|
||||
summary?.projectionValues?.subagentTiming,
|
||||
entry.activity,
|
||||
summary?.updatedAt,
|
||||
now,
|
||||
)
|
||||
const metrics = [
|
||||
totalTokens === undefined ? undefined : `${formatTokens(totalTokens)} tok`,
|
||||
durationMs === undefined ? undefined : formatDuration(durationMs),
|
||||
].filter(value => value !== undefined).join(' · ')
|
||||
|
||||
const open = (): void => {
|
||||
openChild({ parentSessionId, childSessionId: entry.id, mode: entry.mode })
|
||||
@@ -222,7 +269,7 @@ function CatalogRows({
|
||||
role="treeitem"
|
||||
tabIndex={0}
|
||||
aria-level={level}
|
||||
aria-label={[label, secondary, time].filter(value => value !== undefined).join(' ')}
|
||||
aria-label={[label, secondary, metrics].filter(value => value !== '').join(' ')}
|
||||
{...knownLeaf ? {} : { 'aria-expanded': isExpanded }}
|
||||
className={css.row}
|
||||
onClick={open}
|
||||
@@ -247,7 +294,7 @@ function CatalogRows({
|
||||
<span className={css.label}>{label}</span>
|
||||
<span className={css.summary}>{secondary}</span>
|
||||
</span>
|
||||
{time !== undefined && <span className={css.time}>{time}</span>}
|
||||
{metrics !== '' && <span className={css.metrics}>{metrics}</span>}
|
||||
</div>
|
||||
</div>
|
||||
{isExpanded && !knownLeaf && (
|
||||
@@ -300,6 +347,7 @@ export function SubagentCatalogAction({
|
||||
const summaries = useSessions(state => state.byId)
|
||||
const catalog = catalogs[sessionId]
|
||||
const [open, setOpen] = useState(false)
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
const [expanded, setExpanded] = useState<ReadonlySet<SessionId>>(() => new Set())
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||
@@ -328,7 +376,10 @@ export function SubagentCatalogAction({
|
||||
|
||||
const changeOpen = (next: boolean, restoreFocus = false): void => {
|
||||
setOpen(next)
|
||||
if (next) observeCatalog(sessionId, true)
|
||||
if (next) {
|
||||
setNow(Date.now())
|
||||
observeCatalog(sessionId, true)
|
||||
}
|
||||
else closeAllCatalogs()
|
||||
if (restoreFocus) queueMicrotask(() => { triggerRef.current?.focus() })
|
||||
}
|
||||
@@ -368,6 +419,12 @@ export function SubagentCatalogAction({
|
||||
return () => { document.removeEventListener('pointerdown', closeOutside) }
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !descendants.running) return
|
||||
const timer = setInterval(() => { setNow(Date.now()) }, 1_000)
|
||||
return () => { clearInterval(timer) }
|
||||
}, [open, descendants.running])
|
||||
|
||||
useEffect(() => () => {
|
||||
for (const parentSessionId of observedCatalogs.current) {
|
||||
setCatalogOpenRef.current(parentSessionId, false)
|
||||
@@ -443,7 +500,7 @@ export function SubagentCatalogAction({
|
||||
summaries={summaries}
|
||||
expanded={expanded}
|
||||
level={1}
|
||||
now={Date.now()}
|
||||
now={now}
|
||||
openChild={openChild}
|
||||
refresh={refresh}
|
||||
toggleBranch={toggleBranch}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { SubagentReadOnlyComposer } from '../src/client/SubagentReadOnlyComposer
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
@@ -217,42 +218,56 @@ describe('SubagentCatalogAction', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('renders compact activity times across every unit and clamps future timestamps', () => {
|
||||
it('shows durable token totals, ticks active duration by seconds, and freezes inactive rows', async () => {
|
||||
const now = 2_000_000_000_000
|
||||
vi.spyOn(Date, 'now').mockReturnValue(now)
|
||||
const minute = 60_000
|
||||
const hour = 60 * minute
|
||||
const day = 24 * hour
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(now)
|
||||
const rows = [
|
||||
['future', now + minute],
|
||||
['minutes', now - 2 * minute],
|
||||
['hours', now - 2 * hour],
|
||||
['days', now - 2 * day],
|
||||
['months', now - 60 * day],
|
||||
['years', now - 2 * 365 * day],
|
||||
['running', 'running', 65_000, now - 5_000, now],
|
||||
['finished', 'inactive', 3_723_000, undefined, now - 60_000],
|
||||
['interrupted', 'inactive', 2_000, now - 7_000, now - 3_000],
|
||||
] as const
|
||||
const entries = rows.map(([id]) => ({
|
||||
const entries = rows.map(([id, activity]) => ({
|
||||
kind: 'child' as const,
|
||||
id: id as SessionId,
|
||||
mode: 'continuable' as const,
|
||||
label: id,
|
||||
activity: 'inactive' as const,
|
||||
activity,
|
||||
hasChildren: false,
|
||||
}))
|
||||
const summaries = Object.fromEntries(rows.map(([id, updatedAt]) => [
|
||||
id,
|
||||
summary(id as SessionId, updatedAt),
|
||||
])) as Record<SessionId, SessionSummary>
|
||||
const summaries = Object.fromEntries(rows.map(([id, activity, settledMs, activeSince, updatedAt]) => {
|
||||
const childId = id as SessionId
|
||||
return [id, {
|
||||
...summary(childId, updatedAt),
|
||||
parentId: PARENT,
|
||||
origin: 'subagent' as const,
|
||||
running: activity === 'running',
|
||||
projectionValues: {
|
||||
subagentTiming: {
|
||||
settledMs,
|
||||
...(activeSince === undefined ? {} : { activeSince }),
|
||||
},
|
||||
tokenUsage: {
|
||||
uncachedInputTokens: 1_000,
|
||||
outputTokens: 200,
|
||||
cacheReadTokens: 3_000,
|
||||
cacheWriteTokens: 400,
|
||||
},
|
||||
},
|
||||
}]
|
||||
})) as Record<SessionId, SessionSummary>
|
||||
const input = props(catalog({ entries }), {}, summaries)
|
||||
render(<SubagentCatalogAction {...input} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /6 个子代理/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /3 个子代理/ }))
|
||||
|
||||
expect(screen.getByRole('treeitem', { name: /future.*刚刚/ })).toBeTruthy()
|
||||
expect(screen.getByRole('treeitem', { name: /minutes.*2分钟/ })).toBeTruthy()
|
||||
expect(screen.getByRole('treeitem', { name: /hours.*2小时/ })).toBeTruthy()
|
||||
expect(screen.getByRole('treeitem', { name: /days.*2天/ })).toBeTruthy()
|
||||
expect(screen.getByRole('treeitem', { name: /months.*2个月/ })).toBeTruthy()
|
||||
expect(screen.getByRole('treeitem', { name: /years.*2年/ })).toBeTruthy()
|
||||
expect(screen.getByRole('treeitem', { name: /running.*4\.6K tok · 1分10秒/ })).toBeTruthy()
|
||||
expect(screen.getByRole('treeitem', { name: /finished.*4\.6K tok · 1小时02分03秒/ })).toBeTruthy()
|
||||
expect(screen.getByRole('treeitem', { name: /interrupted.*4\.6K tok · 6秒/ })).toBeTruthy()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
expect(screen.getByRole('treeitem', { name: /running.*4\.6K tok · 1分11秒/ })).toBeTruthy()
|
||||
expect(screen.getByRole('treeitem', { name: /finished.*4\.6K tok · 1小时02分03秒/ })).toBeTruthy()
|
||||
expect(screen.getByRole('treeitem', { name: /interrupted.*4\.6K tok · 6秒/ })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('lazily expands and collapses descendant catalogs with direct-parent navigation', () => {
|
||||
|
||||
@@ -26,6 +26,12 @@
|
||||
{
|
||||
"path": "../ui-slots"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/token-meter"
|
||||
},
|
||||
{
|
||||
"path": "../../subagent/subagent"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
|
||||
@@ -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: 9aea27a0f150d90a41d9a7cb4cd422a75e6107fe
|
||||
README.zh.md: 3f0b534deae53b8d5aff2765974050f26b931953
|
||||
README.md: ec4af55bcd9374b1abb55d7bb098eef568449684
|
||||
README.zh.md: 8323853ff0de2a15da6475fc1433a68f0074ee93
|
||||
@@ -92,6 +92,8 @@ Provider additions and removals also emit `subagent/provider-added` and `subagen
|
||||
|
||||
Continuable children do not create `SubagentRun` or Tasks. The continuation manager directly owns one process-local Activation and retained `AgentHandle` per resident child Session, uses the Agent inbox as the only FIFO, and cold-resumes from the durable descriptor. Exact live direct-parent identity authorizes parent-to-child delivery. Exact live child identity authorizes reports; the manager derives the recipient from durable `parentSession`, and `MessageSource` remains provenance rather than authority.
|
||||
|
||||
When `ctx.sessionProjections` is available, the service registers `subagentTiming`. The projection resets at each descriptor so a fork seed's ancestor work cannot enter the child's total, then accumulates `turn/start` → `turn/end` active time and retains `activeSince` for an open turn. Only descriptors and turn boundaries change the value, so token chunks do not create timing updates.
|
||||
|
||||
`registerContinuableSetup()` lets optional packages add child-scoped capabilities without teaching the continuation manager their names. Contributions install synchronously before Activation publication, roll back with failed setup, and are released with the child scope. New grants wait for the next Activation, while contribution removal revokes every resident installation immediately.
|
||||
|
||||
## Collection model
|
||||
|
||||
@@ -92,6 +92,8 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
|
||||
|
||||
可继续子级不会创建 `SubagentRun` 或 Task。延续管理器为每个驻留子 Session 直接拥有一个仅存在于当前进程的 Activation 和一个留存的 `AgentHandle`,使用 Agent inbox 作为唯一 FIFO,并从持久化描述符冷恢复。父到子投递由准确的实时直接父级身份授权。上报则由准确的实时子级身份授权;管理器根据持久化的 `parentSession` 推导接收方,`MessageSource` 仍只表示来源,不表示权限。
|
||||
|
||||
当 `ctx.sessionProjections` 可用时,服务会注册 `subagentTiming`。该投影会在每个描述符处重置,使 fork 种子中的祖先工作不会计入 child 总量,随后累加 `turn/start` → `turn/end` 活跃时间,并为未结束的轮次保留 `activeSince`。只有描述符和轮次边界会改变该值,因此 token 分片不会产生计时更新。
|
||||
|
||||
`registerContinuableSetup()` 允许可选包添加子级作用域功能,而无需让延续管理器知道这些功能的名称。贡献会在 Activation 发布前同步安装,在设置失败时一并回滚,并随子级作用域释放。新授权须等到下一个 Activation,移除贡献则会立即撤销每个驻留安装项。
|
||||
|
||||
## 收集模型
|
||||
|
||||
@@ -15,17 +15,25 @@
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./lib/types/client.d.ts",
|
||||
"default": "./lib/types/client.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-brand": "^0.0.1",
|
||||
@@ -35,6 +43,7 @@
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-query": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-projection": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tasks": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
@@ -46,6 +55,9 @@
|
||||
"@deepseek-ai/dsh-session-query": {
|
||||
"optional": true
|
||||
},
|
||||
"@deepseek-ai/dsh-session-projection": {
|
||||
"optional": true
|
||||
},
|
||||
"@deepseek-ai/dsh-tasks": {
|
||||
"optional": true
|
||||
}
|
||||
@@ -59,6 +71,7 @@
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-query": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:^",
|
||||
"@deepseek-ai/dsh-tasks": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Browser-safe subagent projection vocabulary.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-subagent/client
|
||||
*/
|
||||
|
||||
export type { SubagentTimingProjection } from './projection-types.ts'
|
||||
@@ -65,6 +65,7 @@ import type { ContinuableSetupContribution } from './activation-setup-registry.t
|
||||
import { listChildren as listSubagentChildren } from './list-children.ts'
|
||||
import type { SubagentListEntry } from './list-children.ts'
|
||||
import { snapshotSubagentDescriptor } from './descriptor.ts'
|
||||
import { subagentTimingProjectionDefinition } from './projection.ts'
|
||||
|
||||
export * from './out-of-process.ts'
|
||||
export { SubagentRunId } from './types.ts'
|
||||
@@ -117,6 +118,7 @@ export type {
|
||||
export type { ContinuableSetupContribution } from './activation-setup-registry.ts'
|
||||
export type { SubagentListEntry } from './list-children.ts'
|
||||
export type { SubagentRunEndInfo, SubagentRunInfo } from './types.ts'
|
||||
export type { SubagentTimingProjection } from './projection-types.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
@@ -186,6 +188,9 @@ export class SubagentService extends Service {
|
||||
if (this.continuations === manager) this.continuations = undefined
|
||||
}, 'subagents.continuationBinding()')
|
||||
})
|
||||
ctx.inject(['sessionProjections'], (projectionCtx) => {
|
||||
projectionCtx.sessionProjections.register(subagentTimingProjectionDefinition)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Pure client-safe subagent projection vocabulary.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-subagent/projection-types
|
||||
*/
|
||||
|
||||
/** Durable active-turn timing for one descriptor-backed child session. */
|
||||
export interface SubagentTimingProjection {
|
||||
/** Milliseconds accumulated across completed turns after the child's own descriptor. */
|
||||
settledMs: number
|
||||
/** Start of the currently open turn, when one has not reached `turn/end`. */
|
||||
activeSince?: number
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-session-projection/types' {
|
||||
interface SessionProjectionMap {
|
||||
/** Active-turn duration for a descriptor-backed subagent session. */
|
||||
subagentTiming: SubagentTimingProjection
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Pure session projection for subagent active-turn duration.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-subagent/projection
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
|
||||
import type { SubagentTimingProjection } from './projection-types.ts'
|
||||
|
||||
interface TimingState extends SubagentTimingProjection {
|
||||
/** Latest pre-descriptor turn start, promoted when the child's own descriptor arrives. */
|
||||
pendingTurnStart?: number
|
||||
/** Whether the fold has crossed a descriptor in this logical log. */
|
||||
descriptorSeen: boolean
|
||||
}
|
||||
|
||||
const projectionSchema = z.object({
|
||||
settledMs: z.number().int().nonnegative(),
|
||||
activeSince: z.number().int().nonnegative().optional(),
|
||||
}).strict() as unknown as z.ZodType<SubagentTimingProjection>
|
||||
|
||||
/**
|
||||
* Fold turn boundaries around the child's own durable descriptor.
|
||||
*
|
||||
* A fork seed may contain an ancestor descriptor and completed turns. Every
|
||||
* descriptor therefore resets the accumulated state; the healthy catalog
|
||||
* admits only a child with exactly one descriptor in its own suffix, making
|
||||
* the final reset the child's authoritative timing origin.
|
||||
*/
|
||||
export const subagentTimingProjectionDefinition:
|
||||
ProjectionDefinition<'subagentTiming', TimingState> = {
|
||||
key: 'subagentTiming',
|
||||
schema: projectionSchema,
|
||||
init: () => ({ descriptorSeen: false, settledMs: 0 }),
|
||||
apply: (state, event) => {
|
||||
if (event.type === 'turn/start') {
|
||||
return state.descriptorSeen
|
||||
? { ...state, activeSince: event.time }
|
||||
: { ...state, pendingTurnStart: event.time }
|
||||
}
|
||||
if (event.type === 'subagent/descriptor') {
|
||||
const activeSince = state.activeSince ?? state.pendingTurnStart
|
||||
return {
|
||||
descriptorSeen: true,
|
||||
settledMs: 0,
|
||||
...(activeSince === undefined ? {} : { activeSince }),
|
||||
}
|
||||
}
|
||||
if (event.type !== 'turn/end') return state
|
||||
if (!state.descriptorSeen) {
|
||||
if (state.pendingTurnStart === undefined) return state
|
||||
const { pendingTurnStart: _closed, ...next } = state
|
||||
return next
|
||||
}
|
||||
if (state.activeSince === undefined) return state
|
||||
const { activeSince, ...rest } = state
|
||||
return {
|
||||
...rest,
|
||||
settledMs: state.settledMs + Math.max(0, event.time - activeSince),
|
||||
}
|
||||
},
|
||||
view: state => ({
|
||||
settledMs: state.settledMs,
|
||||
...(state.activeSince === undefined ? {} : { activeSince: state.activeSince }),
|
||||
}),
|
||||
stateVersion: 1,
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { subagentTimingProjectionDefinition } from '../src/projection.ts'
|
||||
|
||||
function event(type: SessionEvent['type'], seq: number, time: number): SessionEvent {
|
||||
return { type, seq, time, data: {} } as SessionEvent
|
||||
}
|
||||
|
||||
function fold(events: SessionEvent[]) {
|
||||
let state = subagentTimingProjectionDefinition.init()
|
||||
for (const item of events) state = subagentTimingProjectionDefinition.apply(state, item)
|
||||
return subagentTimingProjectionDefinition.view(state)
|
||||
}
|
||||
|
||||
describe('subagent timing projection', () => {
|
||||
it('resets inherited seed timing at the child descriptor and sums later completed turns', () => {
|
||||
expect(fold([
|
||||
event('turn/start', 0, 100),
|
||||
event('subagent/descriptor', 1, 110),
|
||||
event('turn/end', 2, 300),
|
||||
event('turn/start', 3, 1_000),
|
||||
event('subagent/descriptor', 4, 1_100),
|
||||
event('turn/end', 5, 4_100),
|
||||
event('turn/start', 6, 10_000),
|
||||
event('turn/end', 7, 12_000),
|
||||
])).toEqual({ settledMs: 5_100 })
|
||||
})
|
||||
|
||||
it('exposes an open turn start and never subtracts time for reversed boundaries', () => {
|
||||
expect(fold([
|
||||
event('turn/start', 0, 1_000),
|
||||
event('subagent/descriptor', 1, 1_100),
|
||||
event('turn/end', 2, 900),
|
||||
event('turn/start', 3, 2_000),
|
||||
event('assistant/chunk', 4, 2_500),
|
||||
])).toEqual({ settledMs: 0, activeSince: 2_000 })
|
||||
})
|
||||
|
||||
it('ignores completed pre-descriptor turns and unrelated events', () => {
|
||||
const initial = subagentTimingProjectionDefinition.init()
|
||||
expect(subagentTimingProjectionDefinition.apply(
|
||||
initial,
|
||||
event('assistant/chunk', 0, 1),
|
||||
)).toBe(initial)
|
||||
expect(fold([
|
||||
event('turn/start', 0, 100),
|
||||
event('turn/end', 1, 200),
|
||||
event('subagent/descriptor', 2, 300),
|
||||
])).toEqual({ settledMs: 0 })
|
||||
})
|
||||
})
|
||||
@@ -32,6 +32,9 @@
|
||||
{
|
||||
"path": "../../session-query/session-query"
|
||||
},
|
||||
{
|
||||
"path": "../../session-projection/session-projection"
|
||||
},
|
||||
{
|
||||
"path": "../../tasks/tasks"
|
||||
},
|
||||
|
||||
Generated
+13
@@ -1897,6 +1897,12 @@ importers:
|
||||
'@deepseek-ai/dsh-invariants':
|
||||
specifier: workspace:^
|
||||
version: link:../../support/invariants
|
||||
'@deepseek-ai/dsh-subagent':
|
||||
specifier: workspace:^
|
||||
version: link:../../subagent/subagent
|
||||
'@deepseek-ai/dsh-token-meter':
|
||||
specifier: workspace:^
|
||||
version: link:../../llm/token-meter
|
||||
'@types/react':
|
||||
specifier: ~18.3.1
|
||||
version: 18.3.31
|
||||
@@ -4882,6 +4888,10 @@ importers:
|
||||
version: link:../../../vendor/cordis
|
||||
|
||||
packages/subagent/subagent:
|
||||
dependencies:
|
||||
zod:
|
||||
specifier: ^4.4.3
|
||||
version: 4.4.3
|
||||
devDependencies:
|
||||
'@deepseek-ai/dsh-agent':
|
||||
specifier: workspace:^
|
||||
@@ -4904,6 +4914,9 @@ importers:
|
||||
'@deepseek-ai/dsh-session-persistence':
|
||||
specifier: workspace:^
|
||||
version: link:../../session-persistence/session-persistence
|
||||
'@deepseek-ai/dsh-session-projection':
|
||||
specifier: workspace:^
|
||||
version: link:../../session-projection/session-projection
|
||||
'@deepseek-ai/dsh-session-query':
|
||||
specifier: workspace:^
|
||||
version: link:../../session-query/session-query
|
||||
|
||||
Reference in New Issue
Block a user