feat(subagent): migrate direct catalog consumers to parent projections

This commit is contained in:
Dudu-0223 authored and Tianyi Cui committed 2026-09-16 22:05:17 +08:00
1 parent 1d1ae1a5af
commit e55093b47d
208 files changed
+2378 -2913

No files matched your search

@@ -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/architecture/2026-09-01-parent-owned-subagent-catalog.md
2026-09-01-parent-owned-subagent-catalog.md: 20e2dd035b41612592e7baeb55f89322dff02848
2026-09-01-parent-owned-subagent-catalog.zh.md: 6ec33e8be8d4373ba98a9934178a67e207ffb0bb
2026-09-01-parent-owned-subagent-catalog.md: 536ec33148bd675a974622b98ee23de812691dc0
2026-09-01-parent-owned-subagent-catalog.zh.md: ec78ff9e4b7d54a7309baf21c383ec5ff83a19c8
@@ -44,6 +44,6 @@ Current-writer snapshot expectations include catalog facts even when replay inpu
## Consequences
Session observations and client snapshots expose the direct-child list through `projections.values.subagentCatalog`. The projection change feed publishes a complete list when catalog state changes. Each view costs O(D), so D creations can incur O(D²) cumulative view work; this follows the existing projection mechanism. Direct-child and descendant listing still use the Session corpus and child identity projection.
Session observations and client snapshots expose the direct-child list through `projections.values.subagentCatalog`. The projection change feed publishes a complete list when catalog state changes. Each view costs O(D), so D creations can incur O(D²) cumulative view work; this follows the existing projection mechanism. `listChildren()` reads the parent projection through one live-preferred Session observation; descendant listing uses the Session corpus and child identity projection. [Web projection consumption](../simplification/2026-09-08-web-subagent-catalog-projections.md) owns browser loading and synchronization.
Backends that do not know the required event refuse the log under the existing Session event mechanism. Catalog projection does not reconstruct missing parent facts by scanning old child logs.
[Creation metadata exclusion](../simplification/2026-09-16-subagent-catalog-membership-only.md) records why model configuration is absent from catalog facts.
@@ -44,6 +44,6 @@ snapshot normalizer 会把 `childCreatedAt` 归零,因为它来自 process clo
## 后果
Session 观察和客户端快照通过 `projections.values.subagentCatalog` 暴露直接子级列表。目录状态变化时,projection 变更通知发布完整列表。每次视图计算成本为 O(D),因此 D 次创建的累计视图工作量可能为 O(D²);这沿用既有 projection 机制。直接子级和后代列表仍使用 Session 语料库与子级身份 projection。
Session observation 与 client snapshot 通过 `projections.values.subagentCatalog` 暴露直接子级列表。目录状态变化时,projection change feed 发布完整列表。每次 view 的成本为 O(D),因此 D 次创建可能累计产生 O(D²) 的 view 工作量;这沿用现有 projection 机制。`listChildren()` 通过一次 live-preferred Session observation 读取父 projection;后代枚举使用 Session 语料库与子身份 projection。[Web projection 消费决策](../simplification/2026-09-08-web-subagent-catalog-projections.zh.md) 说明浏览器加载与同步。
不认识该 required event 的 backend 会按既有 Session event 机制拒绝日志。目录投影不通过扫描旧子级日志来重建缺失的父级事实。
[创建元数据排除决策](../simplification/2026-09-16-subagent-catalog-membership-only.zh.md) 说明目录事实为何不包含模型配置。
@@ -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: d0713731de86df639a426ee56c39857c50af6bc7
2026-07-27-web-subagent-conversations.zh.md: 0f4f6467484ee1caa3cdf28f44e016ff674ac1dc
2026-07-27-web-subagent-conversations.md: 84839dc94b9e4aa0d2511339af1fa17486a953cf
2026-07-27-web-subagent-conversations.zh.md: 4ffdd4fa2fa9da6c622c19b650a12bba85850448
@@ -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 membership, modes, and diagnostics of the [durable catalog](../../archived/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.
The UI must also preserve the membership and modes of the [durable catalog](../architecture/2026-09-01-parent-owned-subagent-catalog.md). The Web projection adds 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,16 +30,16 @@ The Figma [subagent list](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Ha
| Design intent | Shipped contract |
| --- | --- |
| 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. |
| The session header opens a compact child list. | The trigger counts healthy direct entries returned by the parent catalog service. |
| 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. |
| Nested agents expand progressively. | A row remains expandable until its own catalog has loaded empty; expansion loads only that direct catalog and retains the row's parent address. |
| 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 active-turn duration come from the list's retained projection values. Compact duration loses smaller units above one day, while hover and accessible naming retain exact whole seconds. `SessionHeader.origin` removes duplicate navigation rows but grants no capability. |
## Product contract
The descendant-count control is absent only when a complete empty direct-catalog response agrees with the session-summary projection that no subagent descendants are known. On an ordinary session, a slash separates the current title from that count control. Every subagent breadcrumb combines its compact 12px title with a fixed double chevron: the current breadcrumb uses primary color and weight 500, while ancestors use tertiary color and weight 400. Hovering a combined control for 150ms opens its direct-parent catalog, with a short crossing grace for the portaled menu; ArrowDown remains the keyboard entry path. Clicking an ancestor cancels pending hover, closes an open catalog, and only navigates upward. Each direct-parent catalog enables sibling switching and bolds its selected row; its label overrides the session-summary title, and every missing switcher catalog refreshes automatically. Only the current subagent appends its own descendant-count control. Long titles truncate before the fixed chevron. The count 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. Because ordinary sidebar rows hide subagent-origin sessions, the Workspace browser indexes the same uninterrupted lineage onto each visible ordinary row: any running descendant supplies its blue activity indicator and exact count in hover and assistive text without describing an idle parent as running. An ordinary fork starts a separate aggregation subtree. Pending interaction outranks parent running; either remains primary while descendant activity becomes a second hover and assistive status. With neither present, descendant activity outranks an unviewed completion reminder, which returns after the last running descendant stops. 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. When summaries establish descendants before that catalog exists or after a stale empty response, the action stays visible and exposes only disabled loading rows until opening it refreshes the catalog; summary-only rows never grant navigation. 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.
The child-count control appears when the direct catalog contains entries or its read fails; it stays hidden while an empty catalog is absent or loading, and after a successful empty read. On an ordinary session, a slash separates the current title from that control. Every subagent breadcrumb combines its compact 12px title with a fixed double chevron: the current breadcrumb uses primary color and weight 500, while ancestors use tertiary color and weight 400. Hovering a combined control for 150ms opens its direct-parent catalog, with a short crossing grace for the portaled menu; ArrowDown remains the keyboard entry path. Clicking an ancestor cancels pending hover, closes an open catalog, and only navigates upward. Each direct-parent catalog enables sibling switching and bolds its selected row; its label overrides the optional session-summary title, and a missing switcher catalog loads on interaction. Only the current subagent appends its own direct-child count control. Long titles truncate before the fixed chevron. The trigger reports the direct catalog's total and running counts. Ordinary sidebar rows hide subagent-origin Sessions and read their direct running-child count from the same loaded catalog map; Session summaries supply activity but never create membership. Pending interaction outranks parent running; either remains primary while direct-child activity becomes a second hover and assistive status. With neither present, direct-child activity outranks an unviewed completion reminder, which returns after the last running direct child stops. A tree row remains expandable while its child catalog is absent, loading, or failed, and becomes a known leaf only after that catalog loads empty. Loading shows a generic notice rather than summary-derived placeholder rows. The tree presents continuable and one-shot rows, falling back to the Session id when an optional one-shot label is absent.
`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.
`running` means the child Agent driver is draining work; `inactive` means that driver is idle or absent. Session-list baselines and status events supply activity, and removal marks completed subagents inactive while retaining their display projections. The shared parent `subagentCatalog` projection supplies membership. Initial reads and pushed values meet in one projection store; a newer sequence wins. [The projection-consumer decision](../simplification/2026-09-08-web-subagent-catalog-projections.md) owns loading, reconnect, and synchronization tradeoffs. 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 same-cut `active.since` and `active.through` bounds for an open turn. Existing session events advance `active.through` while that turn remains open; the menu adds no separate timer or log read and advances its local clock only while a known descendant is running. Below one day it formats whole seconds; longer visual values retain at most two adjacent units, using approximate 30-day months and 365-day years, while hover and accessible naming preserve the exact day/hour/minute/second duration. An inactive row bounds an interrupted open turn with `active.through`, so a stale projection never borrows newer session metadata and reopening the menu never restarts completed work. Neither metric implies a durable outcome.
@@ -51,17 +51,17 @@ Agent-bound auxiliary controls are unavailable in addressed child views. In part
## Host adapter and wire contract
`@deepseek-ai/dsh-subagent` owns the browser-safe generated `subagent` Remote namespace:
The Session Controller owns catalog and history reads; `@deepseek-ai/dsh-subagent` owns continuation controls:
- `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.
- `session.projections` takes `sessionId` and returns one live-preferred Session observation’s complete projection baseline without activating an Agent. The Client seeds the standard projection store; subagent consumers select `subagentCatalog`. Parent Agent availability comes from Session-list summaries and lifecycle events.
- `session.page` and `session.follow` take the full mode-bearing address. They validate the child header, direct parent, descriptor identity, and mode at the observed cut, then return ordinary raw events, pagination, live reconciliation, and Host projection baselines without publishing an Agent.
- `subagent.prompt` accepts only a `mode: 'continuable'` address, `delivery: 'queue' | 'steer'`, and upload-shaped `PromptContentPart[]`; the Host admits and persists image parts into durable references before delivery ([image delivery](../../archived/bug-fix/2026-08-27-steer-followup-image-delivery.md)). It requires the exact live parent, revalidates the catalog address, uses the continuation manager's shared human-delivery admission, and returns the accepted `MessageId`.
The gateway maps missing parent, missing or diagnostic catalog entries, not-resumable and unauthorized children, request cancellation, image admission and image-capability refusals (`subagent/attachment-invalid`), and temporarily unavailable continuation admission to typed RPC errors. It does not expose descriptor or provider details. A list/prompt race is normal: the prompt result, not the earlier availability or activity snapshot, is authoritative.
The gateway maps missing parents or catalog entries, not-resumable and unauthorized children, request cancellation, image admission and image-capability refusals (`subagent/attachment-invalid`), and temporarily unavailable continuation admission to typed RPC errors. It does not expose descriptor or provider details. A list/prompt race is normal: the prompt result, not the earlier availability or activity snapshot, is authoritative.
Viewing persisted history creates no mux subscription by itself. When a follow-up materializes a cold child Activation, the existing Host and mux streams publish its lifecycle and events. Reconnect rebuilds the addressed window through `subagent.history`.
Viewing persisted history creates no Agent by itself. When a follow-up materializes a cold child Activation, the existing Host and Session journal streams publish its lifecycle and events. Reconnect rebuilds the addressed window through `session.follow`.
The ordinary `session.history` route is likewise observation-only for both ordinary and subagent sessions, but it does not carry the catalog address or grant continuation authority. Every ordinary route that needs an Agent resolves through the shared ownership fence before cold resume; `session.cancel` retains that fence. `session.updateQueue` has one target-local exception for a live child whose current projected identity is continuable and comes from its own non-seed suffix; one-shot, missing, unknown, corrupt, seed-only, or cold children remain fenced.
The ordinary `session.page` and `session.follow` address is likewise observation-only for both ordinary and subagent sessions, but it does not carry the catalog address or grant continuation authority. Every ordinary route that needs an Agent resolves through the shared ownership fence before cold resume; `session.cancel` retains that fence. `session.updateQueue` has one target-local exception for a live child whose current projected identity is continuable and comes from its own non-seed suffix; one-shot, missing, unknown, corrupt, seed-only, or cold children remain fenced.
The adapter stays behind the generated Remote namespace; `dsh-host-webserver` remains a carrier. Browser code imports the contract through the existing connection package and never reaches host `ctx`, preserving the [archived GUI RPC layering decision](../../archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.md).
@@ -71,7 +71,7 @@ The React-free runtime owns catalogs, single-flight refreshes, retained addresse
Catalogs ride the standard `useSessions` snapshot. Component-local state owns menu visibility, expanded branches, focus, and hover timers. `ui-conversation` declares a lineage slot for the current ordinary title and every subagent breadcrumb, passes plain breadcrumb identity and display text plus an upward-navigation callback for ancestors, and retains the ordinary title as the render fallback. `@deepseek-ai/dsh-client-ui-subagent` occupies each lineage slot with direct-parent catalog navigation and elects a reason-specific read-only composer from ordinary owner props. Components receive derived props and callbacks, never `ctx`.
Every in-process subagent child stamps `SessionHeader.origin: 'subagent'` before publication. Session list summaries and incremental Host frames project it so grouped and flat sidebars omit duplicate child rows while preserving ordinary forks. The same existing `host/session-added` frame marks a loaded direct parent row expandable without introducing a catalog event stream. Descriptor mode and catalog verification remain the authority for navigation, continuation, and authorization.
Every in-process subagent child stamps `SessionHeader.origin: 'subagent'` before publication. Session list summaries and incremental Host frames project it so grouped and flat sidebars omit duplicate child rows while preserving ordinary forks. Parent catalog projection owns membership and tree structure; descriptor identity and exact-parent checks own addressed history and continuation validation.
The package's existing `@label` source remains separate plain-text model input. It does not resolve labels to addresses or acquire continuation semantics.
@@ -95,23 +95,23 @@ The shipped Web composition mounts SQLite session query beside JSONL persistence
**Infer mode or sidebar filtering from lineage.** Rejected because ordinary forks share `parentSession`. The descriptor-backed catalog owns mode; the separate `origin` marker is only a cheap navigation classifier.
**Build an eager recursive tree or dedicated catalog stream.** Rejected for the current scale. Header-only one-level expandability lookahead preserves pre-click stability without reading descendant events, while disclosure remains a lazy authoritative direct-child read; existing Host frames update activity, restore expandable parent rows, and trigger bounded membership refreshes.
**Build an eager recursive tree or dedicated catalog stream.** Rejected for the current scale. Disclosure loads missing catalogs lazily, and existing control frames carry complete projection updates.
**Let a child remain independently interactive after its parent disappears.** Rejected because independent lifetime and user ownership require side-session semantics.
## Testing
- Host protocol tests pin schemas including required boolean expandability, id echoing, mode verification, non-activating history, exact-parent enforcement, FIFO admission receipts, cancellation, and sanitized failure mapping.
- Host protocol tests pin healthy direct catalog schemas, 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 and cancel rejection, history routing, continuable prompt and interrupt routing, 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 ordinary-title separator, per-level combined subagent-title switchers, 12px nested titles, current and ancestor styling, selected-row weight, catalog-label precedence, hover delay, upward-click suppression, truncation with a retained chevron, the aggregate descendant count and activity, sidebar propagation across nested lineage and ordinary-fork boundaries, row-status precedence, token totals, second-precision running and frozen inactive durations, adaptive long-duration units with exact accessible text, the summary-backed root action across absent and stale-empty catalogs, 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 with a deterministic long duration, and a persisted grandchild; it pins the three-descendant trigger across a stale empty catalog response, usage and timing rows, adaptive long-duration presentation, and the 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. A separate assembled scenario holds a real child Agent turn at the LLM seam while it pins the aggregate running state in both the header and visible idle owner row, then cancels the turn during teardown.
- Client object tests pin retained and restored addresses, one-shot read-only and cancel rejection, history routing, continuable prompt and interrupt routing, suppression of Agent-bound model controls, live activity from Session summaries and Agent disposal, authoritative catalog membership, and membership refresh.
- jsdom tests pin the ordinary-title separator, per-level combined subagent-title switchers, 12px nested titles, current and ancestor styling, selected-row weight, catalog-label precedence, hover delay, upward-click suppression, truncation with a retained chevron, direct counts and activity from catalogs, sidebar direct-child activity, row-status precedence, token totals, second-precision running and frozen inactive durations, adaptive long-duration units with exact accessible text, generic loading, mixed-mode rows, ready-empty leaf detection, 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 with a deterministic long duration, and a persisted grandchild; it pins direct catalog counts, usage and timing rows, adaptive long-duration presentation, and lazy nested expansion, opens persisted history without activation, admits a human FIFO follow-up, reconciles child mux events, and proves one-shot history remains read-only. A separate assembled scenario holds a real child Agent turn at the LLM seam while it pins the direct running state in both the header and visible idle owner row, then cancels the turn during teardown.
- 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 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.
- Catalog reads fold only the selected parent's catalog projection. Web activity comes from Session summaries, while usage and duration reuse projection baselines and pushes with no per-row log read; projection reads remain single-flight.
- Parent availability and child activity 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.
- Beyond the current-turn Stop of a running continuable child ([interrupt contract](2026-08-06-continuable-subagent-interrupt.md)), 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.
@@ -10,7 +10,7 @@ Status: implemented
浏览器必须遵守[可继续 subagent 约定](../../implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md):一个可继续 child 在进程内最多只能有一项 Activation,只能通过确切的存活直接 parent 接受后续工作,并将 agent inbox 用作唯一的 FIFO。查看历史不得创建 Activation。inbox 消息一经接受,HTTP 调用方既不拥有其执行过程,也不会获得取消句柄。
UI 还必须保留[持久化目录](../../archived/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)的成员、mode 与 diagnostic。共享服务报告采用实时优先规则的语料活动状态,而 Web 投影会将其替换为确切 child Agent driver 的 `running` 或 `inactive` 状态。这两种活动状态都不是持久化结果,也不承诺继续执行会成功。
UI 还必须保留[持久化目录](../architecture/2026-09-01-parent-owned-subagent-catalog.zh.md)的成员与 mode。Web 投影会添加确切 child Agent driver 的 `running` 或 `inactive` 状态。这两种活动状态都不是持久化结果,也不承诺继续执行会成功。
## 决策
@@ -30,16 +30,16 @@ Figma 中的 [subagent 列表](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5
| 设计意图 | 已交付约定 |
| --- | --- |
| 会话页头可打开紧凑的 child 列表。 | 触发器会汇总仅含 subagent 的完整后代谱系;树按服务顺序显示每个直接目录条目,包括已禁用的 diagnostic。 |
| 会话页头可打开紧凑的 child 列表。 | 触发器统计 parent catalog service 返回的健康直接条目。 |
| 选择一行会复用对话 UI。 | 已寻址历史绝不激活 child;只有 parent 存活的可继续行才保留普通输入框。 |
| 嵌套 agent 会逐层展开。 | 每行携带一层 `hasChildren` 快照;展开时会立即预留已知直接后代行,随后仍只加载该行的直接目录,并保留其自身的 parent 地址。 |
| 嵌套 agent 会逐层展开。 | row 在自身 catalog 成功加载为空前保持可展开;展开时只加载该直接 catalog,并保留 row 的 parent 地址。 |
| 条目显示 label、状态、token 用量与活跃耗时,同时避免侧边栏条目重复。 | mode 与 `running`/`inactive` 活动状态会同时以文字和视觉呈现;可选 title、持久化 token 用量与活跃轮次耗时来自列表保留的投影值。紧凑耗时从一天起省略更小的单位,而悬停和无障碍名称仍保留精确的整秒数。`SessionHeader.origin` 会移除重复的导航条目,但不授予任何能力。 |
## 产品约定
只有当完整的直接目录空响应与会话摘要投影相符,二者均表明没有已知的 subagent 后代时,才不显示后代数量控件。普通会话会用斜杠分隔当前 title 和该数量控件。每一级 subagent 面包屑都将紧凑的 12px title 与固定显示的双向箭头组合成一个控件:当前面包屑使用主标签颜色和 500 字重,祖先则使用三级标签颜色和 400 字重。悬停组合控件 150ms 后会打开其直接 parent 目录;浮层菜单具有短暂的跨越宽限时间,ArrowDown 保留为键盘入口。点击祖先会取消待触发的悬停、关闭已打开的目录,并且只向上导航。每个直接 parent 目录都可切换 sibling,并会加粗其选中行;目录 label 优先于会话摘要 title,缺失的每个切换器目录都会自动刷新。只有当前 subagent 会追加自己的后代数量控件。过长的 title 会在固定箭头之前截断。数量触发器会统计经不间断的 `origin: 'subagent'` 谱系可达的每个已知会话摘要后代,在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。由于普通侧边栏行会隐藏 origin 为 subagent 的会话,Workspace 浏览器会在每个可见的普通行上索引同一条不间断谱系:任何运行中的后代都会让该行显示蓝色活动指示器,并在悬停与无障碍文本中给出确切数量,同时不会把空闲 parent 描述为正在运行。普通 fork 会开启单独的聚合子树。待处理交互优先于 parent 的运行中状态;二者无论哪一项存在都会保持为主要状态,而后代活动则成为悬停与无障碍状态中的第二项。两者均不存在时,后代活动优先于未查看的完成提醒;最后一个运行中的后代停止后,该提醒会恢复。每个健康的直接目录行都携带读取时的 `hasChildren` 提示,该值只根据持久化 `origin: 'subagent'` 的直接谱系 header 派生;正常的健康与 diagnostic subagent 候选都会携带该标记,而普通 fork 不会。该预查不读取任何后代事件日志,展开后仍以描述符支撑的目录为权威依据。当摘要在该目录尚不存在时或在一次陈旧的空响应后确认已有后代时,该操作会保持可见,并且在打开它以刷新目录之前仅显示禁用的加载行;仅由摘要支撑的行绝不会提供导航能力。UI 会在交互前就省略已知叶子节点的展开控件;该提示不承诺 child 会一直是叶子。已展开的直接目录加载期间,已知谱系会为每个直接后代预留一行禁用的加载行,而不会递归获取后代目录。随后树会呈现可继续与 one-shot 行;one-shot 的可选 label 缺失时,回退到其会话 id。损坏、不受支持或不可用的候选仍以禁用的 diagnostic 行显示。
直接 catalog 有子项或读取失败时显示 child 数量控件;空目录缺席、加载中或成功读取为空时均隐藏该控件。普通会话会用斜杠分隔当前 title 和该控件。每一级 subagent 面包屑都将紧凑的 12px title 与固定显示的双向箭头组合成一个控件:当前面包屑使用主标签颜色和 500 字重,祖先则使用三级标签颜色和 400 字重。悬停组合控件 150ms 后会打开其直接 parent 目录;浮层菜单具有短暂的跨越宽限时间,ArrowDown 保留为键盘入口。点击祖先会取消待触发的悬停、关闭已打开的目录,并且只向上导航。每个直接 parent 目录都可切换 sibling,并会加粗其选中行;目录 label 优先于可选的会话摘要 title,缺失的切换器目录在交互时加载。只有当前 subagent 会追加自己的直接 child 数量控件。过长的 title 会在固定箭头之前截断。触发器报告直接 catalog 的总数与运行数。普通侧边栏 row 隐藏 origin 为 subagent 的 Session,并从同一份已加载 catalog map 读取直接运行 child 数;Session summary 提供 activity,但绝不创建 membership。待处理交互优先于 parent 的运行中状态;二者无论哪一项存在都会保持为主要状态,而直接 child activity 则成为悬停与无障碍状态中的第二项。两者均不存在时,直接 child activity 优先于未查看的完成提醒;最后一个运行中的直接 child 停止后,该提醒会恢复。tree row 在 child catalog 缺席、加载中或失败时保持可展开,只有 catalog 成功加载为空后才成为已知叶子。加载时显示通用提示,不会从 summary 生成占位 row。tree 会呈现 continuable 与 one-shot row;one-shot 的可选 label 缺失时,回退到其 Session id。
`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` 刷新来更新。消息投递时仍以提示词响应为权威依据。
`running` 表示子 Agent driver 正在处理工作;`inactive` 表示该 driver 空闲或不存在。Session 列表基线与状态事件提供活动状态,移除事件将完成的子代理标为 inactive 并保留其展示 projection。共享的父 `subagentCatalog` projection 提供成员关系。初始读取与推送值进入同一个 projection store,较新序号优先。[projection 消费决策](../simplification/2026-09-08-web-subagent-catalog-projections.zh.md) 说明加载、重连与同步取舍。提示词响应仍是投递时的权威依据。
健康行会复用列表镜像中保留的标准会话投影。token 用量数值会汇总持久化日志中四个互不重叠的 `tokenUsage` 桶。`subagentTiming` 会在每个描述符处重置,使继承的 fork 种子不会计入 child 总量;它会累加已完成的 `turn/start` → `turn/end` 时段,并携带未结束轮次同一切面的 `active.since` 和 `active.through` 边界。该轮次保持未结束期间,现有会话事件会推进 `active.through`;菜单不会增加单独的计时器或日志读取,且仅在有已知后代处于运行状态时才推进其本地时钟。不足一天时,菜单会以整秒格式化时间;达到一天后的视觉值最多保留两个相邻单位,其中月份按近似 30 天计算,年份按近似 365 天计算,而悬停信息与无障碍名称会保留精确的天/小时/分钟/秒耗时。对 inactive 行,菜单以 `active.through` 为被中断未结束轮次的上界,因此陈旧投影绝不会借用更新的会话元数据,且重新打开菜单绝不会让已完成工作重新计时。这两项指标都不蕴含持久化结果语义。
@@ -51,17 +51,17 @@ one-shot 行始终会用文案替代输入框,说明执行记录为只读。
## 宿主适配器与协议约定
`@deepseek-ai/dsh-subagent` 拥有浏览器安全的生成 `subagent` Remote 命名空间:
Session Controller 负责 catalog 与 history read;`@deepseek-ai/dsh-subagent` 负责 continuation control:
- `subagent.list` 接受 `parentSessionId`,调用 `ctx.subagents.listChildren(parentSessionId, signal)`,返回完整有序的条目以及每个健康行的布尔 `hasChildren` 快照,把每个健康行的语料活动状态替换为其确切 Agent driver 是否正在运行,并说明当前能否从 `ctx.agents` 解析出确切 parent。
- `subagent.history` 接受包含 mode 的完整地址与普通页参数。它对照直接目录校验 child 与 mode,通过 `ctx.sessionQuery.readSession()` 读取,再次检查直接谱系,并在不发布 agent 的情况下返回普通原始事件、渲染意图、分页与由 Host 计算的会话投影基线。
- `session.projections` 接受 `sessionId`,返回一次 live-preferred Session observation 的完整 projection 基线,不激活 Agent。Client 将基线写入标准 projection store;subagent 消费者选择 `subagentCatalog`。父 Agent 可用性来自 Session 列表摘要与生命周期事件。
- `session.page` 与 `session.follow` 接受包含 mode 的完整地址。它们在观察到的 cut 上校验 child header、直接 parent、descriptor identity 与 mode,随后在不发布 Agent 的情况下返回普通 raw event、pagination、live reconciliation 与 Host projection baseline。
- `subagent.prompt` 只接受 `mode: 'continuable'` 地址、`delivery: 'queue' | 'steer'` 与上传形态的 `PromptContentPart[]`;Host 在投递前把图片部分准入并持久化为持久引用([图片投递](../../archived/bug-fix/2026-08-27-steer-followup-image-delivery.md))。它要求确切的存活 parent,重新校验目录地址,使用 continuation manager 共享的人类投递准入,并返回已接受的 `MessageId`。
网关会将 parent 缺失、目录条目缺失或为 diagnostic、child 不可恢复或未授权、请求取消、图片准入或图片能力拒绝(`subagent/attachment-invalid`)以及继续执行准入暂时不可用等失败映射为类型化 RPC 错误。它不会公开描述符或提供方细节。list/prompt 竞态属于正常情况:权威依据是提示词操作的结果,而不是更早的可用性或活动快照。
网关会将 parent 或目录条目缺失、child 不可恢复或未授权、请求取消、图片准入或图片能力拒绝(`subagent/attachment-invalid`)以及继续执行准入暂时不可用等失败映射为类型化 RPC 错误。它不会公开描述符或提供方细节。list/prompt 竞态属于正常情况:权威依据是提示词操作的结果,而不是更早的可用性或活动快照。
查看持久化历史本身不会创建 mux 订阅。当后续消息物化冷态 child Activation 时,现有 Host 与 mux 流会发布其生命周期与事件。重新连接时,系统通过 `subagent.history` 重建已寻址窗口。
查看持久化历史本身不会创建 Agent。当后续消息物化冷态 child Activation 时,现有 Host 与 Session journal stream 会发布其生命周期与事件。重新连接时,系统通过 `session.follow` 重建已寻址窗口。
普通 `session.history` 路由对于普通会话和 subagent 会话同样只执行观察,但它既不携带目录地址,也不授予继续执行权限。每条需要 Agent 的普通路由都会在恢复冷会话前经过共享所有权栅栏;`session.cancel` 保留该栅栏。`session.updateQueue` 只有一个目标本地例外:目标是在线 child,且其当前 projection identity 为 continuable 并来自自身的非 seed suffix;one-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 仍受栅栏阻挡。
普通 `session.page` 与 `session.follow` 地址对于普通会话和 subagent 会话同样只执行观察,但它既不携带目录地址,也不授予继续执行权限。每条需要 Agent 的普通路由都会在恢复冷会话前经过共享所有权栅栏;`session.cancel` 保留该栅栏。`session.updateQueue` 只有一个目标本地例外:目标是在线 child,且其当前 projection identity 为 continuable 并来自自身的非 seed suffix;one-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 仍受栅栏阻挡。
适配器仍位于生成的 Remote 命名空间之后;`dsh-host-webserver` 仍作为载体。浏览器代码通过现有连接包导入约定,绝不直接访问宿主 `ctx`,从而保持[已归档的 GUI RPC 分层决策](../../archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)。
@@ -71,7 +71,7 @@ one-shot 行始终会用文案替代输入框,说明执行记录为只读。
目录通过标准 `useSessions` 快照传递。组件局部状态负责菜单可见性、已展开分支、焦点与悬停计时器。`ui-conversation` 为当前普通 title 和每一级 subagent 面包屑声明谱系 slot,传入纯数据形式的面包屑身份与显示文本,并为祖先传入向上导航回调;普通 title 由 render site 保留为回退。`@deepseek-ai/dsh-client-ui-subagent` 以直接 parent 目录导航占用每个谱系 slot,并根据普通 owner props 选择按原因区分的只读编辑器。组件只接收派生 props 与回调,绝不接收 `ctx`。
每个进程内 subagent child 都会在发布前写入 `SessionHeader.origin: 'subagent'`。会话列表摘要与增量 Host 帧会投影该字段,使分组和扁平侧边栏省略重复的 child 行,同时保留普通 fork。同一条现有的 `host/session-added` 帧还会把已加载的直接 parent 行标记为可展开,而无需引入目录事件流。描述符 mode 与目录校验仍然是导航、继续执行和授权的权威依据。
每个进程内 subagent child 都会在发布前写入 `SessionHeader.origin: 'subagent'`。会话列表摘要与增量 Host 帧会投影该字段,使分组和扁平侧边栏省略重复的 child 行,同时保留普通 fork。parent catalog projection 拥有 membership 与 tree structure 权威;descriptor identity 与精确 parent check 拥有 addressed history 与 continuation validation 权威。
该包现有的 `@label` source 仍然是独立的面向模型纯文本输入。它不会将 label 解析为地址,也不会获得继续执行语义。
@@ -95,23 +95,23 @@ one-shot 行始终会用文案替代输入框,说明执行记录为只读。
**根据谱系推断 mode 或侧边栏过滤。** 不予采纳,因为普通 fork 共享 `parentSession`。由描述符支撑的目录负责提供 mode;单独的 `origin` 标记只是低成本的导航分类器。
**构建预先加载的递归树或专用目录流。** 就当前规模而言不予采纳。仅用于页头的一层可展开性预查会在不读取后代事件的情况下保证点击前的稳定性,而展开仍是懒加载式权威直接 child 读取;现有 Host 帧会更新活动状态、恢复 parent 行的可展开性,并触发有界的成员刷新。
**构建预先加载的递归树或专用目录流。** 就当前规模而言不予采纳。展开控件按需加载缺失目录,现有 control 帧传递完整 projection 更新。
**让 child 在 parent 消失后仍能独立交互。** 不予采纳,因为独立生命周期与用户所有权需要 side session 语义。
## 测试
- 宿主协议测试固定 schema(包括必需的布尔可展开性)、id 回显、mode 校验、非激活式历史、确切 parent 强制要求、FIFO 准入回执、取消与脱敏后的失败映射。
- 宿主协议测试固定健康的直接 catalog schema、id 回显、mode 校验、非激活式历史、确切 parent 强制要求、FIFO 准入回执、取消与脱敏后的失败映射。
- 通用 Host 测试固定在不发布 Agent 的情况下读取已附加与冷态历史及执行 fork、冷态投影归并、按描述符/origin/运行时 owner 拒绝、拒绝显式 id 接纳,以及直接队列控制栅栏。
- 客户端对象测试固定已保留与已恢复的地址、one-shot 只读与取消拒绝、历史路由、可继续提示词与中断路由、屏蔽绑定到 agent 的模型控件、实时活动状态翻转(包括在途响应回放与 detach 回退)、subagent parent 可展开性翻转与成员刷新。
- jsdom 测试固定普通 title 分隔符、逐级合并的 subagent title 切换热区、嵌套 title 的 12px 字号、当前与祖先样式、选中行字重、目录 label 优先级、悬停延迟、向上点击抑制、保留箭头的截断、后代聚合计数与活动状态、侧边栏活动在嵌套谱系中的传播与普通 fork 边界、行状态优先级、token 用量总计、精确到秒的运行中耗时与冻结后 inactive 耗时、采用自适应单位的长耗时及其精确无障碍文本、目录缺失或为陈旧空目录时由摘要支撑的根操作、已知加载行的形态、混合 mode 行、点击前的叶子展开控件、diagnostic、后代懒加载展开、直接 parent 地址、键盘行为与两种只读原因。
- 无密钥的组装 Web 快照包含一个具有持久化 token 用量的 inactive 可继续 child、一个具有确定性长耗时的 inactive one-shot sibling 和一个持久化 grandchild;它会固定触发器在一次陈旧的空目录响应后仍显示三个后代,并固定 token 用量与计时行、自适应长耗时呈现及聚合 `running` 状态转换,在不激活的情况下展开、打开持久化历史、准入一条用户 FIFO 后续消息、归并 child mux 事件,并证明 one-shot 历史仍然只读。另一个独立的组装场景会在 LLM seam 处保持一个真实的 child Agent 轮次进行中,同时固定页头和可见空闲 owner 行中的聚合运行状态,随后在 teardown 期间取消该轮次。
- 客户端对象测试固定已保留与已恢复的地址、one-shot 只读与取消拒绝、历史路由、可继续提示词与中断路由、屏蔽绑定到 agent 的模型控件、来自 Session 摘要与 Agent 销毁的实时活动状态、权威 catalog membership 与成员刷新。
- jsdom 测试固定普通 title 分隔符、逐级合并的 subagent title 切换热区、嵌套 title 的 12px 字号、当前与祖先样式、选中行字重、目录 label 优先级、悬停延迟、向上点击抑制、保留箭头的截断、catalog 直接计数与 activity、侧边栏直接 child activity、行状态优先级、token 用量总计、精确到秒的运行中耗时与冻结后 inactive 耗时、采用自适应单位的长耗时及其精确无障碍文本、通用加载提示、混合 mode row、ready-empty 叶子判定、descendant 懒加载展开、直接 parent 地址、键盘行为与两种只读原因。
- 无密钥的组装 Web 快照包含一个具有持久化 token 用量的 inactive 可继续 child、一个具有确定性长耗时的 inactive one-shot sibling 和一个持久化 grandchild;它会固定直接 catalog 数量、token 用量与计时行、自适应长耗时呈现及嵌套懒加载,在不激活的情况下打开持久化历史、准入一条用户 FIFO 后续消息、归并 child mux 事件,并证明 one-shot 历史仍然只读。另一个独立的组装场景会在 LLM seam 处保持一个真实的 child Agent 轮次进行中,同时固定页头和可见空闲 owner row 中的直接运行状态,随后在 teardown 期间取消该轮次。
- 导航测试固定仅含 subagent 的面包屑导航、从 subagent 创建 fork 时的 Workspace 归属,以及 `origin: 'subagent'` 侧边栏过滤,同时不隐藏普通 fork。
## 后果
- 目录读取可能重新扫描持久化谱系与每个直接候选的描述符日志,但可展开性只复用该追踪中已有的后代 header;Web 活动基线会为每个健康行增加一次 Agent 注册表查找,随后使用现有实时帧,而 token 用量与耗时会复用投影基线和推送,无需按行读取日志,成员刷新则保持去抖动和单次并发。
- parent 可用性、child 活动状态与 `hasChildren` 都是快照。列出之后,发布、dispose(资源释放)、其他发送方或其他进程都可能抢先改变状态;类型化提示词失败仍属预期行为。
- catalog read 只 fold 所选 parent 的 catalog projection。Web activity 来自 Session 摘要;token 用量与耗时复用 projection baseline 和 push,无需按 row 读取日志,投影读取保持 single-flight。
- parent 可用性与 child activity 都是快照。列出之后,发布、dispose(资源释放)、其他发送方或其他进程都可能抢先改变状态;类型化提示词失败仍属预期行为。
- child 可能在历史获取与 mux 订阅之间发布,因此现有序号归并也涵盖从冷态转为存活的已寻址路径。
- 持久化 origin 会为 child header 与列表投影添加一个有意保持弱约束的产品分类字段;它不能变成授权捷径。
- 除对正在运行的可继续 child 的当前轮次 Stop([中断约定](2026-08-06-continuable-subagent-interrupt.zh.md))之外,UI 不提供 child 取消、持久化结果、Activation 身份、删除或可独立交互的离线 mode,其文案不得暗示这些能力已经存在。活跃轮次耗时度量的是已记录工作,而非 Activation 驻留时间。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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/simplification/2026-09-08-web-subagent-catalog-projections.md
2026-09-08-web-subagent-catalog-projections.md: 766d33e581bcac53844b0836f23cda615858fc27
2026-09-08-web-subagent-catalog-projections.zh.md: ef14db2530ed10a7aa34408c5a6fada6f0a28858
@@ -0,0 +1,31 @@
# Agent Note: Web subagent catalogs consume shared projections
Status: implemented
English | [中文](2026-09-08-web-subagent-catalog-projections.zh.md)
## Problem
The parent catalog projection already publishes complete membership through the Session control stream. A separate catalog-change event, repeated RPC reads, and a second membership cache duplicate that delivery and require coordination between responses and later events. Historical Sessions still need initial loading when their projection is absent, and parent Agent availability remains independent of durable membership.
## Decision
The Client retains catalog membership only in its standard per-Session projection store. Initial `session.projections` reads return a complete projection baseline from one live-preferred Session observation. The endpoint accepts any Session id and has no catalog-specific dependency or validation. The same observation supplies the values and sequence cursor. Initial baselines and live control frames use the store's existing sequence ordering, so an older response cannot replace a newer value. The endpoint does not activate an Agent or sample child activity. Opening a conversation uses its follow baseline without scheduling a second projection read; unopened catalog branches still use explicit reads.
Feature consumers select `subagentCatalog` from `projectionsBySession` and derive row activity from Session-list baselines and status events. Session summaries report Agent availability separately; Agent creation and disposal republish the existing summary event. Completed subagents retain their summaries and display projections after removal. Generic projection reads own loading and error state; parent availability comes from the Session list independently of membership. Opening a loaded catalog reuses its data; an initial failure can be retried. Reconnect cancels previous reads and starts fresh reads for requested catalogs. Session removal cancels its pending read and marks parent availability false; projection responses cannot change Agent availability.
The [parent-catalog decision](../architecture/2026-09-01-parent-owned-subagent-catalog.md) owns durable creation facts and ordering. This decision supersedes the dedicated membership-refresh mechanism in [Web subagent conversations](../feature/2026-07-27-web-subagent-conversations.md); that note remains active for navigation, control, and presentation decisions.
Host summaries replace running and availability state; local create/fork placeholders only enrich missing metadata. Sharing overwrite behavior would let a late local response erase a newer Host state and generate a false completion reminder. Removed ordinary Sessions retain stores only for nonempty child catalogs; empty catalogs carry no navigation that needs to survive removal. Breadcrumb selectors derive addresses from the Session-list snapshot so their subscriptions cover every dependency.
## Alternatives considered
**Keep notification-driven reads.** Rejected because the shared stream already delivers the changed value. Menu subscriptions, response-local activity replay, and trailing membership reads add no required delivery capability.
**Remove the initial-read endpoint.** Deferred. Session-list projections are cache hints and may be absent; following a conversation also transfers history and retains observation state. A lightweight read preserves historical catalog access without establishing a conversation follow stream. Parent availability is a live delivery hint, not a durable projection fact.
## Consequences
Membership changes use the existing control stream without extra catalog notifications or reads. Whole-value projection publication still costs O(D) for D children; this change does not introduce delta transport. Initial reads return the observation's complete projection baseline, including values beyond the catalog, so they reuse standard baseline semantics at the cost of that additional initial payload. Session sequence ordering does not establish identity across reused Session ids.
Manager tests cover pushed membership, stale initial responses, lazy loading, status composition, retained completion metadata, retry, removal of empty catalogs, late create/fork responses, and reconnect cancellation. Host tests exercise the shared control stream and cold observations. The persisted-subagent Web scenario covers nested historical navigation and continuation through the shipped application.
@@ -0,0 +1,31 @@
# Agent Note:Web 子代理目录消费共享 projection
Status: implemented
[English](2026-09-08-web-subagent-catalog-projections.md) | 中文
## 问题
父目录 projection 已通过 Session control stream 发布完整成员关系。独立的目录变化事件、重复 RPC 读取与第二份成员缓存重复传递同一数据,并要求协调响应与后续事件。历史 Session 的 projection 缺席时仍需初始加载,父 Agent 可用性也独立于持久成员关系。
## 决策
Client 仅在标准逐 Session projection store 中保留目录成员关系。初始 `session.projections` 读取通过一次 live-preferred Session observation 返回完整 projection 基线。endpoint 接受任意 Session id,不含目录专用依赖或校验。值与序号游标来自同一次 observation。初始基线与实时 control 帧使用 store 既有的序号排序,因此旧响应不能替换新值。该 endpoint 不激活 Agent,也不采样子代理活动状态。打开会话时使用其 follow baseline,不再额外调度一次投影读取;未打开的目录分支仍使用显式读取。
功能消费者从 `projectionsBySession` 中选择 `subagentCatalog`,并从 Session 列表基线与状态事件派生行活动状态。Session 摘要单独报告 Agent 可用性;Agent 创建与销毁重新发布已有摘要事件。完成的子代理在移除后保留摘要与展示 projection。通用 projection 读取拥有加载与错误状态;父 Agent 可用性来自 Session 列表,独立于成员关系。打开已加载目录会复用数据,初始失败可以重试。重连取消此前的读取,并为已请求的目录重新读取。Session 移除会取消其待完成读取并将父 Agent 可用性标为 false;projection 响应不能改变 Agent 可用性。
[父目录决策](../architecture/2026-09-01-parent-owned-subagent-catalog.zh.md) 说明持久创建事实与顺序。本决策取代 [Web 子代理会话](../feature/2026-07-27-web-subagent-conversations.zh.md) 中的专用成员刷新机制;该记录继续保留导航、控制与展示决策。
Host 摘要替换运行状态与可用性;本地 create/fork 占位摘要只补充缺失的元数据。若共用覆盖行为,晚到的本地响应会抹去较新的 Host 状态,并生成虚假的完成提醒。普通 Session 移除后,仅为非空子目录保留 store;空目录没有需要保留的导航关系。面包屑 selector 从 Session 列表快照推导地址,使订阅涵盖全部依赖。
## 考虑过的替代方案
**保留通知驱动的读取。** 不采用,因为共享流已经传递变化后的值。菜单订阅、请求内活动状态回放与尾随成员读取没有提供额外的必要传递能力。
**删除初始读取 endpoint。** 暂缓。Session 列表 projection 是可能缺席的缓存提示;跟随会话还会传输历史并保留 observation 状态。轻量读取保留历史目录访问能力,无需建立会话跟随流。父 Agent 可用性是实时投递提示,而非持久 projection 事实。
## 影响
成员变化使用既有 control stream,无需额外目录通知或读取。对 D 个子级发布完整 projection 值仍需 O(D);本变更不引入增量传输。初始读取返回 observation 的完整 projection 基线,其中包括目录以外的值,以额外初始载荷换取标准基线语义的复用。Session 序号排序不确立复用 Session id 之间的身份关系。
Manager 测试覆盖成员推送、过时初始响应、按需加载、状态组合、完成元数据保留、重试、空目录移除、晚到的 create/fork 响应与重连取消。Host 测试覆盖共享 control stream 与冷 observation。持久化子代理 Web 场景通过发布应用验证嵌套历史导航与续传。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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/simplification/2026-09-16-subagent-catalog-membership-only.md
2026-09-16-subagent-catalog-membership-only.md: d1d0956380ef2b2ffd610e5a3d07c7988991e42e
2026-09-16-subagent-catalog-membership-only.zh.md: 4225a331a0b3c483349760cd805416a94c4b915a
@@ -0,0 +1,25 @@
# Agent Note: Keep model configuration outside subagent catalogs
Status: implemented
English | [中文](2026-09-16-subagent-catalog-membership-only.zh.md)
## Problem
The parent catalog serves direct-child discovery. Recording a child's creation-time LLM provider and model would preserve immutable configuration without opening child histories, but discovery and navigation consumers do not read those values. Creation configuration also does not establish which model served a request.
## Decision
The catalog event and projection retain child identity, creation time, mode, and label. `establishCatalogChild(parent, childHeader, descriptor)` receives only the values needed to publish membership. Child composition and request history retain their own model configuration; catalog loading adds no child observation or model lookup.
This decision supersedes only the creation-model metadata choice in the [parent-catalog decision](../architecture/2026-09-01-parent-owned-subagent-catalog.md). That note remains active for successful publication, event order, fork isolation, and projection ownership. The unreleased optional metadata declaration and its dedicated expectations are absent; existing historical Session generations from the migration base remain intact.
## Alternatives considered
**Keep optional creation metadata for future display.** Optional fields still require durable schemas, cache invalidation, producers, and replay expectations. A consumer requiring the immutable creation route could justify those costs; current consumers do not require it.
**Read child histories during discovery.** This adds work proportional to child histories without helping membership. A display that needs the model used by a request can observe the child's existing `modelSelection` projection independently.
## Consequences
The catalog provides no historical creation-route record. Reintroducing one requires a consumer whose requirement cannot be met by the existing child composition or request projections. Initial reads, live updates, reconnect, and authorization keep their existing owners. Catalog and creation tests, recorded-session replay, and both SDK expectations verify that membership still publishes without the extra metadata.
@@ -0,0 +1,25 @@
# Agent Note: 将模型配置保留在子代理目录之外
Status: implemented
[English](2026-09-16-subagent-catalog-membership-only.md) | 中文
## 问题
父目录用于发现直接子级。记录子级创建时的 LLM provider 和 model,可以在不打开子级历史的情况下保留不可变配置,但发现与导航的消费者不读取这些值。创建配置也不能确定实际处理某次请求的模型。
## 决策
目录事件与投影保留子级身份、创建时间、模式和标签。`establishCatalogChild(parent, childHeader, descriptor)` 只接收发布成员关系所需的值。子级组合与请求历史各自保留模型配置;目录加载不增加子级观察或模型查询。
本决策仅替代[父目录决策](../architecture/2026-09-01-parent-owned-subagent-catalog.zh.md)中的创建模型元数据选择。该记录继续说明成功发布、事件顺序、fork 隔离和投影所有权。未发布的可选元数据声明及其专属预期输出不予保留;迁移基线中已有的历史 Session 世代保持完整。
## 考虑过的替代方案
**保留可选创建元数据,供未来展示使用。** 可选字段仍需要持久化 schema、缓存失效、写入方和回放预期输出。需要不可变创建路由的消费者可以证明这些成本合理,但当前消费者没有此需求。
**在发现过程中读取子级历史。** 这会增加与子级历史规模相关的工作,却不帮助确定成员关系。需要展示请求实际使用模型的功能,可以独立观察子级已有的 `modelSelection` 投影。
## 后果
目录不提供历史创建路由记录。重新引入此记录,需要存在无法通过已有子级组合或请求投影满足需求的消费者。初始读取、实时更新、重连和授权继续由现有模块负责。目录与创建测试、已记录 Session 回放以及两套 SDK 的预期输出验证了成员关系仍可在没有额外元数据的情况下发布。
@@ -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/testing/2026-09-06-backend-continuation-performance.md
2026-09-06-backend-continuation-performance.md: a30c7a47a93f0a8827d1cb4d0857aeabe9dd2fee
2026-09-06-backend-continuation-performance.zh.md: c703ea70e496d279e5965295d308a2919ed2b445
2026-09-06-backend-continuation-performance.md: a2423a404e23f6a4ccb6a4fb177ec6842b7bf584
2026-09-06-backend-continuation-performance.zh.md: 942cfd37528d121bf482c48c5d75dd1f2de30887
@@ -19,13 +19,13 @@ The shared history has 800 completed two-step turns, four tool calls per turn, a
| Request history | After unmeasured cold resume, deliver 40 sequential text-only turns over the tool-heavy history, then flush | Idle Agent with all 40 model requests completed; reports turn and final-flush time separately |
| Tool continuation | Cold resume, 20 sequential turns with eight parallel-safe synthetic tool calls and a final reply per turn, then flush | Idle Agent with 40 model requests and 160 completed tool executions; reports resume, turns, and final flush separately |
| Shipped SDK workflow | Launch built dsh with the sdk-minimal profile, deliver 100 sequential turns with eight real file-view calls per turn, then close the SDK | SDK receives 200 assistant messages and 800 successful file results; includes Loader boot, stdio JSON-RPC, persistence, and shutdown |
| Child catalog | List 16 inactive seeded fork children twice through the real subagent and Session query services | Two complete healthy catalogs with observations released; each child inherits 80 tool-heavy turns and owns its descriptor after the exact fork cut |
| Child catalog | Observe one cold parent catalog twice through the real Session query and subagent services | Two complete 16-child catalogs with parent observations released; each stored fork child inherits 80 tool-heavy turns, and discovery reads only the parent facts |
The tool execution pipeline, request preparation, Session projections required by those services, persistence, and catalog observations remain production code. Only the model adapter and bounded tool body are synthetic. The adapter retains a request counter, not request objects, so the fixture cannot manufacture a growing retention cost. Sequential input means each idle interval belongs to the one request delivered by this worker; it does not generalize idle to a per-message completion API under concurrent input.
The SDK fixture explicitly inserts `fs-local` and `str_replace_editor` through its profile patch. This preserves the calibrated file-view workload independently of the [minimal profile's shell-only defaults](../simplification/2026-09-03-minimal-profiles-persistent-shell-only.md). File reads, timing endpoints, and budgets remain the same.
Five samples report raw wall time, CPU user/system time, peak RSS, endpoint counts, and the minimum, median, and maximum total wall time. Each aggregate report also records CPU models, available parallelism, platform, architecture, and Node/V8 versions after the timed workers exit. Budgets enforce the unrounded median. Continuation additionally measures retained heap against an initialized Host: two explicit GCs separated by an event-loop yield precede and follow the timed operation, while the idle Agent remains reachable. The measured delta therefore includes the resident historical Session and live additions, not just newly appended turns. GC and teardown are outside timing; flush is inside. Request-history retention starts after resume and is diagnostic only. Catalog peak RSS is diagnostic; no retained-heap budget claims to measure already-released child observations.
Five samples report raw wall time, CPU user/system time, peak RSS, endpoint counts, and the minimum, median, and maximum total wall time. Each aggregate report also records CPU models, available parallelism, platform, architecture, and Node/V8 versions after the timed workers exit. Budgets enforce the unrounded median. Continuation additionally measures retained heap against an initialized Host: two explicit GCs separated by an event-loop yield precede and follow the timed operation, while the idle Agent remains reachable. The measured delta therefore includes the resident historical Session and live additions, not just newly appended turns. GC and teardown are outside timing; flush is inside. Request-history retention starts after resume and is diagnostic only. Catalog peak RSS is diagnostic; no retained-heap budget claims to measure already-released parent observations.
The parent bounds every child to 60 seconds, checks timeout, signal, exit, and report independently, awaits process close, and removes private roots after failures. Context and Agent teardown run in finally blocks. Seed processes cannot warm the measured process's caches. Filesystem caches are not forcibly evicted: cold means a fresh process, not cold physical storage.
@@ -41,7 +41,7 @@ The implementation reference is the PR #3537 merge on Apple M4 Pro, macOS arm64,
Continuation retains approximately 22.295 MiB; its source expectation is 23 MiB and its budget is 28.75 MiB. SDK time expectations use the existing [calibration helper](../../../../benchmarks/support/calibration.ts): 2× shared CI time scale and 1.25× variance headroom. Request history uses the direct hosted expectation in the [request-freeze calibration](../simplification/2026-09-06-agent-request-freeze-evidence.md), without the 2× scale. Memory uses only 1.25× headroom. The scale is inherited from the existing lane's calibration, not a new Linux measurement of these cases; CI evidence remains necessary when runner characteristics change. Baseline budgets protect the measured implementation; tighter budgets belong with a measured behavior-preserving fix.
A separate plain-Node request-history CPU profile attributes 132.876 ms of sampled self time to deepFreeze called by buildRequest during a 211.300 ms operation. This identifies repeated traversal of already-frozen history as a focused investigation target, not a proven optimization result. Catalog first/repeat timings remain separate because a second listing still reads body-bearing seeded children after observations are released.
A separate plain-Node request-history CPU profile attributes 132.876 ms of sampled self time to deepFreeze called by buildRequest during a 211.300 ms operation. This identifies repeated traversal of already-frozen history as a focused investigation target, not a proven optimization result. Catalog first/repeat timings separately report the first and second parent-catalog observations.
The shipped SDK variant completes 100 turns, 200 requests, and 800 real file reads. Its five-sample smoke totals are 1,521.773, 1,463.465, 1,689.701, 1,365.485, and 1,417.106 ms (median 1,463.465 ms); a full-suite repeat reports 1,596.183, 1,784.536, 2,120.082, 1,405.365, and 1,355.894 ms (median 1,596.183 ms). Its 1,700 ms reference expectation yields a 4,250 ms CI budget. The repeat also slows the unchanged service cases, so it is validation under variable host load rather than evidence to relax their exclusive calibration. The SDK process receives an allowlisted environment and private home/workspace. A 40-second deadline starts SDK shutdown; every path awaits the same memoized close promise before the outer worker’s 60-second deadline. Profile timing includes boot, all turns, and shutdown, reported separately; no parent-process CPU or heap metric is presented as server memory. The adapter does not serialize requests for an external model provider.
@@ -59,7 +59,7 @@ Baseline request history uses a 600 ms expected CI duration with 1.25× headroom
**Measure only deriveMessages.** Rejected: its incremental cache does not include complete request freezing, adapter dispatch, live append, or persistence. Actual sequential requests protect the cost the Agent pays per step.
**Use only unseeded children with warm projection-cache rows.** Rejected: that path bypasses body observations and misses the exact inherited-cut requirement of fork children. The catalog intentionally omits the optional projection cache and reports the seeded fallback path; it does not characterize cache-hit discovery.
**Use only unseeded children with warm projection-cache rows.** Rejected: that path bypasses body observations and misses the exact inherited-cut requirement of fork children. The original calibration omits the optional projection cache and reports the seeded fallback path. Current catalog reads observe durable parent facts; the recorded child-body timings describe the calibration implementation.
**Apply an optimization and its desired budget together with the first measurements.** Rejected: a baseline-only layer remains independently mergeable and records the current workload before attribution or implementation changes. Source constants cannot be overridden by environment variables.
@@ -19,13 +19,13 @@ Status: implemented
| 请求历史 | 在不计时的冷恢复后,向工具密集历史顺序提交 40 个纯文本轮次,然后 flush | 空闲 Agent,已完成全部 40 次模型请求;分别报告轮次和最终 flush 时间 |
| 工具续聊 | 冷恢复,顺序执行 20 个轮次,每轮八次可安全并行的合成工具调用和一条最终回复,然后 flush | 空闲 Agent,已完成 40 次模型请求和 160 次工具执行;分别报告恢复、轮次和最终 flush 时间 |
| 已发布 SDK 工作流 | 使用 sdk-minimal profile 启动已构建 dsh,顺序提交 100 个轮次,每轮八次真实文件查看调用,然后关闭 SDK | SDK 收到 200 条助手消息和 800 个成功文件结果;包含 Loader 启动、stdio JSON-RPC、持久化和关闭 |
| 子会话目录 | 通过真实 subagent 和 Session 查询服务,两次列出 16 个非活动、带种子的 fork 子会话 | 两份完整健康目录,观察已释放;每个子会话继承 80 个工具密集轮次,并在精确 fork 切点后拥有自己的描述符 |
| 子会话目录 | 通过真实 Session 查询和 subagent 服务,两次观察一个冷 parent 目录 | 两份完整的 16 子级目录,parent 观察已释放;每个存储的 fork 子级继承 80 个工具密集轮次,发现只读取 parent fact |
工具执行管线、请求准备、这些服务所需的 Session 投影、持久化和目录观察均保留生产代码。只有模型适配器和有界工具体是合成的。适配器只保留请求计数,不保留请求对象,因此 fixture(测试前置数据)不会制造不断增长的保留成本。顺序输入使每个空闲区间对应此 worker 提交的唯一请求;这不代表并发输入时可以把空闲状态推广为逐消息完成 API。
SDK fixture 通过 profile patch 显式插入 `fs-local` 和 `str_replace_editor`。这使经校准的文件查看负载不依赖[极简 profile 只提供 shell 的默认组合](../simplification/2026-09-03-minimal-profiles-persistent-shell-only.zh.md)。文件读取、计时终点和预算保持不变。
五个样本报告原始壁钟时间、CPU 用户态/内核态时间、峰值 RSS、终点计数及总壁钟时间的最小值、中位数和最大值。每份汇总报告还在计时 worker 退出后记录 CPU 型号、可用并行度、平台、架构以及 Node/V8 版本。预算约束未经舍入的中位数。续聊还相对已初始化 Host 测量保留堆内存:计时操作前后各执行两次显式 GC,中间让出一次事件循环,空闲 Agent 始终可达。因此该增量包含常驻历史 Session 和实时追加,而不只是新轮次。GC 与资源释放不计时;flush 计时。请求历史的内存基线从恢复后开始,只作诊断。目录峰值 RSS 仅作诊断;没有保留堆预算声称衡量已经释放的子会话观察。
五个样本报告原始壁钟时间、CPU 用户态/内核态时间、峰值 RSS、终点计数及总壁钟时间的最小值、中位数和最大值。每份汇总报告还在计时 worker 退出后记录 CPU 型号、可用并行度、平台、架构以及 Node/V8 版本。预算约束未经舍入的中位数。续聊还相对已初始化 Host 测量保留堆内存:计时操作前后各执行两次显式 GC,中间让出一次事件循环,空闲 Agent 始终可达。因此该增量包含常驻历史 Session 和实时追加,而不只是新轮次。GC 与资源释放不计时;flush 计时。请求历史的内存基线从恢复后开始,只作诊断。目录峰值 RSS 仅作诊断;没有保留堆预算声称衡量已经释放的父会话观察。
父进程为每个子进程设置 60 秒上限,独立检查超时、信号、退出状态和报告,等待进程关闭,并在失败后删除私有根目录。Context 和 Agent 在 finally 中释放。播种进程无法预热被测进程的缓存。不强制清除文件系统缓存:冷指新进程,不指冷物理存储。
@@ -41,7 +41,7 @@ SDK fixture 通过 profile patch 显式插入 `fs-local` 和 `str_replace_editor
续聊保留约 22.295 MiB;源码期望值为 23 MiB,预算为 28.75 MiB。SDK 时间期望值使用现有[校准辅助函数](../../../../benchmarks/support/calibration.ts):2× 共享 CI 时间比例和 1.25× 波动余量。请求历史使用[请求冻结校准](../simplification/2026-09-06-agent-request-freeze-evidence.zh.md)中的直接托管期望值,不乘以 2× 比例。内存只使用 1.25× 余量。比例继承现有通道的校准,并非这些用例的新 Linux 实测值;runner 特征变化时仍需 CI 证据。基线预算保护实测实现;更紧预算属于有测量依据且保持行为的修复。
独立的纯 Node 请求历史 CPU profile 在一次 211.300 ms 操作中,将 132.876 ms 采样自身时间归因于 buildRequest 调用的 deepFreeze。这把重复遍历已冻结历史定位为聚焦调查目标,不是已证实的优化结果。目录首次/重复时间分别保留,因为观察释放后第二次列举仍读取带种子子会话的正文。
独立的纯 Node 请求历史 CPU profile 在一次 211.300 ms 操作中,将 132.876 ms 采样自身时间归因于 buildRequest 调用的 deepFreeze。这把重复遍历已冻结历史定位为聚焦调查目标,不是已证实的优化结果。目录首次/重复时间分别报告第一次和第二次 parent 目录观察。
已发布 SDK 变体完成 100 个轮次、200 次请求和 800 次真实文件读取。五样本 smoke 总时间为 1,521.773、1,463.465、1,689.701、1,365.485 和 1,417.106 ms(中位数 1,463.465 ms);完整套件重复运行报告 1,596.183、1,784.536、2,120.082、1,405.365 和 1,355.894 ms(中位数 1,596.183 ms)。1,700 ms 参考期望对应 4,250 ms CI 预算。重复运行中未改变的服务用例也变慢,因此这是可变主机负载下的验证,不是放宽其独占校准预算的依据。SDK 进程使用白名单环境和私有主目录/工作区。40 秒截止时间启动 SDK 关闭;所有路径等待同一个记忆化 close Promise,并早于外层 worker 的 60 秒截止时间。Profile 时间包含启动、全部轮次和关闭,分别报告;不把父进程 CPU 或堆指标当作服务端内存。适配器不为外部模型服务商序列化请求。
@@ -59,7 +59,7 @@ SDK fixture 通过 profile patch 显式插入 `fs-local` 和 `str_replace_editor
**只测 deriveMessages。** 拒绝:它的增量缓存不包含完整请求冻结、适配器分发、实时追加或持久化。实际顺序请求保护 Agent 每一步支付的成本。
**只使用投影缓存行已预热的无种子子会话。** 拒绝:该路径绕过正文观察,遗漏 fork 子会话的精确继承切点要求。目录用例有意不挂载可选投影缓存,报告带种子的回退路径;它不代表缓存命中的发现过程。
**只使用投影缓存行已预热的无种子子会话。** 拒绝:该路径绕过正文观察,遗漏 fork 子会话的精确继承切点要求。原始校准有意不挂载可选投影缓存,报告带种子的回退路径。parent 自有目录的测量观察持久 parent fact;子级正文读取只属于已记录的校准实现。
**将优化及其目标预算与首次测量一起应用。** 拒绝:纯基线层可以独立合并,并在归因或实现改变前记录当前负载。环境变量不能覆盖源码常量。
@@ -5,6 +5,7 @@
{"type":"user/message","data":{"content":[{"type":"text","text":"Start a background job."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
{"type":"step/end","data":{"turn":1,"step":1}}
{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}
{"type":"subagent/catalog","data":{"version":0,"childId":"{{sessionId}}","childCreatedAt":0,"mode":"continuable","label":"descriptorless child"}}
{"type":"session/end-seed","data":{}}
{"type":"permission/preset","data":{"preset":"workspace-write"}}
{"type":"sandbox/mode","data":{"mode":"workspace-write"}}
@@ -21,9 +22,9 @@
{"type":"session/title","data":{"title":"Start a background job.","messageSeqs":[3],"source":{"kind":"fallback"}}}
{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"list-once","name":"list_agents","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"list-once","name":"list_agents","args":["{}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"list-once","name":"list_agents","arguments":"{}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}
{"type":"tool/call","data":{"turn":2,"step":1,"callId":"list-once","name":"list_agents","arguments":"{}"}}
{"type":"tool/result","data":{"turn":2,"step":1,"message":{"source":{"kind":"tool","callId":"list-once"},"content":[{"type":"tool-result","toolCallId":"list-once","content":[{"type":"text","text":"{{sessionId}} [diagnostic: corrupt]"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[21],"surfaceOp":"append"}
{"type":"tool/result","data":{"turn":2,"step":1,"message":{"source":{"kind":"tool","callId":"list-once"},"content":[{"type":"tool-result","toolCallId":"list-once","content":[{"type":"text","text":"{{sessionId}} [ready] — descriptorless child"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[22],"surfaceOp":"append"}
{"type":"step/end","data":{"turn":2,"step":1}}
{"type":"step/start","data":{"turn":2,"step":2}}
{"type":"assistant/message","data":{"turn":2,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"The stored subagent is unreadable. PARENT_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["The stored subagent is unreadable. PARENT_DONE"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"The stored subagent is unreadable. PARENT_DONE"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}
{"type":"assistant/message","data":{"turn":2,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"The catalogued subagent is ready. PARENT_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["The catalogued subagent is ready. PARENT_DONE"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"The catalogued subagent is ready. PARENT_DONE"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}
{"type":"step/end","data":{"turn":2,"step":2}}
{"type":"turn/end","data":{"turn":2,"reason":{"kind":"completed"}}}
@@ -1 +1 @@
[{"kind": "chunks", "chunks": [{"type": "block-start", "index": 0, "blockType": "tool-call"}, {"type": "tool-call-delta", "index": 0, "id": "list-once", "name": "list_agents", "argumentsDelta": "{}"}, {"type": "block-end", "index": 0, "block": {"type": "tool-call", "id": "list-once", "name": "list_agents", "arguments": "{}"}}, {"type": "usage", "usage": {"inputTokens": 10, "outputTokens": 5}}, {"type": "finish", "reason": {"kind": "tool-calls"}}]}, {"kind": "chunks", "chunks": [{"type": "block-start", "index": 0, "blockType": "text"}, {"type": "text-delta", "index": 0, "text": "The stored subagent is unreadable. PARENT_DONE"}, {"type": "block-end", "index": 0, "block": {"type": "text", "text": "The stored subagent is unreadable. PARENT_DONE"}}, {"type": "usage", "usage": {"inputTokens": 10, "outputTokens": 5}}, {"type": "finish", "reason": {"kind": "stop"}}]}]
[{"kind": "chunks", "chunks": [{"type": "block-start", "index": 0, "blockType": "tool-call"}, {"type": "tool-call-delta", "index": 0, "id": "list-once", "name": "list_agents", "argumentsDelta": "{}"}, {"type": "block-end", "index": 0, "block": {"type": "tool-call", "id": "list-once", "name": "list_agents", "arguments": "{}"}}, {"type": "usage", "usage": {"inputTokens": 10, "outputTokens": 5}}, {"type": "finish", "reason": {"kind": "tool-calls"}}]}, {"kind": "chunks", "chunks": [{"type": "block-start", "index": 0, "blockType": "text"}, {"type": "text-delta", "index": 0, "text": "The catalogued subagent is ready. PARENT_DONE"}, {"type": "block-end", "index": 0, "block": {"type": "text", "text": "The catalogued subagent is ready. PARENT_DONE"}}, {"type": "usage", "usage": {"inputTokens": 10, "outputTokens": 5}}, {"type": "finish", "reason": {"kind": "stop"}}]}]
@@ -1,7 +1,6 @@
/**
* Assembled-app regression: a persisted `origin: 'subagent'` child whose log
* carries no descriptor event is surfaced by `list_agents` as a
* `[diagnostic: corrupt]` row instead of being silently dropped.
* Assembled-app regression: a parent-owned catalog event remains sufficient
* for direct discovery when the persisted child has no descriptor event.
*/
import { readFile, readdir, writeFile } from 'node:fs/promises'
@@ -42,11 +41,10 @@ async function expectSession(actual: string, expectedPath: string): Promise<void
}
/**
* Seed a completed parent turn plus one cold child that durably classifies
* as a subagent (`origin`) but never appended its descriptor event — the
* publication-window death the diagnostic row exists for.
* Seed a completed parent turn with one catalogued cold child whose own log
* has no descriptor event. Direct discovery must not read that child log.
*/
async function seedDescriptorlessChild(root: string, cwd: string): Promise<void> {
async function seedCataloguedDescriptorlessChild(root: string, cwd: string): Promise<void> {
const ctx = new Context()
await ctx.plugin(JsonlSessionPersistence, { root, compression: 'none' })
const parentMeta: SessionHeader = {
@@ -68,6 +66,18 @@ async function seedDescriptorlessChild(root: string, cwd: string): Promise<void>
{ type: 'user/message', seq: SessionSeq(3), time: 13, data: createUserMessage({ content: [{ type: 'text', text: 'Start a background job.' }], source: { kind: 'user' } }), surfaceOp: 'append' },
{ type: 'step/end', seq: SessionSeq(4), time: 14, data: { turn: 1, step: 1 } },
{ type: 'turn/end', seq: SessionSeq(5), time: 15, data: { turn: 1, reason: { kind: 'completed' } } },
{
type: 'subagent/catalog',
seq: SessionSeq(6),
time: 16,
data: {
version: 0,
childId,
childCreatedAt: 2,
mode: 'continuable',
label: 'descriptorless child',
},
},
]
const childMeta: SessionHeader = {
version: SESSION_FORMAT_VERSION,
@@ -95,8 +105,8 @@ async function seedDescriptorlessChild(root: string, cwd: string): Promise<void>
}
}
describe('descriptor-less cold child diagnostic snapshot', () => {
it('surfaces the unreadable child as a corrupt diagnostic through the assembled headless app', async () => {
describe('parent-owned cold child catalog snapshot', () => {
it('discovers a catalogued child without reading its descriptor through the assembled headless app', async () => {
let cwd = ''
const result = await runLoaderSmoke({
label: 'subagent diagnostic headless stream-json snapshot',
@@ -112,7 +122,7 @@ describe('descriptor-less cold child diagnostic snapshot', () => {
},
prepare: async (runCwd) => {
cwd = runCwd
await seedDescriptorlessChild(join(runCwd, '.sessions'), runCwd)
await seedCataloguedDescriptorlessChild(join(runCwd, '.sessions'), runCwd)
},
inspect: async (runCwd) => {
const sessionsDir = join(runCwd, '.sessions')
@@ -121,9 +131,10 @@ describe('descriptor-less cold child diagnostic snapshot', () => {
const parent = logs.find(content => content.includes('"subagent-diagnostic-parent"'))
if (parent === undefined) throw new Error('missing persisted parent log')
// THE model-visible fact: the descriptor-less child is reported, not
// silently dropped, and its reason is the corrupt classification.
expect(parent).toContain(`${childId} [diagnostic: corrupt]`)
// THE model-visible fact: the parent catalog supplies discovery even
// though consulting the descriptor-less child would classify it corrupt.
expect(parent).toContain(`${childId} [ready] — descriptorless child`)
expect(parent).not.toContain('[diagnostic: corrupt]')
const context: NormalizeContext = { sessionIds: [parentId, childId], cwd }
const normalizedParent = normalizeSessionSnapshot(parent, context)
@@ -139,7 +150,7 @@ describe('descriptor-less cold child diagnostic snapshot', () => {
expect(records.at(-1)).toMatchObject({
type: 'result',
sessionId: parentId,
output: 'The stored subagent is unreadable. PARENT_DONE',
output: 'The catalogued subagent is ready. PARENT_DONE',
})
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
})
+22 -12
View File
@@ -34,6 +34,9 @@ const MENU_EXPECTED = join(SNAPSHOT_DIR, 'menu.expected.md')
const HEADER_EXPECTED = join(SNAPSHOT_DIR, 'header.expected.md')
const MODE = webSnapshotMode()
const SEED_ID = 'agent-preset-selection-web-e2e'
const SEED_TIME = 1784974100000
const SEEDED_CHILD_ID = sessionId('agent-preset-selection-child')
const SEEDED_CHILD_CREATED_AT = 1784974100100
/** A project skill only a preset that mounts `skill-filesystem` can discover. */
const SKILL_NAME = 'preset-catalog-demo'
/** The preset whose rows resolve and then refuse to start. */
@@ -87,13 +90,12 @@ async function seedWorkspaceSkill(workspaceCwd: string): Promise<void> {
* @returns a tokenized session log ending on a closed turn.
*/
function seedLog(): string {
const time = 1784974100000
const at = (index: number, event: Record<string, unknown>): string =>
JSON.stringify({ ...event, seq: index, time: time + index })
JSON.stringify({ ...event, seq: index, time: SEED_TIME + index })
return [
JSON.stringify({
type: 'session', version: SESSION_FORMAT_VERSION, id: '{{sessionId}}',
createdAt: time, cwd: '{{cwd}}/workspace', isSeeded: false, delegationDepth: 0,
createdAt: SEED_TIME, cwd: '{{cwd}}/workspace', isSeeded: false, delegationDepth: 0,
}),
at(0, { type: 'turn/start', data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user', rpcId: 'seed' } } } }),
at(1, { type: 'step/start', data: { turn: 1, step: 1 } }),
@@ -114,7 +116,17 @@ function seedLog(): string {
}),
at(4, { type: 'session/title', data: { title: 'Seeded turn', messageSeqs: [3], source: { kind: 'fallback' } } }),
at(5, { type: 'step/end', data: { turn: 1, step: 1 } }),
at(6, { type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } }),
at(6, {
type: 'subagent/catalog',
data: {
version: 0,
childId: SEEDED_CHILD_ID,
childCreatedAt: SEEDED_CHILD_CREATED_AT,
mode: 'one-shot',
label: 'header order probe',
},
}),
at(7, { type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } }),
].join('\n')
}
@@ -125,13 +137,11 @@ function seedLog(): string {
* @param parentId - the seeded session whose header the browser opens.
*/
async function seedSubagent(scaffold: WebScaffold, parentId: SessionId): Promise<void> {
const childId = sessionId('agent-preset-selection-child')
const createdAt = 1784974100100
const header: SessionHeader = {
version: SESSION_FORMAT_VERSION,
id: childId,
id: SEEDED_CHILD_ID,
isSeeded: false,
createdAt,
createdAt: SEEDED_CHILD_CREATED_AT,
cwd: scaffold.workspaceCwd,
parentSession: parentId,
origin: 'subagent',
@@ -143,13 +153,13 @@ async function seedSubagent(scaffold: WebScaffold, parentId: SessionId): Promise
{
type: 'turn/start',
seq: 0,
time: createdAt,
time: SEEDED_CHILD_CREATED_AT,
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
},
{
type: 'user/message',
seq: 1,
time: createdAt + 1,
time: SEEDED_CHILD_CREATED_AT + 1,
data: createUserMessage({
content: [{ type: 'text', text: 'Check the session-header action order.' }],
source: { kind: 'user' },
@@ -159,7 +169,7 @@ async function seedSubagent(scaffold: WebScaffold, parentId: SessionId): Promise
{
type: 'subagent/descriptor',
seq: 2,
time: createdAt + 2,
time: SEEDED_CHILD_CREATED_AT + 2,
data: snapshotSubagentDescriptor({
mode: 'one-shot', provider: 'spawn', label: 'header order probe',
}),
@@ -167,7 +177,7 @@ async function seedSubagent(scaffold: WebScaffold, parentId: SessionId): Promise
{
type: 'turn/end',
seq: 3,
time: createdAt + 3,
time: SEEDED_CHILD_CREATED_AT + 3,
data: { turn: 1, reason: { kind: 'completed' } },
},
] as SessionEvent[])
+5 -1
View File
@@ -139,7 +139,6 @@ export function createAssembledRemote(options: AssembledRemoteOptions = {}): Ass
'settings/canOpenAgentPresetDirectory': ok(true),
'settings/openSettingsDocument': ok({ opened: true }),
'settings/openAgentPresetDirectory': ok({ opened: true }),
'subagents/list': ok({ entries: [], parentAvailable: true }),
'terminal/list': ok([]),
'skills/list': ok({ skills: [] }),
'session/canOpenWorkspacePath': ok(true),
@@ -204,6 +203,11 @@ export function createAssembledRemote(options: AssembledRemoteOptions = {}): Ass
return ok(undefined)
})
mock.unary('session/list', () => ok({ items: structuredClone(sessions) }))
mock.unary('session/projections', (request: unknown) => {
const sessionId = recordString(recordValue(request, 'request'), 'sessionId')
const summary = sessions.find(candidate => candidate.sessionId === sessionId)
return ok(structuredClone(summary?.projections ?? fixture.control.value.projections[sessionId] ?? null))
})
mock.unary('workspace/create', (request: unknown) => {
const path = recordString(recordValue(request, 'request'), 'path')
const existing = workspaces.find(workspace => workspace.path === path)
+79 -80
View File
@@ -7,7 +7,8 @@ import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { prepareSessionSnapshotFixtureForComparison } from '@deepseek-ai/dsh-llm-replay'
import {
SESSION_FORMAT_VERSION, SessionId as sessionId, SessionLogOffset, type SessionEvent, type SessionHeader, type SessionId,
SESSION_FORMAT_VERSION, SessionId as sessionId, SessionLogOffset, SessionSeq,
type SessionEvent, type SessionHeader, type SessionId,
} from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-agent'
import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
@@ -24,9 +25,9 @@ const AVAILABLE_CHILD_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/s
const AVAILABLE_CHILD_EXPANDED_EXPECTED = fileURLToPath(
new URL('../../../snapshots/web/subagent-conversation/ui-expanded.expected.md', import.meta.url),
)
const CATALOG_PUSH_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/catalog-push.expected.md', import.meta.url))
const TREE_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/tree.expected.md', import.meta.url))
const BRANCHLESS_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/branchless.expected.md', import.meta.url))
const STALE_CATALOG_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/stale-catalog.expected.md', import.meta.url))
const SIDEBAR_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/sidebar.expected.md', import.meta.url))
const UNAVAILABLE_GRANDCHILD_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/nested.expected.md', import.meta.url))
const FORK_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/fork.expected.md', import.meta.url))
@@ -197,6 +198,19 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
await oneShotHandle.close()
scaffold.ctx.sessionProjectionCache.coldSnapshot(oneShotHeader, SessionLogOffset(0), oneShotEvents)
await waitForCacheRow(scaffold, oneShotHeader)
await page.getByRole('button', { name: '1 subagent', exact: true }).waitFor({ timeout: 15_000 })
const catalogReads = apiCalls.filter(path => path === '/api/session/projections').length
parent.session.append('subagent/catalog', {
version: 0,
childId: oneShotId,
childCreatedAt: oneShotAt,
mode: 'one-shot',
label: ONE_SHOT_LABEL,
})
const pushedCount = page.getByRole('button', { name: '2 subagents', exact: true })
await pushedCount.waitFor({ timeout: 15_000 })
expect(apiCalls.filter(path => path === '/api/session/projections')).toHaveLength(catalogReads)
await compareOrRefreshGolden(CATALOG_PUSH_EXPECTED, await pushedCount.ariaSnapshot(), MODE)
grandchildId = sessionId('recorded-grandchild')
const authoredAt = Date.now()
const grandchildHeader: SessionHeader = {
@@ -248,38 +262,65 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
await grandchildHandle.close()
scaffold.ctx.sessionProjectionCache.coldSnapshot(grandchildHeader, SessionLogOffset(0), grandchildEvents)
await waitForCacheRow(scaffold, grandchildHeader)
const childHandle = await scaffold.ctx.sessionPersistence.open(childId, 'write')
const childHeader = childHandle.header
const childInheritedEventCount = childHandle.inheritedEventCount
const { events: childEvents } = await childHandle.read()
const grandchildCatalogEvent: SessionEvent<'subagent/catalog'> = {
type: 'subagent/catalog',
seq: SessionSeq((childEvents.at(-1)?.seq ?? -1) + 1),
time: authoredAt,
data: {
version: 0,
childId: grandchildId,
childCreatedAt: authoredAt,
mode: 'continuable',
label: NESTED_LABEL,
},
}
await childHandle.append([grandchildCatalogEvent])
await childHandle.close()
scaffold.ctx.sessionProjectionCache.coldSnapshot(
childHeader,
childInheritedEventCount,
[...childEvents, grandchildCatalogEvent],
)
expect(scaffold.ctx.agents.get(childId)).toBeUndefined()
expect(scaffold.ctx.agents.get(oneShotId)).toBeUndefined()
expect(scaffold.ctx.agents.get(grandchildId)).toBeUndefined()
await expect(scaffold.ctx.subagents.listChildren(parent.id)).resolves.toMatchObject([
expect(await scaffold.ctx.subagents.listChildren(parent.id)).toMatchObject([
{
kind: 'child', id: oneShotId, mode: 'one-shot',
label: ONE_SHOT_LABEL, activity: 'inactive', hasChildren: false,
id: childId, mode: 'continuable', label: LABEL,
},
{
kind: 'child', id: childId, mode: 'continuable', label: LABEL,
activity: 'inactive', hasChildren: true,
},
])
await expect(scaffold.ctx.subagents.listChildren(childId)).resolves.toMatchObject([
{
kind: 'child', id: grandchildId, mode: 'continuable',
label: NESTED_LABEL, activity: 'inactive', hasChildren: false,
id: oneShotId, mode: 'one-shot', label: ONE_SHOT_LABEL,
},
])
{
using observed = await scaffold.ctx.sessionQuery.observeSession(childId)
expect(observed.source).toBe('prepared')
expect(observed.projections?.values.subagentCatalog).toEqual([
{ id: grandchildId, createdAt: authoredAt, mode: 'continuable', label: NESTED_LABEL },
])
expect(await scaffold.ctx.subagents.listChildren(childId)).toMatchObject([
{
id: grandchildId, mode: 'continuable', label: NESTED_LABEL,
},
])
}
// These two cold fixtures were authored after the page's initial
// session.list and intentionally emitted no api-session/added event. 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: '3 subagents', exact: true })
const catalogButton = page.getByRole('button', { name: '2 subagents', exact: true })
await catalogButton.waitFor({ timeout: 15_000 })
await catalogButton.hover()
const catalogTree = page.getByRole('tree', { name: 'Subagent sessions' })
await catalogTree.getByRole('treeitem').nth(1).waitFor({ timeout: 15_000 })
await catalogTree.press('Escape')
await page.getByRole('button', { name: '3 subagents' }).waitFor({ timeout: 15_000 })
await page.getByRole('button', { name: '2 subagents' }).waitFor({ timeout: 15_000 })
acknowledgeReloadConnectionLoss(tripwire, warningStart)
}, 120_000)
@@ -295,71 +336,19 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
if (failures.length > 1) throw new AggregateError(failures, 'subagent Web teardown failed')
})
it('keeps known descendants reachable across a stale empty catalog response', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-stale-catalog'))
const pattern = '**/api/subagents/list'
let firstClaimed = false
let emptyDelivered = false
let trailingRequested = false
let releaseCatalog = (): void => {}
const catalogHeld = new Promise<void>((resolve) => { releaseCatalog = resolve })
await page.route(pattern, async (route) => {
if (firstClaimed) {
const response = await route.fetch()
trailingRequested = true
await catalogHeld
await route.fulfill({ response })
return
}
firstClaimed = true
const response = await route.fetch()
const body = await response.json() as {
result: { ok: true; value: { entries: unknown[] } } | { ok: false }
}
if (body.result.ok) body.result.value.entries = []
await route.fulfill({ response, json: body })
emptyDelivered = true
})
const warningStart = tripwire.warnings.length
try {
await page.reload({ waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await expect.poll(() => emptyDelivered, { timeout: 15_000 }).toBe(true)
await page.getByRole('button', { name: '3 subagents' }).waitFor({ timeout: 15_000 })
acknowledgeReloadConnectionLoss(tripwire, warningStart)
await page.getByRole('button', { name: '3 subagents' }).hover()
await expect.poll(() => trailingRequested, { timeout: 15_000 }).toBe(true)
const tree = page.getByRole('tree', { name: 'Subagent sessions' })
await tree.getByRole('treeitem', { name: 'Loading subagents' }).first().waitFor()
expect(await tree.getByRole('treeitem', { name: 'Loading subagents' }).count()).toBe(2)
await compareOrRefreshGolden(
STALE_CATALOG_EXPECTED,
await captureStableAria(page, '[role="tree"][aria-label="Subagent sessions"]', scaffold.workspaceCwd),
MODE,
)
releaseCatalog()
await tree.getByRole('treeitem', { name: new RegExp(LABEL) }).waitFor({ timeout: 15_000 })
await tree.press('Escape')
} finally {
releaseCatalog()
await page.unroute(pattern)
}
})
it('expands a persisted grandchild progressively without activating either level', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-tree'))
await page.getByRole('button', { name: '3 subagents' }).hover()
await page.getByRole('button', { name: '2 subagents' }).hover()
const catalogTree = page.getByRole('tree', { name: 'Subagent sessions' })
expect(await catalogTree.evaluate((element) => {
const rect = element.getBoundingClientRect()
const hit = document.elementFromPoint(rect.left + 8, rect.top + 8)
return hit !== null && element.contains(hit)
})).toBe(true)
expect(await page.getByRole('button', {
const oneShotDisclosure = page.getByRole('button', {
name: `Expand ${ONE_SHOT_LABEL} descendants`,
}).count()).toBe(0)
})
expect(await oneShotDisclosure.count()).toBe(0)
const oneShotRow = page.getByRole('treeitem', { name: new RegExp(ONE_SHOT_LABEL) })
expect(await oneShotRow.getByText('~6mo 12d', { exact: true }).count()).toBe(1)
expect(await oneShotRow.getAttribute('aria-label')).toContain('192d 00h 00m 00s')
@@ -377,12 +366,19 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
scaffold.workspaceCwd,
)
await compareOrRefreshGolden(TREE_EXPECTED, snapshot, MODE)
await page.getByRole('tree', { name: 'Subagent sessions' }).press('Escape')
await page.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) }).click()
await page.getByText(NESTED_PROMPT).waitFor()
const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' })
await hierarchy.getByRole('button', { name: `Switch subagent: ${LABEL}`, exact: true }).waitFor()
expect(scaffold.ctx.agents.get(childId)).toBeUndefined()
expect(scaffold.ctx.agents.get(grandchildId)).toBeUndefined()
await hierarchy.getByRole('button').first().click()
await page.getByRole('button', { name: '2 subagents', exact: true }).waitFor()
})
it('opens the completed child from persistence without activating it', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-open'))
await page.getByRole('button', { name: '3 subagents' }).hover()
await page.getByRole('button', { name: '2 subagents' }).hover()
await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
await expect.poll(
() => page.getByText(/^Explain event sourcing in one sentence\.Your parent agent id is /).count(),
@@ -403,7 +399,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
it('keeps a restored child neutral until its parent availability arrives', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-restore'))
const pattern = '**/api/subagents/list'
const pattern = '**/api/session/list'
let requested = false
let releaseCatalog = (): void => {}
const catalogHeld = new Promise<void>((resolve) => { releaseCatalog = resolve })
@@ -481,6 +477,10 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
const tree = page.getByRole('tree', { name: 'Subagent sessions' })
const nestedRow = tree.locator('[role="treeitem"]').filter({ hasText: NESTED_LABEL })
await nestedRow.waitFor({ timeout: 15_000 })
const nestedDisclosure = page.getByRole('button', {
name: `Expand ${NESTED_LABEL} descendants`,
})
expect(await nestedDisclosure.count()).toBe(0)
const clickArea = nestedRow.locator(':scope > div')
expect(await clickArea.count()).toBe(1)
const [treeBox, clickAreaBox] = await Promise.all([
@@ -521,10 +521,9 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
it('opens a one-shot child as permanently read-only history', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-one-shot'))
const parentSession = page.getByRole('tree', { name: 'Sessions' })
.getByRole('treeitem')
.last()
.getByRole('treeitem', { name: /Ask a research subagent to/ })
await parentSession.click()
await page.getByRole('button', { name: '3 subagents' }).hover()
await page.getByRole('button', { name: '2 subagents' }).hover()
await page.getByRole('treeitem', { name: new RegExp(ONE_SHOT_LABEL) }).click()
await page.getByText('One-shot tasks do not accept follow-ups; review the full execution record here.').waitFor()
expect(scaffold.ctx.agents.get(oneShotId)).toBeUndefined()
@@ -535,7 +534,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: '3 subagents' }).hover()
await page.getByRole('button', { name: '2 subagents' }).hover()
await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
await page.getByRole('textbox', { name: 'Message or run a task, / commands, @ files or sessions' }).waitFor()
const forkResponse = page.waitForResponse(response =>
@@ -561,7 +560,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: '3 subagents' }).hover()
await page.getByRole('button', { name: '2 subagents' }).hover()
await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
await page.locator('[data-composer-input][contenteditable="true"]').first().waitFor()
expect(scaffold.ctx.agents.get(childId)).toBeUndefined()
@@ -578,7 +577,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: '3 subagents' }).press('ArrowDown')
await page.getByRole('button', { name: '2 subagents' }).press('ArrowDown')
await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
const input = page.locator('[data-composer-input][contenteditable="true"]').first()
await input.waitFor()
+17 -8
View File
@@ -185,20 +185,25 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co
it('interrupts the live child through the parent-offline composer', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-interrupt-offline'))
// Simulate a parent that went offline: the catalog delivers
// parentAvailable: false while the child Activation stays live (the
// interrupt RPC itself needs no live parent — covered host-side by
// subagent-interrupt.e2e.ts).
const pattern = '**/api/subagents/list'
// The parent is unavailable in the Session list while the child stays live.
const pattern = '**/api/session/list'
await page.route(pattern, async (route) => {
const response = await route.fetch()
const body = await response.json() as {
result: { ok: true; value: { parentAvailable: boolean } } | { ok: false }
result: { ok: true; value: { items: { sessionId: SessionId; agentAvailable: boolean }[] } } | { ok: false }
}
if (body.result.ok) {
const summary = body.result.value.items.find(session => session.sessionId === parent.id)
if (summary === undefined) throw new Error('parent missing from Session list')
summary.agentAvailable = false
}
if (body.result.ok) body.result.value.parentAvailable = false
await route.fulfill({ response, json: body })
})
try {
const warningStart = tripwire.warnings.length
await page.reload({ waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
acknowledgeReloadConnectionLoss(tripwire, warningStart)
await page.getByRole('button', { name: /1 subagent/ }).click()
await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
const input = page.getByRole('textbox', {
@@ -264,7 +269,11 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co
it('interrupts through subagents/interruptByParent, parks the follow-up, and resumes it FIFO', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-interrupt-flow'))
// Reselect the child with the truthful catalog: parent available again.
// A new connection reloads the truthful parent-availability hint.
const warningStart = tripwire.warnings.length
await page.reload({ waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
acknowledgeReloadConnectionLoss(tripwire, warningStart)
await page.getByRole('navigation', { name: 'Session hierarchy' })
.getByRole('button').first().click()
await page.getByRole('button', { name: /1 subagent/ }).click()
@@ -1,4 +1,4 @@
/** Cold catalog observations of persisted fork children with tool-heavy inherited histories. */
/** Cold parent-catalog observations beside fork children with tool-heavy inherited histories. */
import { performance } from 'node:perf_hooks'
import { Context } from '@deepseek-ai/cordis'
@@ -11,7 +11,7 @@ import SubagentRuntime, { SUBAGENT_DESCRIPTOR_VERSION } from '@deepseek-ai/dsh-s
import { assertBuiltBenchmarkRuntime } from '../support/built-worker.ts'
import { PARENT_ID, syntheticHistory, TIME_ZERO, WORKLOAD } from './workload.ts'
/** Two complete catalog reads in one fresh Host, with every child observation released. */
/** Two complete catalog reads in one fresh Host, with each parent observation released. */
export interface CatalogReport {
readonly totalMs: number
readonly firstMs: number
@@ -33,8 +33,14 @@ class CatalogQuery extends SessionQueryEngine {
async function seed(ctx: Context): Promise<void> {
const inherited = syntheticHistory(WORKLOAD.childHistoryTurns)
const catalog: SessionEvent[] = []
for (let child = 0; child < WORKLOAD.children; child++) {
const id = SessionId('bench-child-' + String(child))
catalog.push({
type: 'subagent/catalog', seq: SessionSeq(child), time: TIME_ZERO + child,
data: { version: 0, childId: id, childCreatedAt: TIME_ZERO + child,
mode: 'continuable', label: 'Synthetic child ' + String(child) },
})
const events: SessionEvent[] = [
...inherited,
{ type: 'session/end-seed', seq: SessionSeq(inherited.length), time: TIME_ZERO + inherited.length, data: { inherited: true } },
@@ -51,6 +57,14 @@ async function seed(ctx: Context): Promise<void> {
await handle.flush()
} finally { await handle.close() }
}
const parent = await ctx.sessionPersistence.create({
version: SESSION_FORMAT_VERSION, id: PARENT_ID, createdAt: TIME_ZERO, cwd: '/bench',
isSeeded: false,
})
try {
await parent.append(catalog)
await parent.flush()
} finally { await parent.close() }
}
async function run(root: string, mode: string): Promise<CatalogReport | { seeded: true }> {
@@ -65,15 +79,15 @@ async function run(root: string, mode: string): Promise<CatalogReport | { seeded
await seed(ctx)
return { seeded: true }
}
const readCatalog = () => ctx.subagents.listChildren(PARENT_ID)
const cpuStart = process.cpuUsage()
const start = performance.now()
const first = await ctx.subagents.listChildren(PARENT_ID)
const first = await readCatalog()
const firstDone = performance.now()
const repeated = await ctx.subagents.listChildren(PARENT_ID)
const repeated = await readCatalog()
const end = performance.now()
const cpu = process.cpuUsage(cpuStart)
if (first.length !== WORKLOAD.children || repeated.length !== WORKLOAD.children
|| [...first, ...repeated].some(row => row.kind !== 'child')) {
if (first.length !== WORKLOAD.children || repeated.length !== WORKLOAD.children) {
throw new Error('child-catalog benchmark did not reach the complete healthy catalog')
}
return {
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/config-catalog.md
config-catalog.md: 31f75d5dbc1569d759b8fbcde56dcc7ac3e85bc1
config-catalog.zh.md: e1d3064c1134443d0a3dc17011f068a84560a7d3
config-catalog.md: d9c96b1fd4ab4a119dc95429649c933484da6b33
config-catalog.zh.md: fbb2ef3fc7aa829fe083b0a3b4f16a51c3c9afca
+1 -1
View File
@@ -213,7 +213,7 @@ export interface Config {
}
```
Source: [`packages/api/session-controller/src/index.ts:71`](../packages/api/session-controller/src/index.ts)
Source: [`packages/api/session-controller/src/index.ts:75`](../packages/api/session-controller/src/index.ts)
<a id="deepseek-aidsh-api-settings-controller"></a>
+1 -1
View File
@@ -215,7 +215,7 @@ export interface Config {
}
```
来源:[`packages/api/session-controller/src/index.ts:71`](../packages/api/session-controller/src/index.ts)
来源:[`packages/api/session-controller/src/index.ts:75`](../packages/api/session-controller/src/index.ts)
<a id="deepseek-aidsh-api-settings-controller"></a>
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/event-producer-consumer.md
event-producer-consumer.md: db0f7d4bbe6a0e3a513863fb2d16f56cdeec0488
event-producer-consumer.zh.md: e0f8db40dad9be12c0568e151dc0f40342f71b6d
event-producer-consumer.md: 43dad3cdab75f7569cde6884c7b8f5b6be750650
event-producer-consumer.zh.md: ebd91068d3d8ea4ff3cd16c2fb3259e1e8d4036f
+10 -10
View File
@@ -10,7 +10,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:246`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - |
| `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:82`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` |
| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:363`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` |
| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:261`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | [`agent-presets`](../packages/preset/agent-presets), `agent-team`, `browser-use-runtime`, [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:261`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | [`agent-presets`](../packages/preset/agent-presets), `agent-team`, `browser-use-runtime`, [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), [`schedule`](../packages/schedule/schedule), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:270`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:393`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) |
| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:299`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) |
@@ -21,11 +21,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) |
| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:586`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:566`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:593`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:572`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:579`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:598`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:578`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:605`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:584`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:591`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
@@ -61,10 +61,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:105`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` |
| `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:92`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) |
| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:296`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - |
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:170`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) |
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:144`](../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:150`](../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:161`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) |
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:171`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) |
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:145`](../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:151`](../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:162`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) |
| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), `browser-use-runtime`, [`session-reference`](../packages/context/session-reference), [`system-prompt`](../packages/core/system-prompt) |
| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:201`](../packages/core/tools/src/index.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`), [`tools`](../packages/core/tools) (`emit`) | `browser-use-runtime`, [`tool-subagent`](../packages/subagent/tool-subagent) |
+10 -10
View File
@@ -12,7 +12,7 @@
| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:246`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - |
| `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:82`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` |
| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:363`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` |
| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:261`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | [`agent-presets`](../packages/preset/agent-presets), `agent-team`, `browser-use-runtime`, [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:261`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | [`agent-presets`](../packages/preset/agent-presets), `agent-team`, `browser-use-runtime`, [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), [`schedule`](../packages/schedule/schedule), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:270`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:393`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) |
| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:299`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) |
@@ -23,11 +23,11 @@
| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) |
| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:586`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:566`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:593`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:572`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:579`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:598`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:578`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:605`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:584`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:591`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
@@ -63,10 +63,10 @@
| `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:105`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` |
| `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:92`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) |
| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:296`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - |
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:170`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) |
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:144`](../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:150`](../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:161`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) |
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:171`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) |
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:145`](../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:151`](../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:162`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) |
| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), `browser-use-runtime`, [`session-reference`](../packages/context/session-reference), [`system-prompt`](../packages/core/system-prompt) |
| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:201`](../packages/core/tools/src/index.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`), [`tools`](../packages/core/tools) (`emit`) | `browser-use-runtime`, [`tool-subagent`](../packages/subagent/tool-subagent) |
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/persistence-catalog.md
persistence-catalog.md: 1f28ccf7178ce6c51b6fb2d00a43d1365760945a
persistence-catalog.zh.md: 73bd6df4edd8427910d1e1c1da0c039a0a9732b4
persistence-catalog.md: ff546fe1a7be96dce4026995b76ee3782df7e3ee
persistence-catalog.zh.md: 8b352447d2e8f2b37a85f108bd31c422dd0d3967
+2 -2
View File
@@ -1790,7 +1790,7 @@ SHA-256: `0ddab4577db37a3e0d9e3a91761735c8ef6f5629d526ab8ecaeeea19c097c8f8`
SHA-256: `6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7`
Sources: [`packages/api/session-controller/src/types.ts:380`](../packages/api/session-controller/src/types.ts)
Sources: [`packages/api/session-controller/src/types.ts:383`](../packages/api/session-controller/src/types.ts)
| Property | Presence | Type |
|---|---|---|
@@ -6305,7 +6305,7 @@ SHA-256: `57f3f606ff8b6c84d9c443faace9d1dd10cca0354f37a4f515ef075f7c351b90`
SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b`
Sources: [`packages/api/session-controller/src/types.ts:375`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:19`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:10`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts)
Sources: [`packages/api/session-controller/src/types.ts:378`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:19`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:10`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts)
`string`
+2 -2
View File
@@ -1792,7 +1792,7 @@ SHA-256: `0ddab4577db37a3e0d9e3a91761735c8ef6f5629d526ab8ecaeeea19c097c8f8`
SHA-256: `6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7`
来源:[`packages/api/session-controller/src/types.ts:380`](../packages/api/session-controller/src/types.ts)
来源:[`packages/api/session-controller/src/types.ts:383`](../packages/api/session-controller/src/types.ts)
| 属性 | 存在性 | 类型 |
|---|---|---|
@@ -6307,7 +6307,7 @@ SHA-256: `57f3f606ff8b6c84d9c443faace9d1dd10cca0354f37a4f515ef075f7c351b90`
SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b`
来源:[`packages/api/session-controller/src/types.ts:375`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:19`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:10`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts)
来源:[`packages/api/session-controller/src/types.ts:378`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:19`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:10`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts)
`string`
+2 -2
View File
@@ -32033,7 +32033,7 @@
"packages/workflow/workflow/src/types.ts#WorkflowRunId"
],
"sources": [
"packages/api/session-controller/src/types.ts:375",
"packages/api/session-controller/src/types.ts:378",
"packages/attachment/attachment/src/brand.ts:6",
"packages/compaction/compaction/src/brand.ts:4",
"packages/core/session/src/types.ts:19",
@@ -37967,7 +37967,7 @@
"event:agent/inbox/spliced.data.inserted[0].source[10]"
],
"sources": [
"packages/api/session-controller/src/types.ts:380"
"packages/api/session-controller/src/types.ts:383"
]
},
{
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/session.md
session.md: 7c7e244bfca17c8e22cabe45fc69126113b685d6
session.zh.md: fecae92809bdba93588e163447d372c7a42d0260
session.md: b509cc87ebfb276687aa4eb0072718b7b578347f
session.zh.md: 5bf663ca1218c229584a17265b049dc6143283c6
+12 -3
View File
@@ -886,6 +886,14 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' |
*/
@Remote({ mode: 'stream' }) follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable<SessionFollowFrame>
/**
* Read all registered projections without activating an Agent.
* @param request - Session whose current values are required.
* @param signal - cancellation for the Session observation.
* @returns complete baseline, or null when the Session does not exist.
*/
@Remote('projections') async projections(request: SessionProjectionsRequest, signal: AbortSignal): Promise<SessionProjectionsValue>
/**
* Stream a complete live-control baseline followed by replacement frames.
* @param signal - cancellation owned by the Remote stream carrier.
@@ -1070,13 +1078,14 @@ Source: [`packages/api/session-controller/src/types.ts`](../../packages/api/sess
#### `api-session/added` — emit
A Session became visible to Session list consumers.
A Session became visible or its Agent was created or disposed. Consumers upsert the summary and replace its current running and availability state.
```ts cordis-catalog
/**
* A Session became visible to Session list consumers.
* A Session became visible or its Agent was created or disposed.
* Consumers upsert the summary and replace its current running and availability state.
* @mode emit
* @param summary - initial list row for the Session.
* @param summary - current list row for the Session.
*/
'api-session/added'(summary: SessionSummary): void
```
+12 -3
View File
@@ -890,6 +890,14 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' |
*/
@Remote({ mode: 'stream' }) follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable<SessionFollowFrame>
/**
* Read all registered projections without activating an Agent.
* @param request - Session whose current values are required.
* @param signal - cancellation for the Session observation.
* @returns complete baseline, or null when the Session does not exist.
*/
@Remote('projections') async projections(request: SessionProjectionsRequest, signal: AbortSignal): Promise<SessionProjectionsValue>
/**
* Stream a complete live-control baseline followed by replacement frames.
* @param signal - cancellation owned by the Remote stream carrier.
@@ -1074,13 +1082,14 @@ Source: [`packages/api/session-controller/src/types.ts`](../../packages/api/sess
#### `api-session/added` — emit
A Session became visible to Session list consumers.
A Session became visible or its Agent was created or disposed. Consumers upsert the summary and replace its current running and availability state.
```ts cordis-catalog
/**
* A Session became visible to Session list consumers.
* A Session became visible or its Agent was created or disposed.
* Consumers upsert the summary and replace its current running and availability state.
* @mode emit
* @param summary - initial list row for the Session.
* @param summary - current list row for the Session.
*/
'api-session/added'(summary: SessionSummary): void
```
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/subagent.md
subagent.md: 21c75626158db666fd72bed2742b30e12f4cb719
subagent.zh.md: ca4bb724b82f4f9e81f1e5b7653950ad8c2d3745
subagent.md: dbb90805a236ee1e1ce0522ffdc17181f57e3722
subagent.zh.md: 650bd023a60f4208c401f1124973f0fb6e5e18fe
+15 -36
View File
@@ -4,7 +4,7 @@ English | [中文](subagent.zh.md)
The subagent seam lets an agent delegate work to a child agent. Like [bash](shell.md), it is **one optional capability**, not part of the agent loop, so its types live here rather than in [core.md](core.md). It differs from the other capability seams because **multiple provider implementations coexist** in one context, registered by name (`ctx.subagents`), while bash allows only one executor. Its registry follows the [LLM adapter registry](llm-streaming.md), not the single-service bash executor.
Service Definition: [dsh-subagent](../../packages/subagent/subagent) (`ctx.subagents` + the vocabulary below). Service Providers are sibling packages (`dsh-subagent-spawn-in-process`, `dsh-subagent-fork-in-process`, `dsh-subagent-acp`, `dsh-subagent-codex`, `dsh-subagent-claude-code`, `dsh-subagent-dsh-sdk`); the model-facing Consumers are [dsh-tool-subagent](../../packages/subagent/tool-subagent) (per-provider delegation) and [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control) (the optional global `send_message`, `interrupt_agent`, and `list_agents` controls). The same `ctx.subagents` service owns continuable-child orchestration through an internal activation manager and read-only child and descendant discovery straight from the session store and optional session persistence. Product-provider rationale lives in [the Codex and Claude Code Agent Note](../../.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md); common-seam rationale lives in [the subagent Agent Note](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), [the continuable subagents Agent Note](../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md), and [the adjacent-Agent messaging Agent Note](../../.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.md); [the archived list-identity-projection record](../../.agents/notes/archived/architecture/2026-08-06-subagent-list-identity-projection.md) documents the original list-identity decision.
Service Definition: [dsh-subagent](../../packages/subagent/subagent) (`ctx.subagents` + the vocabulary below). Service Providers are sibling packages (`dsh-subagent-spawn-in-process`, `dsh-subagent-fork-in-process`, `dsh-subagent-acp`, `dsh-subagent-codex`, `dsh-subagent-claude-code`, `dsh-subagent-dsh-sdk`); the model-facing Consumers are [dsh-tool-subagent](../../packages/subagent/tool-subagent) (per-provider delegation) and [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control) (the optional global `send_message`, `interrupt_agent`, and `list_agents` controls). The same `ctx.subagents` service owns continuable-child orchestration through an internal activation manager, direct-child discovery through the parent catalog, and complete descendant discovery through the Session corpus. Product-provider rationale lives in [the Codex and Claude Code Agent Note](../../.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md); common-seam rationale lives in [the subagent Agent Note](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), [the continuable subagents Agent Note](../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md), and [the adjacent-Agent messaging Agent Note](../../.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.md); [the archived list-identity-projection record](../../.agents/notes/archived/architecture/2026-08-06-subagent-list-identity-projection.md) documents the original list-identity decision.
Sources: [`packages/subagent/subagent/src/types.ts`](../../packages/subagent/subagent/src/types.ts), [`packages/subagent/subagent/src/index.ts`](../../packages/subagent/subagent/src/index.ts), and [`packages/subagent/subagent/src/continuation.ts`](../../packages/subagent/subagent/src/continuation.ts)
@@ -266,7 +266,7 @@ A local one-shot provider appends the descriptor inside the child's initial turn
## Durable enumeration: `listChildren()`, `listDescendants()`, and their entries
`SubagentRuntime.listChildren(parentSessionId)` enumerates the parent's direct session-backed subagents from the live-preferred merge of `ctx.sessions` and the session-query engine's `listSessions()` — no Agent is loaded or resumed. Candidates are the direct children whose durable header carries `origin: 'subagent'`; the marker classifies enumeration and coarse generic-route denial but cannot establish a valid descriptor, resumability, or authorization — the projection fold owns identity, and the Activation contract owns resume. Each row's `mode`/`label` is the registered `subagent` projection unit's value, served through a three-rung ladder: the registry's watermark cache for a live child (zero log reads); the optional projection checkpoint cache for a cold one (`cachedSnapshot` — an identity passing the own-suffix seq gate is final, because an own descriptor is immutable once appended); otherwise one `query.observeSession()` cold observation folded through the registry (bounded concurrency, recomputed per listing). The cache is a pure optional accelerator: absent, serving the `null` sentinel or missing the key, failing the seq gate, or faulting, it falls silently through to the authoritative refold. The fold is `subagent/descriptor` last-wins with no failure channel: the child's own descriptor overrides a fork-seeded ancestor's, and a malformed or unknown-version payload folds to a serializable `null` sentinel, treated as no value. The result is one `SubagentListEntry[]` in `createdAt`-then-id order: a served identity yields a `child` entry with `mode: 'one-shot' | 'continuable'` and `activity: 'running' | 'inactive'`; continuable entries always carry `label`, while one-shot entries carry it only when the start caller supplied presentation metadata. A settled candidate whose fold served no identity yields a `corrupt` diagnostic — missing, malformed, and unknown-version descriptors deliberately undistinguished (`unsupported` remains in the type but is never produced); a running candidate without an identity is omitted (the creation window before its descriptor lands); a failed cold inspection yields one `unavailable` diagnostic retried on the next listing, so one damaged sibling cannot hide healthy children. `hasChildren` marks a direct descendant with durable subagent origin, read from the same merged material. Activity snapshots only whether the logical record is live in `ctx.sessions`, not outcome or resumability. Absent persistence, enumeration is live-only rather than an error — a cold child cannot be resumed then either. `listChildren()` throws `SubagentError` with code `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` when the `ctx.sessionProjections` registry is absent and `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` when the session store is, both checked before any read so a deployment with zero children still fails deterministically; the list tool requires `ctx.subagents` and `ctx.agents` at plugin load. A service consumer such as a UI can display both modes and choose an unlabeled one-shot fallback, while the model-facing `list_agents` adapter (the separately loadable `/list-agents` plugin of [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control)) keeps only continuable entries and refines status through the live Agent registry into its own `running`/`idle`/`ready` vocabulary, whose `ready` names a storage-only child as resumable rather than terminal. Listing does not consult the continuation manager's Activation map, Agent registry, or provider availability; `send_message` remains the authoritative delivery-time operation, and a listed running continuable child may still reject delivery as an ownership conflict. [The archived list-identity-projection record](../../.agents/notes/archived/architecture/2026-08-06-subagent-list-identity-projection.md) documents the original read-path decision.
`SubagentRuntime.listChildren(parentSessionId, signal?)` reads the parent's `subagentCatalog` view through a live-preferred Session observation and releases that observation on success or failure. It returns direct-child entries in parent event order without reading child logs or enumerating the Session corpus. Query failures propagate; a missing catalog projection fails explicitly. Browser rows derive membership from the shared projection store and add activity from Session status; the control stream pushes complete catalog updates. `listDescendants()` retains corpus traversal, child-identity diagnostics, and complete `hasChildren` computation. [The parent-catalog Agent Note](../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md) owns creation, fork isolation, ordering, and persistence costs.
`SubagentRuntime.listDescendants(rootSessionId)` applies the same live-preferred corpus and projection-backed interpretation to the root's complete descendant tree in stable pre-order. Ordinary sessions and one-shot children remain traversal nodes, so continuable descendants below them are discovered; only `origin: 'subagent'` candidates produce rows. Each returned child or diagnostic adds its position from the enumerated durable header, while a cold inspection revalidates that complete lifecycle before serving identity:
@@ -463,6 +463,8 @@ The spawn and fork backends create an ordinary one-shot agent through `parent.ct
- **Delegation depth** is durable `SessionHeader.delegationDepth` plus the merge-extensible runtime field `AgentOptions.subagentDepth`; absence means top-level depth zero, and the greater present value is authoritative. The seam owns both fields — the loop neither sets nor reads them — so an in-process child persists parent depth + 1, cold resume cannot lower it, and every start rejects a derived depth outside the safe-integer domain or above a defined absolute `request.maxDepth` cap.
- **Fork seeding** uses [`CreateAgentOptions.seed`](core.md#creation-and-ownership) (a `SessionEvent[]` prefix threaded through `AgentLoop.createAgent` → `ctx.sessions.prepare({ seed })`, the same primitive `ctx.agents.resume()` uses). The fork backend passes a *balanced completed-turn prefix* of the parent's log — the parent's events up to and including its last `turn/end` — so the seed is contiguous-from-0 and the [invariants](../../packages/runtime-diagnostics/invariants) replay accepts it (the in-flight, unbalanced turn is excluded).
`SubagentCatalogEntry` describes one successful direct-child creation fact; `SubagentCatalogState` is the host-only projection state. `listChildren()` owns a live-preferred parent observation without opening child logs. Browser consumers read `subagentCatalog` through the shared Session projection store and combine membership with Session-list activity. `SubagentCatalogRow` belongs to complete-descendant listing. [The parent-catalog decision](../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md) owns the persistent facts and read semantics.
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
<a id="cordis-surface"></a>
@@ -563,23 +565,15 @@ async drainContinuableDescendants(parents: readonly Agent[]): Promise<void>
async drainContinuableChildren(parent: Agent, childIds: readonly SessionId[]): Promise<void>
/**
* Enumerate the parent's direct session-backed subagents without loading or
* resuming an Agent. The Session query service supplies one live-preferred
* corpus and shared point observations; the projection cache supplies
* immutable descriptor hits without opening cold logs. The registered
* `subagent` projection remains the sole mode/label classifier.
*
* Every query receives `signal`, and the listing rechecks cancellation
* around each await. Read rejections that settle
* after an abort become a stable `SubagentError` with code `CANCELLED`.
* @param parentSessionId - parent session whose direct children are listed.
* @param signal - caller-owned cancellation forwarded to Session queries
* and observed around every read await.
* @returns children and per-child diagnostics ordered by `createdAt`, then id.
* @throws {@link SubagentError} when the projection registry or the session
* store is not mounted, or the caller cancels the listing.
* Read the parent's durable direct-child catalog without loading or resuming an Agent.
* The service owns and releases the live-preferred Session observation.
* @param parentSessionId - parent whose direct children are requested.
* @param signal - cancellation forwarded to the Session query.
* @returns catalog children in parent event order.
* @throws {@link SubagentError} when query or catalog projection is unavailable.
* @throws SessionQueryError when the parent cannot be read or the query is cancelled.
*/
listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise<SubagentListEntry[]>
listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise<SubagentCatalogEntry[]>
/**
* Enumerate the root's complete session-backed subagent tree in stable
@@ -587,32 +581,17 @@ listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise<Subagent
* Agent. Ordinary sessions and one-shot children remain traversal nodes so
* continuable descendants below them are discovered; each returned entry
* adds its durable `parentId` and root-relative `depth`. Identity resolution,
* diagnostics, optional persistence, and cancellation follow the same
* projection-backed contract as {@link listChildren}.
* diagnostics, optional persistence, and cancellation use the registered
* child identity projection and complete Session corpus.
* @param rootSessionId - session whose complete descendant tree is listed.
* @param signal - caller-owned cancellation forwarded to persistence reads
* and observed around every read await.
* @returns children and per-candidate diagnostics with tree position, in
* stable pre-order.
* @throws {@link SubagentError} under the same conditions as {@link listChildren}.
* @throws {@link SubagentError} when listing dependencies are unavailable or the caller cancels.
*/
listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise<SubagentDescendantListEntry[]>
/**
* Remote face of {@link listChildren} for one browser: the durable listing
* plus live Agent activity and the delivery-time parent availability hint.
* Parent availability is a hint; {@link prompt} performs the authoritative
* check. Named apart from the provider-name {@link list}, which owns the
* member.
* @param parentSessionId - parent session whose direct children are listed.
* @param signal - carrier cancellation forwarded to Session queries.
* @returns the catalog view for that parent.
* @throws {RemoteError} `gateway/bad-request` for an empty parent id,
* `gateway/cancelled` for an aborted read, `subagent/projections-unavailable` when
* the deployment has no projection registry, otherwise `gateway/internal`.
*/
@Remote('list') async remoteExportList(parentSessionId: SessionId, signal: AbortSignal): Promise<SubagentCatalog>
/**
* Deliver one browser-authored message to a continuable child through the
* exact live direct parent, retaining the caller-minted request identity and
+15 -36
View File
@@ -4,7 +4,7 @@
subagent seam 让一个 agent(智能体)将工作委派给子 agent。与 [bash](shell.zh.md) 一样,它是**一项可选能力**,不属于 agent loop(智能体循环),因此其类型定义在此而非 [core.md](core.zh.md) 中。它不同于其他能力 seam,因为**同一上下文中可共存多个提供方实现**,并按名称注册(`ctx.subagents`),而 bash 只允许一个执行器。该注册表遵循 [LLM(大语言模型)适配器注册表](llm-streaming.zh.md),而非单服务的 bash 执行器。
Service Definition:[dsh-subagent](../../packages/subagent/subagent)(`ctx.subagents` + 下文词汇)。Service Provider 是六个兄弟包:`dsh-subagent-spawn-in-process`、`dsh-subagent-fork-in-process`、`dsh-subagent-acp`、`dsh-subagent-codex`、`dsh-subagent-claude-code`、`dsh-subagent-dsh-sdk`;面向模型的 Consumer 包括 [dsh-tool-subagent](../../packages/subagent/tool-subagent)(按提供方委派)和 [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control)(可选的全局 `send_message`、`interrupt_agent` 与 `list_agents` 控制工具)。同一个 `ctx.subagents` 服务通过内部激活管理器负责可继续子 agent 编排,并直接基于会话存储和可选的会话持久化提供只读的 child 与后代发现。产品提供方设计理由见 [Codex 与 Claude Code Agent Note](../../.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md);通用 seam 的设计理由见 [subagent Agent Note](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md)、[可继续 subagent Agent Note](../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md)和[相邻 Agent 消息 Agent Note](../../.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.zh.md);[已归档的列表身份投影记录](../../.agents/notes/archived/architecture/2026-08-06-subagent-list-identity-projection.md)记录了最初的列表身份决策。
Service Definition:[dsh-subagent](../../packages/subagent/subagent)(`ctx.subagents` + 下文词汇)。Service Provider 是六个兄弟包:`dsh-subagent-spawn-in-process`、`dsh-subagent-fork-in-process`、`dsh-subagent-acp`、`dsh-subagent-codex`、`dsh-subagent-claude-code`、`dsh-subagent-dsh-sdk`;面向模型的 Consumer 包括 [dsh-tool-subagent](../../packages/subagent/tool-subagent)(按提供方委派)和 [dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control)(可选的全局 `send_message`、`interrupt_agent` 与 `list_agents` 控制工具)。同一个 `ctx.subagents` 服务通过内部激活管理器负责可继续子 agent 编排,通过 parent 目录发现直接 child,并通过 Session 语料库发现完整后代。产品提供方设计理由见 [Codex 与 Claude Code Agent Note](../../.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md);通用 seam 的设计理由见 [subagent Agent Note](../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.zh.md)、[可继续 subagent Agent Note](../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md)和[相邻 Agent 消息 Agent Note](../../.agents/notes/implemented/architecture/2026-08-27-adjacent-agent-steer-messaging.zh.md);[已归档的列表身份投影记录](../../.agents/notes/archived/architecture/2026-08-06-subagent-list-identity-projection.md)记录了最初的列表身份决策。
源码:[`packages/subagent/subagent/src/types.ts`](../../packages/subagent/subagent/src/types.ts)、[`packages/subagent/subagent/src/index.ts`](../../packages/subagent/subagent/src/index.ts)和 [`packages/subagent/subagent/src/continuation.ts`](../../packages/subagent/subagent/src/continuation.ts)
@@ -266,7 +266,7 @@ interface ContinuableCreateSpec {
## 持久化枚举:`listChildren()`、`listDescendants()` 与其条目
`SubagentRuntime.listChildren(parentSessionId)` 从 `ctx.sessions` 与会话查询引擎 `listSessions()` 的实时优先合并中枚举 parent 直接且由会话支撑的 subagent——不会加载或恢复任何 Agent。候选是持久 header 携带 `origin: 'subagent'` 的直接 child;该标记只负责枚举分类与粗粒度的通用路由拒绝,不能证明描述符有效、child 可恢复或操作已获授权——身份由投影折叠负责,恢复由 Activation 约定负责。每行的 `mode`/`label` 是已注册 `subagent` projection unit 的值,经三级阶梯供值:存活 child 由注册表水位缓存供值(零日志读取);冷 child 先读可选的投影 checkpoint 缓存(`cachedSnapshot`——过 own-suffix seq 门的身份即定值,own descriptor 一经追加不可变);否则在一次 `query.observeSession()` 冷观察上经注册表折叠(有界并发,每次列表重新计算)。该缓存是纯可选加速层:服务缺席、行里是 `null` 哨兵或 key 缺席、seq 门不过、读取出错,都静默落到权威重折。折叠规则是 `subagent/descriptor` last-wins 且没有失败通道:子 agent 自己的描述符覆盖 fork seed 中祖先的描述符,格式错误或版本不认识的载荷折叠为可序列化的 `null` 哨兵,视同无值。结果是按 `createdAt`、再按 id 排序的 `SubagentListEntry[]`:取到身份即生成带有 `mode: 'one-shot' | 'continuable'` 和 `activity: 'running' | 'inactive'` 的 `child` 条目;可继续条目始终携带 `label`,一次性条目则只在启动调用方提供展示元数据时携带该字段。已定局而折叠无身份的候选生成 `corrupt` diagnostic——缺失、格式错误与版本不认识的描述符有意不再细分(`unsupported` 仍保留在类型中但从不产出);运行中而无身份的候选被省略(描述符落盘前的创建窗口);冷检查失败生成一条 `unavailable` diagnostic 并在下次列表自然重试,因此一个损坏的 sibling 不会隐藏健康 child。`hasChildren` 标记存在持久 subagent origin 的直接后代,读取自同一份合并材料。活动状态只表示逻辑记录是否在 `ctx.sessions` 中存活,而不表示结果或可恢复性。缺少持久化时,枚举退化为仅存活枚举而不是报错——此时冷 child 本就无法恢复。缺少 `ctx.sessionProjections` 注册表时,`listChildren()` 抛出携带错误码 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 的 `SubagentError`,缺少会话存储时则抛出 `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`,两者都在任何读取之前检查,因此零 child 的部署同样确定失败;列表工具在插件加载时要求 `ctx.subagents` 与 `ctx.agents`。UI 等服务消费方可以展示两种模式,并为无标签的一次性 child 选择回退展示;面向模型的 `list_agents` 适配器([dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control) 中可单独加载的 `/list-agents` 插件)则只保留可继续条目,并通过在线 Agent 注册表将状态细化为自己的 `running`/`idle`/`ready` 词汇,其中 `ready` 把仅存于存储的 child 命名为可恢复而非终态。枚举不会查询继续执行管理器的 Activation map、Agent 注册表或提供方可用性;`send_message` 仍是消息送达时的权威操作,列表中的运行中可继续 child 仍可能因所有权冲突而拒绝投递。[已归档的列表身份投影记录](../../.agents/notes/archived/architecture/2026-08-06-subagent-list-identity-projection.md)记录了最初的读路径决策。
`SubagentRuntime.listChildren(parentSessionId, signal?)` 通过优先使用在线 Session 的观察读取父会话的 `subagentCatalog` 视图,并在成功或失败时释放观察。它按父会话事件顺序返回直接子级条目,不读取子级日志,也不枚举 Session 语料库。查询失败直接传播;缺少目录投影时显式失败。浏览器条目从共享 projection store 派生成员关系,并从 Session 状态补充活动状态;control stream 推送完整目录更新。`listDescendants()` 保留语料库遍历、子级身份诊断和完整的 `hasChildren` 计算。[父目录 Agent Note](../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md) 说明创建、fork 隔离、排序和持久化成本。
`SubagentRuntime.listDescendants(rootSessionId)` 将同一份实时优先语料与基于投影的解释应用到根的完整后代树,并按稳定 pre-order 输出。普通会话和一次性 child 仍作为遍历节点,因此其下的可继续后代仍可发现;只有 `origin: 'subagent'` 的候选会生成条目。每个返回的 child 或 diagnostic 都从枚举所得的持久 header 附加树位置;冷检查在提供身份前还会重新校验完整生命周期:
@@ -467,6 +467,8 @@ spawn 和 fork 后端通过 `parent.ctx` 创建一个普通的单次 agent,将
- **委派深度**由持久 `SessionHeader.delegationDepth` 与可合并扩展的运行时字段 `AgentOptions.subagentDepth` 共同表示;缺失表示顶层深度为零,存在的较大值具有权威性。两个字段都归该 seam 所有——循环既不设置也不读取它们——因此进程内子 agent 会持久保存 parent 深度 + 1,冷恢复无法降低深度,而且每次 start 都会拒绝超出安全整数域、或高于已定义绝对 `request.maxDepth` 上限的派生深度。
- **Fork 种子注入**使用 [`CreateAgentOptions.seed`](core.zh.md#creation-and-ownership)(一个 `SessionEvent[]` 前缀,经由 `AgentLoop.createAgent` → `ctx.sessions.prepare({ seed })` 传递,与 `ctx.agents.resume()` 使用的原语相同)。fork 后端传入父级日志的一段*平衡的已完成轮次前缀*——父级事件直到并包括其最后一个 `turn/end`——因此种子从 0 连续,[invariants](../../packages/runtime-diagnostics/invariants) 回放可以接受它(进行中的、未平衡的轮次被排除在外)。
`SubagentCatalogEntry` 描述一条成功创建直接子级的事实;`SubagentCatalogState` 是仅 host 使用的 projection state。`listChildren()` 拥有一次 live-preferred 父 Session observation,不打开子级日志。浏览器消费者通过共享 Session projection store 读取 `subagentCatalog`,并将成员关系与 Session 列表活动状态组合。`SubagentCatalogRow` 属于完整后代列表。[父目录决策](../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md) 规定持久事实与读取语义。
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
<a id="cordis-surface"></a>
@@ -567,23 +569,15 @@ async drainContinuableDescendants(parents: readonly Agent[]): Promise<void>
async drainContinuableChildren(parent: Agent, childIds: readonly SessionId[]): Promise<void>
/**
* Enumerate the parent's direct session-backed subagents without loading or
* resuming an Agent. The Session query service supplies one live-preferred
* corpus and shared point observations; the projection cache supplies
* immutable descriptor hits without opening cold logs. The registered
* `subagent` projection remains the sole mode/label classifier.
*
* Every query receives `signal`, and the listing rechecks cancellation
* around each await. Read rejections that settle
* after an abort become a stable `SubagentError` with code `CANCELLED`.
* @param parentSessionId - parent session whose direct children are listed.
* @param signal - caller-owned cancellation forwarded to Session queries
* and observed around every read await.
* @returns children and per-child diagnostics ordered by `createdAt`, then id.
* @throws {@link SubagentError} when the projection registry or the session
* store is not mounted, or the caller cancels the listing.
* Read the parent's durable direct-child catalog without loading or resuming an Agent.
* The service owns and releases the live-preferred Session observation.
* @param parentSessionId - parent whose direct children are requested.
* @param signal - cancellation forwarded to the Session query.
* @returns catalog children in parent event order.
* @throws {@link SubagentError} when query or catalog projection is unavailable.
* @throws SessionQueryError when the parent cannot be read or the query is cancelled.
*/
listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise<SubagentListEntry[]>
listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise<SubagentCatalogEntry[]>
/**
* Enumerate the root's complete session-backed subagent tree in stable
@@ -591,32 +585,17 @@ listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise<Subagent
* Agent. Ordinary sessions and one-shot children remain traversal nodes so
* continuable descendants below them are discovered; each returned entry
* adds its durable `parentId` and root-relative `depth`. Identity resolution,
* diagnostics, optional persistence, and cancellation follow the same
* projection-backed contract as {@link listChildren}.
* diagnostics, optional persistence, and cancellation use the registered
* child identity projection and complete Session corpus.
* @param rootSessionId - session whose complete descendant tree is listed.
* @param signal - caller-owned cancellation forwarded to persistence reads
* and observed around every read await.
* @returns children and per-candidate diagnostics with tree position, in
* stable pre-order.
* @throws {@link SubagentError} under the same conditions as {@link listChildren}.
* @throws {@link SubagentError} when listing dependencies are unavailable or the caller cancels.
*/
listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise<SubagentDescendantListEntry[]>
/**
* Remote face of {@link listChildren} for one browser: the durable listing
* plus live Agent activity and the delivery-time parent availability hint.
* Parent availability is a hint; {@link prompt} performs the authoritative
* check. Named apart from the provider-name {@link list}, which owns the
* member.
* @param parentSessionId - parent session whose direct children are listed.
* @param signal - carrier cancellation forwarded to Session queries.
* @returns the catalog view for that parent.
* @throws {RemoteError} `gateway/bad-request` for an empty parent id,
* `gateway/cancelled` for an aborted read, `subagent/projections-unavailable` when
* the deployment has no projection registry, otherwise `gateway/internal`.
*/
@Remote('list') async remoteExportList(parentSessionId: SessionId, signal: AbortSignal): Promise<SubagentCatalog>
/**
* Deliver one browser-authored message to a continuable child through the
* exact live direct parent, retaining the caller-minted request identity and
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/tool-catalog.md
tool-catalog.md: 0166c7dd9829f0774c277508299726aac8426969
tool-catalog.zh.md: 7722301c04febbd7b8d1c5509bfdb9e3881d0b76
tool-catalog.md: 397e5aa402e9763bf3c5dc48ab95985292cc667a
tool-catalog.zh.md: 4ae7ebd2beb9376a4f6888e6bfc41fc9d278bfa8
+1 -1
View File
@@ -2063,7 +2063,7 @@ Source: [`packages/subagent/tool-subagent-control/src/index.ts`](../packages/sub
### `list_agents`
List your continuable background subagents by durable id and label. Use it to recall which ones you started, not to poll for completion — you are told when one finishes. Status comes from the live registry: running means the agent is working right now, idle means it is loaded but between turns (it may be waiting on agents it started), and ready means it exists only in storage — resumable, not terminal, and not a result waiting to be collected; a `send_message` steers a running child at its nearest step boundary or starts a turn for an idle or ready child, and a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics instead of being silently dropped. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only.
List your continuable background subagents by durable id and label. Use it to recall which ones you started, not to poll for completion — you are told when one finishes. Status comes from the live registry: running means the agent is working right now, idle means it is loaded but between turns (it may be waiting on agents it started), and ready means it exists only in storage — resumable, not terminal, and not a result waiting to be collected; a `send_message` steers a running child at its nearest step boundary or starts a turn for an idle or ready child, and a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics only in `descendants` scope. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only.
```json
{
+1 -1
View File
@@ -2069,7 +2069,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后,
### `list_agents`
按持久 id 和标签列出你的可继续后台 subagent。用它回忆你启动过哪些 subagent,而不是轮询完成情况——subagent 完成时你会被告知。状态来自实时注册表:running 表示 agent 此刻正在工作;idle 表示已加载但处于轮次之间,可能正在等待它启动的 agent;ready 表示它只存在于存储中——可恢复而非终态,也不表示有结果等待收集;`send_message` 会在运行中 child 的最近 step 边界 steer 消息,或为 idle、ready child 启动轮次,且无论处于哪种状态,直接子级都仍可作为 `send_message` 的目标。该快照并非投递承诺;`send_message` 会执行权威检查,仍可能失败。无法读取的子级会作为诊断信息报告,而不会被静默丢弃。`descendants` 作用域会按稳定的前序顺序遍历你下方的整棵树,并为每个条目标注其持久的直接父会话 id 和深度。只有深度为 1 的条目可以使用 `send_message`;更深的条目只能作为 `interrupt_agent` 的候选目标。
按持久 id 和标签列出你的可继续后台 subagent。用它回忆你启动过哪些 subagent,而不是轮询完成情况——subagent 完成时你会被告知。状态来自实时注册表:running 表示 agent 此刻正在工作;idle 表示已加载但处于轮次之间,可能正在等待它启动的 agent;ready 表示它只存在于存储中——可恢复而非终态,也不表示有结果等待收集;`send_message` 会在运行中 child 的最近 step 边界 steer 消息,或为 idle、ready child 启动轮次,且无论处于哪种状态,直接子级都仍可作为 `send_message` 的目标。该快照并非投递承诺;`send_message` 会执行权威检查,仍可能失败。无法读取的子级仅在 `descendants` 作用域中作为诊断信息报告。`descendants` 作用域会按稳定的前序顺序遍历你下方的整棵树,并为每个条目标注其持久的直接父会话 id 和深度。只有深度为 1 的条目可以使用 `send_message`;更深的条目只能作为 `interrupt_agent` 的候选目标。
```json
{
@@ -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/api/session-controller/README.md
README.md: 772cec4f15da7150617b847824070b27749394ae
README.zh.md: 0f04e539d3eb389273b7b7950acf2dadcc5410bc
README.md: bc42c6520ddfb6af2bd68ab31d14caa424394c25
README.zh.md: 0a79ebd6558d4a5d3e6e8342f88810ae59bf5f70
+4 -2
View File
@@ -28,9 +28,11 @@ History pages and follow opening snapshots carry one `{ type: 'event', event: Se
The Client journal validates exact V3 event envelopes before publishing follow snapshots, live entries, or history pages. It reuses the browser-safe Session validators for required surface markers, exact replacement endpoints, earlier unique source seqs, embedded Assistant provider metadata, request-header omissions, and tool-error consistency. Invalid records fail without field stripping or normalization; range membership and source existence remain durable-log checks on the Host.
Each endpoint states its activation policy. List reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Cancellation requires live state; queue mutation, model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt rejects content with neither non-whitespace text nor an attachment before resolving the Agent or appending Session events; queue edits accept only non-empty text content. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. Queue mutation has one narrow exception: a live child whose current projected identity is continuable and comes from its own non-seed suffix accepts the ordinary Edit, Remove, and QueueDock Steer actions across both inbox destinations. One-shot, missing, unknown, corrupt, seed-only, or cold children remain rejected without resume. The skill catalog uses a live Agent when present or the recorded preset's standing scope when cold, so listing never starts an Agent. The authenticated delivery routes use `workspaceDesktop()` for the serving Host name and file-manager behavior. `openWorkspacePath({ path, action: "reveal" })` delegates file-manager navigation to the native adapter; omitting `action` opens the default application.
Each endpoint states its activation policy. List reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Cancellation requires live state; queue mutation, model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt rejects content with neither non-whitespace text nor an attachment before resolving the Agent or appending Session events; queue edits accept only non-empty text content. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. Queue mutation has one narrow exception: a live child whose current projected identity is continuable and comes from its own non-seed suffix accepts the ordinary Edit, Remove, and QueueDock Steer actions across both inbox destinations. One-shot, missing, unknown, corrupt, seed-only, or cold children remain rejected without resume. The skill catalog uses a live Agent when present or the recorded preset's standing scope when cold, so listing never starts an Agent. The authenticated delivery routes use `workspaceDesktop()` for the serving Host name and file-manager behavior. `openWorkspacePath({ path, action: "reveal" })` delegates file-manager navigation to the native adapter; omitting `action` opens the default application. `session.projections` reads one complete baseline through a live-preferred Session observation without activating an Agent. It returns null for a missing Session and serves any registered projection keys. The Client exposes shared values and explicit-read state through `projectionsBySession`; domains select their own keys. Session-list summaries carry `agentAvailable`, updated through existing summary and status events independently of durable projections. Initial reads and live projection frames use the same sequence ordering.
Client list refreshes retain unchanged row objects and reuse the items array when order and values match. Cache membership checks use a per-refresh ID set, so reconciliation grows linearly with the current list and retained cache sizes.
Client list refreshes retain unchanged row objects and reuse the items array when order and values match. Cache membership checks use a per-refresh ID set, so reconciliation grows linearly with the current list and retained cache sizes. Host summary updates replace running and Agent availability; local create/fork responses only fill missing metadata on existing rows. Removed ordinary Sessions retain projection stores only when their catalogs contain children.
Projection reads retain loading and failure state independently of their values. Reconnect cancels reads from the previous connection and reloads previously requested projections; live membership updates arrive through the control stream. Parent availability comes from Host summaries and remains unknown until a summary or a successful list baseline establishes it. Address lookup resolves projected children without selecting them or creating scopes.
The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, `append`, and `settle-assistant` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one 50-message page, and `loadThrough(seq)` — the turn-jump loader — loops 200-message pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. The Web adapter explicitly opts into cursorless Assistant frames: each opening carries the active attempt's `startedAfterSeq`, `nextIndex`, and compact stream, and every stream member becomes a Client-only `assistant/live-chunk` entry ordered between durable cursors. The Host captures a follower-local arrival ordinal with that baseline and suppresses buffered frames at or before the cut; a replacement Agent may restart frame revision at one. A durable `assistant/message` or `assistant/attempt` arriving after an active opening stays staged only when its seq follows `startedAfterSeq` and its Turn and Step match; the matching end type, seq, and index publishes one named settlement delta that retires the attempt's transient rows and adds the durable entry while earlier same-step retries remain visible. Revision, dense-index, or settlement gaps for a known attempt reopen follow, while a controller that missed the start ignores unknown-attempt frames and publishes their durable settlement normally. An abandoned end publishes a settlement delta without a durable entry so its transient rows retire immediately. A durable gap-repair page has no Assistant baseline, so its held notification reopens follow once for a paired page and baseline. Every history record covers exactly its event seq. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces jobs and projection state instead of treating transient values as durable events. At each ready Host generation, a synchronous Client subscription clears retained projection values and watermarks before refreshing queries and restarting the control stream, including Sessions absent from the control baseline. The first control stream waits for generation readiness, so its opening values cannot precede invalidation. Outstanding list responses from the previous generation cannot republish those values. Within a generation, a delayed control baseline cannot overwrite or clear newer list, history, or live values. The durable `inbox` projection carries both pending lists through the same cold-read and reconnect path as other projections. Client Agent contexts provide the identity used by the independent [`fileUpload`](../../client/file-upload/README.md) service; Session objects expose lifecycle, prompt, queue, and history operations rather than file transfer.
+4 -2
View File
@@ -28,9 +28,11 @@ kind: "package-reference"
Client journal 在发布 follow 快照、live entry 或历史页之前验证精确的 V3 事件 envelope。它复用浏览器安全的 Session validator,检查必需的 surface marker、精确的 replacement endpoint、更早且唯一的 source seq、内嵌 Assistant 提供方元数据、request header 可选字段的省略规则以及工具错误一致性。无效 record 直接失败,不删除字段或归一化;范围成员与来源存在性仍由 Host 的持久日志检查。
每个 endpoint 都声明自己的激活策略。列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。取消要求 live 状态;queue 变更、模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。提示词会在解析 Agent 或追加 Session 事件前,拒绝既没有非空白文本也没有附件的 content;queue edit 只接受非空文本 content。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。Queue 变更只有一个狭窄例外:当前 projection identity 为 continuable 且来自自身非 seed suffix 的在线 child,可以在两个 inbox 目标上使用普通 Edit、Remove 与 QueueDock Steer action。One-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 继续被拒绝,且不会恢复。skill 目录优先使用已有 live Agent,否则使用所记录 preset 的常驻 scope,因此列表查询绝不会启动 Agent。经过鉴权的文件交付路由通过 `workspaceDesktop()` 获取提供服务的 Host 名称和文件管理器行为。`openWorkspacePath({ path, action: "reveal" })` 将文件管理器导航委托给原生适配器;省略 `action` 时打开默认应用。
每个 endpoint 都声明自己的激活策略。列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。取消要求 live 状态;queue 变更、模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。提示词会在解析 Agent 或追加 Session 事件前,拒绝既没有非空白文本也没有附件的 content;queue edit 只接受非空文本 content。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。Queue 变更只有一个狭窄例外:当前 projection identity 为 continuable 且来自自身非 seed suffix 的在线 child,可以在两个 inbox 目标上使用普通 Edit、Remove 与 QueueDock Steer action。One-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 继续被拒绝,且不会恢复。skill 目录优先使用已有 live Agent,否则使用所记录 preset 的常驻 scope,因此列表查询绝不会启动 Agent。经过鉴权的文件交付路由通过 `workspaceDesktop()` 获取提供服务的 Host 名称和文件管理器行为。`openWorkspacePath({ path, action: "reveal" })` 将文件管理器导航委托给原生适配器;省略 `action` 时打开默认应用。 `session.projections` 通过一次 live-preferred Session observation 读取完整基线,不激活 Agent。Session 不存在时返回 null,并可提供任意已注册的 projection key。Client 通过 `projectionsBySession` 暴露共享值和显式读取状态,由领域选择自身的 key。Session 列表摘要携带 `agentAvailable`,通过已有摘要与状态事件更新,与持久化 projection 相互独立。初始读取与实时 projection 帧使用相同的序号排序规则。
Client 列表刷新保留未变化的行对象,并在顺序和值均相同时复用条目数组。缓存成员检查使用每次刷新构建的 ID 集合,因此对账成本随当前列表和保留缓存的规模线性增长。
Client 列表刷新保留未变化的行对象,并在顺序和值均相同时复用条目数组。缓存成员检查使用每次刷新构建的 ID 集合,因此对账成本随当前列表和保留缓存的规模线性增长。 Host 摘要更新会替换运行状态与 Agent 可用性;本地 create/fork 响应只补充已有行缺失的元数据。普通 Session 被移除后,仅在目录仍有子项时保留其投影 store。
投影读取独立于其值保留加载与失败状态。重连会取消上一连接的读取,并重新加载已请求的投影;实时成员更新通过 control stream 到达。父 Agent 可用性来自 Host 摘要,在收到对应摘要或成功的列表 baseline 前保持未知。地址查找直接解析投影中的子项,不会选择它们或创建 scope。
Client 适配器提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend`、`append` 与 `settle-assistant` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页 50 条消息,而 `loadThrough(seq)`——轮次跳转加载器——按每页 200 条消息循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。Web 适配器显式选择接收无 cursor 的 Assistant frame:每个 opening 携带活跃 attempt 的 `startedAfterSeq`、`nextIndex` 与紧凑 stream,每个 stream member 都成为排在持久 cursor 之间的 Client-only `assistant/live-chunk` 条目。Host 会随该 baseline 捕获 follower 本地到达序号,并抑制该 cut 及之前的 buffered frame;replacement Agent 可以从 revision 一重新开始。活跃 opening 之后到达的持久 `assistant/message` 或 `assistant/attempt` 只有在其 seq 晚于 `startedAfterSeq` 且轮次与步骤匹配时才会保持暂存;匹配的 end type、seq 与 index 会发布一个具名 settlement delta,删除该 attempt 的瞬态 row、加入持久条目,并保留同一步骤中更早的 retry。已知 attempt 的 revision、密集 index 或 settlement 缺口会重新打开 follow;若 controller 错过 start,则忽略 unknown-attempt frame,并正常发布其持久 settlement。Abandoned end 会发布不含持久条目的 settlement delta,使瞬态 row 立即退出。持久缺口修复 page 不携带 Assistant baseline,因此 held notification 会重新打开 follow 一次,以取得配对的 page 与 baseline。每条历史 record 只覆盖自身的事件 seq。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 jobs 和 projection 状态,而不会把瞬态值当作 durable event。每次 Host generation 就绪时,同步的 Client 订阅会先清除保留的投影值及其水位,再刷新查询并重新打开 control stream,其中也包括 control baseline 中没有列出的 Session。首次 control stream 会等待 generation 就绪,确保其 opening 值不会先于旧状态清理到达。上一代尚未完成的 list 响应无法重新发布这些值。同一 generation 内,延迟到达的 control baseline 不能覆盖或清除较新的 list、history 或 live 值。持久 `inbox` 投影通过与其他投影相同的冷读取和重连路径传输两份待处理列表。Client Agent 上下文提供独立 [`fileUpload`](../../client/file-upload/README.zh.md) 服务使用的身份;Session 对象提供生命周期、prompt、queue 与历史操作,不提供文件传输。
@@ -50,21 +50,16 @@ export interface ISessions {
/**
* Resolve an already discovered direct-parent address without opening it.
* @param id - possible addressed child id.
* @returns the retained address, when present.
* @returns a retained or loaded-catalog address, without retaining a new selection or scope.
*/
subagentAddress(id: SessionId): SubagentAddress | undefined
/**
* Mark whether a catalog menu is consuming live membership updates.
* @param parentSessionId - catalog owner.
* @param open - current menu state.
*/
setSubagentCatalogOpen(parentSessionId: SessionId, open: boolean): void
/**
* Refresh one direct-child catalog.
* @param parentSessionId - catalog owner.
* Load all Session projections once per connection; retry an unsuccessful initial read.
* @param sessionId - Session to inspect without opening its conversation.
* @returns completion of the current or newly started refresh.
*/
refreshSubagents(parentSessionId: SessionId): Promise<void>
refreshProjections(sessionId: SessionId): Promise<void>
/** Clear the current selection into the no-session view state. */
clear(): void
@@ -31,7 +31,7 @@ export type {
SessionListPhase,
SessionListSnapshot,
SessionSearchResultItem,
SubagentCatalogSnapshot,
SessionProjectionSnapshot,
} from './sessions/manager.ts'
export type { Session } from './sessions/session.ts'
export type {
@@ -68,8 +68,9 @@ export function flattenLineage(
return
}
visited.add(s.sessionId)
const { agentAvailable: _agentAvailable, ...row } = s
out.push({
...s,
...row,
completed: completed?.has(s.sessionId) ?? false,
depth,
})
@@ -2,7 +2,7 @@
// dispatch entry + list state, constructed and held by ClientSessions (one per browser client).
// List data never enters zustand; React connects via subscribe/getListSnapshot.
import type { SubagentAddress, SubagentCatalog } from '@deepseek-ai/dsh-subagent/client'
import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client'
import { SessionSeq, type SessionId, type SessionSeqCursor } from '@deepseek-ai/dsh-session/types'
import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
import type {
@@ -21,6 +21,7 @@ import { flattenLineage } from './lineage.ts'
// useProjection('title') consumer reads). Zero value imports by construction.
import type {} from '@deepseek-ai/dsh-session-title/client'
import { Notifier } from './notifier.ts'
import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
import { ProjectionValueStore } from './projection-store.ts'
import { Session } from './session.ts'
import type { SessionRemotes } from './remotes.ts'
@@ -54,38 +55,30 @@ export interface SessionListSnapshot {
/** Arrival lifecycle (see {@link SessionListPhase}); `state` stays the pull-activity axis. */
phase: SessionListPhase
error: RemoteFailure | null
subagentsByParent: Readonly<Record<SessionId, SubagentCatalogSnapshot>>
projectionsBySession: Readonly<Record<SessionId, SessionProjectionSnapshot>>
/** Background jobs per session; an absent key is an empty set. */
jobsBySession: Readonly<Record<SessionId, readonly JobView[]>>
currentAddress: SubagentAddress | undefined
}
/** One parent-addressed durable catalog projected through the sessions snapshot. */
export type SubagentCatalogSnapshot = Omit<SubagentCatalog, 'parentAvailable'> & {
/** Absent until the first successful catalog read. */
readonly parentAvailable?: boolean
state: 'loading' | 'ready' | 'error'
error: RemoteFailure | null
/** Shared projection values and the lifecycle of their explicit baseline read. */
export interface SessionProjectionSnapshot {
readonly values: Readonly<Partial<SessionProjectionMap>>
readonly state: 'idle' | 'loading' | 'ready' | 'error'
readonly error: RemoteFailure | null
}
function catalogAvailability(parentAvailable: boolean | undefined): {
readonly parentAvailable?: boolean
} {
return parentAvailable === undefined ? {} : { parentAvailable }
}
interface CatalogInflight {
interface ProjectionInflight {
readonly promise: Promise<void>
readonly expandableRows: Set<SessionId>
readonly activityRows: Map<SessionId, 'running' | 'inactive'>
/** Removal-time invalidation replayed over the response this request predates. */
parentAvailableOverride: false | undefined
readonly controller: AbortController
}
type ProjectionLoad = Omit<SessionProjectionSnapshot, 'values'>
type SessionListMutation =
| { kind: 'upsert'; summary: SessionSummary }
| { kind: 'upsert' | 'placeholder'; summary: SessionSummary }
| { kind: 'remove'; sessionId: SessionId }
| { kind: 'status'; sessionId: SessionId; running: boolean }
| { kind: 'status'; sessionId: SessionId; running: boolean; agentAvailable: boolean }
| { kind: 'activity'; sessionId: SessionId; updatedAt: number }
/** Local first-send flip: the sender clears blank without waiting for a host frame. */
| { kind: 'engaged'; sessionId: SessionId }
@@ -117,12 +110,8 @@ export class SessionManager {
/** Active list request's mutation log; its identity also fences completion after reconnect. */
private listMutations: SessionListMutation[] | null = null
private readonly addresses = new Map<SessionId, SubagentAddress>()
private readonly catalogs = new Map<SessionId, SubagentCatalogSnapshot>()
private readonly catalogInflight = new Map<SessionId, CatalogInflight>()
/** Catalog owners whose membership changed while a pull was in flight: one trailing refresh after it settles. */
private readonly catalogStale = new Set<SessionId>()
private readonly openCatalogs = new Set<SessionId>()
private readonly catalogDebounce = new Map<SessionId, ReturnType<typeof setTimeout>>()
private readonly projectionLoads = new Map<SessionId, ProjectionLoad>()
private readonly projectionInflight = new Map<SessionId, ProjectionInflight>()
/**
* Background jobs per session, last-wins from Session Controller's control
* stream. An empty set is stored as an absent key, so absence and `[]` are
@@ -163,7 +152,7 @@ export class SessionManager {
* @param sessionId - listed or catalog-addressed Session id.
*/
select(sessionId: SessionId): void {
const address = this.navigationAddress(sessionId)
const address = this.subagentAddress(sessionId)
if (!this.summaries.some(summary => summary.sessionId === sessionId) && address === undefined) {
throw new Error(`sessions.select: unknown session ${sessionId}`)
}
@@ -172,12 +161,11 @@ export class SessionManager {
address,
address === undefined
? undefined
: this.catalogs.get(address.parentSessionId)?.parentAvailable,
: this.agentAvailable(address.parentSessionId),
)
this.selected = sessionId
// Looking at the session consumes its completion reminder (dot clears).
this.completedNotifications.delete(sessionId)
void this.refreshSubagents(sessionId)
this.notifier.notifyNow()
}
@@ -186,16 +174,16 @@ export class SessionManager {
* @param address - catalog-derived parent and child ids.
*/
selectSubagent(address: SubagentAddress): void {
const catalog = this.catalogs.get(address.parentSessionId)
const entry = catalog?.entries.find(candidate => candidate.id === address.childSessionId)
if (entry === undefined || entry.kind !== 'child' || entry.mode !== address.mode) {
const entries = this.projectionStores.get(address.parentSessionId)?.values().subagentCatalog
const entry = entries?.find(candidate => candidate.id === address.childSessionId)
if (entry === undefined
|| entry.mode !== address.mode) {
throw new Error(`sessions.selectSubagent: ${address.childSessionId} is not a healthy catalog child`)
}
this.addresses.set(address.childSessionId, address)
this.sessions.get(address.childSessionId)?.configureSubagent(address, catalog?.parentAvailable)
this.sessions.get(address.childSessionId)?.configureSubagent(address, this.agentAvailable(address.parentSessionId))
this.selected = address.childSessionId
this.completedNotifications.delete(address.childSessionId)
void this.refreshSubagents(address.childSessionId)
this.notifier.notifyNow()
}
@@ -205,27 +193,23 @@ export class SessionManager {
this.notifier.notifyNow()
}
/**
* Return the durable catalog address retained for one child.
* @param sessionId - possible addressed child id.
* @returns The direct-parent address, when navigation discovered one.
*/
subagentAddress(sessionId: SessionId): SubagentAddress | undefined {
return this.addresses.get(sessionId)
}
/**
* Resolve an address for breadcrumb navigation without retaining transport authority.
* @param sessionId - possible child id in an already-loaded catalog.
* @returns A retained or catalog-derived direct-parent address.
*/
navigationAddress(sessionId: SessionId): SubagentAddress | undefined {
subagentAddress(sessionId: SessionId): SubagentAddress | undefined {
const retained = this.addresses.get(sessionId)
if (retained !== undefined) return retained
for (const [parentSessionId, catalog] of this.catalogs) {
const child = catalog.entries.find(entry => entry.kind === 'child' && entry.id === sessionId)
if (child?.kind === 'child') {
return { parentSessionId, childSessionId: sessionId, mode: child.mode }
for (const parentSessionId of this.projectionStores.keys()) {
const child = this.projectionStores.get(parentSessionId)?.values().subagentCatalog
?.find(entry => entry.id === sessionId)
if (child !== undefined) {
return {
parentSessionId,
childSessionId: sessionId,
mode: child.mode,
}
}
}
return undefined
@@ -250,10 +234,10 @@ export class SessionManager {
* @returns when every Session Remote iterator has completed teardown.
*/
async dispose(): Promise<void> {
for (const timer of this.catalogDebounce.values()) clearTimeout(timer)
this.catalogDebounce.clear()
this.catalogStale.clear()
this.openCatalogs.clear()
const reads = [...this.projectionInflight.values()]
for (const { controller } of reads) controller.abort()
this.projectionInflight.clear()
await Promise.all(reads.map(read => read.promise))
const sessions = [...this.sessions.values()]
this.sessions.clear()
for (const session of sessions) void this.startSessionDisposal(session)
@@ -295,13 +279,13 @@ export class SessionManager {
session.handleRunning(summary.running)
} else {
const address = this.addresses.get(sessionId)
const child = address === undefined ? undefined : this.catalogs.get(address.parentSessionId)?.entries
.find(entry => entry.kind === 'child' && entry.id === sessionId)
if (child?.kind === 'child') {
const child = address === undefined ? undefined : this.projectionStores.get(address.parentSessionId)?.values().subagentCatalog
?.find(entry => entry.id === sessionId)
if (child !== undefined) {
// A catalogued child exists only after its delegated session has
// durable history, even though child rows do not carry `blank`.
session.handleBlank(false)
session.handleRunning(child.activity === 'running')
session.handleRunning(false)
}
}
}
@@ -312,11 +296,11 @@ export class SessionManager {
const address = this.addresses.get(sessionId)
const parentAvailable = address === undefined
? undefined
: this.catalogs.get(address.parentSessionId)?.parentAvailable
: this.agentAvailable(address.parentSessionId)
return new Session(sessionId, this.remote, {
...(address === undefined ? {} : {
address,
...catalogAvailability(parentAvailable),
...parentAvailable === undefined ? {} : { parentAvailable },
}),
// The sender's local first-send flip mirrors into the list row so the
// session surfaces (lists filter on blank) before any host frame lands.
@@ -341,101 +325,58 @@ export class SessionManager {
}
/**
* Refresh one direct-child catalog, reusing its in-flight request.
* @param parentSessionId - catalog owner.
* Load a complete projection baseline once per connection; retry unsuccessful reads.
* @param sessionId - Session to inspect without opening its conversation.
* @returns completion of the current or newly started read.
*/
refreshSubagents(parentSessionId: SessionId): Promise<void> {
const existing = this.catalogInflight.get(parentSessionId)
refreshProjections(sessionId: SessionId): Promise<void> {
const existing = this.projectionInflight.get(sessionId)
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 ?? [],
...(previous?.parentAvailable === undefined
? {}
: { parentAvailable: previous.parentAvailable }),
state: 'loading',
error: null,
})
if (this.projectionLoads.get(sessionId)?.state === 'ready') return Promise.resolve()
const controller = new AbortController()
const store = this.projectionStore(sessionId)
const initialValues = store.values()
this.projectionLoads.set(sessionId, { state: 'loading', error: null })
this.notifier.markDirty()
const operation = (async () => {
try {
const result = await this.remote.subagents.list(parentSessionId)
const result = await this.remote.session.projections({ sessionId }, controller.signal)
if (controller.signal.aborted) return
if (result.ok) {
const parentAvailable = this.catalogInflight.get(parentSessionId)?.parentAvailableOverride
?? result.value.parentAvailable
this.catalogs.set(parentSessionId, {
...result.value,
entries: this.withCatalogMutations(result.value.entries, expandableRows, activityRows),
parentAvailable,
state: 'ready',
error: null,
})
for (const [childId, address] of this.addresses) {
if (address.parentSessionId !== parentSessionId) continue
this.sessions.get(childId)?.handleSubagentParentAvailable(parentAvailable)
if (result.value !== null) {
store.seed({ ...result.value, asOfSeq: sessionSeqCursor(result.value.asOfSeq) })
} else if (store.values() === initialValues) {
// A later frame proves existence independently of an earlier missing read.
store.clear()
}
this.projectionLoads.set(sessionId, { state: 'ready', error: null })
} else {
this.catalogs.set(parentSessionId, {
entries: this.withCatalogMutations(
previous?.entries ?? [], expandableRows, activityRows,
),
...catalogAvailability(
this.catalogInflight.get(parentSessionId)?.parentAvailableOverride
?? previous?.parentAvailable,
),
state: 'error',
error: result.error,
})
this.projectionLoads.set(sessionId, { state: 'error', error: result.error })
}
} catch (error: unknown) {
if (controller.signal.aborted) return
if (!isRemoteFailure(error)) throw error
this.catalogs.set(parentSessionId, {
entries: this.withCatalogMutations(
previous?.entries ?? [], expandableRows, activityRows,
),
...catalogAvailability(
this.catalogInflight.get(parentSessionId)?.parentAvailableOverride
?? previous?.parentAvailable,
),
state: 'error',
error,
})
this.projectionLoads.set(sessionId, { state: 'error', error })
} finally {
this.catalogInflight.delete(parentSessionId)
// Re-arm the trailing pull before the dirty notify: the response the
// caller observed predates the stale-marking change, so the follow-up
// refresh is the only carrier of that change.
if (this.catalogStale.delete(parentSessionId)) void this.refreshSubagents(parentSessionId)
this.notifier.markDirty()
if (!controller.signal.aborted) {
this.projectionInflight.delete(sessionId)
this.notifier.markDirty()
}
}
})()
this.catalogInflight.set(parentSessionId, {
promise: operation,
expandableRows,
activityRows,
parentAvailableOverride: undefined,
})
this.projectionInflight.set(sessionId, { promise: operation, controller })
return operation
}
/**
* Mark whether a catalog menu is consuming live membership updates.
* @param parentSessionId - catalog owner.
* @param open - current menu state.
*/
setSubagentCatalogOpen(parentSessionId: SessionId, open: boolean): void {
if (open) {
this.openCatalogs.add(parentSessionId)
void this.refreshSubagents(parentSessionId)
} else {
this.openCatalogs.delete(parentSessionId)
const timer = this.catalogDebounce.get(parentSessionId)
if (timer !== undefined) {
clearTimeout(timer)
this.catalogDebounce.delete(parentSessionId)
}
private agentAvailable(sessionId: SessionId): boolean | undefined {
return this.summaries.find(summary => summary.sessionId === sessionId)?.agentAvailable
?? (this.listPhase === 'ready' ? false : undefined)
}
private updateParentAvailability(): void {
for (const [childId, address] of this.addresses) {
const available = this.agentAvailable(address.parentSessionId)
if (available !== undefined) this.sessions.get(childId)?.handleSubagentParentAvailable(available)
}
}
@@ -475,6 +416,7 @@ export class SessionManager {
this.summaries = summaries
this.listState = 'idle'
this.listPhase = 'ready'
this.updateParentAvailability()
// Covers the empty-mutations pull (a plain baseline carries no edge).
this.syncCompletedNotifications()
// Push running/blank bits down to instantiated Sessions (the list is the authoritative summary source).
@@ -559,7 +501,7 @@ export class SessionManager {
: { ...(opts.cwd === undefined ? {} : { cwd: opts.cwd }), ...shared }
const result = await this.remote.session.create(payload)
if (result.ok) {
this.recordMutation({ kind: 'upsert', summary: {
this.recordMutation({ kind: 'placeholder', summary: { agentAvailable: true,
sessionId: result.value.sessionId, updatedAt: Date.now(), running: false, blank: true,
...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}),
} })
@@ -569,7 +511,7 @@ export class SessionManager {
// so expose it immediately as Ungrouped while the caller keeps the
// prompt buffer and decides whether to retry attachment.
if (publishedSessionId !== undefined) {
this.recordMutation({ kind: 'upsert', summary: {
this.recordMutation({ kind: 'placeholder', summary: { agentAvailable: true,
sessionId: publishedSessionId,
updatedAt: Date.now(),
running: false,
@@ -601,7 +543,7 @@ export class SessionManager {
? result.value.sessionId
: workspaceAttachSessionId(result.error)
if (childId !== undefined) {
this.recordMutation({ kind: 'upsert', summary: {
this.recordMutation({ kind: 'placeholder', summary: { agentAvailable: true,
sessionId: childId, updatedAt: Date.now(), running: false, blank: false,
parentSessionId: opts.sessionId,
...(source?.cwd !== undefined ? { cwd: source.cwd } : {}),
@@ -611,13 +553,12 @@ export class SessionManager {
}
/**
* Insert-or-enrich a locally synthesized summary: a new id prepends; an
* existing entry only gains fields it lacks (the session-added frame and the
* create() echo race — whichever lands second must fill the placeholder's
* missing cwd/parentSessionId, never overwrite list-refresh data).
* Merge a Host summary, replacing live state and filling missing metadata.
* Local create/fork placeholders only fill metadata on an existing row.
*/
private mergeSummary(summary: SessionSummary): void {
this.recordMutation({ kind: 'upsert', summary })
this.updateParentAvailability()
}
/** Apply immediately and retain for replay when a list response is in flight. */
@@ -698,13 +639,6 @@ export class SessionManager {
store.apply(key, value, sessionSeqCursor(projections.asOfSeq))
}
}
if (summary.origin === 'subagent' && summary.parentSessionId !== undefined) {
this.markCatalogParentExpandable(summary.parentSessionId)
}
if (summary.parentSessionId !== undefined
&& (this.selected === summary.parentSessionId || this.openCatalogs.has(summary.parentSessionId))) {
this.scheduleCatalogRefresh(summary.parentSessionId)
}
}
/**
@@ -712,25 +646,21 @@ export class SessionManager {
* @param sessionId - removed Session identity.
*/
handleSessionRemoved(sessionId: SessionId): void {
const summary = this.summaries.find(candidate => candidate.sessionId === sessionId)
const durableSubagent = summary?.origin === 'subagent' || this.addresses.has(sessionId)
const durableSubagent = this.subagentAddress(sessionId) !== undefined
|| this.summaries.some(summary => summary.sessionId === sessionId && summary.origin === 'subagent')
this.recordMutation(durableSubagent
? { kind: 'status', sessionId, running: false }
? { kind: 'status', sessionId, running: false, agentAvailable: false }
: { kind: 'remove', sessionId })
this.updateCatalogActivity(sessionId, false)
if (durableSubagent) this.sessions.get(sessionId)?.handleRunning(false)
else this.sessions.get(sessionId)?.handleRemoved()
this.jobsBySession.delete(sessionId)
if (!durableSubagent) this.projectionStores.delete(sessionId)
const inflightCatalog = this.catalogInflight.get(sessionId)
if (inflightCatalog !== undefined) {
inflightCatalog.parentAvailableOverride = false
this.catalogStale.add(sessionId)
}
const ownedCatalog = this.catalogs.get(sessionId)
if (ownedCatalog !== undefined && ownedCatalog.parentAvailable) {
this.catalogs.set(sessionId, { ...ownedCatalog, parentAvailable: false })
const catalog = this.projectionStores.get(sessionId)?.values().subagentCatalog
if (!durableSubagent && (catalog === undefined || catalog.length === 0)) {
this.projectionStores.delete(sessionId)
}
this.projectionInflight.get(sessionId)?.controller.abort()
this.projectionInflight.delete(sessionId)
this.projectionLoads.delete(sessionId)
for (const [childId, address] of this.addresses) {
if (address.parentSessionId === sessionId) {
this.sessions.get(childId)?.handleSubagentParentAvailable(false)
@@ -744,9 +674,9 @@ export class SessionManager {
* @param running - current Agent running state.
*/
handleSessionStatus(sessionId: SessionId, running: boolean): void {
this.recordMutation({ kind: 'status', sessionId, running })
this.recordMutation({ kind: 'status', sessionId, running, agentAvailable: true })
this.updateParentAvailability()
this.sessions.get(sessionId)?.handleRunning(running)
this.updateCatalogActivity(sessionId, running)
}
/**
@@ -778,87 +708,14 @@ export class SessionManager {
this.listMutations = null
this.listInflight = null
void this.refreshList()
const parents = new Set(this.projectionLoads.keys())
const selectedAddress = this.selected === undefined ? undefined : this.addresses.get(this.selected)
if (selectedAddress !== undefined) void this.refreshSubagents(selectedAddress.parentSessionId)
if (this.selected !== undefined) void this.refreshSubagents(this.selected)
for (const parentSessionId of this.openCatalogs) void this.refreshSubagents(parentSessionId)
}
/** Debounce membership refetches while one parent catalog is selected or open. */
private scheduleCatalogRefresh(parentSessionId: SessionId): void {
if (this.catalogDebounce.has(parentSessionId)) return
const timer = setTimeout(() => {
this.catalogDebounce.delete(parentSessionId)
// The in-flight response predates the membership frame that scheduled
// this callback. Queue one post-settlement pull instead of treating an
// ordinary overlapping read as evidence that catalog membership changed.
if (this.catalogInflight.has(parentSessionId)) {
this.catalogStale.add(parentSessionId)
return
}
void this.refreshSubagents(parentSessionId)
}, 50)
this.catalogDebounce.set(parentSessionId, timer)
}
/** 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) {
if (!catalog.entries.some(entry =>
entry.kind === 'child' && entry.id === childSessionId && entry.activity !== activity)) continue
const entries = catalog.entries.map((entry) => {
if (entry.kind !== 'child' || entry.id !== childSessionId) return entry
return { ...entry, activity }
})
changed = true
this.catalogs.set(parentSessionId, { ...catalog, entries })
}
if (changed) this.notifier.markDirty()
}
/** Preserve and project a positive expandability hint after one direct subagent publishes. */
private markCatalogParentExpandable(parentSessionId: SessionId): void {
this.applyCatalogParentExpandable(parentSessionId)
for (const inflight of this.catalogInflight.values()) inflight.expandableRows.add(parentSessionId)
}
/** Apply one positive expandability hint to every loaded catalog containing that unique row id. */
private applyCatalogParentExpandable(parentSessionId: SessionId): void {
let changed = false
for (const [catalogParentId, catalog] of this.catalogs) {
if (!catalog.entries.some(entry =>
entry.kind === 'child' && entry.id === parentSessionId && !entry.hasChildren)) continue
const entries = catalog.entries.map((entry) => {
if (entry.kind !== 'child' || entry.id !== parentSessionId || entry.hasChildren) return entry
return { ...entry, hasChildren: true }
})
changed = true
this.catalogs.set(catalogParentId, { ...catalog, entries })
}
if (changed) this.notifier.markDirty()
}
/** 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) => {
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 },
}
})
if (selectedAddress !== undefined) parents.add(selectedAddress.parentSessionId)
if (this.selected !== undefined) parents.add(this.selected)
for (const { controller } of this.projectionInflight.values()) controller.abort()
this.projectionInflight.clear()
this.projectionLoads.clear()
for (const parentSessionId of parents) void this.refreshProjections(parentSessionId)
}
/**
@@ -937,7 +794,10 @@ export class SessionManager {
state: this.listState,
phase: this.listPhase,
error: this.listError,
subagentsByParent: Object.fromEntries(this.catalogs),
projectionsBySession: Object.fromEntries([...this.projectionStores].map(([sessionId, store]) => [
sessionId,
{ values: store.values(), state: 'idle', error: null, ...this.projectionLoads.get(sessionId) },
])),
jobsBySession: Object.fromEntries(this.jobsBySession),
currentAddress: current === undefined ? undefined : this.addresses.get(current),
}
@@ -947,7 +807,8 @@ export class SessionManager {
/** Apply one list mutation without deriving display order. */
function applyMutation(summaries: readonly SessionSummary[], mutation: SessionListMutation): SessionSummary[] {
switch (mutation.kind) {
case 'upsert': {
case 'upsert':
case 'placeholder': {
const existing = summaries.find(summary => summary.sessionId === mutation.summary.sessionId)
if (existing === undefined) return [mutation.summary, ...summaries]
const filled: SessionSummary = {
@@ -955,6 +816,10 @@ function applyMutation(summaries: readonly SessionSummary[], mutation: SessionLi
// Blank only lowers: a stale true (session-added racing the local
// first send) never re-hides an already-surfaced session.
blank: existing.blank && mutation.summary.blank,
...(mutation.kind === 'upsert' ? {
agentAvailable: mutation.summary.agentAvailable,
running: mutation.summary.running,
} : {}),
...(existing.cwd === undefined && mutation.summary.cwd !== undefined ? { cwd: mutation.summary.cwd } : {}),
...(existing.parentSessionId === undefined && mutation.summary.parentSessionId !== undefined
? { parentSessionId: mutation.summary.parentSessionId } : {}),
@@ -963,6 +828,7 @@ function applyMutation(summaries: readonly SessionSummary[], mutation: SessionLi
}
if (filled.cwd === existing.cwd && filled.parentSessionId === existing.parentSessionId
&& filled.origin === existing.origin && filled.blank === existing.blank
&& filled.agentAvailable === existing.agentAvailable && filled.running === existing.running
) return [...summaries]
return summaries.map(summary => summary.sessionId === mutation.summary.sessionId ? filled : summary)
}
@@ -972,8 +838,9 @@ function applyMutation(summaries: readonly SessionSummary[], mutation: SessionLi
// running:true doubles as the cross-client blank flip (a blank session
// never runs, so the first running frame proves a message landed).
return summaries.map(summary => summary.sessionId === mutation.sessionId
&& (summary.running !== mutation.running || (mutation.running && summary.blank))
? { ...summary, running: mutation.running, blank: summary.blank && !mutation.running }
&& (summary.running !== mutation.running
|| summary.agentAvailable !== mutation.agentAvailable || (mutation.running && summary.blank))
? { ...summary, running: mutation.running, agentAvailable: mutation.agentAvailable, blank: summary.blank && !mutation.running }
: summary)
case 'activity':
return summaries.map(summary => summary.sessionId === mutation.sessionId
@@ -9,7 +9,7 @@ import type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client'
import type { CommandSubmitAttachment } from '@deepseek-ai/dsh-commands/types'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type {
SubagentCatalog, SubagentInterruptReceipt, SubagentPromptReceipt, SubagentPromptRequest,
SubagentInterruptReceipt, SubagentPromptReceipt, SubagentPromptRequest,
} from '@deepseek-ai/dsh-subagent/client'
import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
import type { SessionRemote } from '../transport.ts'
@@ -26,7 +26,6 @@ export interface SessionCommandsRemote {
/** Narrow subagent namespace consumed by a Client Session and its manager. */
export interface SessionSubagentsRemote {
list(parentSessionId: SessionId, signal?: AbortSignal): Promise<RemoteResult<SubagentCatalog>>
prompt(
request: SubagentPromptRequest,
signal?: AbortSignal,
@@ -32,7 +32,7 @@ import type { AgentContext, ISessions } from '../contract/sessions.ts'
import { createScope, scopeOf as scopeTagOf } from '../scope.ts'
import { SessionManager } from './manager.ts'
import type { SessionRemotes } from './remotes.ts'
import type { SessionListPhase, SessionSearchResultItem, SubagentCatalogSnapshot } from './manager.ts'
import type { SessionListPhase, SessionSearchResultItem, SessionProjectionSnapshot } from './manager.ts'
import type { Session } from './session.ts'
/** Session list row projected from the host list RPC plus live stream increments. */
@@ -74,8 +74,8 @@ export interface SessionListState {
current: SessionId | undefined
/** Arrival lifecycle projected 1:1 from the manager snapshot (see SessionListPhase): empty-with-ready means "truly no sessions". */
phase: SessionListPhase
/** Direct durable catalogs keyed by their selected parent address. */
subagentsByParent: Readonly<Record<SessionId, SubagentCatalogSnapshot>>
/** Shared projection values and explicit-read state, including unopened Sessions. */
projectionsBySession: Readonly<Record<SessionId, SessionProjectionSnapshot>>
/**
* Background jobs each session can see, mirrored last-wins from Session
* Controller's control baseline and `jobs` frames. A missing key is an empty
@@ -233,7 +233,7 @@ export class ClientSessions implements ISessions {
)
this.list = createSnapshotStore<SessionListState>({
ids: [], byId: {}, current: undefined, phase: 'pending',
subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined,
projectionsBySession: {}, jobsBySession: {}, currentAddress: undefined,
})
// The manager owns wire truth; the store is its projection. Manager
// notifications are already microtask-batched.
@@ -283,27 +283,18 @@ export class ClientSessions implements ISessions {
* Resolve an already discovered direct-parent address without opening it.
* Feature plugins use this to avoid Agent-bound RPCs in persisted child views.
* @param id - possible addressed child id.
* @returns The retained address, when present.
* @returns A retained or loaded-catalog address, without retaining a new selection or scope.
*/
subagentAddress(id: SessionId): SubagentAddress | undefined {
return this.manager.subagentAddress(id)
}
/**
* Inform the Session Controller whether a catalog menu is consuming membership updates.
* @param parentSessionId - selected parent.
* @param open - menu state.
* Load all Session projections once per connection; retry an unsuccessful initial read.
* @param sessionId - Session to inspect without opening its conversation.
*/
setSubagentCatalogOpen(parentSessionId: SessionId, open: boolean): void {
this.manager.setSubagentCatalogOpen(parentSessionId, open)
}
/**
* Refresh one direct-child catalog.
* @param parentSessionId - catalog owner.
*/
refreshSubagents(parentSessionId: SessionId): Promise<void> {
return this.manager.refreshSubagents(parentSessionId)
refreshProjections(sessionId: SessionId): Promise<void> {
return this.manager.refreshProjections(sessionId)
}
/**
@@ -532,7 +523,6 @@ export class ClientSessions implements ISessions {
* cannot miss; kept so a future current writer cannot crash the notify. */
if (record !== undefined) {
void record.session.open()
void this.manager.refreshSubagents(current)
}
}
@@ -576,7 +566,7 @@ export class ClientSessions implements ISessions {
/** Project the manager's list snapshot into the store (title derivation is display-only). */
private projectList(): void {
const {
items, current, phase, subagentsByParent, jobsBySession, currentAddress,
items, current, phase, projectionsBySession, jobsBySession, currentAddress,
} = this.manager.getListSnapshot()
const ids: SessionId[] = []
const byId: Record<SessionId, SessionSummary> = {}
@@ -604,9 +594,9 @@ export class ClientSessions implements ISessions {
while (address !== undefined && !seen.has(address.childSessionId)) {
const childId = address.childSessionId
seen.add(childId)
const child = subagentsByParent[address.parentSessionId]?.entries
.find(entry => entry.kind === 'child' && entry.id === childId)
if (child?.kind !== 'child') break
const child = projectionsBySession[address.parentSessionId]?.values.subagentCatalog
?.find(entry => entry.id === childId)
if (child === undefined) break
const displayTitle = child.label ?? childId
const summary = byId[childId]
if (summary === undefined) {
@@ -615,16 +605,14 @@ export class ClientSessions implements ISessions {
displayTitle,
parentId: address.parentSessionId,
origin: 'subagent',
running: child.activity === 'running',
running: false,
blank: false,
updatedAt: 0,
}
} else if (summary.displayTitle !== displayTitle) {
byId[childId] = { ...summary, displayTitle }
}
const parent = byId[address.parentSessionId]
if (parent !== undefined && parent.origin !== 'subagent') break
address = this.manager.navigationAddress(address.parentSessionId)
address = this.manager.subagentAddress(address.parentSessionId)
}
}
const persisted = this.selection.getSnapshot().sessionId
@@ -642,7 +630,7 @@ export class ClientSessions implements ISessions {
...(currentAddress === undefined ? {} : { subagentAddress: currentAddress }),
})
}
this.list.set({ ids, byId, current, phase, subagentsByParent, jobsBySession, currentAddress })
this.list.set({ ids, byId, current, phase, projectionsBySession, jobsBySession, currentAddress })
this.pruneScopes()
}
@@ -57,7 +57,7 @@ export const JUMP_PAGE_MESSAGES = 200
export interface SessionOptions {
/** Catalog-discovered address selecting non-activating subagent transport. */
address?: SubagentAddress
/** Whether the exact direct parent Agent was live at the latest catalog read; absent before that read. */
/** Whether the exact direct parent Agent is available in Host summaries; absent until known. */
parentAvailable?: boolean
/**
* First ACCEPTED prompt on a blank session (fires at most once, on the
+42 -1
View File
@@ -1,6 +1,7 @@
/** Session Remote owner: cold reads, explicit Agent commands, and live control state. */
import { hostname } from 'node:os'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import { errorChain } from '@deepseek-ai/dsh-llm'
@@ -8,7 +9,7 @@ import type {} from '@deepseek-ai/dsh-client-file-upload'
import { canOpenNativePath, nativeFileManager, openNativePath, revealNativePath } from '@deepseek-ai/dsh-native-command'
import type { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence'
import type { SessionObservation } from '@deepseek-ai/dsh-session-query'
import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query'
import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
import {
ApiSessionAgentController,
@@ -51,6 +52,9 @@ import type {
SessionSearchValue,
SessionSelectModelRequest,
SessionSelectModelValue,
SessionProjectionsRequest,
SessionProjectionsValue,
SessionProjectionValues,
SessionUpdateQueueRequest,
SessionUpdateQueueValue,
} from './types.ts'
@@ -149,6 +153,13 @@ export class SessionController extends TypertRemoteService {
ctx.on('session/disposed', (session) => {
ctx.emit('api-session/removed', session.id)
})
const publishAgentAvailability = ({ agent }: { agent: Agent }): undefined => {
if (ctx.sessions.get(agent.id) === agent.session) {
ctx.emit('api-session/added', this.listState.summaryFor(agent.session))
}
}
ctx.on('agent/created', publishAgentAvailability)
ctx.on('agent/disposed', publishAgentAvailability)
ctx.on('agent/status', ({ agent, status }) => {
ctx.emit('api-session/status', agent.id, status === 'running')
})
@@ -402,6 +413,36 @@ export class SessionController extends TypertRemoteService {
return this.history.follow(request, signal)
}
/**
* Read all registered projections without activating an Agent.
* @param request - Session whose current values are required.
* @param signal - cancellation for the Session observation.
* @returns complete baseline, or null when the Session does not exist.
*/
@Remote('projections')
async projections(request: SessionProjectionsRequest, signal: AbortSignal): Promise<SessionProjectionsValue> {
const { sessionId } = request
if (sessionId.length === 0) {
throw new RemoteError('gateway/bad-request', 'sessionId must not be empty', {})
}
try {
using observation = await this.ctx.sessionQuery.observeSession(sessionId, { signal })
const projections = observation.projections
if (projections === undefined) {
throw new RemoteError('session/projections-unavailable', 'Session projections are unavailable', {})
}
return { asOfSeq: projections.asOfSeq, values: projections.values as SessionProjectionValues }
} catch (error: unknown) {
if (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') return null
if (signal.aborted
|| (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED')) {
throw new RemoteError('gateway/cancelled', 'Session projection read was cancelled', {}, { cause: error })
}
if (error instanceof RemoteError) throw error
throw new RemoteError('gateway/internal', 'Session projection read failed', {}, { cause: error })
}
}
/**
* Stream a complete live-control baseline followed by replacement frames.
* @param signal - cancellation owned by the Remote stream carrier.
@@ -111,6 +111,7 @@ export class ApiSessionList {
return {
sessionId: session.id,
updatedAt: updatedAt(session.header, metadata),
agentAvailable: this.ctx.agents.get(session.id)?.session === session,
running: this.ctx.agents.get(session.id)?.status === 'running',
blank: metadata?.blank ?? session.seq === 0,
...listFields(session.header),
@@ -149,6 +150,7 @@ export class ApiSessionList {
return {
sessionId: header.id,
updatedAt: updatedAt(header, metadata),
agentAvailable: false,
running: false,
// A large, metadata-less, or inaccessible cache miss remains unknown and visible.
blank: metadata?.blank ?? false,
+14 -2
View File
@@ -161,6 +161,8 @@ export type QueueAction =
/** One Session list entry. */
export interface SessionSummary {
/** Whether this Session currently owns a live Agent. */
readonly agentAvailable: boolean
readonly sessionId: SessionId
readonly updatedAt: number
readonly running: boolean
@@ -191,6 +193,7 @@ declare module '@deepseek-ai/dsh-typert-protocol' {
readonly requestedCwd: string
readonly existingCwd?: string
}
'session/projections-unavailable': Record<string, never>
'session/agent-busy': { readonly reason: string }
'session/invalid-time-zone': { readonly value: string }
'session/workspace-attach-failed': { readonly sessionId: SessionId; readonly workspaceId: string }
@@ -391,6 +394,14 @@ export type SessionAddress =
readonly mode: 'one-shot' | 'continuable'
}
/** One non-activating Session projection read. */
export interface SessionProjectionsRequest {
readonly sessionId: SessionId
}
/** Complete Session projection baseline; null when the Session does not exist. */
export type SessionProjectionsValue = SessionProjectionBaseline | null
/** One raw Session event in the Remote journal. */
export interface SessionEventEntry {
readonly type: 'event'
@@ -559,9 +570,10 @@ export type SessionControlFrame =
declare module '@deepseek-ai/cordis' {
interface Events {
/**
* A Session became visible to Session list consumers.
* A Session became visible or its Agent was created or disposed.
* Consumers upsert the summary and replace its current running and availability state.
* @mode emit
* @param summary - initial list row for the Session.
* @param summary - current list row for the Session.
*/
'api-session/added'(summary: SessionSummary): void
/**
@@ -51,7 +51,7 @@ describe('Session Controller Client apply', () => {
// The first generation's `connection/reset` already ran it; apply itself saw no Host yet.
await vi.waitFor(() => { expect(connected).toHaveBeenCalledOnce() })
await emit(mock, 'api-session/added', { sessionId: sid('session-1'), updatedAt: 1, running: false, blank: true })
await emit(mock, 'api-session/added', { agentAvailable: true, sessionId: sid('session-1'), updatedAt: 1, running: false, blank: true })
await vi.waitFor(() => {
expect(sessions.list.getSnapshot().byId[sid('session-1')]).toMatchObject({ running: false, updatedAt: 1 })
})
@@ -84,7 +84,7 @@ describe('Session Controller Client apply', () => {
const connected = vi.spyOn(ClientSessions.prototype, 'handleConnected')
const sessionId = sid('immediate-baseline')
mock.remote.session.list.mockResolvedValue(ok({ items: [{
sessionId, updatedAt: 1, running: false, blank: false,
sessionId, updatedAt: 1, running: false, blank: false, agentAvailable: true,
}] }))
let projection = { asOfSeq: 20, values: { title: 'Before restart' } }
mock.stream(CONTROL, (_args, stream) => {
@@ -150,7 +150,7 @@ describe('Session Controller Client apply', () => {
const { client, sessions } = await bench(start)
await vi.waitFor(() => { expect(sessions.list.getSnapshot().phase).toBe('ready') })
await emit(mock, 'api-session/added', { sessionId: sid('agent-1'), updatedAt: 1, running: false, blank: true })
await emit(mock, 'api-session/added', { agentAvailable: true, sessionId: sid('agent-1'), updatedAt: 1, running: false, blank: true })
await vi.waitFor(() => { expect(sessions.scope(sid('agent-1'))).toBeDefined() })
const scoped = sessions.scope(sid('agent-1')) as Context
const adapter = client.ctx.typert.contexts.getClient('agent')
@@ -63,7 +63,7 @@ describe('Inbox projection intake', () => {
const empty = { 'next-turn': [], 'next-step': [] }
const stale = { ...empty, 'next-turn': [message('removed', 'already removed')] }
const result = ok({ items: [{
sessionId: SID, updatedAt: 1, running: false, blank: false,
sessionId: SID, updatedAt: 1, running: false, blank: false, agentAvailable: false,
projections: { asOfSeq: 21, values: { inbox: empty } },
}] })
let refreshed: Promise<void> | undefined
@@ -108,9 +108,9 @@ describe('Inbox projection intake', () => {
const face = manager.get(SID).projections.faceOf('inbox')
const baseline = { type: 'baseline', value: { jobs: {}, projections: {} } } as const
const result = ok({ items: [
{ sessionId: SID, updatedAt: 1, running: false, blank: false,
{ sessionId: SID, updatedAt: 1, running: false, blank: false, agentAvailable: false,
projections: { asOfSeq: 1, values: { inbox: empty } } },
{ sessionId: hiddenSessionId, updatedAt: 1, running: false, blank: false,
{ sessionId: hiddenSessionId, updatedAt: 1, running: false, blank: false, agentAvailable: false,
projections: { asOfSeq: 1, values: { inbox: restored } } },
] })
let refreshed: Promise<void> | undefined
@@ -7,7 +7,7 @@ import { describe, expect, it, vi } from 'vitest'
import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-api-remotes/client'
import { flattenLineage } from '../src/client/sessions/lineage.ts'
const s = (id: string, updatedAt: number, parent?: string): SessionSummary => ({
const s = (id: string, updatedAt: number, parent?: string): SessionSummary => ({ agentAvailable: true,
sessionId: id as SessionId, updatedAt, running: false, blank: false,
...(parent !== undefined ? { parentSessionId: parent as SessionId } : {}),
})
@@ -24,12 +24,11 @@ const S2 = 'fk-m2' as SessionId
/** Gateway Client cone used by the subagent-catalog and connected-generation cases. */
const API_ROSTER = webApp.closure(['@deepseek-ai/dsh-api-gateway'])
const it = createClientTest({ roster: API_ROSTER })
/** The first client boot pays the cold module transform of the api cone. */
const COLD_BOOT_TIMEOUT_MS = 60_000
type SummaryOver = Partial<{
updatedAt: number
running: boolean
agentAvailable: boolean
blank: boolean
cwd: string
parentSessionId: SessionId
@@ -37,7 +36,7 @@ type SummaryOver = Partial<{
}>
function summary(sessionId: SessionId, over: SummaryOver = {}) {
return { sessionId, updatedAt: 100, running: false, blank: false, ...over }
return { agentAvailable: true, sessionId, updatedAt: 100, running: false, blank: false, ...over }
}
function makeManager(
@@ -263,29 +262,29 @@ describe('Host Remote event routing', () => {
describe('subagent catalogs', () => {
it('keeps a catalog-discovered child address across ordinary selection and status frames', async ({ mock, remote, start }) => {
remote.session.list.mockResolvedValue(ok({ items: [
remote.session.list.mockImplementation(() => Promise.resolve(ok({ items: [
summary(S1),
summary(S2, { parentSessionId: S1, origin: 'subagent' }),
] as never[] }))
remote.subagents.list.mockResolvedValue(ok({
entries: [{
kind: 'child', id: S2, mode: 'continuable', label: 'worker',
activity: 'running', hasChildren: false,
}] as never[],
parentAvailable: true,
}))
] as never[] })))
remote.session.projections.mockImplementation(() => Promise.resolve(ok({ asOfSeq: 0, values: { subagentCatalog: [{ createdAt: 1,
id: S2, mode: 'continuable', label: 'worker',
}] } })))
mock.load(sessionWorld)
const client = await start()
const manager = new SessionManager(client.ctx.remote)
await manager.refreshList()
await manager.refreshSubagents(S1)
manager.selectSubagent({ parentSessionId: S1, childSessionId: S2, mode: 'continuable' })
await manager.refreshProjections(S1)
manager.selectSubagent({
parentSessionId: S1, childSessionId: S2, mode: 'continuable',
})
expect(manager.getListSnapshot().currentAddress).toEqual({
parentSessionId: S1, childSessionId: S2, mode: 'continuable',
})
expect(manager.get(S2).getSnapshot().subagent).toEqual({
address: { parentSessionId: S1, childSessionId: S2, mode: 'continuable' },
address: {
parentSessionId: S1, childSessionId: S2, mode: 'continuable',
},
parentAvailable: true,
})
// Clicking the same child through an ordinary list-selection path must not
@@ -295,21 +294,26 @@ describe('subagent catalogs', () => {
parentSessionId: S1, childSessionId: S2, mode: 'continuable',
})
expect(manager.get(S2).getSnapshot().subagent).toEqual({
address: { parentSessionId: S1, childSessionId: S2, mode: 'continuable' },
address: {
parentSessionId: S1, childSessionId: S2, mode: 'continuable',
},
parentAvailable: true,
})
await manager.get(S2).open()
await manager.get(S2).prompt([{ type: 'text', text: 'continue' }], 'queue')
expect(remote.session.follow.mock.calls.map(([request]) => request)).toEqual([
expect(mock.log.requests(FOLLOW)).toEqual([
{
address: {
kind: 'subagent', parentSessionId: S1, childSessionId: S2, mode: 'continuable',
kind: 'subagent',
parentSessionId: S1,
childSessionId: S2,
mode: 'continuable',
},
assistantStream: true,
maxMessages: 50,
},
])
expect(remote.session.page).not.toHaveBeenCalled()
expect(mock.log.requests('session/page')).toEqual([])
expect(remote.subagents.prompt.mock.calls.map(([request]) => request)).toEqual([
{
requestId: expect.any(String) as unknown as string,
@@ -320,13 +324,14 @@ describe('subagent catalogs', () => {
clientTimeZone: new Intl.DateTimeFormat().resolvedOptions().timeZone,
},
])
expect(remote.session.prompt).not.toHaveBeenCalled()
const listCalls = remote.subagents.list.mock.calls.length
expect(mock.log.requests('session/page')).toEqual([])
expect(remote.session.prompt.mock.calls.map(([request]) => request)).toEqual([])
const listCalls = remote.session.projections.mock.calls.map(([request]) => request).length
manager.handleSessionStatus(S2, false)
expect(manager.getListSnapshot().subagentsByParent[S1]?.entries[0]).toMatchObject({
kind: 'child', id: S2, activity: 'inactive',
expect(manager.getListSnapshot().projectionsBySession[S1]?.values.subagentCatalog?.[0]).toMatchObject({
id: S2,
})
expect(remote.subagents.list).toHaveBeenCalledTimes(listCalls)
expect(remote.session.projections.mock.calls.map(([request]) => request)).toHaveLength(listCalls)
manager.handleSessionRemoved(S2)
expect(manager.getListSnapshot().items.find(item => item.sessionId === S2)).toMatchObject({
@@ -335,274 +340,170 @@ describe('subagent catalogs', () => {
expect(manager.get(S2).getSnapshot()).toMatchObject({
removed: false,
subagent: {
address: { parentSessionId: S1, childSessionId: S2, mode: 'continuable' },
address: {
parentSessionId: S1, childSessionId: S2, mode: 'continuable',
},
},
})
}, COLD_BOOT_TIMEOUT_MS)
it('refetches debounced membership only while the parent catalog is open', async ({ mock, remote }) => {
vi.useFakeTimers()
try {
const manager = makeManager(mock, remote)
await manager.refreshSubagents(S1)
manager.setSubagentCatalogOpen(S1, true)
await Promise.resolve()
const baseline = remote.subagents.list.mock.calls.length
manager.handleSessionAdded(summary(S2, { parentSessionId: S1 }))
manager.handleSessionAdded(summary('fk-m3' as SessionId, { parentSessionId: S1 }))
await vi.advanceTimersByTimeAsync(50)
expect(remote.subagents.list).toHaveBeenCalledTimes(baseline + 1)
manager.setSubagentCatalogOpen(S1, false)
manager.handleSessionAdded(summary('fk-m4' as SessionId, { parentSessionId: S1 }))
await vi.advanceTimersByTimeAsync(50)
expect(remote.subagents.list).toHaveBeenCalledTimes(baseline + 1)
} finally {
vi.useRealTimers()
}
})
it('marks a loaded parent row expandable only for a direct subagent publication', async ({ mock, remote }) => {
const root = 'fk-root' as SessionId
remote.subagents.list.mockResolvedValue(ok({
entries: [
{
kind: 'child', id: S1, mode: 'continuable', label: 'parent',
activity: 'inactive', hasChildren: false,
},
{
kind: 'child', id: S2, mode: 'continuable', label: 'ordinary parent',
activity: 'inactive', hasChildren: false,
},
] as never[],
parentAvailable: true,
}))
it('publishes pushed membership without a catalog request or an instantiated parent', ({ mock, remote }) => {
const manager = makeManager(mock, remote)
await manager.refreshSubagents(root)
manager.handleSessionAdded(summary('fk-grandchild' as SessionId, {
parentSessionId: S1, origin: 'subagent',
}))
manager.handleSessionAdded(summary('fk-fork' as SessionId, { parentSessionId: S2 }))
expect(manager.getListSnapshot().subagentsByParent[root]?.entries).toMatchObject([
{ kind: 'child', id: S1, hasChildren: true },
{ kind: 'child', id: S2, hasChildren: false },
manager.handleSessionAdded(summary(S2, { origin: 'subagent', parentSessionId: S1, running: true }))
manager.handleControlFrame({
type: 'projection', sessionId: S1, key: 'subagentCatalog', seq: 4,
value: [{ id: S2, createdAt: 1, mode: 'continuable', label: 'worker' }],
})
expect(manager.getListSnapshot().projectionsBySession[S1]?.values.subagentCatalog).toEqual([
{ id: S2, createdAt: 1, mode: 'continuable', label: 'worker' },
])
expect(manager.subagentAddress(S2)).toEqual({ parentSessionId: S1, childSessionId: S2, mode: 'continuable' })
expect(remote.session.projections.mock.calls.map(([request]) => request)).toEqual([])
})
it('preserves a live expandability hint across only the older in-flight catalog response', async ({ mock, remote }) => {
const root = 'fk-root' as SessionId
const response = Promise.withResolvers<Awaited<ReturnType<typeof remote.subagents.list>>>()
remote.subagents.list.mockReturnValue(response.promise)
it('keeps newer pushed membership when the initial read returns an older baseline', async ({ mock, remote }) => {
const response = Promise.withResolvers<Awaited<ReturnType<typeof remote.session.projections>>>()
remote.session.projections.mockImplementation(() => response.promise)
const manager = makeManager(mock, remote)
const refresh = manager.refreshSubagents(root)
manager.handleSessionAdded(summary('fk-grandchild' as SessionId, {
parentSessionId: S1, origin: 'subagent',
}))
response.resolve(ok({
entries: [{
kind: 'child', id: S1, mode: 'continuable', label: 'parent',
activity: 'inactive', hasChildren: false,
}] as never[],
parentAvailable: true,
}))
await refresh
expect(manager.getListSnapshot().subagentsByParent[root]?.entries).toMatchObject([
{ kind: 'child', id: S1, hasChildren: true },
])
remote.subagents.list.mockResolvedValue(ok({
entries: [{
kind: 'child', id: S1, mode: 'continuable', label: 'parent',
activity: 'inactive', hasChildren: false,
}] as never[],
parentAvailable: true,
}))
await manager.refreshSubagents(root)
expect(manager.getListSnapshot().subagentsByParent[root]?.entries).toMatchObject([
{ kind: 'child', id: S1, hasChildren: false },
])
const read = manager.refreshProjections(S1)
manager.handleControlFrame({
type: 'projection', sessionId: S1, key: 'subagentCatalog', seq: 5,
value: [{ id: S2, createdAt: 1, mode: 'continuable', label: 'pushed' }],
})
response.resolve(ok({ asOfSeq: 3, values: { subagentCatalog: [] } }))
await read
expect(manager.getListSnapshot().projectionsBySession[S1]?.values.subagentCatalog?.[0]?.label).toBe('pushed')
expect(remote.session.projections.mock.calls.map(([request]) => request)).toHaveLength(1)
})
it('replays status frames over an older in-flight catalog response', async ({ mock, remote }) => {
const root = 'fk-root' as SessionId
const response = Promise.withResolvers<Awaited<ReturnType<typeof remote.subagents.list>>>()
remote.subagents.list.mockReturnValue(response.promise)
const manager = makeManager(mock, remote)
const refresh = manager.refreshSubagents(root)
it.for([false, true])('keeps live parent availability when an earlier initial read settles (failure: %s)', async (failure, { mock, remote }) => {
const response = Promise.withResolvers<Awaited<ReturnType<typeof remote.session.projections>>>()
remote.session.projections.mockImplementation(() => response.promise)
const manager = makeManager(mock, remote, S2, { parentSessionId: S1, childSessionId: S2, mode: 'continuable' })
const child = manager.get(S2)
const read = manager.refreshProjections(S1)
manager.handleSessionAdded(summary(S1))
manager.handleSessionStatus(S1, false)
response.resolve(failure
? err(new RemoteError('gateway/internal', 'offline', {}))
: ok({ asOfSeq: 0, values: { subagentCatalog: [] } }))
await read
expect(child.getSnapshot().subagent?.parentAvailable).toBe(true)
expect(remote.session.projections.mock.calls.map(([request]) => request)).toHaveLength(1)
})
it('keeps parent availability unknown after list failure and accepts a later Host summary', async ({ mock, remote }) => {
const manager = makeManager(mock, remote, S2, { parentSessionId: S1, childSessionId: S2, mode: 'continuable' })
const child = manager.get(S2)
remote.session.list.mockResolvedValue(err(new RemoteError('gateway/internal', 'offline', {})))
await manager.refreshList()
expect(manager.getListSnapshot().state).toBe('error')
expect(child.getSnapshot().subagent?.parentAvailable).toBeUndefined()
manager.handleSessionAdded(summary(S1))
expect(child.getSnapshot().subagent?.parentAvailable).toBe(true)
await manager.dispose()
})
it('keeps a pushed catalog when an earlier initial read reports the parent missing', async ({ mock, remote }) => {
const response = Promise.withResolvers<Awaited<ReturnType<typeof remote.session.projections>>>()
remote.session.projections.mockImplementation(() => response.promise)
const manager = makeManager(mock, remote)
const read = manager.refreshProjections(S1)
manager.handleControlFrame({ type: 'projection', sessionId: S1, key: 'subagentCatalog', seq: 1,
value: [{ id: S2, createdAt: 1, mode: 'one-shot' }] })
response.resolve(ok(null))
await read
expect(manager.getListSnapshot().projectionsBySession[S1]?.values.subagentCatalog).toEqual([
{ id: S2, createdAt: 1, mode: 'one-shot' },
])
})
it('shares cold initial loading and reuses the loaded catalog when reopened', async ({ mock, remote }) => {
const response = Promise.withResolvers<Awaited<ReturnType<typeof remote.session.projections>>>()
remote.session.projections.mockImplementation(() => response.promise)
const manager = makeManager(mock, remote)
const read = manager.refreshProjections(S1)
expect(manager.refreshProjections(S1)).toBe(read)
expect(manager.getListSnapshot().projectionsBySession[S1]?.state).toBe('loading')
response.resolve(ok({ asOfSeq: 0, values: {
subagentCatalog: [{ id: S2, createdAt: 1, mode: 'one-shot' }],
title: 'cold parent',
} }))
await read
await manager.refreshProjections(S1)
manager.handleSessionAdded(summary(S1))
expect(manager.getListSnapshot().items[0]?.title).toBe('cold parent')
expect(manager.getListSnapshot().projectionsBySession[S1]).toMatchObject({
values: { subagentCatalog: [{ id: S2, createdAt: 1, mode: 'one-shot' }] }, state: 'ready',
})
expect(remote.session.projections.mock.calls.map(([request]) => request)).toHaveLength(1)
})
it('uses current Session status rather than the time an initial catalog read started', async ({ mock, remote }) => {
const response = Promise.withResolvers<Awaited<ReturnType<typeof remote.session.projections>>>()
remote.session.projections.mockImplementation(() => response.promise)
const manager = makeManager(mock, remote)
const read = manager.refreshProjections(S1)
manager.handleSessionAdded(summary(S2, { origin: 'subagent', parentSessionId: S1 }))
manager.handleSessionStatus(S2, 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' },
])
response.resolve(ok({ asOfSeq: 0, values: {
subagentCatalog: [{ id: S2, createdAt: 1, mode: 'continuable', label: 'worker' }],
} }))
await read
expect(manager.getListSnapshot().items.find(item => item.sessionId === S2)?.running).toBe(true)
manager.handleSessionStatus(S2, false)
expect(manager.getListSnapshot().items.find(item => item.sessionId === S2)?.running).toBe(false)
expect(remote.session.projections.mock.calls.map(([request]) => request)).toHaveLength(1)
})
it('marks a detached catalog child inactive without requiring a selected address', async ({ mock, remote }) => {
remote.subagents.list.mockResolvedValue(ok({
entries: [{
kind: 'child', id: S2, mode: 'continuable', label: 'worker',
activity: 'running', hasChildren: false,
}] as never[],
parentAvailable: true,
}))
it('keeps completed unselected child metadata with its durable catalog row', ({ mock, remote }) => {
const manager = makeManager(mock, remote)
await manager.refreshSubagents(S1)
manager.handleSessionAdded(summary(S2, { origin: 'subagent', parentSessionId: S1, running: true }))
manager.handleControlFrame({ type: 'projection', sessionId: S2, key: 'title', value: 'finished child', seq: 2 })
manager.handleControlFrame({ type: 'projection', sessionId: S1, key: 'subagentCatalog', seq: 1,
value: [{ id: S2, createdAt: 1, mode: 'one-shot' }] })
manager.handleSessionRemoved(S2)
expect(manager.getListSnapshot().subagentsByParent[S1]?.entries).toMatchObject([
{ kind: 'child', id: S2, activity: 'inactive' },
])
expect(manager.getListSnapshot().items[0]).toMatchObject({ title: 'finished child', running: false })
expect(manager.getListSnapshot().items.find(item => item.sessionId === S2)?.running).toBe(false)
})
it('coalesces overlapping catalog reads without scheduling a trailing pull', async ({ mock, remote }) => {
const root = 'fk-root' as SessionId
const first = Promise.withResolvers<Awaited<ReturnType<typeof remote.subagents.list>>>()
remote.subagents.list.mockReturnValue(first.promise)
it('does not invent membership from Session summaries', ({ mock, remote }) => {
const manager = makeManager(mock, remote)
const refresh = manager.refreshSubagents(root)
expect(manager.refreshSubagents(root)).toBe(refresh)
remote.subagents.list.mockResolvedValue(ok({ entries: [], parentAvailable: true }))
first.resolve(ok({ entries: [], parentAvailable: true }))
await refresh
expect(remote.subagents.list).toHaveBeenCalledOnce()
manager.handleSessionAdded(summary(S2, { origin: 'subagent', parentSessionId: S1 }))
expect(manager.getListSnapshot().projectionsBySession[S1]).toBeUndefined()
})
it('runs one trailing catalog refresh for a membership change coalesced into an in-flight pull', async ({ mock, remote }) => {
vi.useFakeTimers()
try {
const root = 'fk-root' as SessionId
const first = Promise.withResolvers<Awaited<ReturnType<typeof remote.subagents.list>>>()
const second = Promise.withResolvers<Awaited<ReturnType<typeof remote.subagents.list>>>()
remote.subagents.list.mockReturnValue(first.promise)
const manager = makeManager(mock, remote, root)
const refresh = manager.refreshSubagents(root)
manager.setSubagentCatalogOpen(root, true)
// A membership frame arrives while the pull is in flight; the debounced
// refresh it schedules fires 50ms later and is coalesced into the pull —
// which was requested before the new child existed. The stale mark must
// queue one trailing pull carrying the change.
manager.handleSessionAdded(summary(S2, { parentSessionId: root }))
await vi.advanceTimersByTimeAsync(50)
remote.subagents.list.mockReturnValueOnce(second.promise)
first.resolve(ok({
entries: [{
kind: 'child', id: S1, mode: 'continuable', label: 'older',
activity: 'inactive', hasChildren: false,
}] as never[],
parentAvailable: true,
}))
await refresh
// The trailing pull is already in flight (kicked synchronously in finally).
second.resolve(ok({
entries: [
{
kind: 'child', id: S1, mode: 'continuable', label: 'older',
activity: 'inactive', hasChildren: false,
},
{
kind: 'child', id: S2, mode: 'continuable', label: 'new child',
activity: 'inactive', hasChildren: false,
},
] as never[],
parentAvailable: true,
}))
await second.promise
// The Remote face resolves one microtask after the response settles.
await vi.advanceTimersByTimeAsync(0)
expect(remote.subagents.list).toHaveBeenCalledTimes(2)
expect(manager.getListSnapshot().subagentsByParent[root]?.entries).toMatchObject([
{ kind: 'child', id: S1, label: 'older' },
{ kind: 'child', id: S2, label: 'new child' },
])
} finally {
vi.useRealTimers()
}
it('retries a failed initial read without discarding pushed entries', async ({ mock, remote }) => {
remote.session.projections.mockImplementation(() => Promise.resolve(err(new RemoteError('gateway/internal', 'offline', {}))))
const manager = makeManager(mock, remote)
manager.handleControlFrame({ type: 'projection', sessionId: S1, key: 'subagentCatalog', seq: 2,
value: [{ id: S2, createdAt: 1, mode: 'one-shot' }] })
await manager.refreshProjections(S1)
expect(manager.getListSnapshot().projectionsBySession[S1]).toMatchObject({ state: 'error', values: { subagentCatalog: [{ id: S2 }] } })
remote.session.projections.mockImplementation(() => Promise.resolve(ok({ asOfSeq: 2, values: { subagentCatalog: [] } })))
await manager.refreshProjections(S1)
expect(manager.getListSnapshot().projectionsBySession[S1]).toMatchObject({ state: 'ready', values: { subagentCatalog: [{ id: S2 }] } })
})
it('keeps removal invalidation across a stale success and failed trailing pull', async ({ mock, remote }) => {
const root = 'fk-root' as SessionId
const child = () => ({
kind: 'child' as const, id: S2, mode: 'continuable' as const, label: 'worker',
activity: 'inactive' as const, hasChildren: false,
it('ignores a late response after parent removal without a trailing request', async ({ mock, remote }) => {
const response = Promise.withResolvers<Awaited<ReturnType<typeof remote.session.projections>>>()
remote.session.projections.mockImplementation(() => response.promise)
const manager = makeManager(mock, remote)
const read = manager.refreshProjections(S1)
manager.handleSessionRemoved(S1)
response.resolve(ok({ asOfSeq: 0, values: { subagentCatalog: [] } }))
await read
expect(manager.getListSnapshot().items.some(item => item.sessionId === S1)).toBe(false)
expect(remote.session.projections.mock.calls.map(([request]) => request)).toHaveLength(1)
})
it('represents a missing parent as an empty unavailable catalog', async ({ mock, remote }) => {
remote.session.projections.mockImplementation(() => Promise.resolve(ok(null)))
const manager = makeManager(mock, remote)
await manager.refreshProjections(S1)
expect(manager.getListSnapshot().projectionsBySession[S1]).toEqual({
values: {}, state: 'ready', error: null,
})
const first = Promise.withResolvers<Awaited<ReturnType<typeof remote.subagents.list>>>()
remote.subagents.list.mockReturnValue(first.promise)
const manager = makeManager(mock, remote)
const refresh = manager.refreshSubagents(root)
first.resolve(ok({ entries: [child()] as never[], parentAvailable: true }))
await refresh
manager.selectSubagent({ parentSessionId: root, childSessionId: S2, mode: 'continuable' })
// The removal lands while a second pull is in flight: the invalidation
// must survive the pre-removal ok response, so one trailing pull runs.
const mid = Promise.withResolvers<Awaited<ReturnType<typeof remote.subagents.list>>>()
remote.subagents.list.mockReturnValueOnce(mid.promise)
const midRefresh = manager.refreshSubagents(root)
manager.handleSessionRemoved(root)
const trailing = Promise.withResolvers<Awaited<ReturnType<typeof remote.subagents.list>>>()
remote.subagents.list.mockReturnValueOnce(trailing.promise)
mid.resolve(ok({ entries: [child()] as never[], parentAvailable: true }))
await midRefresh
expect(manager.getListSnapshot().subagentsByParent[root]?.parentAvailable).toBe(false)
expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: false })
trailing.resolve(err(new RemoteError('gateway/internal', 'trailing pull failed', {})))
await vi.waitFor(() => {
expect(manager.getListSnapshot().subagentsByParent[root]).toMatchObject({
state: 'error',
parentAvailable: false,
})
})
const rootCalls = remote.subagents.list.mock.calls.filter(([call]) => call === root)
expect(rootCalls).toHaveLength(3)
expect(manager.getListSnapshot().subagentsByParent[root]?.parentAvailable).toBe(false)
expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: false })
})
it('invalidates catalog availability when the owning parent is removed', async ({ mock, remote }) => {
const root = 'fk-root' as SessionId
remote.subagents.list.mockResolvedValue(ok({
entries: [{
kind: 'child', id: S2, mode: 'continuable', label: 'worker',
activity: 'inactive', hasChildren: false,
}] as never[],
parentAvailable: true,
}))
const manager = makeManager(mock, remote)
await manager.refreshSubagents(root)
manager.selectSubagent({ parentSessionId: root, childSessionId: S2, mode: 'continuable' })
expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: true })
manager.handleSessionRemoved(root)
expect(manager.getListSnapshot().subagentsByParent[root]?.parentAvailable).toBe(false)
expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: false })
})
})
@@ -636,6 +537,38 @@ describe('remaining branches', () => {
expect(await manager.create()).toMatchObject({ ok: false })
})
it.for(['create', 'fork'] as const)('preserves pushed running state when a %s response arrives later', async (operation, { mock, remote }) => {
const response = Promise.withResolvers<Awaited<ReturnType<typeof remote.session.create>>>()
remote.session[operation].mockImplementation(() => response.promise)
const manager = makeManager(mock, remote)
const request = operation === 'create' ? manager.create() : manager.fork({ sessionId: S1 })
manager.handleSessionAdded(summary(S2, { running: true }))
response.resolve(ok({ sessionId: S2 }))
await request
expect(manager.getListSnapshot().items.find(item => item.sessionId === S2))
.toMatchObject({ running: true, completed: false })
manager.handleSessionAdded(summary(S2, { running: false, agentAvailable: false }))
expect(manager.getListSnapshot().items.find(item => item.sessionId === S2))
.toMatchObject({ running: false, completed: true })
await manager.dispose()
})
it('preserves Host availability when a local create echo follows Agent disposal', async ({ mock, remote }) => {
const response = Promise.withResolvers<Awaited<ReturnType<typeof remote.session.create>>>()
remote.session.create.mockImplementation(() => response.promise)
const manager = makeManager(mock, remote, S2, {
parentSessionId: S1, childSessionId: S2, mode: 'continuable',
})
const child = manager.get(S2)
const request = manager.create()
manager.handleSessionAdded(summary(S1, { agentAvailable: false }))
response.resolve(ok({ sessionId: S1 }))
await request
manager.select(S2)
expect(child.getSnapshot().subagent?.parentAvailable).toBe(false)
await manager.dispose()
})
it('publishes a real Ungrouped summary from workspace-attach-failed', async ({ mock, remote }) => {
remote.session.create.mockResolvedValue(err(new RemoteError('session/workspace-attach-failed', 'published but unattached', {
sessionId: S1, workspaceId: 'w1',
@@ -852,6 +785,55 @@ describe('connected generation', () => {
}
})
it.for([false, true])('refreshes an unselected cached catalog after offline activity changes: %s', async (running, { mock, remote }) => {
let hostRunning = !running
remote.session.list.mockImplementation(() => Promise.resolve(ok({ items: [
summary(S1),
summary(S2, { parentSessionId: S1, origin: 'subagent', running: hostRunning }),
] as never[] })))
remote.session.projections.mockImplementation(() => Promise.resolve(ok({ asOfSeq: 0, values: { subagentCatalog: [{ createdAt: 1, id: S2, mode: 'continuable', label: 'worker' }] } })))
const manager = makeManager(mock, remote)
try {
await manager.refreshList()
await manager.refreshProjections(S1)
const reads = remote.session.projections.mock.calls.map(([request]) => request).length
hostRunning = running
manager.handleConnected()
await manager.refreshList()
await vi.waitFor(() => {
expect(manager.getListSnapshot().items.find(item => item.sessionId === S2)?.running)
.toBe(running)
})
expect(manager.getListSnapshot().current).toBeUndefined()
expect(remote.session.projections.mock.calls.map(([request]) => request)).toHaveLength(reads + 1)
} finally {
await manager.dispose()
}
})
it('starts a fresh catalog read on reconnect and ignores the previous response', async ({ mock, remote }) => {
const previous = Promise.withResolvers<Awaited<ReturnType<typeof remote.session.projections>>>()
remote.session.projections
.mockReturnValueOnce(previous.promise)
.mockResolvedValue(ok({ asOfSeq: 0, values: { subagentCatalog: [] } }))
const manager = makeManager(mock, remote)
try {
const read = manager.refreshProjections(S1)
manager.handleConnected()
expect(remote.session.projections.mock.calls.map(([request]) => request)).toHaveLength(2)
previous.resolve(ok({ asOfSeq: 0, values: { subagentCatalog: [{ createdAt: 1, id: S2, mode: 'continuable', label: 'worker' }] } }))
await read
await vi.waitFor(() => {
expect(manager.getListSnapshot().projectionsBySession[S1]).toMatchObject({
values: {}, state: 'ready',
})
})
expect(remote.session.projections.mock.calls.map(([request]) => request)).toHaveLength(2)
} finally {
await manager.dispose()
}
})
it('refreshes query baselines without rebuilding independently resumed Session sources', async ({ mock, remote, start }) => {
mock.stream(FOLLOW, followScript(ok({
records: entries(plainTurn(SessionSeq(0), 0, 'a', 'b')) as never[],
@@ -874,23 +856,33 @@ describe('connected generation', () => {
it('retains the durable parent address and refreshes its catalogs across reconnect', async ({ mock, remote }) => {
const address = {
parentSessionId: S1, childSessionId: S2, mode: 'continuable' as const,
parentSessionId: S1,
childSessionId: S2,
mode: 'continuable' as const,
}
const parent = Promise.withResolvers<Awaited<ReturnType<typeof remote.subagents.list>>>()
const child = Promise.withResolvers<Awaited<ReturnType<typeof remote.subagents.list>>>()
remote.subagents.list.mockImplementation(payload => (payload === S1 ? parent.promise : child.promise))
remote.session.list.mockImplementation(() => Promise.resolve(ok({ items: [summary(S1)] as never[] })))
const parent = Promise.withResolvers<Awaited<ReturnType<typeof remote.session.projections>>>()
const child = Promise.withResolvers<Awaited<ReturnType<typeof remote.session.projections>>>()
remote.session.projections.mockImplementation(payload => (
payload as { sessionId: SessionId }
).sessionId === S1 ? parent.promise : child.promise)
const manager = makeManager(mock, remote, S2, address)
manager.handleConnected()
expect(manager.get(S2).getSnapshot().subagent).toEqual({ address })
parent.resolve(ok({ entries: [], parentAvailable: true }))
child.resolve(ok({ entries: [], parentAvailable: true }))
parent.resolve(ok({ asOfSeq: 0, values: { subagentCatalog: [{ createdAt: 1,
id: S2, mode: 'continuable', label: 'worker',
}] } }))
child.resolve(ok({ asOfSeq: 0, values: { subagentCatalog: [] } }))
await vi.waitFor(() => {
expect(remote.session.list).toHaveBeenCalledOnce()
expect(remote.session.list.mock.calls.map(([request]) => request)).toHaveLength(1)
})
await vi.waitFor(() => {
expect(remote.subagents.list.mock.calls.map(([parentSessionId]) => parentSessionId)).toEqual([S1, S2])
expect(remote.session.projections.mock.calls.map(([request]) => request)).toEqual([
{ sessionId: S1 },
{ sessionId: S2 },
])
})
expect(manager.get(S2).getSnapshot().subagent).toEqual({
address,
@@ -187,7 +187,7 @@ describe('manager frame routing', () => {
it('preserves a newer title when the control baseline omits it', async ({ mock, remote }) => {
const manager = makeManager(mock, remote)
remote.session.list.mockResolvedValue(ok({
items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }],
items: [{ agentAvailable: true, sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }],
}))
await manager.refreshList()
manager.handleControlFrame({
@@ -209,7 +209,7 @@ describe('manager frame routing', () => {
it('projects every retained value into list rows with stable snapshot identity', async ({ mock, remote }) => {
const manager = makeManager(mock, remote)
remote.session.list.mockResolvedValue(ok({
items: [{
items: [{ agentAvailable: true,
sessionId: sid('s1'), updatedAt: 1, running: false, blank: false,
projections: {
asOfSeq: 2,
@@ -232,16 +232,21 @@ describe('manager frame routing', () => {
expect(manager.getListSnapshot().items[0]?.projectionValues).not.toBe(baseline)
})
it('drops the projection store with the removed session', async ({ mock, remote }) => {
it.for([undefined, []])('drops the removed ordinary Session store with catalog %s', async (catalog, { mock, remote }) => {
const manager = makeManager(mock, remote)
remote.session.list.mockResolvedValue(ok({
items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }],
items: [{ agentAvailable: true, sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }],
}))
await manager.refreshList()
manager.handleControlFrame({
type: 'projection', sessionId: sid('s1'), key: 'title', value: 'Doomed', seq: 4,
})
if (catalog !== undefined) {
manager.handleControlFrame({ type: 'projection', sessionId: sid('s1'), key: 'subagentCatalog', value: catalog, seq: 4 })
}
manager.handleSessionRemoved(sid('s1'))
expect(manager.getListSnapshot().projectionsBySession[sid('s1')]).toBeUndefined()
expect(manager.get(sid('s1')).projections.get('title')).toBeUndefined()
await manager.dispose()
})
})
@@ -145,7 +145,7 @@ export const sessionWorld: RemoteTable = {
data: 'AA==',
}),
'session/page': pageRule(ok({ records: [], hasMore: false })),
'subagents/list': ok({ entries: [], parentAvailable: true }),
'session/projections': ok({ asOfSeq: -1, values: { subagentCatalog: [] } }),
'subagents/prompt': ok({ messageId: 'fake-message' }),
'subagents/interruptByParent': ok({ accepted: true }),
},
@@ -62,12 +62,12 @@ type FeedRow = {
origin?: 'subagent'
running?: boolean
blank?: boolean
projections?: Record<string, unknown>
projections?: import('../src/types.ts').SessionProjectionValues
}
async function feedList(b: Bench, rows: FeedRow[]): Promise<void> {
b.mock.remote.session.list.mockResolvedValue(ok({
items: rows.map(r => ({
items: rows.map(r => ({ agentAvailable: true,
sessionId: sid(r.id), updatedAt: 1, running: r.running ?? false, blank: r.blank ?? false,
...(r.cwd !== undefined ? { cwd: r.cwd } : {}),
...(r.parentId !== undefined ? { parentSessionId: sid(r.parentId) } : {}),
@@ -76,7 +76,7 @@ async function feedList(b: Bench, rows: FeedRow[]): Promise<void> {
? {}
: { projections: { asOfSeq: 0, values: r.projections } }),
})),
}) as never)
}))
await b.svc.refresh()
await Promise.resolve() // manager notifier flush
}
@@ -116,7 +116,7 @@ describe('list store projection', () => {
it('reflects live increments (host stream via manager) into the store', async ({ bench }) => {
const b = bench()
await feedList(b, [{ id: 's1' }])
b.svc.handleSessionAdded({
b.svc.handleSessionAdded({ agentAvailable: true,
sessionId: sid('s2'), updatedAt: 2, running: false, blank: true,
})
await Promise.resolve()
@@ -149,6 +149,23 @@ describe('search', () => {
})
describe('scope tree', () => {
it('opens a conversation from follow projections without a second projection request', async ({ bench }) => {
const b = bench()
b.mock.stream(FOLLOW, followScript(ok({
records: [], hasMore: false,
projections: { asOfSeq: 0, values: { subagentCatalog: [{
id: sid('child'), createdAt: 1, mode: 'one-shot',
}] } },
})))
await feedList(b, [{ id: 's1' }])
b.svc.open(sid('s1'))
await vi.waitFor(() => {
expect(b.svc.list.getSnapshot().projectionsBySession[sid('s1')]?.values.subagentCatalog)
.toEqual([{ id: sid('child'), createdAt: 1, mode: 'one-shot' }])
})
expect(b.mock.remote.session.projections).not.toHaveBeenCalled()
})
it('publishes transient Assistant chunks and the named durable v2 settlement through one event source', async ({ bench }) => {
const b = bench()
await feedList(b, [{ id: 's1' }])
@@ -544,7 +561,7 @@ describe('Agent scope disposal lifecycle', () => {
const b = bench()
const readiness = b.ctx.plugin(() => undefined)
await readiness
b.svc.handleSessionAdded({
b.svc.handleSessionAdded({ agentAvailable: true,
sessionId: sid('live'), updatedAt: 1, running: false, blank: true,
})
await Promise.resolve()
@@ -834,72 +851,60 @@ describe('binding and stage lifecycle', () => {
describe('catalog-addressed navigation', () => {
it('uses catalog labels for a listed addressed route', async ({ bench }) => {
const b = bench()
b.mock.remote.subagents.list.mockImplementation((payload) => {
const parentSessionId = payload
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,
}))
b.mock.remote.session.projections.mockImplementation((payload) => {
const { sessionId } = payload as { sessionId: SessionId }
if (sessionId === sid('root')) {
return Promise.resolve(ok({ asOfSeq: 0, values: { subagentCatalog: [{ createdAt: 1,
id: sid('child'), mode: 'continuable', label: 'Child',
}] } }))
}
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,
}))
if (sessionId === sid('child')) {
return Promise.resolve(ok({ asOfSeq: 0, values: { subagentCatalog: [{ createdAt: 1,
id: sid('grandchild'), mode: 'continuable', label: 'Grandchild',
}] } }))
}
return Promise.resolve(ok({ entries: [], parentAvailable: false }))
return Promise.resolve(ok({ asOfSeq: 0, values: { subagentCatalog: [] } }))
})
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'))
await b.svc.refreshProjections(sid('root'))
await b.svc.refreshProjections(sid('child'))
b.svc.openSubagent({
parentSessionId: sid('child'), childSessionId: sid('grandchild'), mode: 'continuable',
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 ({ bench }) => {
it('resolves catalog addresses for never-selected ancestors without opening their scopes', async ({ bench }) => {
const b = bench()
b.mock.remote.subagents.list.mockImplementation((payload) => {
const parentSessionId = payload
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,
}))
b.mock.remote.session.projections.mockImplementation((payload) => {
const { sessionId } = payload as { sessionId: SessionId }
if (sessionId === sid('root')) {
return Promise.resolve(ok({ asOfSeq: 0, values: { subagentCatalog: [{ createdAt: 1,
id: sid('child'), mode: 'continuable', label: 'Child',
}] } }))
}
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,
}))
if (sessionId === sid('child')) {
return Promise.resolve(ok({ asOfSeq: 0, values: { subagentCatalog: [{ createdAt: 1,
id: sid('grandchild'), mode: 'continuable', label: 'Grandchild',
}] } }))
}
return Promise.resolve(ok({ entries: [], parentAvailable: false }))
return Promise.resolve(ok({ asOfSeq: 0, values: { subagentCatalog: [] } }))
})
await feedList(b, [{ id: 'root' }])
await b.svc.refreshSubagents(sid('root'))
await b.svc.refreshSubagents(sid('child'))
await b.svc.refreshProjections(sid('root'))
await b.svc.refreshProjections(sid('child'))
b.svc.openSubagent({
parentSessionId: sid('child'), childSessionId: sid('grandchild'), mode: 'continuable',
parentSessionId: sid('child'),
childSessionId: sid('grandchild'),
mode: 'continuable',
})
const list = b.svc.list.getSnapshot()
@@ -907,12 +912,20 @@ describe('catalog-addressed navigation', () => {
expect(list.byId[sid('child')]).toMatchObject({ parentId: sid('root'), origin: 'subagent' })
expect(list.byId[sid('grandchild')]).toMatchObject({ parentId: sid('child'), origin: 'subagent' })
expect(b.svc.binding(sid('child'))).toBeUndefined()
expect(b.svc.subagentAddress(sid('child'))).toBeUndefined()
expect(b.svc.subagentAddress(sid('child'))).toEqual({
parentSessionId: sid('root'),
childSessionId: sid('child'),
mode: 'continuable',
})
expect(b.svc.binding(sid('child'))).toBeUndefined()
expect(b.svc.list.getSnapshot().currentAddress?.childSessionId).toBe(sid('grandchild'))
b.svc.open(sid('child'))
expect(b.svc.list.getSnapshot().current).toBe(sid('child'))
expect(b.svc.subagentAddress(sid('child'))).toEqual({
parentSessionId: sid('root'), childSessionId: sid('child'), mode: 'continuable',
parentSessionId: sid('root'),
childSessionId: sid('child'),
mode: 'continuable',
})
})
})
@@ -1040,7 +1053,7 @@ describe('scope lifecycle rides the list mirror (entity parity: no client-side p
const b = bench()
await feedList(b, [])
expect(b.svc.scope(sid('s-new'))).toBeUndefined() // not in view: no scope, no exceptions
b.svc.handleSessionAdded({
b.svc.handleSessionAdded({ agentAvailable: true,
sessionId: sid('s-new'), updatedAt: 2, running: false, blank: true, cwd: '/w/a',
})
await Promise.resolve()
@@ -1100,7 +1113,7 @@ describe('blank mirror', () => {
it('takes session-added blank=true as the hidden birth and list blank as reconnect authority', async ({ bench }) => {
const b = bench()
await feedList(b, [])
b.svc.handleSessionAdded({
b.svc.handleSessionAdded({ agentAvailable: true,
sessionId: sid('s-new'), updatedAt: 2, running: false, blank: true, cwd: '/w/a',
})
await Promise.resolve()
@@ -0,0 +1,216 @@
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SessionStore, { SESSION_FORMAT_VERSION, SessionLogOffset, SessionSeq, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
import { SessionQueryError } from '@deepseek-ai/dsh-session-query'
import type { SessionObservation } from '@deepseek-ai/dsh-session-query'
import SubagentRuntime from '@deepseek-ai/dsh-subagent'
import { describe, expect, it, vi } from 'vitest'
import {
createSessionTestController,
installSessionReadTestServices,
testSessionPersistence,
} from './test-remote.ts'
const defaults = {
defaultModelSelection: () => ({ provider: 'fixture', model: 'fixture-model' }),
cwd: '/tmp',
}
const PARENT = SessionId('catalog-parent')
const CHILD = SessionId('catalog-child')
const header: SessionHeader = {
version: SESSION_FORMAT_VERSION,
id: PARENT,
createdAt: 1,
isSeeded: false,
cwd: '/workspace',
}
const events: SessionEvent[] = [{
type: 'subagent/catalog',
seq: SessionSeq(0),
time: 1,
data: {
version: 0,
childId: CHILD,
childCreatedAt: 2,
mode: 'continuable',
label: 'worker',
},
}]
async function bench() {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
ctx.provide('sessionPersistence', testSessionPersistence(ctx, {
list: () => Promise.resolve([header]),
inspect: (sessionId: SessionId) => Promise.resolve(
sessionId === PARENT ? { meta: header, events } : undefined,
),
}) as never)
installSessionReadTestServices(ctx)
await ctx.plugin(SubagentRuntime)
const controller = createSessionTestController(ctx, defaults)
return { ctx, controller }
}
describe('SessionController subagent catalog', () => {
it('pushes complete catalog values through the shared control stream', async () => {
const { ctx, controller } = await bench()
const signal = new AbortController()
const stream = controller.control(signal.signal)[Symbol.asyncIterator]()
try {
await stream.next()
const parent = ctx.sessions.create(PARENT, { meta: { createdAt: 7, cwd: '/workspace' } })
parent.append('subagent/catalog', {
version: 0, childId: CHILD, childCreatedAt: 8, mode: 'continuable', label: 'worker',
})
let frame = await stream.next()
while (!frame.done && !(frame.value.type === 'projection' && frame.value.key === 'subagentCatalog'
&& Array.isArray(frame.value.value) && frame.value.value.length === 1)) {
frame = await stream.next()
}
expect(frame.value).toMatchObject({ type: 'projection', sessionId: PARENT, key: 'subagentCatalog',
value: [{ id: CHILD, createdAt: 8, mode: 'continuable', label: 'worker' }] })
} finally {
signal.abort()
await stream.return?.()
}
})
it('requests a stored parent catalog by id without loading an Agent', async () => {
const { ctx, controller } = await bench()
const observe = vi.spyOn(ctx.sessionQuery, 'observeSession')
const signal = new AbortController().signal
await expect(controller.projections({ sessionId: PARENT }, signal)).resolves.toMatchObject({ values: { subagentCatalog: [{
id: CHILD,
mode: 'continuable',
label: 'worker',
}] } })
expect(observe).toHaveBeenCalledOnce()
expect(observe).toHaveBeenCalledWith(PARENT, {
signal,
})
})
it('serves a live parent from its maintained registry state without folding its log', async () => {
const { ctx, controller } = await bench()
const parent = ctx.sessions.create(PARENT, { meta: { createdAt: 7, cwd: '/workspace' } })
parent.append('turn/start', { turn: 1 })
parent.append('subagent/catalog', {
version: 0,
childId: CHILD,
childCreatedAt: 8,
mode: 'one-shot',
label: 'live worker',
})
const snapshot = vi.spyOn(ctx.sessionProjections, 'snapshot')
const hydrate = vi.spyOn(ctx.sessionProjections, 'hydrate')
const result = await controller.projections({ sessionId: PARENT }, new AbortController().signal)
expect(result).toMatchObject({ values: { subagentCatalog: [{
id: CHILD,
mode: 'one-shot',
label: 'live worker',
}] } })
expect(snapshot).toHaveBeenCalledWith(parent)
expect(hydrate).not.toHaveBeenCalled()
})
it('reports Agent availability through Session summaries independently of projections', async () => {
const { ctx, controller } = await bench()
const parent = ctx.sessions.create(PARENT, { meta: { createdAt: 1, cwd: '/workspace' } })
parent.append('subagent/catalog', {
version: 0, childId: CHILD, childCreatedAt: 8, mode: 'one-shot',
})
const summaries: boolean[] = []
ctx.on('api-session/added', (summary) => { summaries.push(summary.agentAvailable) })
const dispose = ctx.agents.register({ id: PARENT, session: parent, status: 'idle', ctx } as Agent)
await dispose
expect(summaries).toEqual([true])
const available = await controller.list({}, new AbortController().signal)
expect(available.items.find(item => item.sessionId === PARENT)?.agentAvailable).toBe(true)
await dispose()
expect(summaries).toEqual([true, false])
const unavailable = await controller.list({}, new AbortController().signal)
expect(unavailable.items.find(item => item.sessionId === PARENT)?.agentAvailable).toBe(false)
const result = await controller.projections({ sessionId: PARENT }, new AbortController().signal)
expect(result).toMatchObject({ asOfSeq: 0, values: { subagentCatalog: [{ id: CHILD, createdAt: 8, mode: 'one-shot' }] } })
})
it('does not republish a Session when its Agent outlives the Session registration', async () => {
const { ctx } = await bench()
const session = ctx.sessions.prepare(SessionId('detached'), { meta: { cwd: '/workspace' } })
const detach = ctx.sessions.enter(session)
const disposeAgent = ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
await disposeAgent
const added = vi.fn()
ctx.on('api-session/added', added)
detach()
await disposeAgent()
expect(added).not.toHaveBeenCalled()
})
it('reads registered projections without a subagent provider or catalog key', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
installSessionReadTestServices(ctx)
const controller = createSessionTestController(ctx, defaults)
const session = ctx.sessions.create(PARENT, { meta: { cwd: '/workspace' } })
const expected = ctx.sessionProjections.snapshot(session)
expect(expected.values.subagentCatalog).toBeUndefined()
await expect(controller.projections({ sessionId: PARENT }, new AbortController().signal)).resolves.toEqual(expected)
expect(ctx.agents.get(PARENT)).toBeUndefined()
})
it('validates the parent id and keeps not-found distinct from cancellation', async () => {
const { controller } = await bench()
const signal = new AbortController().signal
await expect(controller.projections({ sessionId: SessionId('') }, signal)).rejects.toMatchObject({
code: 'gateway/bad-request',
})
await expect(controller.projections({ sessionId: SessionId('missing') }, signal)).resolves.toEqual(null)
const aborted = new AbortController()
aborted.abort()
await expect(controller.projections({ sessionId: PARENT }, aborted.signal)).rejects.toMatchObject({
code: 'gateway/cancelled',
})
})
it('maps query cancellation, missing projections, and unexpected failures', async () => {
const { ctx, controller } = await bench()
const observe = vi.spyOn(ctx.sessionQuery, 'observeSession')
observe.mockRejectedValueOnce(new SessionQueryError(
'query cancelled',
'SESSION_QUERY_ABORTED',
))
await expect(controller.projections({ sessionId: PARENT }, new AbortController().signal)).rejects.toMatchObject({
code: 'gateway/cancelled',
})
observe.mockRestore()
// An observation without the catalog state means no registry served it.
const stateless: SessionObservation = {
source: 'prepared',
header,
inheritedEventCount: SessionLogOffset(0),
events,
cursor: SessionSeq(0),
retain: () => stateless,
[Symbol.dispose]: () => {},
}
vi.spyOn(ctx.sessionQuery, 'observeSession').mockResolvedValueOnce(stateless)
await expect(controller.projections({ sessionId: PARENT }, new AbortController().signal)).rejects.toMatchObject({
code: 'session/projections-unavailable',
})
vi.spyOn(ctx.sessionQuery, 'observeSession').mockRejectedValueOnce(new Error('storage offline'))
await expect(controller.projections({ sessionId: PARENT }, new AbortController().signal)).rejects.toMatchObject({
code: 'gateway/internal',
})
})
})
@@ -9,6 +9,7 @@ import type {
ImageAttachmentLimits,
} from '@deepseek-ai/dsh-attachment'
import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionProjectionsValue } from '../src/types.ts'
import {
SessionPersistenceNotFoundError,
SessionPersistenceRevision,
@@ -57,6 +58,7 @@ import type {
SessionSearchValue,
SessionSelectModelRequest,
SessionSelectModelValue,
SessionProjectionsRequest,
SessionUpdateQueueRequest,
SessionUpdateQueueValue,
} from '../src/types.ts'
@@ -80,6 +82,7 @@ export interface TestSessionRemote {
signal?: AbortSignal,
): Promise<RemoteResult<SessionOpenWorkspacePathValue>>
page(request: SessionPageRequest, signal?: AbortSignal): Promise<RemoteResult<SessionPage>>
projections(request: SessionProjectionsRequest, signal?: AbortSignal): Promise<RemoteResult<SessionProjectionsValue>>
follow(request: SessionFollowRequest, signal?: AbortSignal): AsyncIterable<SessionFollowFrame>
control(signal?: AbortSignal): AsyncIterable<SessionControlFrame>
}
@@ -360,6 +363,10 @@ export function createSessionTestRemote(
() => direct.page(request, signal),
signal,
),
projections: (request, signal = new AbortController().signal) => remoteResult(
() => direct.projections(request, signal),
signal,
),
follow: (request, signal = new AbortController().signal) => direct.follow(request, signal),
control: (signal = new AbortController().signal) => direct.control(signal),
}
@@ -81,7 +81,6 @@ function sessionClient(remote: SessionTransportRemote): SessionRemotes {
),
commands: { execute: () => Promise.reject(new Error('stream tests never run commands')) },
subagents: {
list: () => Promise.reject(new Error('stream tests never read the subagent catalog')),
prompt: () => Promise.reject(new Error('stream tests never prompt a subagent')),
interruptByParent: () => Promise.reject(new Error('stream tests never interrupt a subagent')),
},
@@ -441,7 +441,12 @@ describe('SessionHistoryController', () => {
const signal = new AbortController().signal
await expect(transport.page({
address: { kind: 'subagent', parentSessionId, childSessionId, mode: 'continuable' },
address: {
kind: 'subagent',
parentSessionId,
childSessionId,
mode: 'continuable',
},
throughSeq: 0,
}, signal)).resolves.toMatchObject({
records: [{ type: 'event', event: { type: 'subagent/descriptor' } }],
@@ -456,7 +461,12 @@ describe('SessionHistoryController', () => {
throughSeq: 0,
}, signal)).rejects.toMatchObject({ code: 'subagent/unauthorized' })
await expect(transport.page({
address: { kind: 'subagent', parentSessionId, childSessionId, mode: 'one-shot' },
address: {
kind: 'subagent',
parentSessionId,
childSessionId,
mode: 'one-shot',
},
throughSeq: 0,
}, signal)).rejects.toMatchObject({ code: 'subagent/unauthorized' })
await expect(transport.page({
@@ -659,7 +669,12 @@ describe('SessionHistoryController', () => {
const history = new SessionHistoryController(ctx, vi.fn())
await expect(history.page({
address: { kind: 'subagent', parentSessionId, childSessionId, mode: 'continuable' },
address: {
kind: 'subagent',
parentSessionId,
childSessionId,
mode: 'continuable',
},
throughSeq: -1,
}, signal())).rejects.toMatchObject({
code: 'subagent/catalog-diagnostic', details: { reason: 'unsupported' },
@@ -690,7 +705,12 @@ describe('SessionHistoryController', () => {
}))
const childSnapshot = vi.spyOn(child.ctx.sessionProjections, 'snapshot')
const page = await child.transport.page({
address: { kind: 'subagent', parentSessionId, childSessionId, mode: 'continuable' },
address: {
kind: 'subagent',
parentSessionId,
childSessionId,
mode: 'continuable',
},
throughSeq: 0,
}, signal())
expect('projections' in page).toBe(false)
@@ -20,7 +20,7 @@ const OPTIONS = [{ id: 'zh', label: '中文' }, { id: 'en', label: 'English' }]
function emptySessions() {
const store = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined })
{ ids: [], byId: {}, current: undefined, phase: 'ready', projectionsBySession: {}, jobsBySession: {}, currentAddress: undefined })
return bindSnapshotSelector(store)
}
function emptyWorkspaces() {
@@ -203,7 +203,7 @@ const compaction = (over: Partial<CompactionSummaryNode> = {}): CompactionSummar
/** Empty sessions-list hook for the global standard-kit seat. */
function emptySessions() {
const store = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined })
{ ids: [], byId: {}, current: undefined, phase: 'ready', projectionsBySession: {}, jobsBySession: {}, currentAddress: undefined })
return bindSnapshotSelector(store)
}
@@ -14,7 +14,7 @@ afterEach(cleanup)
function emptySessions() {
return bindSnapshotSelector(createSnapshotStore<SessionListState>({
ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined,
ids: [], byId: {}, current: undefined, phase: 'ready', projectionsBySession: {}, jobsBySession: {}, currentAddress: undefined,
}))
}
@@ -115,8 +115,8 @@ export function ConversationMainPanel(props: ConversationSlotProps) {
// The exemption is deliberately open-state-wide, not loading-only: a
// summary-blank session is the hero before its open starts (`cold`) and
// after one fails (`error`) for the same reason — there is no history.
// A restored continuable subagent also stays settled until its eagerly
// loaded parent catalog establishes availability. This keeps the composer
// A restored continuable subagent waits for a Host summary to establish
// parent availability. This keeps the composer
// hidden instead of briefly rendering the parent-offline takeover.
const parentAvailabilityPending = session?.subagent?.address.mode === 'continuable'
&& session.subagent.parentAvailable === undefined
@@ -72,7 +72,7 @@ function fakeSessions(ctx: Context): { sessions: ISessions; binding: SessionBind
byId: {},
current: undefined,
phase: 'ready',
subagentsByParent: {},
projectionsBySession: {},
jobsBySession: {},
currentAddress: undefined,
})
@@ -83,8 +83,7 @@ function fakeSessions(ctx: Context): { sessions: ISessions; binding: SessionBind
open: () => {},
openSubagent: () => {},
subagentAddress: () => undefined,
setSubagentCatalogOpen: () => {},
refreshSubagents: () => Promise.reject(new Error('unused fake Sessions operation')),
refreshProjections: () => Promise.reject(new Error('unused fake Sessions operation')),
clear: () => {},
refresh: () => Promise.reject(new Error('unused fake Sessions operation')),
search: () => Promise.reject(new Error('unused fake Sessions operation')),
@@ -22,7 +22,7 @@ afterEach(() => {
function emptySessions() {
return bindSnapshotSelector(createSnapshotStore<SessionListState>({
ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined,
ids: [], byId: {}, current: undefined, phase: 'ready', projectionsBySession: {}, jobsBySession: {}, currentAddress: undefined,
}))
}
@@ -174,7 +174,7 @@ function bench(over?: BenchOptions) {
useResource,
useSessions: bindSnapshotSelector(createSnapshotStore<SessionListState>({
ids: [], byId: {}, current: undefined, phase: 'ready',
subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined,
projectionsBySession: {}, jobsBySession: {}, currentAddress: undefined,
})),
useWorkspaces: bindSnapshotSelector(createSnapshotStore({
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
@@ -54,7 +54,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled
useSession: bindSnapshotSelector(session),
useSessions: bindSnapshotSelector(createSnapshotStore({
ids: [], byId: {}, current: undefined, phase: 'ready',
subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined,
projectionsBySession: {}, jobsBySession: {}, currentAddress: undefined,
})),
useSessionPendingInteraction: bindSnapshotSelector(
createSnapshotStore<SessionPendingInteractionSnapshot>(new Map()),
@@ -145,7 +145,7 @@ async function scopedBench(register?: (inputTriggers: InputTriggerService) => vo
useSession: bindSnapshotSelector(sessionStore),
useSessions: bindSnapshotSelector(createSnapshotStore({
ids: [], byId: {}, current: undefined, phase: 'ready',
subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined,
projectionsBySession: {}, jobsBySession: {}, currentAddress: undefined,
})),
useSessionPendingInteraction: bindSnapshotSelector(
createSnapshotStore<SessionPendingInteractionSnapshot>(new Map()),
@@ -151,7 +151,7 @@ function mount(
...listed && { [SID]: childRow },
},
current: SID,
phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined,
phase: 'ready', projectionsBySession: {}, jobsBySession: {}, currentAddress: undefined,
})
const workspaces = createSnapshotStore<WorkspaceSnapshot>(workspaceState(workspaceRows))
const session = createSnapshotStore<SessionSnapshot>(snapshot)
@@ -35,7 +35,7 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
function openProps(controller = new PresentedOpenController()) {
controller.host.set({ name: 'desktop', available: true, fileManager: 'finder' })
const sessions: SessionListState = { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined }
const sessions: SessionListState = { ids: [], byId: {}, current: undefined, phase: 'ready', projectionsBySession: {}, jobsBySession: {}, currentAddress: undefined }
return {
useSessions: <T,>(select: (state: SessionListState) => T): T => select(sessions),
reloadPresentedHost: vi.fn(() => controller.loadHost()),
@@ -41,7 +41,7 @@ function props(jobs: readonly JobView[] | undefined): JobListActionProps {
byId: {},
current: SESSION,
phase: 'ready',
subagentsByParent: {},
projectionsBySession: {},
jobsBySession: jobs === undefined ? {} : { [SESSION]: jobs },
currentAddress: undefined,
} satisfies SessionListState
@@ -78,7 +78,7 @@ function mountFrame(windowWidth = frameWidth) {
},
current: selectedSession,
phase: 'ready',
subagentsByParent: {},
projectionsBySession: {},
jobsBySession: {},
currentAddress: undefined,
})
@@ -21,7 +21,7 @@ function titleSources() {
byId: { [sessionId]: { id: sessionId, displayTitle: 'Test', running: false, blank: false, updatedAt: 1 } },
current: sessionId,
phase: 'ready',
subagentsByParent: {},
projectionsBySession: {},
jobsBySession: {},
currentAddress: undefined,
})
@@ -34,7 +34,7 @@ function bench(over: {
byId: over.cwd === undefined ? {} : { [SESSION]: { cwd: over.cwd } },
current: SESSION,
phase: 'ready',
subagentsByParent: {},
projectionsBySession: {},
jobsBySession: {},
currentAddress: undefined,
} as unknown as SessionListState
@@ -39,7 +39,7 @@ function createSessionsBench(_ctx: Context): SessionsBench {
byId: {},
current: undefined,
phase: 'ready',
subagentsByParent: {},
projectionsBySession: {},
jobsBySession: {},
currentAddress: undefined,
})
@@ -29,7 +29,7 @@ function sessionState(sessions: readonly SessionSummary[], phase: SessionListSta
byId: Object.fromEntries(sessions.map(session => [session.id, session])),
current: undefined,
phase,
subagentsByParent: {},
projectionsBySession: {},
jobsBySession: {},
currentAddress: undefined,
}
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-subagent/README.md
README.md: f4c6da14e5cf3788f2ea0c40902b2dc07558ec7c
README.zh.md: d96b6dfcaadd02514dc65d74a3a1f266a98f1f3c
README.md: 3e3da6d65af38cbd4d6dad69ec8d081e0185caa8
README.zh.md: 5b41a44ec93f783d8dc19b459bbe206f62edaba5
+6 -4
View File
@@ -25,11 +25,11 @@ Use this package to browse every subagent conversation beneath a parent session,
<a id="use-this-package"></a>
## Use this package
The session header keeps the current session title as the lineage breadcrumb and, when the session has subagent descendants, appends a `/` count trigger before the header's action row; the trigger opens the descendant catalog, counts the complete subagent-only lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. Select any depth to open that child's conversation with its exact `{parentSessionId, childSessionId, mode}` address.
The session header keeps the current session title as the lineage breadcrumb and appends a `/` count trigger when its direct catalog has entries or a read has failed. An absent catalog, an empty loading catalog, or a successfully loaded empty catalog hides the count trigger. The trigger opens that direct catalog, reports its total and running counts, and loads nested catalogs only when their rows expand. Select any depth to open that child's conversation with its exact `{parentSessionId, childSessionId, mode}` address.
### Browsing the tree
Rows display mode plus `running`/`inactive` activity and an optional log-backed title; the trailing column stacks total durable provider usage above active-turn duration. Keyboard navigation works with ArrowRight/ArrowLeft to expand and collapse branches and ArrowUp/ArrowDown, Home, End, and Escape to navigate or close the tree. An unlabeled one-shot row falls back to its session id; corrupt, unsupported, or unavailable rows remain readable but disabled.
Rows display mode plus `running`/`inactive` activity and an optional log-backed title; the trailing column stacks total durable provider usage above active-turn duration. Keyboard navigation works with ArrowRight/ArrowLeft to expand and collapse branches and ArrowUp/ArrowDown, Home, End, and Escape to navigate or close the tree. An unlabeled one-shot row falls back to its session id. A row is a known leaf only after its own catalog loads empty.
### Continuing a conversation
@@ -51,11 +51,13 @@ The catalog and composer behavior are specified by the [Web subagent conversatio
### Catalog derivation
The header lineage renderer reads `subagentsByParent` and session summaries through the standard `useSessions` hook. The compact tree remains direct-catalog authoritative: each healthy row's `hasChildren` hint determines disclosure before interaction, a catalog level reserves the disclosure column only when at least one healthy row is a branch, and expanding a branch immediately reserves one disabled loading row per known direct descendant before lazily replacing 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.
The header lineage renderer reads `projectionsBySession` through the standard `useSessions` hook. The renderer selects `subagentCatalog` from each Session’s shared values for membership, disclosure, and counts; Session summaries supply activity, titles, and usage. Expanding a row loads its initial catalog when needed. Live projection frames update every loaded level without menu subscriptions or repeated membership queries. A row remains expandable while its catalog is absent, loading, or failed, and becomes a known leaf after a ready empty catalog.
Breadcrumb addresses derive from the selected address and loaded catalogs in the Session-list snapshot, including never-selected ancestors.
### Duration and tokens
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 its same-cut `active.through`, never by newer session metadata.
Each visible catalog level advances its own clock while it contains a running child; collapsing the level or closing the menu releases that clock. 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 its same-cut `active.through`, never by newer session metadata.
### Composer election
+6 -4
View File
@@ -25,11 +25,11 @@ kind: "package-reference"
<a id="use-this-package"></a>
## 使用本包
会话页头保留当前会话 title 作为谱系面包屑,并在会话存在 subagent 后代时,于页头操作行之前追加 `/` 数量触发器;触发器打开后代目录,统计仅含 subagent 的完整谱系、在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。选择任意深度,即可用该子会话的确切 `{parentSessionId, childSessionId, mode}` 地址打开其对话。
会话页头保留当前会话 title 作为谱系面包屑,并在直接目录有子项或读取失败时追加 `/` 数量触发器。目录缺席、空目录加载中或成功加载为空时,均隐藏数量触发器。触发器打开该直接目录,报告总数与运行数,并且只在行展开时加载嵌套目录。选择任意深度,即可用该子会话的确切 `{parentSessionId, childSessionId, mode}` 地址打开其对话。
### 浏览目录
行显示 mode、`running`/`inactive` 活动状态与由日志支撑的可选 title;尾随列在上行显示提供方的持久化 token 用量总计,在下行显示活跃轮次耗时。键盘导航:ArrowRight/ArrowLeft 展开和折叠分支;ArrowUp/ArrowDown、Home、End 与 Escape 用于导航或关闭树。没有 label 的 one-shot 行回退到其会话 id;损坏、不受支持或不可用的行仍保持可读但禁用。
行显示 mode、`running`/`inactive` 活动状态与由日志支撑的可选 title;尾随列在上行显示提供方的持久化 token 用量总计,在下行显示活跃轮次耗时。键盘导航:ArrowRight/ArrowLeft 展开和折叠分支;ArrowUp/ArrowDown、Home、End 与 Escape 用于导航或关闭树。没有 label 的 one-shot 行回退到其会话 id。只有一行自身的目录加载为空后,它才是已知叶子。
### 续接对话
@@ -51,11 +51,13 @@ kind: "package-reference"
### 目录派生
页头谱系 renderer 通过标准 `useSessions` 钩子读取 `subagentsByParent` 与会话摘要。紧凑树仍以直接目录为权威依据:每个健康行的 `hasChildren` 提示在交互前决定是否显示展开控件;每层目录仅在其中至少一个健康行是分支时才预留展开列;展开分支时会立即为每个已知直接后代预留一行禁用的加载行,随后再用该 child 的权威目录懒加载结果替换。每个可见分支都会上报给运行时,使成员帧只在树正被消费的位置触发去抖动刷新。
页头谱系 renderer 通过标准 `useSessions` 钩子读取 `projectionsBySession`。renderer 从每个 Session 的共享值中选择 `subagentCatalog`,用于成员关系、展开控件与数量;Session 摘要提供活动状态、标题与用量。展开行会按需加载初始目录。实时 projection 帧更新所有已加载层级,无需菜单订阅或重复成员查询。行在目录缺席、加载中或失败时保持可展开,并在目录就绪且为空后成为已知叶子。
面包屑地址从 Session 列表快照中的当前选中地址和已加载目录推导,也包括从未选中过的祖先。
### 耗时与 token
token 用量总计为四个互不重叠的 `tokenUsage` 桶之和。耗时会累加已完成的 `subagentTiming` 轮次,仅在运行中 child 存在未结束轮次时每秒递增一次,并在 child 变为 inactive 后冻结;被中断的未结束轮次以其同一切面的 `active.through` 为上界,绝不使用更新的会话元数据。
每个可见目录层在包含运行中 child 时独立推进时钟;折叠该层或关闭菜单会释放时钟。token 用量总计为四个互不重叠的 `tokenUsage` 桶之和。耗时会累加已完成的 `subagentTiming` 轮次,仅在运行中 child 存在未结束轮次时每秒递增一次,并在 child 变为 inactive 后冻结;被中断的未结束轮次以其同一切面的 `active.through` 为上界,绝不使用更新的会话元数据。
### 编辑器选举
@@ -162,19 +162,6 @@
margin-top: 4px;
}
.disabled {
color: var(--dsw-alias-label-dimmed);
cursor: not-allowed;
}
.disabled:hover {
background: transparent;
}
.loadingRow {
cursor: default;
}
.disclosure,
.disclosureSpace {
flex: none;
@@ -1,10 +1,8 @@
import {
useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type MouseEvent,
} from 'react'
import { useMemo, useEffect, useRef, useState, type CSSProperties, type KeyboardEvent, type MouseEvent } from 'react'
import { createPortal } from 'react-dom'
import {
type SessionListState, type SessionProjectionMap, type SessionSummary,
type SubagentCatalogSnapshot,
type SessionProjectionMap, type SessionSummary,
type SessionProjectionSnapshot,
} from '@deepseek-ai/dsh-api-session-controller/client'
import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
@@ -16,16 +14,17 @@ import { NS } from './locales.ts'
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type {} from '@deepseek-ai/dsh-token-meter/client'
import css from './SubagentHeaderLineage.module.css'
import { indexSubagentDescendants } from './subagent-lineage.ts'
type CatalogEntry = SubagentCatalogSnapshot['entries'][number]
type Catalogs = SessionListState['subagentsByParent']
type SubagentCatalogSnapshot = Omit<SessionProjectionSnapshot, 'values' | 'state'> & {
state: 'loading' | 'ready' | 'error'
entries: (SessionProjectionMap['subagentCatalog'][number] & { activity: 'running' | 'inactive' })[]
}
type Catalogs = Readonly<Record<SessionId, SubagentCatalogSnapshot>>
/** Business actions supplied by the slot registration. */
export interface SubagentCatalogInjected {
openChild: (address: SubagentAddress) => void
refresh: (parentSessionId: SessionId) => void
setCatalogOpen: (parentSessionId: SessionId, open: boolean) => void
}
/** Full props for the session-header lineage renderer. */
@@ -40,24 +39,12 @@ interface CatalogRowsProps {
summaries: Readonly<Record<SessionId, SessionSummary>>
expanded: ReadonlySet<SessionId>
level: number
now: number
openChild: (address: SubagentAddress) => void
refresh: (parentSessionId: SessionId) => void
toggleBranch: (childSessionId: SessionId) => void
closeCatalog: () => void
}
function diagnosticReason(
entry: Extract<CatalogEntry, { kind: 'diagnostic' }>,
t: TranslateNS<typeof NS>,
): string {
switch (entry.reason) {
case 'corrupt': return t('diagnostic.corrupt')
case 'unsupported': return t('diagnostic.unsupported')
case 'unavailable': return t('diagnostic.unavailable')
}
}
function treeItems(root: HTMLDivElement | null): HTMLElement[] {
return root === null
? []
@@ -175,8 +162,6 @@ function formatExactDuration(ms: number, t: TranslateNS<typeof NS>): string {
})
}
const NO_DESCENDANTS = { count: 0, runningCount: 0 } as const
function SubagentSwitcherIcon() {
return (
<svg
@@ -200,59 +185,34 @@ function SubagentSwitcherIcon() {
)
}
/** Render the known direct-child shape while its authoritative catalog hydrates. */
function CatalogLoadingRows({
parentSessionId,
summaries,
level,
t,
}: {
parentSessionId: SessionId
summaries: Readonly<Record<SessionId, SessionSummary>>
level: number
t: TranslateNS<typeof NS>
}) {
const children = Object.values(summaries).filter(summary => (
summary.origin === 'subagent' && summary.parentId === parentSessionId
))
if (children.length === 0) return <div className={css.notice}>{t('loading.label')}</div>
return children.map(summary => (
<div key={summary.id} className={css.node}>
<div
role="treeitem"
aria-disabled="true"
aria-level={level}
aria-label={t('loading.aria')}
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}>{t('loading.label')}</span>
</span>
</div>
</div>
))
/** Render catalog loading without inventing child membership. */
function CatalogLoadingRows({ t }: { t: TranslateNS<typeof NS> }) {
return <div className={css.notice}>{t('loading.label')}</div>
}
/** A child becomes a known leaf only after its own authoritative catalog loads empty. */
function isKnownLeaf(catalog: SubagentCatalogSnapshot | undefined): boolean {
return catalog?.state === 'ready' && catalog.entries.length === 0
}
/** Render one catalog level and recurse only through explicitly expanded rows. */
function CatalogRows({
parentSessionId, currentSessionId, catalog, catalogs, summaries, expanded, level, now,
parentSessionId, currentSessionId, catalog, catalogs, summaries, expanded, level,
openChild, refresh, toggleBranch, closeCatalog, t,
}: CatalogRowsProps & { t: TranslateNS<typeof NS> }) {
const [now, setNow] = useState(() => Date.now())
const running = catalog.entries.some(entry => entry.activity === 'running')
useEffect(() => {
if (!running) return
const timer = setInterval(() => { setNow(Date.now()) }, 1_000)
return () => { clearInterval(timer) }
}, [running])
const emptyLoading = catalog.state === 'loading' && catalog.entries.length === 0
const reserveDisclosure = catalog.entries.some(
entry => entry.kind === 'child' && entry.hasChildren,
)
const reserveDisclosure = catalog.entries.some(entry => !isKnownLeaf(catalogs[entry.id]))
return (
<>
{emptyLoading && (
<CatalogLoadingRows
parentSessionId={parentSessionId}
summaries={summaries}
level={level}
t={t}
/>
<CatalogLoadingRows t={t} />
)}
{catalog.state === 'error' && (
<div className={css.error}>
@@ -268,33 +228,10 @@ function CatalogRows({
</div>
)}
{catalog.entries.map((entry) => {
if (entry.kind === 'diagnostic') {
const reason = diagnosticReason(entry, t)
return (
<div key={entry.id} className={css.node}>
<div
role="treeitem"
aria-disabled="true"
aria-level={level}
aria-label={`${entry.id} ${reason}`}
className={`${css.row} ${css.disabled}`}
title={reason}
>
{reserveDisclosure && <span className={css.disclosureSpace} />}
<StateDot state="error" />
<span className={css.content}>
<span className={css.label}>{entry.id}</span>
<span className={css.summary}>{reason}</span>
</span>
</div>
</div>
)
}
const childCatalog = catalogs[entry.id]
const isCurrent = entry.id === currentSessionId
const isExpanded = expanded.has(entry.id)
const knownLeaf = !entry.hasChildren
const knownLeaf = isKnownLeaf(childCatalog)
const childLoading = childCatalog === undefined
|| (childCatalog.state === 'loading' && childCatalog.entries.length === 0)
const summary = summaries[entry.id]
@@ -324,7 +261,11 @@ function CatalogRows({
.join(' · ')
const open = (): void => {
openChild({ parentSessionId, childSessionId: entry.id, mode: entry.mode })
openChild({
parentSessionId,
childSessionId: entry.id,
mode: entry.mode,
})
closeCatalog()
}
const handleKey = (event: KeyboardEvent<HTMLDivElement>): void => {
@@ -401,14 +342,7 @@ function CatalogRows({
aria-busy={childLoading || undefined}
>
{childCatalog === undefined
? (
<CatalogLoadingRows
parentSessionId={entry.id}
summaries={summaries}
level={level + 1}
t={t}
/>
)
? <CatalogLoadingRows t={t} />
: (
<CatalogRows
parentSessionId={entry.id}
@@ -418,7 +352,6 @@ function CatalogRows({
summaries={summaries}
expanded={expanded}
level={level + 1}
now={now}
openChild={openChild}
refresh={refresh}
toggleBranch={toggleBranch}
@@ -482,66 +415,43 @@ function catalogMenuPosition(trigger: HTMLButtonElement): CSSProperties {
/** One trigger-plus-tree dropdown over the catalog rooted at `rootSessionId`. */
function CatalogDropdown({
rootSessionId, currentSessionId, displayTitle, openTitle, variant, separator = false,
useSessions, openChild, refresh, setCatalogOpen, t,
useSessions, openChild, refresh, t,
}: CatalogDropdownProps) {
const ancestorSwitcher = variant === 'switcher' && openTitle !== undefined
const catalogs = useSessions(state => state.subagentsByParent)
const projections = useSessions(state => state.projectionsBySession)
const summaries = useSessions(state => state.byId)
const catalogs = useMemo<Catalogs>(() => Object.fromEntries(Object.entries(projections).map(([id, snapshot]) => [id, {
state: snapshot.state === 'idle'
? snapshot.values.subagentCatalog === undefined ? 'loading' : 'ready'
: snapshot.state,
error: snapshot.error,
entries: (snapshot.values.subagentCatalog ?? []).map(entry => ({
...entry, activity: summaries[entry.id]?.running === true ? 'running' as const : 'inactive' as const,
})),
}])), [projections, summaries])
const catalog = catalogs[rootSessionId]
const [open, setOpen] = useState(false)
const [menuPosition, setMenuPosition] = useState<CSSProperties>()
const [now, setNow] = useState(() => Date.now())
const [expanded, setExpanded] = useState<ReadonlySet<SessionId>>(() => new Set())
const rootRef = useRef<HTMLDivElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
const hoverOpenTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
const hoverCloseTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
const observedCatalogs = useRef(new Set<SessionId>())
const setCatalogOpenRef = useRef(setCatalogOpen)
setCatalogOpenRef.current = setCatalogOpen
const currentEntry = currentSessionId === undefined
? undefined
: catalog?.entries.find(entry => entry.kind === 'child' && entry.id === currentSessionId)
const switcherDisplayTitle = currentEntry?.kind === 'child'
: catalog?.entries.find(entry => entry.id === currentSessionId)
const switcherDisplayTitle = currentEntry !== undefined
? currentEntry.label ?? currentEntry.id
: displayTitle
const healthy = catalog?.entries.filter(entry => entry.kind === 'child') ?? []
const descendants = useMemo(
() => indexSubagentDescendants(summaries).get(rootSessionId) ?? NO_DESCENDANTS,
[rootSessionId, 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 totalCountKey = descendantCount === 1 ? 'count.total.one' : 'count.total.other'
const runningCountKey = descendants.runningCount === 1 ? 'count.running.one' : 'count.running.other'
// Session summaries can announce membership before the descriptor-backed catalog catches up.
// Keep that entry point visible through disabled loading rows; only catalog rows are navigable.
const summaryBackedLoading = (descendants.count > 0 || variant === 'switcher')
&& (catalog === undefined || (catalog.state === 'ready' && catalog.entries.length === 0))
const presentedCatalog: SubagentCatalogSnapshot | undefined = summaryBackedLoading
? {
entries: [],
parentAvailable: catalog?.parentAvailable ?? false,
state: 'loading',
error: null,
}
: catalog
const observeCatalog = (parentSessionId: SessionId, next: boolean): void => {
if (next) observedCatalogs.current.add(parentSessionId)
else observedCatalogs.current.delete(parentSessionId)
setCatalogOpen(parentSessionId, next)
}
const closeAllCatalogs = (): void => {
for (const parentSessionId of observedCatalogs.current) {
setCatalogOpen(parentSessionId, false)
}
observedCatalogs.current.clear()
setExpanded(new Set())
}
const directChildren = catalog?.entries ?? []
const directCount = directChildren.length
const runningCount = directChildren.filter(entry => entry.activity === 'running').length
const totalCountKey = directCount === 1 ? 'count.total.one' : 'count.total.other'
const runningCountKey = runningCount === 1 ? 'count.running.one' : 'count.running.other'
const presentedCatalog: SubagentCatalogSnapshot | undefined = catalog ?? (variant === 'switcher'
? { entries: [], state: 'loading', error: null }
: undefined)
const cancelHoverClose = (): void => {
if (hoverCloseTimer.current === undefined) return
@@ -564,13 +474,12 @@ function CatalogDropdown({
if (trigger === null) return
setOpen(true)
setMenuPosition(catalogMenuPosition(trigger))
setNow(Date.now())
observeCatalog(rootSessionId, true)
refresh(rootSessionId)
}
else {
setOpen(false)
setMenuPosition(undefined)
closeAllCatalogs()
setExpanded(new Set())
}
if (restoreFocus) queueMicrotask(() => { triggerRef.current?.focus() })
}
@@ -601,11 +510,10 @@ function CatalogDropdown({
closing.add(parentSessionId)
const branch = catalogs[parentSessionId]
for (const entry of branch?.entries ?? []) {
if (entry.kind === 'child') visit(entry.id)
visit(entry.id)
}
}
visit(root)
for (const parentSessionId of closing) observeCatalog(parentSessionId, false)
setExpanded(current => new Set([...current].filter(id => !closing.has(id))))
}
@@ -615,7 +523,7 @@ function CatalogDropdown({
return
}
setExpanded(current => new Set(current).add(childSessionId))
observeCatalog(childSessionId, true)
refresh(childSessionId)
}
useEffect(() => {
@@ -649,37 +557,24 @@ function CatalogDropdown({
}
}, [open])
useEffect(() => {
if (!open || descendants.runningCount === 0) return
const timer = setInterval(() => { setNow(Date.now()) }, 1_000)
return () => { clearInterval(timer) }
}, [open, descendants.runningCount])
useEffect(() => () => {
cancelHoverOpen()
cancelHoverClose()
for (const parentSessionId of observedCatalogs.current) {
setCatalogOpenRef.current(parentSessionId, false)
}
observedCatalogs.current.clear()
}, [])
// Visibility needs evidence of children (entries, summary-known descendants,
// or a failed load worth retrying). A bare loading catalog is not evidence:
// selecting any session schedules a refresh whose loading snapshot would
// otherwise flash the action in and out on childless sessions.
// Visibility needs catalog evidence of children or a failed load worth retrying.
// An empty loading catalog is not evidence of children.
const visible = presentedCatalog !== undefined
&& (variant === 'switcher'
|| presentedCatalog.state === 'error'
|| presentedCatalog.entries.length > 0
|| descendantCount > 0)
|| presentedCatalog.entries.length > 0)
useEffect(() => {
if (visible) return
cancelHoverOpen()
cancelHoverClose()
if (!open) return
setOpen(false)
closeAllCatalogs()
setExpanded(new Set())
}, [visible, open])
if (!visible) return null
@@ -731,8 +626,8 @@ function CatalogDropdown({
aria-label={variant === 'switcher'
? t('switcher.aria', { title: switcherDisplayTitle })
: t(
descendants.runningCount > 0 ? runningCountKey : totalCountKey,
{ count: descendants.runningCount > 0 ? descendants.runningCount : descendantCount },
runningCount > 0 ? runningCountKey : totalCountKey,
{ count: runningCount > 0 ? runningCount : directCount },
)}
onClick={openTitle === undefined
? undefined
@@ -752,12 +647,12 @@ function CatalogDropdown({
? <span className={css.switcherTitle}>{switcherDisplayTitle}</span>
: (
<>
{descendants.runningCount > 0 && (
{runningCount > 0 && (
<span className={css.activitySlot}>
<StateDot state="ongoing" />
</span>
)}
<span className={css.count}>{t(totalCountKey, { count: descendantCount })}</span>
<span className={css.count}>{t(totalCountKey, { count: directCount })}</span>
</>
)}
{variant === 'switcher'
@@ -782,7 +677,6 @@ function CatalogDropdown({
summaries={summaries}
expanded={expanded}
level={1}
now={now}
openChild={openChild}
refresh={refresh}
toggleBranch={toggleBranch}
@@ -798,17 +692,20 @@ function CatalogDropdown({
/**
* Render one breadcrumb title together with its subagent navigation.
* @param props - Breadcrumb title, session standard props, and catalog actions.
* @returns An ordinary-title descendant count, or a title-and-chevron sibling switcher.
* @returns An ordinary-title direct-child count, or a title-and-chevron sibling switcher.
*/
export function SubagentHeaderLineage({
lineageSessionId, displayTitle, openTitle,
useSessions, openChild, refresh, setCatalogOpen, t,
useSessions, openChild, refresh, t,
}: SubagentHeaderLineageProps) {
const parentId = useSessions((state) => {
const summary = state.byId[lineageSessionId]
return summary?.origin === 'subagent' ? summary.parentId : undefined
if (state.currentAddress?.childSessionId === lineageSessionId) return state.currentAddress.parentSessionId
for (const [parentId, snapshot] of Object.entries(state.projectionsBySession)) {
if (snapshot.values.subagentCatalog?.some(entry => entry.id === lineageSessionId)) return parentId as SessionId
}
return undefined
})
const shared = { useSessions, openChild, refresh, setCatalogOpen, t }
const shared = { useSessions, openChild, refresh, t }
if (parentId === undefined) {
return (
<CatalogDropdown
@@ -34,9 +34,8 @@ function selectReadOnlySubagent(owner: ComposerChainProps): SubagentReadOnlyMatc
const subagent = owner.session?.subagent
if (subagent === undefined || subagent === null) return null
if (subagent.address.mode === 'one-shot') return { reason: 'one-shot' }
// The parent catalog is fetched ahead of the selected Session. Until it
// resolves, leave the normal disabled composer in place instead of briefly
// claiming that the parent is offline.
// Until a Host summary establishes parent availability, keep the normal
// disabled composer instead of claiming that the parent is offline.
if (subagent.parentAvailable !== false) return null
// A RUNNING parent-offline continuable child keeps the default composer:
// its input is disabled there, but the same primary Stop stays available so
@@ -56,10 +55,7 @@ export function apply(ctx: ClientContext): void {
sessions.openSubagent(address)
},
refresh(parentSessionId: SessionId) {
void sessions.refreshSubagents(parentSessionId)
},
setCatalogOpen(parentSessionId: SessionId, open: boolean) {
sessions.setSubagentCatalogOpen(parentSessionId, open)
void sessions.refreshProjections(parentSessionId)
},
})
ctx.slots.inject(
@@ -5,9 +5,6 @@ export const NS = 'subagent'
/** Simplified Chinese dictionary (the key-set source of truth). */
export const zh = {
'diagnostic.corrupt': '会话记录损坏',
'diagnostic.unsupported': '子代理记录版本不受支持',
'diagnostic.unavailable': '会话记录暂不可用',
'duration.seconds': '{seconds}秒',
'duration.minutes': '{minutes}分{seconds}秒',
'duration.hours': '{hours}小时{minutes}分{seconds}秒',
@@ -23,7 +20,6 @@ export const zh = {
'tokens.million': '{value}M',
'tokens.total': '{value} tok',
'loading.label': '正在加载子代理…',
'loading.aria': '正在加载子代理',
'load.error': '无法加载子代理',
'retry': '重试',
'mode.oneShot': '一次性',
@@ -46,9 +42,6 @@ export const zh = {
/** English dictionary, key-identical to the Chinese source of truth. */
export const en: Record<SubagentKey, string> = {
'diagnostic.corrupt': 'corrupted session record',
'diagnostic.unsupported': 'unsupported subagent record version',
'diagnostic.unavailable': 'session record temporarily unavailable',
'duration.seconds': '{seconds}s',
'duration.minutes': '{minutes}m {seconds}s',
'duration.hours': '{hours}h {minutes}m {seconds}s',
@@ -64,7 +57,6 @@ export const en: Record<SubagentKey, string> = {
'tokens.million': '{value}M',
'tokens.total': '{value} tok',
'loading.label': 'Loading subagents…',
'loading.aria': 'Loading subagents',
'load.error': 'Unable to load subagents',
'retry': 'Retry',
'mode.oneShot': 'one-shot',
@@ -1,46 +0,0 @@
/** UI Subagent-owned projection of descendant counts from Session summaries. */
import type { SessionId } from '@deepseek-ai/dsh-session/types'
interface LineageEntry {
readonly id: SessionId
readonly parentId?: SessionId
readonly origin?: 'subagent'
readonly running: boolean
}
/** Descendant counts for one possible parent Session. */
export interface SubagentDescendantSummary {
readonly count: number
readonly runningCount: number
}
/* jscpd:ignore-start -- UI Subagent and UI Workspace independently project their own views. */
/**
* Index uninterrupted subagent descendants under each ancestor.
* @param summaries - Session summaries keyed by id.
* @returns descendant totals keyed by possible parent id.
*/
export function indexSubagentDescendants(
summaries: Readonly<Record<SessionId, LineageEntry>>,
): ReadonlyMap<SessionId, SubagentDescendantSummary> {
const indexed = new Map<SessionId, { count: number; runningCount: number }>()
for (const descendant of Object.values(summaries)) {
if (descendant.origin !== 'subagent') continue
const seen = new Set<SessionId>()
let current: LineageEntry | undefined = descendant
while (current?.origin === 'subagent' && current.parentId !== undefined && !seen.has(current.id)) {
seen.add(current.id)
const aggregate = indexed.get(current.parentId)
if (aggregate === undefined) {
indexed.set(current.parentId, { count: 1, runningCount: descendant.running ? 1 : 0 })
} else {
aggregate.count += 1
if (descendant.running) aggregate.runningCount += 1
}
current = summaries[current.parentId]
}
}
return indexed
}
/* jscpd:ignore-end */
@@ -35,22 +35,27 @@ function sessionsWith(sessions: SessionSummary[]) {
for (const s of sessions) byId[s.id] = s
const snapshot = { ids: sessions.map(s => s.id), byId, current: undefined } as unknown as SessionListState
const actionCalls: { method: string; args: unknown[] }[] = []
const address: SubagentAddress = {
parentSessionId: sid('parent'),
childSessionId: sid('c1'),
mode: 'continuable',
}
return {
list: {
getSnapshot: () => snapshot,
subscribe: () => () => {},
},
actionCalls,
subagentAddress: (childSessionId: SessionId) => childSessionId === address.childSessionId
? address
: undefined,
openSubagent: (address: SubagentAddress) => {
actionCalls.push({ method: 'openSubagent', args: [address] })
},
refreshSubagents: (parentSessionId: SessionId) => {
actionCalls.push({ method: 'refreshSubagents', args: [parentSessionId] })
refreshProjections: (parentSessionId: SessionId) => {
actionCalls.push({ method: 'refreshProjections', args: [parentSessionId] })
return Promise.resolve()
},
setSubagentCatalogOpen: (parentSessionId: SessionId, open: boolean) => {
actionCalls.push({ method: 'setSubagentCatalogOpen', args: [parentSessionId, open] })
},
}
}
@@ -105,11 +110,9 @@ describe('apply', () => {
}
actions.openChild(address)
actions.refresh(sid('parent'))
actions.setCatalogOpen(sid('parent'), true)
expect(face.actionCalls).toEqual([
{ method: 'openSubagent', args: [address] },
{ method: 'refreshSubagents', args: [sid('parent')] },
{ method: 'setSubagentCatalogOpen', args: [sid('parent'), true] },
{ method: 'refreshProjections', args: [sid('parent')] },
])
const composerEntry = ctx.slots.entries('conversation.composer')
@@ -3,8 +3,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react'
import { makeTranslate, RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
import type {
SessionListState, SessionSummary, SubagentCatalogSnapshot,
SessionListState, SessionSummary,
} from '@deepseek-ai/dsh-api-session-controller/client'
import type { SubagentAddress, SubagentCatalogRow } from '@deepseek-ai/dsh-subagent/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import {
SubagentHeaderLineage, type SubagentHeaderLineageProps,
@@ -23,18 +24,19 @@ const CHILD = 'child' as SessionId
const GRANDCHILD = 'grandchild' as SessionId
const t: SubagentHeaderLineageProps['t'] = makeTranslate(zh)
function catalog(over: Partial<SubagentCatalogSnapshot> = {}): SubagentCatalogSnapshot {
type CatalogFixture = { entries: readonly SubagentCatalogRow[]; parentAvailable: boolean; state: 'loading' | 'ready' | 'error'; error: SessionListState['projectionsBySession'][SessionId]['error'] }
function catalog(over: Partial<CatalogFixture> = {}): CatalogFixture {
return {
entries: [
{
kind: 'child', id: CHILD, mode: 'continuable', label: 'worker',
activity: 'running', hasChildren: true,
id: CHILD, mode: 'continuable', label: 'worker',
activity: 'running',
},
{
kind: 'child', id: 'child-2' as SessionId, mode: 'one-shot',
label: 'reviewer', activity: 'inactive', hasChildren: false,
id: 'child-2' as SessionId, mode: 'one-shot',
label: 'reviewer', activity: 'inactive',
},
{ kind: 'diagnostic', id: 'bad' as SessionId, reason: 'corrupt' },
],
parentAvailable: true,
state: 'ready',
@@ -44,26 +46,34 @@ function catalog(over: Partial<SubagentCatalogSnapshot> = {}): SubagentCatalogSn
}
function props(
value: SubagentCatalogSnapshot | undefined,
nested: Readonly<Record<SessionId, SubagentCatalogSnapshot>> = {},
value: CatalogFixture | undefined,
nested: Readonly<Record<SessionId, CatalogFixture>> = {},
summaries?: Readonly<Record<SessionId, SessionSummary>>,
currentAddress?: SubagentAddress,
) {
const catalogs = value === undefined ? nested : { [PARENT]: value, ...nested }
const state = {
ids: [CHILD],
byId: summaries ?? {
...Object.fromEntries(Object.values(catalogs).flatMap(catalog => catalog.entries.map(entry => [entry.id, {
id: entry.id, displayTitle: entry.label ?? entry.id, running: entry.activity === 'running', blank: false, updatedAt: 1,
}]))),
[CHILD]: {
id: CHILD,
title: '正在扫描项目文件',
displayTitle: 'worker',
running: true,
running: Object.values(catalogs).some(catalog => catalog.entries.some(entry => entry.id === CHILD && entry.activity === 'running')),
blank: false,
updatedAt: Date.now(),
},
},
current: PARENT, phase: 'ready',
subagentsByParent: value === undefined ? nested : { [PARENT]: value, ...nested },
projectionsBySession: Object.fromEntries(Object.entries(catalogs).map(([id, catalog]) => [id, {
state: catalog.state, error: catalog.error,
values: { subagentCatalog: catalog.entries.map(({ activity: _activity, ...entry }) => ({ ...entry, createdAt: 1 })) },
}])) ,
jobsBySession: {},
currentAddress: undefined,
currentAddress,
} satisfies SessionListState
function useSessions<T>(select: (snapshot: SessionListState) => T): T {
return select(state)
@@ -73,7 +83,6 @@ function props(
useSessions,
openChild: vi.fn(),
refresh: vi.fn(),
setCatalogOpen: vi.fn(),
lineageSessionId: PARENT,
displayTitle: 'Parent title',
t,
@@ -99,10 +108,32 @@ function hoverCatalog(trigger: HTMLElement): void {
}
describe('SubagentHeaderLineage', () => {
it('aggregates live descendant activity onto the closed trigger', () => {
it('shows retained children while an idle projection awaits refresh', () => {
const injected = props(catalog())
const initial = injected.useSessions(state => state)
let projections: SessionListState['projectionsBySession'] = {
[PARENT]: { state: 'idle', error: null, values: {} },
}
const current: SubagentHeaderLineageProps = {
...injected,
useSessions: select => select({ ...initial, projectionsBySession: projections }),
}
const view = render(<SubagentHeaderLineage {...current} />)
expect(screen.queryByRole('button')).toBeNull()
projections = {
[PARENT]: { ...initial.projectionsBySession[PARENT]!, state: 'idle' },
}
view.rerender(<SubagentHeaderLineage {...current} />)
hoverCatalog(screen.getByRole('button'))
expect(screen.getByRole('treeitem', { name: /worker/ })).toBeTruthy()
expect(screen.getByRole('treeitem', { name: /reviewer/ })).toBeTruthy()
})
it('combines projected membership with Session activity on the closed trigger', () => {
const summaries: Record<SessionId, SessionSummary> = {
[CHILD]: {
...summary(CHILD, Date.now()),
...summary(CHILD, Date.now()), running: true,
parentId: PARENT,
origin: 'subagent',
},
@@ -124,17 +155,20 @@ describe('SubagentHeaderLineage', () => {
expect(trigger.querySelector('[data-state="ongoing"]')).not.toBeNull()
view.rerender(<SubagentHeaderLineage {...props(catalog(), {}, {
...summaries,
[GRANDCHILD]: { ...summaries[GRANDCHILD]!, running: false },
...summaries, [CHILD]: { ...summaries[CHILD]!, running: false },
})} />)
const inactiveTrigger = screen.getByRole('button', { name: '3 个子代理' })
const inactiveTrigger = screen.getByRole('button', { name: '2 个子代理' })
expect(inactiveTrigger.querySelector('[data-state="ongoing"]')).toBeNull()
})
it('does not aggregate subagents reached through an ordinary fork', () => {
it('does not count Sessions outside projected membership', () => {
const fork = 'fork' as SessionId
const forkChild = 'fork-child' as SessionId
render(<SubagentHeaderLineage {...props(catalog(), {}, {
render(<SubagentHeaderLineage {...props(catalog({
entries: [{
id: CHILD, mode: 'continuable', label: 'worker', activity: 'inactive',
}],
}), {}, {
[CHILD]: { ...summary(CHILD, 1), parentId: PARENT, origin: 'subagent' },
['child-2' as SessionId]: {
...summary('child-2' as SessionId, 1), parentId: PARENT, origin: 'subagent',
@@ -143,38 +177,35 @@ describe('SubagentHeaderLineage', () => {
[forkChild]: { ...summary(forkChild, 1), parentId: fork, origin: 'subagent', running: true },
})} />)
const trigger = screen.getByRole('button', { name: '2 个子代理' })
const trigger = screen.getByRole('button', { name: '1 个子代理' })
expect(trigger.querySelector('[data-state="ongoing"]')).toBeNull()
})
it('renders healthy counts, stable rows, diagnostics, and catalog-addressed navigation', () => {
it('renders stable rows and catalog-addressed navigation', () => {
const input = props(catalog())
render(<SubagentHeaderLineage {...input} />)
const trigger = screen.getByRole('button', { name: /2 个子代理/ })
const trigger = screen.getByRole('button', { name: /1 个子代理,正在运行/ })
hoverCatalog(trigger)
expect(input.setCatalogOpen).toHaveBeenCalledWith(PARENT, true)
expect(screen.getAllByRole('treeitem')).toHaveLength(3)
expect(input.refresh).toHaveBeenCalledWith(PARENT)
expect(screen.getAllByRole('treeitem')).toHaveLength(2)
expect(screen.getByText('正在扫描项目文件 · 可继续 · 正在运行')).toBeTruthy()
expect(screen.getByText('一次性 · 当前未运行')).toBeTruthy()
const diagnostic = screen.getByRole('treeitem', { name: /会话记录损坏/ })
expect(diagnostic.getAttribute('aria-disabled')).toBe('true')
expect(screen.getByRole('button', { name: '展开 worker 的下级子代理' })).toBeTruthy()
expect(screen.queryByRole('button', { name: '展开 reviewer 的下级子代理' })).toBeNull()
expect(screen.getByRole('treeitem', { name: /reviewer/ }).children).toHaveLength(2)
expect(screen.getByRole('button', { name: '展开 reviewer 的下级子代理' })).toBeTruthy()
fireEvent.click(screen.getByRole('treeitem', { name: /worker/ }))
expect(input.openChild).toHaveBeenCalledWith({
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable',
parentSessionId: PARENT,
childSessionId: CHILD, mode: 'continuable',
})
expect(input.setCatalogOpen).toHaveBeenLastCalledWith(PARENT, false)
})
it('selects singular count keys for one descendant', () => {
it('selects singular count keys for one direct child', () => {
const base = props(catalog({
entries: [{
kind: 'child', id: CHILD, mode: 'continuable', label: 'worker',
activity: 'running', hasChildren: false,
id: CHILD, mode: 'continuable', label: 'worker',
activity: 'running',
}],
}), {}, {
[CHILD]: {
@@ -189,22 +220,26 @@ describe('SubagentHeaderLineage', () => {
})
it('removes the disclosure column from branchless catalog levels', () => {
const input = props(catalog({
entries: [{
kind: 'child', id: CHILD, mode: 'continuable', label: 'worker',
activity: 'running', hasChildren: false,
}],
}))
render(<SubagentHeaderLineage {...input} />)
render(<SubagentHeaderLineage {...props(catalog({
entries: catalog().entries.slice(0, 1),
}), { [CHILD]: catalog({ entries: [] }) })} />)
hoverCatalog(screen.getByRole('button', { name: /1 个子代理/ }))
expect(screen.getByRole('treeitem', { name: /worker/ }).children).toHaveLength(1)
})
it('aligns a known leaf with siblings whose child catalog is still unknown', () => {
render(<SubagentHeaderLineage {...props(catalog(), { [CHILD]: catalog({ entries: [] }) })} />)
hoverCatalog(screen.getByRole('button', { name: /1 个子代理,正在运行/ }))
expect(screen.getByRole('treeitem', { name: /worker/ }).children).toHaveLength(2)
expect(screen.getByRole('button', { name: /展开 reviewer/ })).toBeTruthy()
})
it('supports trigger/menu keyboard traversal, Escape focus restore, and outside close', async () => {
const input = props(catalog())
render(<SubagentHeaderLineage {...input} />)
const trigger = screen.getByRole('button', { name: /2 个子代理/ })
const trigger = screen.getByRole('button', { name: /1 个子代理,正在运行/ })
fireEvent.keyDown(trigger, { key: 'ArrowDown' })
await Promise.resolve()
expect(document.activeElement).toBe(screen.getByRole('treeitem', { name: /worker/ }))
@@ -233,7 +268,7 @@ describe('SubagentHeaderLineage', () => {
await act(async () => { await vi.advanceTimersByTimeAsync(duration) })
}
const view = render(<SubagentHeaderLineage {...props(catalog())} />)
const trigger = screen.getByRole('button', { name: /2 个子代理/ })
const trigger = screen.getByRole('button', { name: /1 个子代理,正在运行/ })
const triggerRect = vi.spyOn(trigger, 'getBoundingClientRect')
.mockReturnValue({ bottom: 40, left: 50 } as DOMRect)
@@ -270,7 +305,7 @@ describe('SubagentHeaderLineage', () => {
it('repositions an open catalog after viewport resize and document scroll', () => {
const view = render(<SubagentHeaderLineage {...props(catalog())} />)
const trigger = screen.getByRole('button', { name: /2 个子代理/ })
const trigger = screen.getByRole('button', { name: /1 个子代理,正在运行/ })
const bounds = vi.spyOn(trigger, 'getBoundingClientRect')
bounds.mockReturnValue({ bottom: 20, left: 30 } as DOMRect)
hoverCatalog(trigger)
@@ -293,7 +328,7 @@ describe('SubagentHeaderLineage', () => {
it('cancels a pending hover when the trigger becomes hidden', async () => {
vi.useFakeTimers()
const view = render(<SubagentHeaderLineage {...props(catalog())} />)
const trigger = screen.getByRole('button', { name: /2 个子代理/ })
const trigger = screen.getByRole('button', { name: /1 个子代理,正在运行/ })
fireEvent.mouseEnter(trigger.parentElement!)
await vi.advanceTimersByTimeAsync(149)
@@ -305,40 +340,36 @@ describe('SubagentHeaderLineage', () => {
expect(screen.queryByRole('tree')).toBeNull()
})
it('covers diagnostic variants, fallback labels, and keyboard row activation', () => {
const unsupported = 'unsupported' as SessionId
const unavailable = 'unavailable' as SessionId
it('covers fallback labels and keyboard row activation', () => {
const unlabeled = 'unlabeled' as SessionId
const input = props(catalog({
entries: [
{ kind: 'diagnostic', id: unsupported, reason: 'unsupported' },
{ kind: 'diagnostic', id: unavailable, reason: 'unavailable' },
{
kind: 'child', id: CHILD, mode: 'continuable', label: 'worker',
activity: 'running', hasChildren: false,
id: CHILD, mode: 'continuable', label: 'worker',
activity: 'running',
},
{
kind: 'child', id: unlabeled, mode: 'one-shot',
activity: 'inactive', hasChildren: false,
id: unlabeled, mode: 'one-shot',
activity: 'inactive',
},
],
}))
render(<SubagentHeaderLineage {...input} />)
const trigger = screen.getByRole('button', { name: /2 个子代理/ })
const trigger = screen.getByRole('button', { name: /1 个子代理,正在运行/ })
fireEvent.keyDown(trigger, { key: 'Tab' })
expect(screen.queryByRole('tree')).toBeNull()
hoverCatalog(trigger)
expect(screen.getByRole('treeitem', { name: /子代理记录版本不受支持/ })).toBeTruthy()
expect(screen.getByRole('treeitem', { name: /会话记录暂不可用/ })).toBeTruthy()
fireEvent.keyDown(screen.getByRole('treeitem', { name: /worker/ }), { key: 'Enter' })
expect(input.openChild).toHaveBeenLastCalledWith({
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable',
parentSessionId: PARENT,
childSessionId: CHILD, mode: 'continuable',
})
hoverCatalog(trigger)
fireEvent.keyDown(screen.getByRole('treeitem', { name: /unlabeled/ }), { key: ' ' })
expect(input.openChild).toHaveBeenLastCalledWith({
parentSessionId: PARENT, childSessionId: unlabeled, mode: 'one-shot',
parentSessionId: PARENT,
childSessionId: unlabeled, mode: 'one-shot',
})
})
@@ -381,12 +412,10 @@ describe('SubagentHeaderLineage', () => {
},
} as const
const entries = rows.map(([id, activity]) => ({
kind: 'child' as const,
id: id as SessionId,
mode: 'continuable' as const,
label: id,
activity,
hasChildren: false,
}))
const summaries = Object.fromEntries(rows.map(([
id, activity, settledMs, activeSince, activeThrough, updatedAt,
@@ -439,12 +468,47 @@ describe('SubagentHeaderLineage', () => {
expect(screen.getByRole('treeitem', { name: /interrupted.*123M tok · 6秒/ })).toBeTruthy()
})
it('ticks expanded running grandchildren under an idle child and releases their clock on collapse', async () => {
const now = 2_000_000_000_000
vi.useFakeTimers()
vi.setSystemTime(now)
const input = props(catalog({ entries: [{
id: CHILD, mode: 'continuable', label: 'worker', activity: 'inactive',
}] }), {
[CHILD]: catalog({ entries: [{
id: GRANDCHILD, mode: 'continuable', label: 'indexer', activity: 'running',
}] }),
}, {
[GRANDCHILD]: {
...summary(GRANDCHILD, now),
parentId: CHILD,
origin: 'subagent',
running: true,
projectionValues: {
subagentTiming: { settledMs: 0, active: { since: now - 5_000, through: now } },
},
},
})
render(<SubagentHeaderLineage {...input} />)
hoverCatalog(screen.getByRole('button', { name: '1 个子代理' }))
const beforeExpand = vi.getTimerCount()
fireEvent.click(screen.getByRole('button', { name: '展开 worker 的下级子代理' }))
expect(screen.getByRole('treeitem', { name: /indexer.*5秒/ })).toBeTruthy()
await act(async () => { await vi.advanceTimersByTimeAsync(1_000) })
expect(screen.getByRole('treeitem', { name: /indexer.*6秒/ })).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: '收起 worker 的下级子代理' }))
expect(vi.getTimerCount()).toBe(beforeExpand)
await act(async () => { await vi.advanceTimersByTimeAsync(1_000) })
fireEvent.click(screen.getByRole('button', { name: '展开 worker 的下级子代理' }))
expect(screen.getByRole('treeitem', { name: /indexer.*7秒/ })).toBeTruthy()
})
it('lazily expands and collapses descendant catalogs with direct-parent navigation', () => {
const childCatalog = catalog({
entries: [
{
kind: 'child', id: GRANDCHILD, mode: 'continuable',
label: 'indexer', activity: 'inactive', hasChildren: false,
id: GRANDCHILD, mode: 'continuable',
label: 'indexer', activity: 'inactive',
},
],
})
@@ -454,24 +518,24 @@ describe('SubagentHeaderLineage', () => {
[GRANDCHILD]: grandchildCatalog,
})
render(<SubagentHeaderLineage {...input} />)
hoverCatalog(screen.getByRole('button', { name: /2 个子代理/ }))
hoverCatalog(screen.getByRole('button', { name: /1 个子代理,正在运行/ }))
fireEvent.click(screen.getByRole('button', { name: '展开 worker 的下级子代理' }))
expect(input.setCatalogOpen).toHaveBeenCalledWith(CHILD, true)
expect(input.refresh).toHaveBeenCalledWith(CHILD)
const nested = screen.getByRole('treeitem', { name: /indexer/ })
expect(nested.getAttribute('aria-level')).toBe('2')
fireEvent.click(nested)
expect(input.openChild).toHaveBeenCalledWith({
parentSessionId: CHILD, childSessionId: GRANDCHILD, mode: 'continuable',
parentSessionId: CHILD,
childSessionId: GRANDCHILD, mode: 'continuable',
})
expect(input.setCatalogOpen).toHaveBeenCalledWith(PARENT, false)
expect(input.setCatalogOpen).toHaveBeenCalledWith(CHILD, false)
})
it('shows known descendant rows while their catalog loads', () => {
it('shows one generic state while a child catalog loads', () => {
const secondGrandchild = 'grandchild-2' as SessionId
const summaries = {
[CHILD]: { ...summary(CHILD, 1), running: true },
[GRANDCHILD]: {
...summary(GRANDCHILD, 1), parentId: CHILD, origin: 'subagent' as const,
},
@@ -482,32 +546,30 @@ describe('SubagentHeaderLineage', () => {
}
const deferred = props(catalog(), {}, summaries)
const view = render(<SubagentHeaderLineage {...deferred} />)
hoverCatalog(screen.getByRole('button', { name: /2 个子代理/ }))
hoverCatalog(screen.getByRole('button', { name: /1 个子代理,正在运行/ }))
fireEvent.click(screen.getByRole('button', { name: '展开 worker 的下级子代理' }))
expect(deferred.setCatalogOpen).toHaveBeenCalledWith(CHILD, true)
expect(deferred.refresh).toHaveBeenCalledWith(CHILD)
expect(screen.getByRole('group').getAttribute('aria-busy')).toBe('true')
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()
expect(screen.getByText('正在加载子代理…')).toBeTruthy()
expect(screen.queryByRole('treeitem', { name: '正在加载子代理' })).toBeNull()
const loading = props(catalog(), {
[CHILD]: catalog({ entries: [], state: 'loading' }),
}, summaries)
view.rerender(<SubagentHeaderLineage {...loading} />)
expect(screen.getAllByRole('treeitem', { name: '正在加载子代理' })).toHaveLength(2)
expect(screen.getByText('正在加载子代理…')).toBeTruthy()
const ready = props(catalog(), {
[CHILD]: catalog({
entries: [
{
kind: 'child', id: GRANDCHILD, mode: 'continuable',
label: 'indexer', activity: 'inactive', hasChildren: false,
id: GRANDCHILD, mode: 'continuable',
label: 'indexer', activity: 'inactive',
},
{
kind: 'child', id: secondGrandchild, mode: 'one-shot',
label: 'critic', activity: 'running', hasChildren: false,
id: secondGrandchild, mode: 'one-shot',
label: 'critic', activity: 'running',
},
],
}),
@@ -516,20 +578,20 @@ describe('SubagentHeaderLineage', () => {
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()
expect(screen.queryByText('正在加载子代理…')).toBeNull()
})
it('uses ArrowRight and ArrowLeft for branch disclosure', async () => {
const input = props(catalog(), {
[CHILD]: catalog({
entries: [{
kind: 'child', id: GRANDCHILD, mode: 'continuable',
label: 'indexer', activity: 'running', hasChildren: false,
id: GRANDCHILD, mode: 'continuable',
label: 'indexer', activity: 'running',
}],
}),
})
render(<SubagentHeaderLineage {...input} />)
const trigger = screen.getByRole('button', { name: /2 个子代理/ })
const trigger = screen.getByRole('button', { name: /1 个子代理,正在运行/ })
fireEvent.keyDown(trigger, { key: 'ArrowDown' })
await Promise.resolve()
const worker = screen.getByRole('treeitem', { name: /worker/ })
@@ -537,29 +599,23 @@ describe('SubagentHeaderLineage', () => {
expect(screen.getByRole('treeitem', { name: /indexer/ })).toBeTruthy()
fireEvent.keyDown(worker, { key: 'ArrowLeft' })
expect(screen.queryByRole('treeitem', { name: /indexer/ })).toBeNull()
expect(input.setCatalogOpen).toHaveBeenCalledWith(CHILD, false)
})
it('closes expanded descendants even when their own catalogs have not arrived', () => {
const input = props(catalog(), {
[CHILD]: catalog({
entries: [
{
kind: 'child', id: GRANDCHILD, mode: 'continuable',
label: 'indexer', activity: 'running', hasChildren: true,
},
{ kind: 'diagnostic', id: 'nested-bad' as SessionId, reason: 'corrupt' },
],
entries: [{
id: GRANDCHILD, mode: 'continuable',
label: 'indexer', activity: 'running',
}],
}),
})
render(<SubagentHeaderLineage {...input} />)
hoverCatalog(screen.getByRole('button', { name: /2 个子代理/ }))
hoverCatalog(screen.getByRole('button', { name: /1 个子代理,正在运行/ }))
fireEvent.click(screen.getByRole('button', { name: '展开 worker 的下级子代理' }))
fireEvent.click(screen.getByRole('button', { name: '展开 indexer 的下级子代理' }))
fireEvent.click(screen.getByRole('button', { name: '收起 worker 的下级子代理' }))
expect(input.setCatalogOpen).toHaveBeenCalledWith(GRANDCHILD, false)
expect(input.setCatalogOpen).toHaveBeenCalledWith(CHILD, false)
expect(screen.queryByRole('treeitem', { name: /indexer/ })).toBeNull()
})
@@ -585,7 +641,7 @@ describe('SubagentHeaderLineage', () => {
expect(failed.refresh).toHaveBeenCalledWith(PARENT)
})
it('keeps known descendants reachable while their catalog is absent or stale-empty', () => {
it('does not expose summary-only descendants as catalog rows', () => {
const second = 'child-2' as SessionId
const summaries = {
[CHILD]: {
@@ -597,19 +653,11 @@ describe('SubagentHeaderLineage', () => {
}
const absent = props(undefined, {}, summaries)
const view = render(<SubagentHeaderLineage {...absent} />)
const trigger = screen.getByRole('button', { name: '1 个子代理,正在运行' })
expect(within(trigger).getByText('2 个子代理')).toBeTruthy()
hoverCatalog(trigger)
expect(absent.setCatalogOpen).toHaveBeenCalledWith(PARENT, true)
expect(screen.getAllByRole('treeitem', { name: '正在加载子代理' })).toHaveLength(2)
expect(absent.openChild).not.toHaveBeenCalled()
expect(screen.queryByRole('button')).toBeNull()
const staleEmpty = props(catalog({ entries: [] }), {}, summaries)
view.rerender(<SubagentHeaderLineage {...staleEmpty} />)
expect(screen.getByRole('button', { name: '1 个子代理,正在运行' })).toBeTruthy()
expect(screen.getAllByRole('treeitem', { name: '正在加载子代理' })).toHaveLength(2)
expect(staleEmpty.openChild).not.toHaveBeenCalled()
expect(screen.queryByRole('button')).toBeNull()
})
it('hides a bare loading catalog and keeps the error fallback without focusable rows', async () => {
@@ -634,7 +682,7 @@ describe('SubagentHeaderLineage', () => {
it('navigates from outside the tree and tolerates a deferred focus after unmount', async () => {
const input = props(catalog())
const view = render(<SubagentHeaderLineage {...input} />)
const trigger = screen.getByRole('button', { name: /2 个子代理/ })
const trigger = screen.getByRole('button', { name: /1 个子代理,正在运行/ })
hoverCatalog(trigger)
fireEvent.keyDown(screen.getByRole('tree'), { key: 'ArrowUp' })
expect(document.activeElement).toBe(screen.getByRole('treeitem', { name: /reviewer/ }))
@@ -647,27 +695,25 @@ describe('SubagentHeaderLineage', () => {
const populated = props(catalog(), {
[CHILD]: catalog({
entries: [{
kind: 'child', id: GRANDCHILD, mode: 'continuable',
label: 'indexer', activity: 'inactive', hasChildren: false,
id: GRANDCHILD, mode: 'continuable',
label: 'indexer', activity: 'inactive',
}],
}),
})
const view = render(<SubagentHeaderLineage {...populated} />)
hoverCatalog(screen.getByRole('button', { name: /2 个子代理/ }))
hoverCatalog(screen.getByRole('button', { name: /1 个子代理,正在运行/ }))
fireEvent.click(screen.getByRole('button', { name: '展开 worker 的下级子代理' }))
const empty = props(catalog({ entries: [] }))
view.rerender(<SubagentHeaderLineage {...empty} />)
expect(screen.queryByRole('button')).toBeNull()
expect(empty.setCatalogOpen).toHaveBeenCalledWith(PARENT, false)
expect(empty.setCatalogOpen).toHaveBeenCalledWith(CHILD, false)
})
it('places a separator before a visible ordinary-session descendant count', () => {
it('places a separator before a visible ordinary-session direct-child count', () => {
const view = render(<SubagentHeaderLineage {...props(catalog())} />)
expect(screen.getByText('/')).toBeTruthy()
expect(screen.getByRole('button', { name: /2 个子代理/ })).toBeTruthy()
expect(screen.getByRole('button', { name: /1 个子代理,正在运行/ })).toBeTruthy()
view.rerender(<SubagentHeaderLineage {...props(catalog({ entries: [] }))} />)
expect(screen.queryByText('/')).toBeNull()
@@ -700,21 +746,37 @@ describe('SubagentHeaderLineage', () => {
expect(switcherIcon?.getAttribute('height')).toBe('16')
hoverCatalog(switcher)
expect(input.setCatalogOpen).toHaveBeenCalledWith(PARENT, true)
expect(input.refresh).toHaveBeenCalledWith(PARENT)
const current = screen.getByRole('treeitem', { name: /worker/ })
expect(current.getAttribute('aria-current')).toBe('true')
expect(within(current).getByText('worker').className).toContain('currentLabel')
fireEvent.click(screen.getByRole('treeitem', { name: /reviewer/ }))
expect(input.openChild).toHaveBeenCalledWith({
parentSessionId: PARENT, childSessionId: sibling, mode: 'one-shot',
parentSessionId: PARENT,
childSessionId: sibling, mode: 'one-shot',
})
})
it('does not derive a subagent address from Session summary lineage', () => {
const input = {
...props(undefined, {}, {
[CHILD]: {
...summary(CHILD, 1), parentId: PARENT, origin: 'subagent' as const,
},
}),
lineageSessionId: CHILD,
displayTitle: 'worker',
}
render(<SubagentHeaderLineage {...input} />)
expect(screen.queryByRole('button', { name: '切换子代理:worker' })).toBeNull()
})
it('falls back to the catalog session id when the selected row has no label', () => {
const input = {
...props(catalog({ entries: [{
kind: 'child', id: CHILD, mode: 'one-shot',
activity: 'inactive', hasChildren: false,
id: CHILD, mode: 'one-shot',
activity: 'inactive',
}] }), {}, {
[CHILD]: {
...summary(CHILD, 1), parentId: PARENT, origin: 'subagent' as const,
@@ -733,8 +795,8 @@ describe('SubagentHeaderLineage', () => {
const input = {
...props(catalog(), {
[CHILD]: catalog({ entries: [{
kind: 'child', id: GRANDCHILD, mode: 'continuable',
label: 'indexer', activity: 'inactive', hasChildren: false,
id: GRANDCHILD, mode: 'continuable',
label: 'indexer', activity: 'inactive',
}] }),
}, {
[CHILD]: {
@@ -777,7 +839,7 @@ describe('SubagentHeaderLineage', () => {
...summary(CHILD, 1), parentId: PARENT, origin: 'subagent' as const,
displayTitle: '正在扫描项目文件',
},
}),
}, { parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable' }),
lineageSessionId: CHILD,
displayTitle: '正在扫描项目文件',
...(openTitle === undefined ? {} : { openTitle }),
@@ -791,8 +853,8 @@ describe('SubagentHeaderLineage', () => {
const input = {
...props(catalog(), {
[CHILD]: catalog({ entries: [{
kind: 'child', id: GRANDCHILD, mode: 'continuable',
label: 'indexer', activity: 'inactive', hasChildren: false,
id: GRANDCHILD, mode: 'continuable',
label: 'indexer', activity: 'inactive',
}] }),
}, {
[CHILD]: {
@@ -810,7 +872,7 @@ describe('SubagentHeaderLineage', () => {
hoverCatalog(screen.getByRole('button', { name: '切换子代理:indexer' }))
expect(input.setCatalogOpen).toHaveBeenCalledWith(CHILD, true)
expect(input.refresh).toHaveBeenCalledWith(CHILD)
const current = screen.getByRole('treeitem', { name: /indexer/ })
expect(current.getAttribute('aria-current')).toBe('true')
expect(within(current).getByText('indexer').className).toContain('currentLabel')
@@ -1,31 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { indexSubagentDescendants } from '../src/client/subagent-lineage.ts'
const sid = (id: string): SessionId => id as SessionId
describe('UI Subagent descendant projection', () => {
it('counts nested running descendants and stops at ordinary forks', () => {
const owner = { id: sid('owner'), running: false }
const child = { id: sid('child'), parentId: owner.id, origin: 'subagent' as const, running: false }
const grandchild = { id: sid('grandchild'), parentId: child.id, origin: 'subagent' as const, running: true }
const fork = { id: sid('fork'), parentId: child.id, running: false }
const forkChild = { id: sid('fork-child'), parentId: fork.id, origin: 'subagent' as const, running: true }
const result = indexSubagentDescendants(Object.fromEntries(
[owner, child, grandchild, fork, forkChild].map(item => [item.id, item]),
))
expect(result.get(owner.id)).toEqual({ count: 2, runningCount: 1 })
expect(result.get(child.id)).toEqual({ count: 1, runningCount: 1 })
expect(result.get(fork.id)).toEqual({ count: 1, runningCount: 1 })
})
it('terminates cycles and retains missing-parent aggregates', () => {
const cycleA = { id: sid('a'), parentId: sid('b'), origin: 'subagent' as const, running: false }
const cycleB = { id: sid('b'), parentId: sid('a'), origin: 'subagent' as const, running: false }
const orphan = { id: sid('orphan'), parentId: sid('missing'), origin: 'subagent' as const, running: true }
const result = indexSubagentDescendants({ [cycleA.id]: cycleA, [cycleB.id]: cycleB, [orphan.id]: orphan })
expect(result.get(cycleA.id)?.count).toBe(2)
expect(result.get(cycleB.id)?.count).toBe(2)
expect(result.get(sid('missing'))).toEqual({ count: 1, runningCount: 1 })
})
})
@@ -26,7 +26,7 @@ const COPY: Record<string, string> = {
function emptySessions() {
const store = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined })
{ ids: [], byId: {}, current: undefined, phase: 'ready', projectionsBySession: {}, jobsBySession: {}, currentAddress: undefined })
return bindSnapshotSelector(store)
}
function emptyWorkspaces() {
@@ -28,7 +28,7 @@ const COPY: Record<string, string> = {
/** Empty global standard-kit hooks (the row reads neither). */
function emptySessions() {
const store = createSnapshotStore<SessionListState>(
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined })
{ ids: [], byId: {}, current: undefined, phase: 'ready', projectionsBySession: {}, jobsBySession: {}, currentAddress: undefined })
return bindSnapshotSelector(store)
}
function emptyWorkspaces() {
@@ -29,7 +29,7 @@ function listStore() {
},
current: undefined,
phase: 'ready',
subagentsByParent: {}, jobsBySession: {},
projectionsBySession: {}, jobsBySession: {},
currentAddress: undefined,
})
}
@@ -203,7 +203,7 @@ describe('FileMutationRow diff card', () => {
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd: '/w/app' } },
current: SID,
phase: 'ready',
subagentsByParent: {}, jobsBySession: {},
projectionsBySession: {}, jobsBySession: {},
currentAddress: undefined,
})
@@ -269,7 +269,7 @@ describe('ReadImageRow keyed toolview', () => {
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd: '/w/app' } },
current: SID,
phase: 'ready',
subagentsByParent: {}, jobsBySession: {},
projectionsBySession: {}, jobsBySession: {},
currentAddress: undefined,
} as unknown as SessionListState)
@@ -207,7 +207,7 @@ describe('ReadRow keyed toolview', () => {
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd: '/w/app' } },
current: SID,
phase: 'ready',
subagentsByParent: {}, jobsBySession: {},
projectionsBySession: {}, jobsBySession: {},
currentAddress: undefined,
})
Loaded 100 of 208 files, more files were not shown because too many files have changed in this diff. Show more