diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml index 9a53b740a4..24c362151f 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md -2026-07-27-web-subagent-conversations.md: 400931ba38ac0b3c148d0554ae01d7e8089c2714 -2026-07-27-web-subagent-conversations.zh.md: c8dd8acca8ff9c33148e1b34b9d28fc9158e83dd +2026-07-27-web-subagent-conversations.md: c84e9df6f9d25f99d798651bac4af15249af2bb7 +2026-07-27-web-subagent-conversations.zh.md: 95ba12138b5db595bf1fd844e13741ca2471444d diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md index 400931ba38..c84e9df6f9 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md @@ -29,7 +29,7 @@ The Figma [subagent list](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Ha | The session header shows a subagent count and opens a compact list. | The header action shows the direct durable catalog, including healthy entries and explicit diagnostic rows. | | Selecting a row opens a child with ordinary conversation chrome, title, transcript, and composer. | The child reuses the conversation UI, but history and input route through the addressed subagent RPCs. The composer sends human follow-ups only while the exact parent Agent is live and otherwise explains the read-only state. | | Nested agents can be explored progressively. | Expanding a row loads only that child's direct catalog and inserts it as the next tree level. The client never materializes an eager recursive catalog. | -| Rows show labels, activity dots, and relative time, while the sidebar omits the old subagent tree. | Labels and coarse `running` or `inactive` activity come from the catalog. An optional log-backed title and relative last-activity time come from the ordinary session summary; they are not Activation outcome or duration. Complete sidebar de-duplication remains deferred. | +| Rows show labels, activity dots, and relative time, while the sidebar omits duplicate subagent rows. | Labels and coarse `running` or `inactive` activity come from the catalog. An optional log-backed title and relative last-activity time come from the ordinary session summary; they are not Activation outcome or duration. A durable coarse `SessionHeader.origin` classification removes duplicate subagent rows without hiding ordinary forks. | ## Product contract @@ -61,7 +61,7 @@ The adapter belongs in `dsh-host-apiproxy`, which owns the channel-independent c ## Client object layer and presentation -The React-free client runtime will own durable catalog snapshots, in-flight refreshes, subagent addresses, and prompt/history routing. Opening a catalog child records its `{ parentSessionId, childSessionId }` address before opening the resident `Session`; that Session uses `subagent.history` and `subagent.prompt`, while ordinary sessions keep their existing transport. A child address discovered from the catalog is the only browser fact that selects this route; `parentId` alone is insufficient because ordinary forks share the same lineage field. +The React-free client runtime will own durable catalog snapshots, in-flight refreshes, subagent addresses, and prompt/history routing. Opening a catalog child records its `{ parentSessionId, childSessionId }` address before opening the resident `Session`; that Session uses `subagent.history` and `subagent.prompt`, while ordinary sessions keep their existing transport. Re-selecting the same child through an ordinary selection path retains the known address so navigation cannot silently switch transports. A child address discovered from the catalog is the only browser fact that selects this route; `parentId` or `origin` alone is insufficient because ordinary forks share the lineage field and origin is only a presentation classifier. Catalog data is projected through the existing sessions snapshot consumed by `useSessions`, rather than placed in a component store or exposed through a feature-defined hook. The tree reads ordinary session summaries from the same snapshot for optional title and last-activity presentation. While the root or an expanded descendant catalog is open, its consumer attaches to the existing host-frame fan-out, as the workspaces manager already does: a `host/session-status` frame naming a listed child flips that child's `running`/`inactive` activity in place through the same optimistic mutation path as ordinary session `running`, with no `subagent.list` refetch. A `host/session-added` frame whose parent matches an open branch triggers one debounced, single-flight `subagent.list` refetch to admit the new member and its label and descriptor. Component-local state owns dropdown visibility, expanded branch ids, and keyboard focus. @@ -91,7 +91,7 @@ The default Web composition will mount the model-facing `send_message` and `list **Build an eager recursive tree.** Rejected because `listChildren()` is direct and can scan every candidate log. The presentation composes a recursive tree from lazy direct-child queries, preserving each catalog's ordering and diagnostic semantics without multiplying work across an unseen hierarchy. -**Remove every subagent from the sidebar session tree.** Deferred because `session.list` exposes lineage but not continuable identity, and the catalog is parent-addressed rather than a cheap global classifier. Hiding all child sessions would also hide ordinary forks. The header becomes the authoritative subagent entry point, while complete sidebar de-duplication awaits a scalable durable projection. +**Infer sidebar filtering from lineage or a global catalog scan.** Rejected because ordinary forks share `parentSession`, while a global catalog scan is parent-addressed and too expensive for a navigation classifier. Every in-process subagent-backed session instead stamps `SessionHeader.origin: 'subagent'` before publication; `session.list` and `host/session-added` project it to the client, and the shared sidebar filter omits only those rows. The header catalog remains the navigation entry point and descriptor authority; `origin` never proves lifecycle mode, resumability, or authorization. **Push catalog changes as a dedicated server stream.** Deferred in favor of reusing the existing `host/session-status` and `host/session-added` fan-out. A `subagent.catalog` delta frame would make membership and diagnostics fully live without any refetch, but it is a new host wire contract and a real-time projection over the durable catalog — exactly the derived index the [durable catalog](../../implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md) leaves to measured scale. The first version flips activity from the existing liveness frame and refetches only on membership change. @@ -106,6 +106,7 @@ The default Web composition will mount the model-facing `send_message` and `list - A listed `running` child settling to `inactive` updates its activity in place from the live frame stream without a `subagent.list` refetch; a newly created direct child appears after one debounced refetch. - A child whose parent is absent remains readable and rejects input without auto-resuming the parent. No child history, prompt, or stop gesture calls the ordinary Agent APIs. - Refresh and reconnect rebuild an addressed child through the subagent history path without duplicating events or losing events emitted across cold-resume publication. +- Both grouped and flat sidebars omit `origin: 'subagent'` rows, including the current child, while ordinary fork rows remain visible; the same-child ordinary selection path retains the catalog-derived address and therefore keeps subagent history/prompt routing. - Host protocol tests pin schemas, id echoing, direct-parent validation, non-activating history, live-parent enforcement, error mapping, and inbox-message acknowledgement. Client object tests pin catalog/address state and transport selection; jsdom tests pin the header tree, lazy nested disclosure, diagnostics, enabled/read-only composer states, keyboard behavior, and draft restoration. - A keyless assembled Web snapshot demonstrates a settled continuable child plus a descriptor-bearing persisted grandchild, progressive catalog expansion without Activation, opening from persistence, and accepting one human follow-up into a cold-resumed Activation's inbox. @@ -116,5 +117,5 @@ The default Web composition will mount the model-facing `send_message` and `list - Parent availability and child activity are process-local snapshots. Publication, Activation disposal, another sender, or another process may win after listing; explicit prompt failure remains normal behavior rather than an invariant violation. - A child Activation may publish between history fetch and mux subscription. The existing sequence reconciliation must be proven against this cold-to-live, subagent-specific open path. - Switching the default Web delegation tools to continuable background mode changes the model-visible acknowledgement and durability requirements for `run_in_background`; snapshot coverage must land with the assembly change. -- Keeping subagents in the sidebar temporarily creates two navigation paths. Removing the duplicate safely requires a later durable classification or global projection rather than presentation-only filtering. +- Persisted subagent origin adds one coarse product-classification field to every local child header and its list/increment projections. It is intentionally weaker than the descriptor and addressed continuation contracts, so navigation de-duplication cannot become an authorization shortcut. - The feature has no correct cancellation button, durable outcome, Activation duration, deletion, pagination for the catalog, or independently interactive offline child. The UI must not imply those capabilities; its relative time is only the session summary's last-activity hint. diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md index c8dd8acca8..95ba12138b 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md @@ -29,7 +29,7 @@ Figma 中的 [subagent 列表](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5 | 会话页头显示 subagent 数量,并可打开紧凑列表。 | 页头操作显示直接持久化目录,其中包括健康条目和显式 diagnostic 行。 | | 选择条目后,系统会使用普通对话 chrome、标题、transcript 与输入框打开 child。 | child 复用对话 UI,但历史和输入通过已寻址的 subagent RPC 路由。只有确切的 parent Agent 存活时,输入框才发送用户后续消息;否则会说明当前为只读状态。 | | 用户可以逐层浏览嵌套 agent。 | 展开某一行时,只加载该 child 的直接目录,并将其插入为下一层树节点。客户端绝不会物化预先加载的递归目录。 | -| 条目显示 label、活动状态点与相对时间,侧边栏则移除原有 subagent 树。 | label 与粗粒度的 `running` 或 `inactive` 活动状态来自目录。由日志支撑的可选 title 与相对最近活动时间来自普通会话摘要;它们不是 Activation 结果或耗时。侧边栏完全去重仍然暂缓。 | +| 条目显示 label、活动状态点与相对时间,侧边栏则省略重复的 subagent 行。 | label 与粗粒度的 `running` 或 `inactive` 活动状态来自目录。由日志支撑的可选 title 与相对最近活动时间来自普通会话摘要;它们不是 Activation 结果或耗时。持久化的粗粒度 `SessionHeader.origin` 分类会移除重复的 subagent 行,同时不会隐藏普通 fork。 | ## 产品契约 @@ -61,7 +61,7 @@ mux 仍然是实时事件路径。仅查看持久化 child 的历史不会产生 ## 客户端对象层与呈现 -不依赖 React 的客户端运行时将负责持久化目录快照、进行中的刷新、subagent 地址及提示词/历史路由。打开目录中的 child 时,系统会先记录其 `{ parentSessionId, childSessionId }` 地址,再打开常驻的 `Session`;该 Session 使用 `subagent.history` 与 `subagent.prompt`,普通会话则保持现有传输路径。只有从目录中发现的 child 地址,才能作为浏览器选择这条路由的事实;单凭 `parentId` 不足以判断,因为普通 fork 也使用同一个谱系字段。 +不依赖 React 的客户端运行时将负责持久化目录快照、进行中的刷新、subagent 地址及提示词/历史路由。打开目录中的 child 时,系统会先记录其 `{ parentSessionId, childSessionId }` 地址,再打开常驻的 `Session`;该 Session 使用 `subagent.history` 与 `subagent.prompt`,普通会话则保持现有传输路径。通过普通选择路径再次选择同一 child 时,会保留已知地址,避免导航操作静默切换传输。只有从目录中发现的 child 地址,才能作为浏览器选择这条路由的事实;单凭 `parentId` 或 `origin` 都不足以判断,因为普通 fork 也使用同一个谱系字段,而 origin 只是呈现分类器。 目录数据通过 `useSessions` 消费的现有会话快照投影,而不会放入组件 store,也不会通过功能自定义钩子公开。树还从同一份快照读取普通会话摘要,用于显示可选 title 与最近活动时间。根目录或某个已展开后代的目录打开期间,其消费方会像 workspaces manager 那样挂到现有的宿主帧分发上:命中某个已列出 child 的 `host/session-status` 帧会通过与普通会话 `running` 相同的乐观 mutation 路径就地翻转该 child 的 `running`/`inactive` 活动状态,且不重新拉取 `subagent.list`。parent 与某个已打开分支匹配的 `host/session-added` 帧会触发一次去抖动、单次并发(single-flight)的 `subagent.list` 重新拉取,以纳入新成员及其 label 与描述符。组件局部状态负责下拉菜单可见性、已展开分支 id 与键盘焦点。 @@ -91,7 +91,7 @@ mux 仍然是实时事件路径。仅查看持久化 child 的历史不会产生 **构建预先加载的递归树。** 不予采纳,因为 `listChildren()` 只查询直接 child,而且可能扫描每份候选日志。界面通过懒加载的直接 child 查询组合出递归树,既保留每份目录的排序与 diagnostic 语义,又不会在用户看不到的层级中成倍增加工作量。 -**从侧边栏会话树中移除所有 subagent。** 暂缓,因为 `session.list` 公开谱系,却不公开可继续身份;目录又按 parent 寻址,而不是一种低成本的全局分类器。隐藏所有 child 会话还会隐藏普通 fork。页头将成为权威的 subagent 入口;侧边栏要完全去重,仍需等待可扩展的持久化投影。 +**从谱系推断侧边栏过滤,或扫描全局目录。** 不予采纳,因为普通 fork 共享 `parentSession`,而全局目录扫描按 parent 寻址,用作导航分类器成本过高。每个由进程内 subagent 支撑的会话会在发布前写入 `SessionHeader.origin: 'subagent'`;`session.list` 与 `host/session-added` 将其投影到客户端,共享侧边栏过滤器只省略这些行。页头目录仍然是导航入口与描述符权威来源;`origin` 绝不证明生命周期 mode、可恢复性或授权。 **将目录变化作为专用服务端流推送。** 暂缓,转而复用现有的 `host/session-status` 与 `host/session-added` 分发。`subagent.catalog` 增量帧能让成员与 diagnostic 完全实时而无需任何重新拉取,但它是一项新的宿主协议契约,也是在持久化目录之上的实时投影——恰恰是[持久化目录](../../implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)留待规模验证的派生索引。第一版从现有存活帧翻转活动状态,只在成员变化时重新拉取。 @@ -106,6 +106,7 @@ mux 仍然是实时事件路径。仅查看持久化 child 的历史不会产生 - 已列出的 `running` child 结算为 `inactive` 时,其活动状态会从实时帧流就地更新,而不重新拉取 `subagent.list`;新创建的直接 child 会在一次去抖动的重新拉取后出现。 - parent 缺失时,child 仍然可读并拒绝输入,且不会自动恢复 parent。任何 child 历史、提示词或停止操作都不会调用普通 agent API。 - 刷新和重新连接会通过 subagent 历史路径重建已寻址的 child,不重复事件,也不会丢失 cold resume 发布前后产生的事件。 +- 分组与扁平侧边栏都会省略 `origin: 'subagent'` 行,包括当前 child;普通 fork 行仍然可见。同一 child 的普通选择路径会保留目录派生地址,因此继续使用 subagent 历史/提示词路由。 - 宿主协议测试固定 schema、id 回显、直接 parent 校验、非激活式历史、存活 parent 强制要求、错误映射以及 inbox 消息确认。客户端对象测试固定目录/地址状态与传输选择;jsdom 测试固定页头树、懒加载式嵌套展开、diagnostic、启用/只读输入框状态、键盘行为与草稿恢复。 - 一项无密钥的组装 Web 快照展示已结算的可继续 child 与带描述符的已持久化 grandchild、在不物化 Activation 的情况下逐层展开目录、从持久化存储打开,以及让 cold-resume Activation 的 inbox 接受一次用户后续消息。 @@ -116,5 +117,5 @@ mux 仍然是实时事件路径。仅查看持久化 child 的历史不会产生 - parent 可用性与 child 活动状态都是进程局部快照。列出之后,发布、Activation dispose、其他发送方或其他进程都可能抢先改变状态;明确的提示词失败属于正常行为,不是违反不变量。 - child Activation 可能在历史获取与 mux 订阅之间发布。现有序号归并必须针对这条从冷态转为存活的 subagent 专用打开路径得到验证。 - 将默认 Web 委派工具切换为可继续后台模式,会改变 `run_in_background` 面向模型的确认消息与持久性要求;快照覆盖必须与组合变更一同落地。 -- 暂时在侧边栏中保留 subagent 会产生两条导航路径。安全移除重复入口需要后续的持久化分类或全局投影,不能只在呈现层过滤。 +- 持久化 subagent origin 会给每个本地 child header 及其列表/增量投影增加一个粗粒度产品分类字段。它刻意弱于描述符与已寻址继续执行契约,因此导航去重不能变成授权捷径。 - 该功能没有正确的取消按钮、持久化结果、Activation 耗时、删除、目录分页或可独立交互的离线 child。UI 不得暗示这些功能已经存在;其中的相对时间仅表示会话摘要给出的最近活动提示。 diff --git a/apps/web/tests/snapshots/subagent-conversation/sidebar.expected.md b/apps/web/tests/snapshots/subagent-conversation/sidebar.expected.md new file mode 100644 index 0000000000..934cc4a210 --- /dev/null +++ b/apps/web/tests/snapshots/subagent-conversation/sidebar.expected.md @@ -0,0 +1,5 @@ +- tree "Sessions": + - treeitem "workspace 1 session" [expanded]: + - img + - text: workspace 1 session + - treeitem "Ask a research subagent to now" diff --git a/apps/web/tests/subagent-conversation.e2e.ts b/apps/web/tests/subagent-conversation.e2e.ts index 1f4f1de160..f4dedd098c 100644 --- a/apps/web/tests/subagent-conversation.e2e.ts +++ b/apps/web/tests/subagent-conversation.e2e.ts @@ -19,6 +19,7 @@ import { connectFreshWorkspace, saveFailureShot } from './support.ts' const BASE_FIXTURE = fileURLToPath(new URL('./snapshots/live-interactions/session.jsonl', import.meta.url)) const UI_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/ui.expected.md', import.meta.url)) const TREE_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/tree.expected.md', import.meta.url)) +const SIDEBAR_EXPECTED = fileURLToPath(new URL('./snapshots/subagent-conversation/sidebar.expected.md', import.meta.url)) const MODE = webSnapshotMode() const LABEL = 'event-sourcing researcher' const NESTED_LABEL = 'example editor' @@ -184,6 +185,12 @@ describe('web e2e: persisted subagent conversation and human continuation', () = throw new Error(`viewing the child activated it; API calls: ${apiCalls.join(', ')}`) } await page.getByRole('heading', { name: LABEL }).waitFor() + const sidebar = await captureStableAria( + page, + '[role="tree"][aria-label="Sessions"]', + scaffold.workspaceCwd, + ) + await compareOrRefreshGolden(SIDEBAR_EXPECTED, sidebar, MODE) }) it('continues through a cold-resumed Activation and receives the child mux events', async () => { diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 73e167cf8b..e47a2bcf65 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -216,7 +216,7 @@ roots(): Agent[] Types: [Agent](../core-data-structures/core.md) · [SessionId](../core-data-structures/core.md) -Source: [`packages/core/agent/src/index.ts:216`](../../packages/core/agent/src/index.ts) +Source: [`packages/core/agent/src/index.ts:217`](../../packages/core/agent/src/index.ts) ## `ctx.approval` — `ApprovalService` @@ -1673,7 +1673,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [Session](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md) -Source: [`packages/core/session/src/index.ts:764`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:767`](../../packages/core/session/src/index.ts) ## `ctx.sessionTitle` — `SessionTitleService` diff --git a/docs/core-data-structures/persistence.i18n.yaml b/docs/core-data-structures/persistence.i18n.yaml index 87b7587d30..9f42f7dab9 100644 --- a/docs/core-data-structures/persistence.i18n.yaml +++ b/docs/core-data-structures/persistence.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/persistence.md -persistence.md: 0280093883b9fbd4c4bbc788bce7193d59bb7182 -persistence.zh.md: d32274bbc2e96e3754a8061ba37427e34b6205f6 +persistence.md: 237ce268691fa6f4b0546c6c1c14c21eaa462612 +persistence.zh.md: 9cb555f2e157844704896d9b04feb822428540e5 diff --git a/docs/core-data-structures/persistence.md b/docs/core-data-structures/persistence.md index 0280093883..237ce26869 100644 --- a/docs/core-data-structures/persistence.md +++ b/docs/core-data-structures/persistence.md @@ -66,6 +66,11 @@ interface SessionHeader { * boundary lets resume and replay distinguish parent history from child work. */ readonly seedLength?: number + /** + * Coarse product classification for a session created as a subagent child. + * This is presentation metadata, not proof that the child is continuable. + */ + readonly origin?: 'subagent' /** * Delegation depth: absent (zero) for a top-level session, parent depth + 1 * for a subagent child. Persisted so a recursion budget survives restart and @@ -77,7 +82,7 @@ interface SessionHeader { ## `CreateSessionOptions` — seeding and metadata -Creating a `Session` through the store takes a `seed` (initial replay or fork history) and `meta` (the storage-level fields the store folds into a `SessionHeader`). The store fills in `version`/`id` and defaults `createdAt`; the caller supplies the validated absolute `cwd`, the `parentSession` lineage, the `seedLength` seed boundary, the `delegationDepth`, and — only when reconstructing a persisted session — the original `createdAt` to preserve it. +Creating a `Session` through the store takes a `seed` (initial replay or fork history) and `meta` (the storage-level fields the store folds into a `SessionHeader`). The store fills in `version`/`id` and defaults `createdAt`; the caller supplies the validated absolute `cwd`, the `parentSession` lineage, the `seedLength` seed boundary, the optional coarse `origin`, the `delegationDepth`, and — only when reconstructing a persisted session — the original `createdAt` to preserve it. `origin: 'subagent'` lets product navigation hide duplicate child rows; it does not prove that a descriptor is valid or that the child can resume. ```ts type-equiv /** @@ -97,6 +102,7 @@ interface CreateSessionOptions { readonly parentSession?: SessionId readonly createdAt?: number readonly seedLength?: number + readonly origin?: 'subagent' readonly delegationDepth?: number } } diff --git a/docs/core-data-structures/persistence.zh.md b/docs/core-data-structures/persistence.zh.md index d32274bbc2..9cb555f2e1 100644 --- a/docs/core-data-structures/persistence.zh.md +++ b/docs/core-data-structures/persistence.zh.md @@ -66,6 +66,11 @@ interface SessionHeader { * boundary lets resume and replay distinguish parent history from child work. */ readonly seedLength?: number + /** + * Coarse product classification for a session created as a subagent child. + * This is presentation metadata, not proof that the child is continuable. + */ + readonly origin?: 'subagent' /** * Delegation depth: absent (zero) for a top-level session, parent depth + 1 * for a subagent child. Persisted so a recursion budget survives restart and @@ -77,7 +82,7 @@ interface SessionHeader { ## `CreateSessionOptions`:seed 与元数据 -通过 store 创建 `Session` 时会接收 `seed`(初始回放或 fork 历史)与 `meta`(store 折叠进 `SessionHeader` 的存储层字段)。store 填充 `version`/`id` 并为 `createdAt` 提供默认值;调用方提供已校验的绝对 `cwd`、`parentSession` 谱系、`seedLength` 种子边界、`delegationDepth`,以及——仅在重建已持久化会话时——需要保留的原始 `createdAt`。 +通过 store 创建 `Session` 时会接收 `seed`(初始回放或 fork 历史)与 `meta`(store 折叠进 `SessionHeader` 的存储层字段)。store 填充 `version`/`id` 并为 `createdAt` 提供默认值;调用方提供已校验的绝对 `cwd`、`parentSession` 谱系、`seedLength` 种子边界、可选的粗粒度 `origin`、`delegationDepth`,以及——仅在重建已持久化会话时——需要保留的原始 `createdAt`。`origin: 'subagent'` 让产品导航能够隐藏重复的 child 行;它不证明描述符有效,也不证明 child 可以恢复。 ```ts type-equiv /** @@ -97,6 +102,7 @@ interface CreateSessionOptions { readonly parentSession?: SessionId readonly createdAt?: number readonly seedLength?: number + readonly origin?: 'subagent' readonly delegationDepth?: number } } diff --git a/docs/core-data-structures/subagent.i18n.yaml b/docs/core-data-structures/subagent.i18n.yaml index f68f28bfe4..3f95ba136e 100644 --- a/docs/core-data-structures/subagent.i18n.yaml +++ b/docs/core-data-structures/subagent.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/subagent.md -subagent.md: 08270a0487402d29e12bb1fc2687b29903f5bd6b -subagent.zh.md: aade7a2edc13a6ec694634cee9dcb47cfb9e6bea +subagent.md: 4d389c106133114781ef0a3128032433fd0ed7ec +subagent.zh.md: cb374d03bbd9716ee53a1cecf938ff1dc1f8ce66 diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index 08270a0487..4d389c1061 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -220,7 +220,7 @@ A local one-shot provider appends the descriptor inside the child's initial turn ## Durable enumeration: `listChildren()` and `SubagentListEntry` -`SubagentService.listChildren(parentSessionId)` enumerates the parent's direct session-backed subagents from one `ctx.sessionQuery.traceSession()` observation, without loading or resuming any Agent. Session lineage is broader than subagent identity — ordinary forks share `parentSession` — so exactly one supported `subagent/descriptor` event in the child's own suffix (after `seedLength`, so a fork seed cannot leak an ancestor's descriptor) is the sole subagent discriminator. The result is one `SubagentListEntry[]` in the trace's `createdAt`-then-id candidate order: a valid descriptor 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 per-child inspection failure yields a `diagnostic` entry (`corrupt`, `unsupported`, or `unavailable`) so one damaged sibling cannot hide healthy children; a missing descriptor yields no entry. Activity snapshots only whether the logical record is live in `ctx.sessions`, not outcome or resumability. 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 maps activity to its existing `running`/`complete` vocabulary. A failure while building the initial trace fails the whole call — per-child isolation begins only after a trustworthy candidate set exists. The service keeps `sessionQuery` optional for by-id continuation: `listChildren()` throws `SubagentError` with code `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` when it is absent, while the list tool requires `ctx.subagents` and `ctx.sessionQuery` at plugin load. 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. +`SubagentService.listChildren(parentSessionId)` enumerates the parent's direct session-backed subagents from one `ctx.sessionQuery.traceSession()` observation, without loading or resuming any Agent. Session lineage is broader than subagent identity — ordinary forks share `parentSession` — so exactly one supported `subagent/descriptor` event in the child's own suffix (after `seedLength`, so a fork seed cannot leak an ancestor's descriptor) is the sole subagent discriminator. `SessionHeader.origin: 'subagent'` is only a coarse product-navigation classifier stamped before publication; it can suppress duplicate sidebar rows but cannot establish a valid descriptor, resumability, or authorization. The result is one `SubagentListEntry[]` in the trace's `createdAt`-then-id candidate order: a valid descriptor 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 per-child inspection failure yields a `diagnostic` entry (`corrupt`, `unsupported`, or `unavailable`) so one damaged sibling cannot hide healthy children; a missing descriptor yields no entry. Activity snapshots only whether the logical record is live in `ctx.sessions`, not outcome or resumability. 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 maps activity to its existing `running`/`complete` vocabulary. A failure while building the initial trace fails the whole call — per-child isolation begins only after a trustworthy candidate set exists. The service keeps `sessionQuery` optional for by-id continuation: `listChildren()` throws `SubagentError` with code `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` when it is absent, while the list tool requires `ctx.subagents` and `ctx.sessionQuery` at plugin load. 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 terminal result: `SubagentResult` diff --git a/docs/core-data-structures/subagent.zh.md b/docs/core-data-structures/subagent.zh.md index aade7a2edc..cb374d03bb 100644 --- a/docs/core-data-structures/subagent.zh.md +++ b/docs/core-data-structures/subagent.zh.md @@ -220,7 +220,7 @@ interface ContinuableCreateSpec { ## 持久化枚举:`listChildren()` 与 `SubagentListEntry` -`SubagentService.listChildren(parentSessionId)` 从一次 `ctx.sessionQuery.traceSession()` 观测中枚举 parent 直接且由会话支撑的 subagent,而不会加载或恢复任何 Agent。会话谱系的范围比 subagent 身份更广——普通 fork 也会共享 `parentSession`——因此,child 自身后缀中恰好一个受支持的 `subagent/descriptor` 事件(位于 `seedLength` 之后,避免 fork seed 泄漏祖先描述符)是唯一的 subagent 判别信息。结果是一个按追踪结果中 `createdAt`、再按 id 排列候选顺序的 `SubagentListEntry[]`:有效描述符生成带有 `mode: 'one-shot' | 'continuable'` 和 `activity: 'running' | 'inactive'` 的 `child` 条目;可继续条目始终携带 `label`,一次性条目则只在启动调用方提供展示元数据时携带该字段。逐 child 检查失败生成 `diagnostic` 条目(`corrupt`、`unsupported` 或 `unavailable`),因此一个损坏的 sibling 不会隐藏健康 child;缺少描述符则不生成条目。活动状态只表示逻辑记录是否在 `ctx.sessions` 中存活,而不表示结果或可恢复性。UI 等服务消费方可以展示两种模式,并为无标签的一次性 child 选择回退展示;面向模型的 `list_agents` 适配器([dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control) 中可单独加载的 `/list-agents` 插件)则只保留可继续条目,并将活动状态映射到现有的 `running`/`complete` 词汇。构建初始追踪时的失败会让整个调用失败——只有得到可信候选集后才开始逐 child 隔离。服务将 `sessionQuery` 保持为按 id 继续执行时的可选依赖:缺少该服务时,`listChildren()` 抛出 `SubagentError`,并携带错误码 `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE`;列表工具则在插件加载时要求 `ctx.subagents` 与 `ctx.sessionQuery`。枚举不会查询继续执行管理器的 Activation map、Agent 注册表或提供方可用性;`send_message` 仍是消息送达时的权威操作,列表中的运行中可继续 child 仍可能因所有权冲突而拒绝投递。 +`SubagentService.listChildren(parentSessionId)` 从一次 `ctx.sessionQuery.traceSession()` 观测中枚举 parent 直接且由会话支撑的 subagent,而不会加载或恢复任何 Agent。会话谱系的范围比 subagent 身份更广——普通 fork 也会共享 `parentSession`——因此,child 自身后缀中恰好一个受支持的 `subagent/descriptor` 事件(位于 `seedLength` 之后,避免 fork seed 泄漏祖先描述符)是唯一的 subagent 判别信息。`SessionHeader.origin: 'subagent'` 只是在发布前写入的粗粒度产品导航分类器;它可以隐藏重复的侧边栏行,却不能证明描述符有效、child 可恢复或操作已获授权。结果是一个按追踪结果中 `createdAt`、再按 id 排列候选顺序的 `SubagentListEntry[]`:有效描述符生成带有 `mode: 'one-shot' | 'continuable'` 和 `activity: 'running' | 'inactive'` 的 `child` 条目;可继续条目始终携带 `label`,一次性条目则只在启动调用方提供展示元数据时携带该字段。逐 child 检查失败生成 `diagnostic` 条目(`corrupt`、`unsupported` 或 `unavailable`),因此一个损坏的 sibling 不会隐藏健康 child;缺少描述符则不生成条目。活动状态只表示逻辑记录是否在 `ctx.sessions` 中存活,而不表示结果或可恢复性。UI 等服务消费方可以展示两种模式,并为无标签的一次性 child 选择回退展示;面向模型的 `list_agents` 适配器([dsh-tool-subagent-control](../../packages/subagent/tool-subagent-control) 中可单独加载的 `/list-agents` 插件)则只保留可继续条目,并将活动状态映射到现有的 `running`/`complete` 词汇。构建初始追踪时的失败会让整个调用失败——只有得到可信候选集后才开始逐 child 隔离。服务将 `sessionQuery` 保持为按 id 继续执行时的可选依赖:缺少该服务时,`listChildren()` 抛出 `SubagentError`,并携带错误码 `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE`;列表工具则在插件加载时要求 `ctx.subagents` 与 `ctx.sessionQuery`。枚举不会查询继续执行管理器的 Activation map、Agent 注册表或提供方可用性;`send_message` 仍是消息送达时的权威操作,列表中的运行中可继续 child 仍可能因所有权冲突而拒绝投递。 ## 终态结果:`SubagentResult` diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 56af1860ef..0aa81a7939 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -78,7 +78,7 @@ export type SessionEvent = { }[T] ``` -Sources: [`packages/core/session/src/types.ts:308`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:315`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:344`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:376`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:314`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:321`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:350`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:382`](../packages/core/session/src/types.ts) ## Events @@ -154,7 +154,7 @@ Source: [`packages/ui/user-approval/src/index.ts:67`](../packages/ui/user-approv Types: [StreamChunk](core-data-structures/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:229`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:235`](../packages/core/session/src/types.ts) #### `assistant/message` — surface @@ -170,7 +170,7 @@ Source: [`packages/core/session/src/types.ts:229`](../packages/core/session/src/ Types: [TokenUsage](core-data-structures/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:236`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:242`](../packages/core/session/src/types.ts) ### `command/*` @@ -391,7 +391,7 @@ Source: [`packages/plan/plan-mode/src/index.ts:51`](../packages/plan/plan-mode/s 'request/context': RequestContext ``` -Source: [`packages/core/session/src/types.ts:279`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:285`](../packages/core/session/src/types.ts) #### `request/header` — log-only @@ -403,7 +403,7 @@ Source: [`packages/core/session/src/types.ts:279`](../packages/core/session/src/ 'request/header': { header: EpochHeader; reason: RequestHeaderReason } ``` -Source: [`packages/core/session/src/types.ts:269`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:275`](../packages/core/session/src/types.ts) ### `sandbox/*` @@ -458,7 +458,7 @@ Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:33`](../packages/s 'session/end-seed': Record ``` -Source: [`packages/core/session/src/types.ts:304`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:310`](../packages/core/session/src/types.ts) #### `session/title` — log-only @@ -494,7 +494,7 @@ Source: [`packages/session-title/session-title-llm/src/index.ts:43`](../packages 'steering/message': { turn: number; message: UserMessage } ``` -Source: [`packages/core/session/src/types.ts:262`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:268`](../packages/core/session/src/types.ts) ### `step/*` @@ -505,7 +505,7 @@ Source: [`packages/core/session/src/types.ts:262`](../packages/core/session/src/ 'step/end': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:218`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:224`](../packages/core/session/src/types.ts) #### `step/start` — log-only @@ -514,7 +514,7 @@ Source: [`packages/core/session/src/types.ts:218`](../packages/core/session/src/ 'step/start': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:216`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:222`](../packages/core/session/src/types.ts) ### `subagent/*` @@ -544,7 +544,7 @@ Source: [`packages/subagent/subagent/src/descriptor.ts:32`](../packages/subagent Types: [TodoItem](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:264`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:270`](../packages/core/session/src/types.ts) ### `tool/*` @@ -561,7 +561,7 @@ Source: [`packages/core/session/src/types.ts:264`](../packages/core/session/src/ Types: [CallId](core-data-structures/core.md) -Source: [`packages/core/session/src/types.ts:242`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:248`](../packages/core/session/src/types.ts) #### `tool/code-dispatch` — log-only @@ -634,7 +634,7 @@ Source: [`packages/core/tools/src/code-mode.ts:33`](../packages/core/tools/src/c } ``` -Source: [`packages/core/session/src/types.ts:254`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:260`](../packages/core/session/src/types.ts) ### `turn/*` @@ -652,7 +652,7 @@ Source: [`packages/core/session/src/types.ts:254`](../packages/core/session/src/ Types: [TurnEndReason](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:214`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:220`](../packages/core/session/src/types.ts) #### `turn/start` — log-only @@ -665,7 +665,7 @@ Source: [`packages/core/session/src/types.ts:214`](../packages/core/session/src/ Types: [TurnTrigger](core-data-structures/session.md) -Source: [`packages/core/session/src/types.ts:207`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:213`](../packages/core/session/src/types.ts) ### `user/*` @@ -683,7 +683,7 @@ Source: [`packages/core/session/src/types.ts:207`](../packages/core/session/src/ 'user/message': UserMessage ``` -Source: [`packages/core/session/src/types.ts:227`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:233`](../packages/core/session/src/types.ts) ### `web/*` diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index 798fa297be..3224a5572d 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/runtime/README.md -README.md: ffee75c0e28cd39c4b80a2f93a2496f2a8a22092 -README.zh.md: 85511bc191d64cb4954515a2ba1efdf912c3f937 +README.md: 0f5b35b6fa4a92f083d5e20c354c19eaae1d5b70 +README.zh.md: 67cdf6479ef2ba7156b7babb7249b51f8640b134 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index ffee75c0e2..0f5b35b6fa 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -56,7 +56,7 @@ Each resident `Session` owns a `modelSelection` snapshot containing the current ## Addressed subagent conversations -`SessionListState.subagentsByParent` carries direct durable catalogs and `currentAddress` records the catalog-derived `{parentSessionId, childSessionId}` for the selected child. Only that recorded address selects subagent transport: lineage alone remains insufficient because ordinary forks also have `parentId`. An addressed Session loads and reconnects through `subagent.history`, sends through `subagent.prompt`, never calls ordinary cancel, and persists its address with the selected session across refresh. Catalog reads are single-flight; `host/session-status` flips a listed child's coarse activity in place, while `host/session-added` causes one debounced refetch only while that parent catalog is open. Parent availability propagates into `ConversationSnapshot.subagent` so presentation can replace the composer with a read-only explanation without activating the parent. +`SessionListState.subagentsByParent` carries direct durable catalogs and `currentAddress` records the catalog-derived `{parentSessionId, childSessionId}` for the selected child. Only that recorded address selects subagent transport: lineage alone remains insufficient because ordinary forks also have `parentId`. An addressed Session loads and reconnects through `subagent.history`, sends through `subagent.prompt`, never calls ordinary cancel, and persists its address with the selected session across refresh and repeated ordinary selection of that same child. The list also projects the header's coarse `origin: 'subagent'` classification for navigation filtering; the recorded address, not `origin`, remains transport authority. Catalog reads are single-flight; `host/session-status` flips a listed child's coarse activity in place, while `host/session-added` causes one debounced refetch only while that parent catalog is open. Parent availability propagates into `ConversationSnapshot.subagent` so presentation can replace the composer with a read-only explanation without activating the parent. ## Model Experience diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 85511bc191..67cdf6479e 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -56,7 +56,7 @@ Session 对象会在事件 wire 边界依据生产方的完整字段契约,验 ## 已寻址的 subagent 对话 -`SessionListState.subagentsByParent` 携带直接持久化目录,`currentAddress` 则记录所选 child 从目录得到的 `{parentSessionId, childSessionId}`。只有这份已记录地址能选择 subagent 传输;单凭谱系仍然不足,因为普通 fork 同样具有 `parentId`。已寻址的 Session 通过 `subagent.history` 加载和重连,通过 `subagent.prompt` 发送,绝不调用普通取消,并在刷新期间把地址与所选会话一同持久化。目录读取为 single-flight;`host/session-status` 就地翻转已列 child 的粗粒度活跃状态,`host/session-added` 则只在对应 parent 目录打开时触发一次去抖动的重拉。parent 可用性会传播到 `ConversationSnapshot.subagent`,使呈现层可以把编辑器替换为只读说明,而不激活 parent。 +`SessionListState.subagentsByParent` 携带直接持久化目录,`currentAddress` 则记录所选 child 从目录得到的 `{parentSessionId, childSessionId}`。只有这份已记录地址能选择 subagent 传输;单凭谱系仍然不足,因为普通 fork 同样具有 `parentId`。已寻址的 Session 通过 `subagent.history` 加载和重连,通过 `subagent.prompt` 发送,绝不调用普通取消,并在刷新期间及通过普通选择路径重复选择同一 child 时,把地址与所选会话一同持久化。列表还会投影 header 的粗粒度 `origin: 'subagent'` 分类供导航过滤;传输的权威依据仍是已记录地址,而不是 `origin`。目录读取为 single-flight;`host/session-status` 就地翻转已列 child 的粗粒度活动状态,`host/session-added` 则只在对应 parent 目录打开时触发一次去抖动的重拉。parent 可用性会传播到 `ConversationSnapshot.subagent`,使呈现层可以把编辑器替换为只读说明,而不激活 parent。 ## 模型体验 diff --git a/packages/client/runtime/src/client/sessions/lineage.ts b/packages/client/runtime/src/client/sessions/lineage.ts index d003c9e9be..08f8361a92 100644 --- a/packages/client/runtime/src/client/sessions/lineage.ts +++ b/packages/client/runtime/src/client/sessions/lineage.ts @@ -18,6 +18,8 @@ export interface SessionListEntry { /** Empty-log bit mirrored from the summary; lists hide blank sessions (filtering stays with the consumer). */ blank: boolean parentSessionId?: SessionId + /** Coarse durable origin for navigation filtering; not a continuation capability. */ + origin?: 'subagent' cwd?: string /** An approval question is pending on this session (mux-frame derived; the sidebar's amber dot). */ waitingApproval: boolean diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index a2edc8f9e8..bc6e751c7a 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -134,8 +134,13 @@ export class SessionManager { if (!this.summaries.some(summary => summary.sessionId === sessionId)) { throw new Error(`sessions.select: unknown session ${sessionId}`) } - this.addresses.delete(sessionId) - this.sessions.get(sessionId)?.configureSubagent(undefined) + const address = this.addresses.get(sessionId) + this.sessions.get(sessionId)?.configureSubagent( + address, + address === undefined + ? false + : this.catalogs.get(address.parentSessionId)?.parentAvailable ?? false, + ) this.selected = sessionId void this.refreshSubagents(sessionId) this.notifier.notifyNow() @@ -607,6 +612,7 @@ export class SessionManager { this.mergeSummary({ sessionId: frame.sessionId, updatedAt: Date.now(), running: false, blank: frame.blank, ...(frame.parentSessionId !== undefined ? { parentSessionId: frame.parentSessionId } : {}), + ...(frame.origin !== undefined ? { origin: frame.origin } : {}), ...(frame.cwd !== undefined ? { cwd: frame.cwd } : {}), }) this.sessions.get(frame.sessionId)?.handleBlank(frame.blank) @@ -724,7 +730,7 @@ export class SessionManager { prev !== undefined && prev.updatedAt === entry.updatedAt && prev.running === entry.running && prev.blank === entry.blank && prev.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd - && prev.title === entry.title && prev.depth === entry.depth + && prev.origin === entry.origin && prev.title === entry.title && prev.depth === entry.depth && prev.waitingApproval === entry.waitingApproval ) return prev this.entryCache.set(entry.sessionId, entry) @@ -766,9 +772,11 @@ function applyMutation(summaries: readonly SessionSummary[], mutation: SessionLi ...(existing.cwd === undefined && mutation.summary.cwd !== undefined ? { cwd: mutation.summary.cwd } : {}), ...(existing.parentSessionId === undefined && mutation.summary.parentSessionId !== undefined ? { parentSessionId: mutation.summary.parentSessionId } : {}), + ...(existing.origin === undefined && mutation.summary.origin !== undefined + ? { origin: mutation.summary.origin } : {}), } if (filled.cwd === existing.cwd && filled.parentSessionId === existing.parentSessionId - && filled.blank === existing.blank) return [...summaries] + && filled.origin === existing.origin && filled.blank === existing.blank) return [...summaries] return summaries.map(summary => summary.sessionId === mutation.summary.sessionId ? filled : summary) } case 'remove': diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index f71da45d1a..d5eb160441 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -44,6 +44,8 @@ export interface SessionSummary { displayTitle: string cwd?: string parentId?: SessionId + /** Coarse durable origin for navigation filtering; not a continuation capability. */ + origin?: 'subagent' running: boolean /** An approval question is pending on this session (sidebar amber-dot state). */ waitingApproval: boolean @@ -631,6 +633,7 @@ export class SessionsService implements ISessions { ...(entry.title !== undefined ? { title: entry.title } : {}), ...(entry.cwd !== undefined ? { cwd: entry.cwd } : {}), ...(entry.parentSessionId !== undefined ? { parentId: entry.parentSessionId } : {}), + ...(entry.origin !== undefined ? { origin: entry.origin } : {}), } } if (current !== undefined && currentAddress !== undefined && byId[current] === undefined) { @@ -641,6 +644,7 @@ export class SessionsService implements ISessions { id: current, displayTitle: child.label, parentId: currentAddress.parentSessionId, + origin: 'subagent', running: child.activity === 'running', waitingApproval: false, blank: false, diff --git a/packages/client/runtime/tests/manager.spec.ts b/packages/client/runtime/tests/manager.spec.ts index 5ad38eeedd..4e39668244 100644 --- a/packages/client/runtime/tests/manager.spec.ts +++ b/packages/client/runtime/tests/manager.spec.ts @@ -12,7 +12,13 @@ import { entries, plainTurn } from './event-script.ts' const S1 = 'fk-m1' as SessionId const S2 = 'fk-m2' as SessionId -type SummaryOver = Partial<{ updatedAt: number; running: boolean; blank: boolean; parentSessionId: SessionId }> +type SummaryOver = Partial<{ + updatedAt: number + running: boolean + blank: boolean + parentSessionId: SessionId + origin: 'subagent' +}> function summary(sessionId: SessionId, over: SummaryOver = {}) { return { sessionId, updatedAt: 100, running: false, blank: false, ...over } @@ -273,10 +279,11 @@ describe('host frame routing', () => { }) describe('subagent catalogs', () => { - it('selects only a catalog-discovered child and keeps its durable address across status frames', async () => { + it('keeps a catalog-discovered child address across ordinary selection and status frames', async () => { const api = new FakeApiClient() api.onList = () => Promise.resolve(ok({ items: [ summary(S1), + summary(S2, { parentSessionId: S1, origin: 'subagent' }), ] as never[] })) api.onSubagentList = () => Promise.resolve(ok({ entries: [{ kind: 'child', id: S2, label: 'worker', activity: 'running' }] as never[], @@ -294,6 +301,26 @@ describe('subagent catalogs', () => { address: { parentSessionId: S1, childSessionId: S2 }, parentAvailable: true, }) + // Clicking the same child through an ordinary list-selection path must not + // erase the catalog-derived address and fall back to session.* transport. + manager.select(S2) + expect(manager.getListSnapshot().currentAddress).toEqual({ + parentSessionId: S1, childSessionId: S2, + }) + expect(manager.get(S2).getSnapshot().subagent).toEqual({ + address: { parentSessionId: S1, childSessionId: S2 }, + parentAvailable: true, + }) + await manager.get(S2).open() + await manager.get(S2).prompt([{ type: 'text', text: 'continue' }], 'queue') + expect(api.callsOf('subagent.history')).toEqual([ + { parentSessionId: S1, childSessionId: S2, maxMessages: 50 }, + ]) + expect(api.callsOf('subagent.prompt')).toEqual([ + { parentSessionId: S1, childSessionId: S2, content: [{ type: 'text', text: 'continue' }] }, + ]) + expect(api.callsOf('session.history')).toEqual([]) + expect(api.callsOf('session.prompt')).toEqual([]) const listCalls = api.callsOf('subagent.list').length manager.handleHostEnvelope({ rpcId: 'child-complete' as never, @@ -490,9 +517,17 @@ describe('remaining branches', () => { const api = new FakeApiClient() const manager = new SessionManager(api) manager.handleHostEnvelope({ rpcId: 'h1' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } }) - manager.handleHostEnvelope({ rpcId: 'h2' as never, payload: { type: 'host/session-added', blank: true, sessionId: S2, parentSessionId: S1 } }) + manager.handleHostEnvelope({ + rpcId: 'h2' as never, + payload: { + type: 'host/session-added', blank: true, sessionId: S2, + parentSessionId: S1, origin: 'subagent', + }, + }) const items = manager.getListSnapshot().items - expect(items.find(e => e.sessionId === S2)).toMatchObject({ parentSessionId: S1, depth: 1 }) + expect(items.find(e => e.sessionId === S2)).toMatchObject({ + parentSessionId: S1, origin: 'subagent', depth: 1, + }) }) }) diff --git a/packages/client/runtime/tests/sessions-service.spec.ts b/packages/client/runtime/tests/sessions-service.spec.ts index eb12fd71cb..8391fc8e76 100644 --- a/packages/client/runtime/tests/sessions-service.spec.ts +++ b/packages/client/runtime/tests/sessions-service.spec.ts @@ -28,7 +28,14 @@ function bench(): Bench { } /** Refresh the manager list from programmable rows and flush the microtask batch. */ -type FeedRow = { id: string; cwd?: string; parentId?: string; running?: boolean; blank?: boolean } +type FeedRow = { + id: string + cwd?: string + parentId?: string + origin?: 'subagent' + running?: boolean + blank?: boolean +} async function feedList(b: Bench, rows: FeedRow[]): Promise { b.api.onList = () => Promise.resolve(ok({ @@ -36,6 +43,7 @@ async function feedList(b: Bench, rows: FeedRow[]): Promise { 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) } : {}), + ...(r.origin !== undefined ? { origin: r.origin } : {}), })), }) as never) await b.svc.refresh() @@ -51,12 +59,14 @@ describe('list store projection', () => { }) await feedList(b, [ { id: 's1', cwd: '/home/u/proj-a/' }, - { id: 's2', parentId: 's1', running: true }, + { id: 's2', parentId: 's1', origin: 'subagent', running: true }, ]) const state = b.svc.list.getSnapshot() expect(state.ids).toEqual(['s1', 's2']) expect(state.byId[sid('s1')]).toMatchObject({ title: 'Durable title', displayTitle: 'Durable title', cwd: '/home/u/proj-a/' }) - expect(state.byId[sid('s2')]).toMatchObject({ displayTitle: 's2', parentId: 's1', running: true }) + expect(state.byId[sid('s2')]).toMatchObject({ + displayTitle: 's2', parentId: 's1', origin: 'subagent', running: true, + }) expect(state.byId[sid('s2')]?.title).toBeUndefined() }) diff --git a/packages/client/ui-subagent/README.i18n.yaml b/packages/client/ui-subagent/README.i18n.yaml index 082cba321d..36cb547883 100644 --- a/packages/client/ui-subagent/README.i18n.yaml +++ b/packages/client/ui-subagent/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-subagent/README.md -README.md: 06cfc368577a41b405336025e75e61998b2051ae -README.zh.md: 64b096ad4804694a9b0d7d3c8c26a8f3f867bf23 +README.md: c69211bd6f84b6e09760ff47d5a319b1ef2ce28c +README.zh.md: 43776fbc32a5bd369e837779787cdc82ceb0f749 diff --git a/packages/client/ui-subagent/README.md b/packages/client/ui-subagent/README.md index 06cfc36857..c69211bd6f 100644 --- a/packages/client/ui-subagent/README.md +++ b/packages/client/ui-subagent/README.md @@ -8,6 +8,8 @@ The header action reads `subagentsByParent` and session summaries through the st An addressed child with no exact live parent elects the read-only composer entry and explains the recovery path. A child with a live parent keeps the ordinary input chrome, whose Session routes through `subagent.prompt`; this package never receives host context or calls a model-facing tool. The catalog and composer behavior are specified by the [Web subagent conversations Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md). +Subagent-origin Session rows are omitted from the ordinary sidebar, so the parent header catalog is their navigation entry point. Ordinary forks remain in the sidebar. + The `@` source remains deliberately separate and inert. Candidates are zero-RPC running children from `ctx.sessions.list`; picking one inserts literal `@label ` text, and the codec projects `@label`. It has no command-adjudication hooks and does not resolve labels into continuation addresses. ## Model Experience @@ -29,5 +31,4 @@ Append-only. This package never edits earlier request tokens. ## Known Limitations and Deferred Work - **The catalog has coarse liveness only** — it cannot show durable outcome, elapsed time, exact Activation state, or a correct cancel button. -- **The sidebar still contains child sessions** — complete de-duplication needs a scalable durable classifier that does not hide ordinary forks. - **`@` references remain display-title text** — duplicate or renamed labels are ambiguous, so they intentionally do not acquire continuation semantics. diff --git a/packages/client/ui-subagent/README.zh.md b/packages/client/ui-subagent/README.zh.md index 64b096ad48..43776fbc32 100644 --- a/packages/client/ui-subagent/README.zh.md +++ b/packages/client/ui-subagent/README.zh.md @@ -8,6 +8,8 @@ Web subagent 功能 owner:向 `conversation.session.header.actions` 贡献可 已寻址 child 没有确切的存活 parent 时,会选中只读编辑器配置项并说明恢复路径。parent 存活时,child 保留普通输入 chrome,其 Session 会通过 `subagent.prompt` 路由;本包绝不接收宿主 context,也不调用面向模型的工具。目录与编辑器行为由 [Web subagent 对话 Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md)规定。 +普通侧边栏会省略带 subagent origin 的 Session 行,因此 parent 页头目录是它们的导航入口。普通 fork 仍保留在侧边栏中。 + `@` source 仍然刻意保持独立且惰性。候选是从 `ctx.sessions.list` 零 RPC 得到的运行中 child;pick 会插入字面文本 `@label `,codec 投影为 `@label`。它不参与命令裁决,也不会把 label 解析成继续执行地址。 ## 模型体验 @@ -29,5 +31,4 @@ Web subagent 功能 owner:向 `conversation.session.header.actions` 贡献可 ## 已知限制与暂缓事项 - **目录只有粗粒度存活状态**:它不能显示持久化结果、耗时、确切的 Activation 状态或正确的取消按钮。 -- **侧边栏仍包含 child Session**:完全去重需要可扩展的持久化分类器,且不得误隐藏普通 fork。 - **`@` 引用仍是显示标题文本**:重复或改名后的 label 会有歧义,因此它们刻意不获得继续执行语义。 diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index f03db81178..4023983d6d 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-workspace/README.md -README.md: 2854c678fd93d56267d0aec8515455b77d4bcbf1 -README.zh.md: 51db6cacfe83d176a6cc68b01fe3dd91acf49e25 +README.md: 2670bdfa2fb1a223bf0c0ea65fbacc1cfb30c607 +README.zh.md: 1e68cfb1a94c240c32949059ca6a3c6adc25208b diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index 2854c678fd..2670bdfa2f 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -14,6 +14,8 @@ The Session row's Fork action forks at the source's last completed turn, increme Both target slots are declared by other plugins, so `apply` registers through declaration-aware deferral and re-registers after a declaring slot is restored. +The shared sidebar projection hides rows whose durable Session summary has `origin: 'subagent'`; users enter those conversations through the selected parent's subagent header catalog. Ordinary forks remain visible because lineage alone does not set that origin. The runtime keeps hidden rows available for conversation, title, and addressed transport state. + ## Model Experience None, as the picker is browser chrome; nothing here reaches a model request. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index 51db6cacfe..1e68cfb1a9 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -14,6 +14,8 @@ Session 行内的 Fork 操作在源会话最后一个已完成轮次处 fork, 两个目标 slot 都由其他插件声明,因此 `apply` 通过声明感知的延迟机制完成注册,并在声明该 slot 的插件恢复后重新注册。 +共享侧边栏投影会隐藏持久化 Session 摘要中带有 `origin: 'subagent'` 的行;用户从所选 parent 的 subagent 页头目录进入这些对话。普通 fork 仍然可见,因为仅有谱系不会设置该 origin。运行时仍保留隐藏行,供对话、标题与已寻址传输状态使用。 + ## 模型体验 无。选择器属于浏览器 chrome;这里没有任何内容进入模型请求。 diff --git a/packages/client/ui-workspace/src/client/tree.ts b/packages/client/ui-workspace/src/client/tree.ts index 7ed7e684d1..6010a1030e 100644 --- a/packages/client/ui-workspace/src/client/tree.ts +++ b/packages/client/ui-workspace/src/client/tree.ts @@ -92,11 +92,14 @@ function byRecency(a: SessionSummary, b: SessionSummary): number { /** * Ordinary sessions are visible; among blank sessions, only the current one - * is visible; archived sessions are visible nowhere (their accounting slots - * remain, so unarchiving restores position). + * is visible. Subagent children use their parent header catalog; archived + * sessions are visible nowhere, while their accounting slots remain so + * unarchiving restores position. */ function sessionVisible(session: SessionSummary, current: SessionId | undefined, archived: ReadonlySet): boolean { - return !archived.has(session.id) && (!session.blank || session.id === current) + return session.origin !== 'subagent' + && !archived.has(session.id) + && (!session.blank || session.id === current) } /** diff --git a/packages/client/ui-workspace/tests/tree.spec.ts b/packages/client/ui-workspace/tests/tree.spec.ts index 288e4fa58d..2d0055ce7f 100644 --- a/packages/client/ui-workspace/tests/tree.spec.ts +++ b/packages/client/ui-workspace/tests/tree.spec.ts @@ -69,6 +69,22 @@ describe('deriveGroups', () => { expect(strayGroups.map(group => group.key)).toEqual(['first']) }) + it('hides subagent-origin sessions without hiding ordinary forks', () => { + const parent = summary('parent', 1) + const fork = { ...summary('fork', 2), parentId: parent.id } + const subagent = { ...summary('subagent', 3), parentId: parent.id, origin: 'subagent' as const } + const sessions = { ...list(parent, fork, subagent), current: subagent.id } + const groups = deriveGroups( + sessions, + [workspace('first', ['parent', 'fork', 'subagent'])], + noArchive, + view(['first']), + ) + + expect(groups[0]!.sessions.map(node => node.id)).toEqual([parent.id, fork.id]) + expect(groups[0]!.sessionCount).toBe(2) + }) + it('ignores fork lineage and sorts every ungrouped session as a top-level row', () => { const parent = summary('parent', 1) const oldChild = { ...summary('old-child', 10), parentId: parent.id } @@ -143,6 +159,17 @@ describe('deriveFlat', () => { expect(rows.map(row => row.id)).toEqual([sid('child'), sid('tie-a'), sid('tie-b'), sid('parent')]) }) + it('hides subagent-origin rows but keeps ordinary forks', () => { + const parent = summary('parent', 1) + const fork = { ...summary('fork', 2), parentId: parent.id } + const subagent = { ...summary('subagent', 3), parentId: parent.id, origin: 'subagent' as const } + const rows = deriveFlat( + { ...list(parent, fork, subagent), current: subagent.id }, + noArchive, + ) + expect(rows.map(row => row.id)).toEqual([fork.id, parent.id]) + }) + it('tolerates ids whose summary has not landed yet', () => { const partial: SessionListState = { ...list(summary('present', 1)), ids: [sid('ghost'), sid('present')] } expect(deriveFlat(partial, noArchive).map(row => row.id)).toEqual([sid('present')]) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 93d732a9ab..d9f0836b5d 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1821,7 +1821,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'CreateAgentOptions', - declaration: 'export interface CreateAgentOptions {\n readonly sessionId: SessionId;\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n };\n readonly seed?: readonly SessionEvent[];\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise | void;\n}', + declaration: 'export interface CreateAgentOptions {\n readonly sessionId: SessionId;\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: \'subagent\';\n readonly delegationDepth?: number;\n };\n readonly seed?: readonly SessionEvent[];\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise | void;\n}', }, { name: 'CreateGoalRequest', @@ -1829,7 +1829,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'CreateSessionOptions', - declaration: 'export interface CreateSessionOptions {\n readonly seed?: readonly SessionEvent[];\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly createdAt?: number;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n };\n}', + declaration: 'export interface CreateSessionOptions {\n readonly seed?: readonly SessionEvent[];\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly createdAt?: number;\n readonly seedLength?: number;\n readonly origin?: \'subagent\';\n readonly delegationDepth?: number;\n };\n}', }, { name: 'CredentialInfo', @@ -2461,7 +2461,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionHeader', - declaration: 'export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n}', + declaration: 'export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: \'subagent\';\n readonly delegationDepth?: number;\n}', }, { name: 'SessionId', diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index 0d87778135..d5c19aae3e 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -48,9 +48,9 @@ export interface CreateAgentOptions { readonly sessionId: SessionId /** * Session creation metadata: validated absolute `cwd`, `parentSession` - * fork lineage, the `seedLength` seed boundary, and the `delegationDepth` - * recursion budget. Mirrors the - * `cwd`/`parentSession`/`seedLength`/`delegationDepth` fields of + * fork lineage, the `seedLength` seed boundary, the coarse `origin` + * classification, and the `delegationDepth` recursion budget. Mirrors the + * `cwd`/`parentSession`/`seedLength`/`origin`/`delegationDepth` fields of * {@link CreateSessionOptions.meta} in dsh-session (the internal-only * `createdAt`, used when reconstructing a persisted session, is deliberately * excluded — a factory caller never sets it). This is durable session data, @@ -61,6 +61,7 @@ export interface CreateAgentOptions { readonly cwd?: string readonly parentSession?: SessionId readonly seedLength?: number + readonly origin?: 'subagent' readonly delegationDepth?: number } /** diff --git a/packages/core/session/README.i18n.yaml b/packages/core/session/README.i18n.yaml index 00260f89cb..9f0aec4eb8 100644 --- a/packages/core/session/README.i18n.yaml +++ b/packages/core/session/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/session/README.md -README.md: 4730cac913e949d642d049a5c53ab2dd47e10627 -README.zh.md: 12aa1625d7b0568196cd788c2a25cfb869d8780d +README.md: d78dc5bcfe1df2edd01280208f3859eb1b2d6763 +README.zh.md: 40c58a539d5027f2619b5b2102b94e76f2c73e23 diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 4730cac913..d78dc5bcfe 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -12,7 +12,7 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall ### Public API -- `ctx.sessions.create(id?, { seed?, meta? }?)` validates and detaches durable seed/header data, fills the version and id, defaults `createdAt` to now, publishes the session, and binds it to the calling fiber. Persisted reconstruction supplies its original `createdAt`, `seedLength`, and `delegationDepth`. +- `ctx.sessions.create(id?, { seed?, meta? }?)` validates and detaches durable seed/header data, fills the version and id, defaults `createdAt` to now, publishes the session, and binds it to the calling fiber. Persisted reconstruction supplies its original `createdAt`, `seedLength`, `origin`, and `delegationDepth`. - `ctx.sessions.flush(session)` dispatches the awaited parallel durability checkpoint through the session's captured scope. Every listener starts and the call waits for all to settle before reporting failure; it returns `true` when at least one listener participated and `false` for an empty snapshot, while unpublished, detached, and stale objects reject. A caller that requires durable storage rejects `false` at its own policy boundary. - `findLastMessageTurnEnd(events)` pairs message-triggered starts with their ends and returns the latest matched `turn/end`. Outcome consumers use this fold instead of the raw latest log event because between-turn records and non-message turns have no prompt outcome. - `ctx.sessions.fork(source, boundary?, childSessionId?): Session` — Resolve a live session object or id, select a seed through the inclusive `boundary` event seq (default: current last event), require that prefix to end outside an open turn, and create a live child session with lineage metadata. @@ -43,7 +43,7 @@ Plain class (not a Cordis Service). Create via `ctx.sessions.create()`. - `session.surface` exposes the readonly `SessionSurface` view owned by the session's single incremental surface manager; `replaceGeneration` changes on every committed rewrite. - `session.events` is a cached frozen snapshot invalidated by append; accepted events remain deeply frozen. - `session.seq`, `session.id` — current sequence and readonly typed identity. -- `session.header: SessionHeader` — detached, deep-frozen creation metadata (`version`, `id`, `createdAt`, optional `cwd`/`parentSession`/`seedLength`/`delegationDepth`). Construction validates the durable record and requires its id to match `session.id`. +- `session.header: SessionHeader` — detached, deep-frozen creation metadata (`version`, `id`, `createdAt`, optional `cwd`/`parentSession`/`seedLength`/`origin`/`delegationDepth`). `origin: 'subagent'` is a coarse product classification, not a continuation capability. Construction validates the durable record and requires its id to match `session.id`. ### Lossless JSON utilities @@ -89,7 +89,7 @@ Every `SessionEvent` carries two optional top-level fields (structural metadata) ### Metadata types (`types.ts`) -- `SessionHeader` — session metadata written once when published as `Session.header`, where detachment and deep-freezing enforce immutability at runtime: `{ version, id, createdAt, cwd?, parentSession?, seedLength?, delegationDepth? }`. Persistence loaders may return mutable detached copies of the same data type. Owned here (beside `SessionId`) because `Session.header` is typed by it; persistence backends re-export it rather than own it (which would force a package cycle). +- `SessionHeader` — session metadata written once when published as `Session.header`, where detachment and deep-freezing enforce immutability at runtime: `{ version, id, createdAt, cwd?, parentSession?, seedLength?, origin?, delegationDepth? }`. Persistence loaders may return mutable detached copies of the same data type. Owned here (beside `SessionId`) because `Session.header` is typed by it; persistence backends re-export it rather than own it (which would force a package cycle). ### Extension points diff --git a/packages/core/session/README.zh.md b/packages/core/session/README.zh.md index 12aa1625d7..40c58a539d 100644 --- a/packages/core/session/README.zh.md +++ b/packages/core/session/README.zh.md @@ -12,7 +12,7 @@ ### 公共 API -- `ctx.sessions.create(id?, { seed?, meta? }?)` 校验持久种子/头部数据并生成脱离副本,补齐版本和 id,在未提供 `createdAt` 时使用当前时间,发布会话并将其绑定到调用方 fiber。持久化重建会提供原始的 `createdAt`、`seedLength` 和 `delegationDepth`。 +- `ctx.sessions.create(id?, { seed?, meta? }?)` 校验持久种子/头部数据并生成脱离副本,补齐版本和 id,在未提供 `createdAt` 时使用当前时间,发布会话并将其绑定到调用方 fiber。持久化重建会提供原始的 `createdAt`、`seedLength`、`origin` 和 `delegationDepth`。 - `ctx.sessions.flush(session)` 通过会话捕获的作用域分发受等待的并行持久性检查点。每个监听器都会启动;调用会等待全部结算后才报告失败;至少一个监听器参与时返回 `true`,监听器快照为空时返回 `false`,而未发布、已脱离和陈旧的对象会被拒绝。要求持久化存储的调用方应在自己的策略边界拒绝 `false`。 - `findLastMessageTurnEnd(events)` 将由消息触发的开始与结束配对,并返回最近匹配的 `turn/end`。结果消费方使用该折叠逻辑,而不直接取日志中最近的事件,因为轮次间记录和非消息轮次没有提示词结果。 - `ctx.sessions.fork(source, boundary?, childSessionId?): Session`:解析实时会话对象或 id,选取截至 `boundary` 事件序号(含该事件)的种子(默认为当前最后一个事件),要求所选前缀结束时没有开放轮次,再创建带谱系元数据的实时子会话。 @@ -43,7 +43,7 @@ - `session.surface` 暴露只读 `SessionSurface` 视图,由会话唯一的增量 surface 管理器所有;每次提交重写,`replaceGeneration` 都会变化。 - `session.events` 是按追加失效的缓存冻结快照;已接受事件保持深度冻结。 - `session.seq`、`session.id`:当前序号和只读类型化身份。 -- `session.header: SessionHeader`:脱离、深冻结的创建元数据(`version`、`id`、`createdAt`,以及可选的 `cwd`/`parentSession`/`seedLength`/`delegationDepth`)。构造时会校验持久记录,并要求其中的 id 与 `session.id` 一致。 +- `session.header: SessionHeader`:脱离、深冻结的创建元数据(`version`、`id`、`createdAt`,以及可选的 `cwd`/`parentSession`/`seedLength`/`origin`/`delegationDepth`)。`origin: 'subagent'` 是粗粒度产品分类,不代表具备继续执行能力。构造时会校验持久记录,并要求其中的 id 与 `session.id` 一致。 ### 无损 JSON 工具 @@ -89,7 +89,7 @@ ### 元数据类型(`types.ts`) -- `SessionHeader`:会话元数据,在发布为 `Session.header` 时写入一次;脱离和深冻结保证运行时不可变:`{ version, id, createdAt, cwd?, parentSession?, seedLength?, delegationDepth? }`。持久化 loader 可返回相同数据类型的可变脱离副本。该类型由此包与 `SessionId` 一同所有,因为 `Session.header` 以它为类型;持久化后端只是重新导出而不拥有它,否则会形成包循环依赖。 +- `SessionHeader`:会话元数据,在发布为 `Session.header` 时写入一次;脱离和深冻结保证运行时不可变:`{ version, id, createdAt, cwd?, parentSession?, seedLength?, origin?, delegationDepth? }`。持久化 loader 可返回相同数据类型的可变脱离副本。该类型由此包与 `SessionId` 一同所有,因为 `Session.header` 以它为类型;持久化后端只是重新导出而不拥有它,否则会形成包循环依赖。 ### 扩展点 diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index ad63179156..1fe3879074 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -138,6 +138,9 @@ function snapshotSessionHeader(id: SessionId, source?: SessionHeader): SessionHe && (typeof record.seedLength !== 'number' || !Number.isSafeInteger(record.seedLength) || record.seedLength < 0)) { throw new Error('session header seedLength must be a non-negative safe integer') } + if (record.origin !== undefined && record.origin !== 'subagent') { + throw new Error('session header origin must be "subagent"') + } if (record.delegationDepth !== undefined && (typeof record.delegationDepth !== 'number' || !Number.isSafeInteger(record.delegationDepth) || record.delegationDepth < 0)) { throw new Error('session header delegationDepth must be a non-negative safe integer') @@ -837,6 +840,7 @@ export class SessionStore extends Service { ...meta?.cwd === undefined ? {} : { cwd: meta.cwd }, ...meta?.parentSession === undefined ? {} : { parentSession: meta.parentSession }, ...meta?.seedLength === undefined ? {} : { seedLength: meta.seedLength }, + ...meta?.origin === undefined ? {} : { origin: meta.origin }, ...meta?.delegationDepth === undefined ? {} : { delegationDepth: meta.delegationDepth }, } return new Session(sessionId, seed, header) diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index 8818188349..e3fc6797e5 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -59,6 +59,11 @@ export interface SessionHeader { * boundary lets resume and replay distinguish parent history from child work. */ readonly seedLength?: number + /** + * Coarse product classification for a session created as a subagent child. + * This is presentation metadata, not proof that the child is continuable. + */ + readonly origin?: 'subagent' /** * Delegation depth: absent (zero) for a top-level session, parent depth + 1 * for a subagent child. Persisted so a recursion budget survives restart and @@ -84,6 +89,7 @@ export interface CreateSessionOptions { readonly parentSession?: SessionId readonly createdAt?: number readonly seedLength?: number + readonly origin?: 'subagent' readonly delegationDepth?: number } } diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index f86b8b59f5..e47f2fc452 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -1251,15 +1251,16 @@ describe('SessionStore', () => { }) }) - it('attaches delegationDepth from meta to the header', async () => { + it('attaches subagent origin and delegationDepth from meta to the header', async () => { const ctx = new Context() await ctx.plugin(SessionStore) const session = ctx.sessions.create(SessionId('delegated-child'), { - meta: { parentSession: SessionId('parent'), delegationDepth: 2 }, + meta: { parentSession: SessionId('parent'), origin: 'subagent', delegationDepth: 2 }, }) expect(session.header).toMatchObject({ id: 'delegated-child', parentSession: 'parent', + origin: 'subagent', delegationDepth: 2, }) }) @@ -1278,6 +1279,7 @@ describe('SessionStore', () => { { meta: { seedLength: '1' }, error: /seedLength must be a non-negative safe integer/ }, { meta: { seedLength: 0.5 }, error: /seedLength must be a non-negative safe integer/ }, { meta: { seedLength: -1 }, error: /seedLength must be a non-negative safe integer/ }, + { meta: { origin: 'fork' }, error: /origin must be "subagent"/ }, { meta: { delegationDepth: '1' }, error: /delegationDepth must be a non-negative safe integer/ }, { meta: { delegationDepth: 0.5 }, error: /delegationDepth must be a non-negative safe integer/ }, { meta: { delegationDepth: -1 }, error: /delegationDepth must be a non-negative safe integer/ }, diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 10e708ab2c..f3c1ae6e14 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md -README.md: abca1e9b209891b152ff7f1a58fadb9f91e65839 -README.zh.md: dca3924af940a99fa99afb283f37a84570d1ad90 +README.md: 15a8a6f80dc3f4c37950e85a902f5c53e83cfcac +README.zh.md: 6c61d8e2a4c44e9c1b8bbec1555c36f0cd39c1e2 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index abca1e9b20..15a8a6f80d 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -22,7 +22,7 @@ Session model routing is a session-domain contract. `session.models` returns the Pending queued input is a live control-plane contract, not session history. The gateway mirrors queued `InboxItem` occurrences from `agent/inbox/*` and broadcasts authoritative `session/queue` snapshots on every queued change and reconnect; pending steering stays outside this Web projection. `session.updateQueue` addresses one `InboxItemId`: edit replaces pending content and remove discards it. `session.cancel` aborts only the active turn and preserves pending inbox work; after cancellation reaches quiescence and the closing turn flushes, AgentLoop claims the next waking occurrence in FIFO order. The browser never resends or promotes that occurrence. A driver claim wins races by retiring the address before admission; a later operation returns `queue-item-not-found`. Queue operations query only an attached Agent and never resume a cold session because process-local inbox identities do not survive restart or disposal. The client never infers retirement from turn or status events. -Workspace and Session lists are separate reconnect baselines. `workspace.create({ name })` creates a uniquely titled directory under the configured root, while `workspace.create({ path })` adopts an existing canonical directory and permits basename-derived titles to repeat. `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. `workspace.archiveSession` adds one session to the registry-global archive set and answers the full updated set; `workspace.list` carries that set as the reconnect baseline and `host/archived-sessions-changed` pushes the full snapshot after every durable change. Archiving hides the session from grouping surfaces without touching its log or its workspace account; a session neither live nor persisted fails with `session-not-found`. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`. +Workspace and Session lists are separate reconnect baselines. `workspace.create({ name })` creates a uniquely titled directory under the configured root, while `workspace.create({ path })` adopts an existing canonical directory and permits basename-derived titles to repeat. `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. `workspace.archiveSession` adds one session to the registry-global archive set and answers the full updated set; `workspace.list` carries that set as the reconnect baseline and `host/archived-sessions-changed` pushes the full snapshot after every durable change. Archiving hides the session from grouping surfaces without touching its log or its workspace account; a session neither live nor persisted fails with `session-not-found`. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`. The `session.list` summaries and `host/session-added` frames also carry the optional durable `origin: 'subagent'` classification so navigation can suppress duplicate child rows immediately and after reconnect; that bit is never continuation authority. `session.search` is a bounded content-search projection over the sessions visible through `session.list`. The gateway asks the optional `ctx.sessionQuery` service for globally ranked current-surface user, assistant, and steering matches, consumes that stream until it has at most 20 visible session/snippet pairs plus one lookahead, and revalidates every hit against the list-derived authorization set before returning it. Provider pages start at 20 hits; when a first-page request rejects that limit, the gateway probes 10, 5, 2, then 1 and retains the learned size for continuation and stale-generation restarts. Returned snippets contain at most 240 Unicode code points, and the response schema independently enforces that bound at each client boundary. Keeping the authorization set in Host memory avoids SQLite's variable ceiling for large valid corpora without weakening visibility or ranking. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index dca3924af9..6c61d8e2a4 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -22,7 +22,7 @@ 待处理的 queued 输入属于实时控制平面契约,而非会话历史。网关镜像来自 `agent/inbox/*` 的 queued `InboxItem` 入队项,并在每次 queued 变更和重连时广播权威的 `session/queue` 快照;待处理 steering(中途引导)不进入此 Web 投影。`session.updateQueue` 通过 `InboxItemId` 寻址单个项:编辑会替换待处理内容,移除会将其丢弃。`session.cancel` 仅中止活动轮次,并保留待处理 inbox 工作;取消达到完全停稳且结束中的轮次完成 flush 后,AgentLoop 按 FIFO 顺序认领下一个可唤醒入队项。浏览器绝不重发或提升该入队项。驱动器在接纳前退役寻址标识,因此认领会赢得竞态;之后的操作返回 `queue-item-not-found`。队列操作只查询当前已挂载的 Agent,绝不恢复冷会话,因为进程本地 inbox 标识无法在重启或资源释放后存活。客户端绝不根据轮次或状态事件推断项已退役。 -Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create({ name })` 会在配置根目录下创建显示标题唯一的目录,而 `workspace.create({ path })` 会接纳已有的规范目录,并允许由 basename 派生的标题重复。`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。`workspace.archiveSession` 向注册表级全局归档集合添加一个会话,并应答完整的更新后集合;`workspace.list` 携带该集合作为重连基线,`host/archived-sessions-changed` 在每次持久变更后推送完整快照。归档只把会话从各分组视图中隐藏,不触碰其日志和 workspace 记账;既非实时也未持久化的会话以 `session-not-found` 失败。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。 +Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create({ name })` 会在配置根目录下创建显示标题唯一的目录,而 `workspace.create({ path })` 会接纳已有的规范目录,并允许由 basename 派生的标题重复。`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。`workspace.archiveSession` 向注册表级全局归档集合添加一个会话,并应答完整的更新后集合;`workspace.list` 携带该集合作为重连基线,`host/archived-sessions-changed` 在每次持久变更后推送完整快照。归档只把会话从各分组视图中隐藏,不触碰其日志和 workspace 记账;既非实时也未持久化的会话以 `session-not-found` 失败。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。`session.list` 摘要与 `host/session-added` 帧还会携带可选的持久化分类 `origin: 'subagent'`,使导航在实时创建与重连后都能隐藏重复的 child 行;该标记绝不是继续执行的权威依据。 `session.search` 是以 `session.list` 所列会话为范围的有界内容搜索投影。网关向可选的 `ctx.sessionQuery` 服务请求全局排序后的当前 surface user、assistant 和 steering(中途引导)匹配项,并持续消费该结果流,直到获得至多 20 个可见会话/snippet 对及一个前瞻项;返回前仍会依据从列表推导的授权集合重新校验每个命中。提供方分页初始请求 20 个命中;如果第一页请求因这一上限被拒绝,网关会依次探测 10、5、2、1,并在续传和陈旧世代重启中沿用探测所得的页面大小。返回的 snippet 最多包含 240 个 Unicode 码点,响应 schema 则会在每个客户端边界独立强制执行该上限。将授权集合保留在宿主内存中,可在不削弱可见性或排序的前提下避开有效大型语料库的 SQLite 变量上限。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index f1612bc2da..32b0329fa4 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -283,6 +283,7 @@ function summarize(session: Session, running: boolean): SessionSummary { running, blank: sessionBlank(session), ...session.header.parentSession === undefined ? {} : { parentSessionId: session.header.parentSession }, + ...session.header.origin === undefined ? {} : { origin: session.header.origin }, ...session.header.cwd === undefined ? {} : { cwd: session.header.cwd }, } } @@ -318,6 +319,7 @@ async function summarizeCold( // cold session is served as not-blank (its log holds its conversation). blank: false, ...meta.parentSession === undefined ? {} : { parentSessionId: meta.parentSession }, + ...meta.origin === undefined ? {} : { origin: meta.origin }, /* v8 ignore next -- the empty arm needs a cwd-less meta, but list() filters those out (legacy logs are not served); the conditional mirrors summarize() shape. */ @@ -2340,6 +2342,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro // has run no turn yet, so this is constantly true in practice. blank: sessionBlank(session), ...session.header.parentSession === undefined ? {} : { parentSessionId: session.header.parentSession }, + ...session.header.origin === undefined ? {} : { origin: session.header.origin }, // cwd rides the frame so the client list needs no refresh to group the new session. ...session.header.cwd === undefined ? {} : { cwd: session.header.cwd }, })) diff --git a/packages/host/apiproxy/src/api/events.schema.ts b/packages/host/apiproxy/src/api/events.schema.ts index ea4b6c892f..0fd97b346e 100644 --- a/packages/host/apiproxy/src/api/events.schema.ts +++ b/packages/host/apiproxy/src/api/events.schema.ts @@ -65,7 +65,14 @@ export const muxFrameSchema = z.discriminatedUnion('type', [ /** HostFrame union (payload slot of a host-stream ServerRequest). */ export const hostFrameSchema = z.discriminatedUnion('type', [ - z.object({ type: z.literal('host/session-added'), sessionId: sessionIdSchema, blank: z.boolean(), parentSessionId: sessionIdSchema.optional(), cwd: z.string().optional() }), + z.object({ + type: z.literal('host/session-added'), + sessionId: sessionIdSchema, + blank: z.boolean(), + parentSessionId: sessionIdSchema.optional(), + origin: z.literal('subagent').optional(), + cwd: z.string().optional(), + }), z.object({ type: z.literal('host/session-removed'), sessionId: sessionIdSchema }), z.object({ type: z.literal('host/session-status'), sessionId: sessionIdSchema, running: z.boolean() }), z.object({ type: z.literal('host/agent-error'), sessionId: sessionIdSchema, message: z.string() }), diff --git a/packages/host/apiproxy/src/api/events.ts b/packages/host/apiproxy/src/api/events.ts index d8ee3f6bff..43bebaa595 100644 --- a/packages/host/apiproxy/src/api/events.ts +++ b/packages/host/apiproxy/src/api/events.ts @@ -90,9 +90,9 @@ export type MuxFrame = | { type: 'stream/error'; error: RpcError } /** - * Host stream frames. session-added carries the lineage anchor, the project - * cwd, and the blank bit (the list-summary fields a client cannot wait for a - * refresh to learn); the frame fires at session/created, so blank is + * Host stream frames. session-added carries the lineage anchor, product + * origin, project cwd, and blank bit (the list-summary fields a client cannot + * wait for a refresh to learn); the frame fires at session/created, so blank is * constantly true — clients flip it on the session's first * `host/session-status(running:true)` (a blank session never runs), and a * reconnecting client takes `session.list`'s summary.blank as authoritative. @@ -106,7 +106,14 @@ export type MuxFrame = * workspace-changed — `workspace.list` re-baselines it on reconnect). */ export type HostFrame = - | { type: 'host/session-added'; sessionId: SessionId; blank: boolean; parentSessionId?: SessionId; cwd?: string } + | { + type: 'host/session-added' + sessionId: SessionId + blank: boolean + parentSessionId?: SessionId + origin?: 'subagent' + cwd?: string + } | { type: 'host/session-removed'; sessionId: SessionId } | { type: 'host/session-status'; sessionId: SessionId; running: boolean } | { type: 'host/agent-error'; sessionId: SessionId; message: string } diff --git a/packages/host/apiproxy/src/api/sessions.schema.ts b/packages/host/apiproxy/src/api/sessions.schema.ts index 8816ba9359..8fcce1e648 100644 --- a/packages/host/apiproxy/src/api/sessions.schema.ts +++ b/packages/host/apiproxy/src/api/sessions.schema.ts @@ -53,6 +53,7 @@ export const sessionSummarySchema = z.object({ running: z.boolean(), blank: z.boolean(), parentSessionId: sessionIdSchema.optional(), + origin: z.literal('subagent').optional(), cwd: z.string().optional(), projections: z.lazy(() => sessionProjectionsBlockSchema).optional(), }) as unknown as z.ZodType> diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts index c95d1f0832..3d80f4f15d 100644 --- a/packages/host/apiproxy/src/api/sessions.ts +++ b/packages/host/apiproxy/src/api/sessions.ts @@ -153,6 +153,8 @@ export interface SessionSummary { blank: boolean /** fork/spawn lineage (session.header.parentSession passthrough); absent for root sessions. */ parentSessionId?: SessionId + /** Coarse durable origin used by navigation surfaces; never proves resumability. */ + origin?: 'subagent' /** Session working directory (header.cwd passthrough); absent when unrecorded. */ cwd?: string /** diff --git a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts index b9d5018975..108cb72e25 100644 --- a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts @@ -42,7 +42,7 @@ describe('sessions.list cold merge', () => { utimesSync(logPath, 5000, 5000) // mtime 5_000_000 ms — newer than every createdAt below const metas = [ header('session-a', 1000), - header('session-b', 2000, { parentSession: sid('session-parent') }), + header('session-b', 2000, { parentSession: sid('session-parent'), origin: 'subagent' }), header('session-c', 1500), ] // Structural fake of the persistence face list() consumes: list + locate. @@ -74,6 +74,7 @@ describe('sessions.list cold merge', () => { expect(a?.parentSessionId).toBeUndefined() expect(b?.updatedAt).toBe(2000) expect(b?.parentSessionId).toBe('session-parent') + expect(b?.origin).toBe('subagent') expect(c?.updatedAt).toBe(1500) }) }) diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts index c59dcf7e64..f006df2906 100644 --- a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts @@ -364,6 +364,36 @@ describe('session creation and Workspace membership', () => { }) describe('Host Workspace increments', () => { + it('projects subagent origin in attached summaries and creation increments', async () => { + const { api, ctx } = await harness() + const abort = new AbortController() + const stream: AsyncIterator> = + api.events.host(request({}), abort.signal)[Symbol.asyncIterator]() + const pending = nextHostFrame(stream) + const childId = SessionId('session-subagent-child') + + ctx.sessions.create(childId, { + meta: { + cwd: '/tmp', + parentSession: SessionId('session-parent'), + origin: 'subagent', + }, + }) + + expect(await pending).toMatchObject({ + payload: { + type: 'host/session-added', + sessionId: childId, + parentSessionId: 'session-parent', + origin: 'subagent', + }, + }) + expect(expectOk(await api.sessions.list(request({}))).items).toContainEqual( + expect.objectContaining({ sessionId: childId, origin: 'subagent' }), + ) + abort.abort() + }) + it('streams committed Workspace and Session increments after empty baselines', async () => { const { api } = await harness() expect(expectOk(await api.workspace.list(request({}))).items).toEqual([]) diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 7e1c8cdb34..41c2be9620 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -141,7 +141,13 @@ describe('sessions domain schemas', () => { expect(sessionIdSchema.parse('s1')).toBe('s1') expect(() => sessionIdSchema.parse('')).toThrow() expect(sessionSummarySchema.parse({ sessionId: 's1', updatedAt: 1, running: false, blank: true })).toMatchObject({ sessionId: 's1', blank: true }) - expect(sessionSummarySchema.parse({ sessionId: 's1', updatedAt: 1, running: true, blank: false, parentSessionId: 'p', cwd: '/x' }).cwd).toBe('/x') + expect(sessionSummarySchema.parse({ + sessionId: 's1', updatedAt: 1, running: true, blank: false, + parentSessionId: 'p', origin: 'subagent', cwd: '/x', + })).toMatchObject({ origin: 'subagent', cwd: '/x' }) + expect(() => sessionSummarySchema.parse({ + sessionId: 's1', updatedAt: 1, running: false, blank: false, origin: 'fork', + })).toThrow() // blank is mandatory: a summary without it fails the parse. expect(() => sessionSummarySchema.parse({ sessionId: 's1', updatedAt: 1, running: false })).toThrow() const event = sessionEventSchema.parse({ @@ -498,7 +504,7 @@ describe('events frame schemas', () => { it('accepts every host frame branch', () => { const frames = [ - { type: 'host/session-added', sessionId: 's', blank: true, parentSessionId: 'p' }, + { type: 'host/session-added', sessionId: 's', blank: true, parentSessionId: 'p', origin: 'subagent' }, { type: 'host/session-added', sessionId: 's', blank: true }, { type: 'host/session-removed', sessionId: 's' }, { type: 'host/session-status', sessionId: 's', running: true }, @@ -512,6 +518,9 @@ describe('events frame schemas', () => { { type: 'stream/error', error: { code: 'internal', message: 'm', details: {} } }, ] for (const frame of frames) expect(hostFrameSchema.parse(frame)).toMatchObject({ type: frame.type }) + expect(() => hostFrameSchema.parse({ + type: 'host/session-added', sessionId: 's', blank: true, origin: 'fork', + })).toThrow() }) }) diff --git a/packages/subagent/subagent-inprocess/README.i18n.yaml b/packages/subagent/subagent-inprocess/README.i18n.yaml index 03a56c4ac1..5e8341bff7 100644 --- a/packages/subagent/subagent-inprocess/README.i18n.yaml +++ b/packages/subagent/subagent-inprocess/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/subagent-inprocess/README.md -README.md: ea6443e9a862c03ce4e43b32d5c1b1d8996f670f -README.zh.md: fb5710a363faa828d0c7252723cc552094eb3eb2 +README.md: 61e5c8381fcd8972815129a8a2171fcf7d864113 +README.zh.md: 1caf43427229afcd7083f929adbaa083a1d1ac2e diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index ea6443e9a8..61e5c8381f 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -10,7 +10,7 @@ This package is the shared run driver for the two in-process providers' one-shot The driver follows this sequence: -1. Validate the parent depth and optional absolute `maxDepth`, then derive child depth as parent depth plus one and persist it in the child session header. +1. Validate the parent depth and optional absolute `maxDepth`, then derive child depth as parent depth plus one and persist it together with `origin: 'subagent'` in the child session header. Origin is a coarse product-navigation classifier; the later descriptor remains lifecycle and continuation authority. 2. Mint a fresh child session id and call `parent.ctx.agents.create` directly, passing the optional fork seed and required request signal into the factory's creation transaction. During the unpublished setup window, install the requested persona, tool restriction, structured-output runtime, and a one-shot `agent/step` contribution that appends the resolved `subagent/descriptor` event after the initial `turn/start` and before the first request. 3. Publish the child, retain the returned `AgentHandle`, and return its holder-owned run. The run's `result` drives one task with `child.followup(prompt)` followed by `child.whenIdle()`. 4. Read the child's own last assistant message and latest message-triggered turn reason, excluding the fork seed prefix so a seeded parent message is never mistaken for child output. diff --git a/packages/subagent/subagent-inprocess/README.zh.md b/packages/subagent/subagent-inprocess/README.zh.md index fb5710a363..1caf434272 100644 --- a/packages/subagent/subagent-inprocess/README.zh.md +++ b/packages/subagent/subagent-inprocess/README.zh.md @@ -10,7 +10,7 @@ 驱动器按以下顺序运行: -1. 校验父 agent 深度和可选的绝对 `maxDepth`,然后把子 agent 深度推导为父 agent 深度加一,并将其持久化到子 agent 会话 header。 +1. 校验父 agent 深度和可选的绝对 `maxDepth`,然后把子 agent 深度推导为父 agent 深度加一,并与 `origin: 'subagent'` 一同持久化到子 agent 会话 header。origin 是粗粒度产品导航分类器;后续描述符仍是生命周期与继续执行的权威依据。 2. 生成全新的子 agent 会话 id,并直接调用 `parent.ctx.agents.create`,把可选的 fork 初始内容和必需的请求信号传入工厂的创建事务。在未发布的设置窗口中,安装请求的 persona、工具限制、结构化输出运行时,以及一次性的 `agent/step` contribution;该 contribution 会在初始 `turn/start` 之后、首次请求之前追加已解析的 `subagent/descriptor` 事件。 3. 发布子 agent,保留返回的 `AgentHandle`,并返回由持有方拥有的 run。该 run 的 `result` 会通过先调用 `child.followup(prompt)`、再调用 `child.whenIdle()` 来驱动一项任务。 4. 读取子 agent 自身最后一条 assistant 消息,以及由消息触发的最新轮次原因;排除 fork 初始内容前缀,确保作为初始内容的父 agent 消息绝不会被误认为子 agent 输出。 diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts index 4cb7ad0b30..4ae58920bb 100644 --- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -175,13 +175,16 @@ describe('startInProcessRun', () => { await run.dispose() }) - it('persists the child depth in its session header', async () => { + it('persists the child origin and depth in its session header', async () => { const { ctx, parent } = await setup([textResponse('child answer')]) const run = await startInProcessRun(request(parent), {}) await run.result // The recursion budget is durable session data, not only runtime options — // a depth that lived only in AgentOptions would reset to 0 on resume. - expect(ctx.agents.get(run.id)!.session.header.delegationDepth).toBe(1) + expect(ctx.agents.get(run.id)!.session.header).toMatchObject({ + origin: 'subagent', + delegationDepth: 1, + }) await run.dispose() }) diff --git a/packages/subagent/subagent/src/child-agent.ts b/packages/subagent/subagent/src/child-agent.ts index f93e5d5dff..96e94493a9 100644 --- a/packages/subagent/subagent/src/child-agent.ts +++ b/packages/subagent/subagent/src/child-agent.ts @@ -71,8 +71,9 @@ export function resolveChildAgentOptions( /** * Build the child session's durable creation metadata: the parent's workspace, - * its direct lineage, the recursion budget that must survive persistence, and - * the seed boundary that separates inherited parent history from child work. + * its direct lineage, coarse product origin, the recursion budget that must + * survive persistence, and the seed boundary that separates inherited parent + * history from child work. * @param parent - the delegating parent agent. * @param childDepth - the resolved delegation depth to persist. * @param lineageSeedLength - how many leading events came from the parent's log. @@ -87,6 +88,7 @@ export function childSessionMeta( return { ...parentHeader.cwd !== undefined ? { cwd: parentHeader.cwd } : {}, parentSession: parentHeader.id, + origin: 'subagent', // Durable: the recursion budget must survive persistence and resume. delegationDepth: childDepth, ...lineageSeedLength > 0 ? { seedLength: lineageSeedLength } : {},