Merge latest native Windows CI base into coverage follow-up

This commit is contained in:
Tianyi Cui
2026-08-08 20:44:36 +08:00
86 changed files with 3289 additions and 232 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md
2026-07-22-durable-subagent-catalog-and-list-agents.md: b96d6e1dd36c58af67c8e93e62515672790ad009
2026-07-22-durable-subagent-catalog-and-list-agents.zh.md: 856bac615db84bfe2898ec0838094c6bc29f77b2
2026-07-22-durable-subagent-catalog-and-list-agents.md: 8337a926238bf7fc4395896fcd4ca180c9c1ac1c
2026-07-22-durable-subagent-catalog-and-list-agents.zh.md: bcf2895a9a69c8cff949788c78158bfccd198c5c

View File

@@ -23,7 +23,7 @@ Parent-to-child enumeration is a service capability with consumer-specific proje
- report corpus activity separately as `running` or `inactive`, without implying completion or resumability;
- return every resulting child in stable `createdAt` ascending, child-id ascending order.
Every ordinary local start receives a `one-shot` descriptor with an optional caller-owned display label, while the continuation manager persists a labeled `continuable` descriptor containing its additional reconstruction fields. The model-facing delegation tool already owns a short `description` and supplies it for one-shot display; lower-level callers such as workflows need not invent presentation metadata. The model-facing `list_agents` adapter filters the service result to continuable children and maps `inactive` to its existing `complete` presentation; a UI can consume both modes and choose an id-based fallback for unlabeled one-shot history. Descriptor persistence, by-id lookup, direct-parent authorization, and provider-independent cold resume remain owned by the implemented Activation contract. Listing consumes those facts but cannot weaken them or invent a second descriptor representation.
Every ordinary local start receives a `one-shot` descriptor with an optional caller-owned display label, while the continuation manager persists a labeled `continuable` descriptor containing its additional reconstruction fields. The model-facing delegation tool already owns a short `description` and supplies it for one-shot display; lower-level callers such as workflows need not invent presentation metadata. The model-facing `list_agents` adapter filters the service result to continuable children and refines status through the live Agent registry (`running`/`idle`/`complete`); a UI can consume both modes and choose an id-based fallback for unlabeled one-shot history. Descriptor persistence, by-id lookup, direct-parent authorization, and provider-independent cold resume remain owned by the implemented Activation contract. Listing consumes those facts but cannot weaken them or invent a second descriptor representation.
### Enumeration decision
@@ -52,7 +52,7 @@ If measured scale later requires an index, that index is derived state: session
A valid descriptor produces one child entry, a per-child inspection failure produces one diagnostic entry, and a candidate without a descriptor produces no entry. `mode` is durable creation policy; `activity` is a process-local corpus snapshot. Activity is neither `AgentStatus`, the manager's internal Activation state, nor a durable outcome, and the result does not expose the internal `createdAt` sorting key. Exact Activation states and durable outcomes such as successful completion, failure, cancellation, and stop reason require a separate durable activation record and are outside this feature.
The model-facing `list_agents` tool takes no arguments, derives `parentSessionId` from the current execution Agent, and is a thin adapter in `@deepseek-ai/dsh-tool-subagent-control`. It keeps diagnostics, drops `one-shot` child entries, maps a continuable child's `running` activity to `running` and `inactive` activity to `complete`, then renders `<id> [<status>] — <label>` or `<id> [diagnostic: <reason>]` in the surviving trace order. An empty projection renders `(no subagents)`.
The model-facing `list_agents` tool takes one optional `scope: 'children' | 'descendants'` argument, derives the root id from the current execution Agent, and is a thin adapter in `@deepseek-ai/dsh-tool-subagent-control`. It keeps diagnostics, drops `one-shot` child entries, derives status from the live Agent registry — `running` for an active driver, `idle` for a resident Agent between turns, and `complete` when no live Agent remains — then renders `<id> [<status>] — <label>` or `<id> [diagnostic: <reason>]` in stable catalog order. The `descendants` scope reads `SubagentService.listDescendants(rootSessionId)`, which flattens the complete tree from one live-preferred corpus in stable pre-order, traverses ordinary and one-shot intermediates so deeper continuable agents are discovered, revalidates each cold candidate against its enumerated lifecycle, and adds `parentId`/`depth` to every entry. The tool inserts ` parent=<id> depth=<n>` before the label; `parent` is the durable direct-parent session id and may name an omitted ordinary session. For the current caller, only depth-1 child rows are `send_message` candidates, while deeper child rows may be selected for `interrupt_agent` ([interrupt contract](2026-08-06-continuable-subagent-interrupt.md)). Discovery is a hint only — follow-up authority stays exact-direct-parent, and interrupt authority stays with the service's live-lineage check. An empty projection renders `(no subagents)`.
Diagnostics use three fixed reasons. Malformed event surfaces, conflicting headers discovered during an exact child load, a read result whose immutable header differs from the traced candidate or no longer names the requested direct parent, a target that is no longer the located descriptor event, malformed descriptor content, and multiple descriptor events map to `corrupt`. An unknown descriptor version maps to `unsupported`. `SESSION_QUERY_SESSION_NOT_FOUND`, `SESSION_QUERY_EVENT_NOT_FOUND`, and `SESSION_QUERY_PERSISTENCE_FAILED` from a per-child read map to `unavailable`. This phase boundary is intentional: a persistence outage during the initial trace fails the operation, while the same outage beginning during candidate reads may produce one identical `unavailable` diagnostic per affected child; the first version neither coalesces those diagnostics nor promotes them to a global failure. A missing descriptor is instead a non-subagent exclusion without a diagnostic. Configuration/window errors and unrecognized failures are not child diagnostics and propagate as operation failures. Each diagnostic identifies the child id and reason without exposing model-hidden descriptor content; the candidate is omitted while healthy siblings remain visible. Sessions outside the trace's direct descendants are never read and produce no diagnostic.
@@ -93,8 +93,8 @@ The first version has no child deletion operation. If later product behavior del
## Testing
- `packages/subagent/subagent/tests/service.spec.ts` pins descriptor v2 parsing for both modes and proves an unlabeled raw start resolves a one-shot descriptor before provider dispatch. `packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts` proves the local driver appends that descriptor inside the initial turn, returns the published id when cancellation lands in the factory-to-run handoff, and keeps result and handle-disposal failures on separate channels. Delegation-tool tests pin propagation of their existing display description and preserve independent result and disposal diagnostics.
- `packages/subagent/subagent/tests/list-children.spec.ts` pins the current read path against a real composition of the session store, JSONL persistence, spawn/fork providers, the subagent service, and the projection registry — no query service — keylessly: live-only listing without persistence; loud `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` and `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` even with zero children; the three-rung ladder (a live child never inspected, a cold child inspected exactly once, and the cache-hit, absent-key, absent-service, and poisoned-row second-rung cases); last-wins over multiple descriptors; malformed payloads and unknown versions diagnosed as `corrupt`; a failed cold inspection as one `unavailable` diagnostic retried on the next listing; a fork seed's ancestor descriptor listed under that identity; foreign-unit fold failures contained per child as `corrupt` on both the live and cold paths; `createdAt`-then-id ordering without ordinary forks; provider absence without child omission; compacted/uncompacted twins listing identically; a persisted-listing failure failing the whole enumeration; cancellation normalized to stable `CANCELLED`; and typed stable error codes. A companion spec (retired together with the query-backed read path) rejected eager evaluation of the optional session-query runtime while importing the ordinary subagent surface.
- `packages/subagent/tool-subagent-control/tests/list-agents.spec.ts` pins the `list_agents` schema (no parameters), the continuable-only projection that omits a healthy one-shot sibling while preserving diagnostics, the fixed child/diagnostic/empty text forms, an end-to-end settled-child listing with its durable label, forwarding of the tool cancellation signal, the no-agent rejection, the narrowed load requirement without `sessionQuery`, and HMR disposal.
- `packages/subagent/subagent/tests/list-children.spec.ts` pins the current read path against a real composition of the session store, JSONL persistence, spawn/fork providers, the subagent service, and the projection registry — no query service — keylessly: live-only listing without persistence; loud `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` and `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` even with zero children; the three-rung ladder (a live child never inspected, a cold child inspected exactly once, and the cache-hit, absent-key, absent-service, and poisoned-row second-rung cases); last-wins over multiple descriptors; malformed payloads and unknown versions diagnosed as `corrupt`; a failed cold inspection as one `unavailable` diagnostic retried on the next listing; a fork seed's ancestor descriptor listed under that identity; foreign-unit fold failures contained per child as `corrupt` on both the live and cold paths; `createdAt`-then-id ordering without ordinary forks; provider absence without child omission; compacted/uncompacted twins listing identically; a persisted-listing failure failing the whole enumeration; cancellation normalized to stable `CANCELLED`; typed stable error codes; and descendant listing's iterative stable pre-order, traversal through ordinary and one-shot intermediates, positioned diagnostics, lifecycle revalidation, and cancellation. A companion spec (retired together with the query-backed read path) rejected eager evaluation of the optional session-query runtime while importing the ordinary subagent surface.
- `packages/subagent/tool-subagent-control/tests/list-agents.spec.ts` pins the `list_agents` schema (one optional `scope` enum), the continuable-only projection that omits a healthy one-shot sibling while preserving diagnostics, registry-derived child/diagnostic/empty text forms, an end-to-end settled-child listing with its durable label, the descendants scope's pre-order parent/depth annotations across a live waiting branch, cancellation forwarding to both scopes, the no-agent rejection, the `agents` load requirement without `sessionQuery`, and HMR disposal.
- The keyless ACP snapshot scenario `subagent-list-agents` (examples/acp-agent) fences its second parent turn on a snapshot-only `subagent/end` marker, then executes `list_agents` for real against the subagent service, the projection registry, and JSONL persistence, rendering `<id> [complete] — <label>`.
- The keyless snapshot scenario `subagent-diagnostic` (examples/headless-agent) pins the current listing's model-visible diagnostic classification, including a descriptor-less settled child surfacing as a `corrupt` diagnostic.
- The keyless ACP snapshot scenario `subagent-published-run-failure` publishes a real one-shot child, injects independent run-result and handle-disposal failures, and preserves both diagnostics in the parent tool result.

View File

@@ -23,7 +23,7 @@ parent 到 child 的枚举是一项带消费方专用投影的服务功能。`Su
- 将语料活动状态单独报告为 `running``inactive`,但不暗示已完成或可恢复;
-`createdAt` 升序、再按 child id 升序稳定返回所有结果 child。
每次普通的本地启动都会收到带可选、由调用方拥有之显示标签的 `one-shot` 描述符,而继续执行管理器会持久化带标签、包含附加重建字段的 `continuable` 描述符。面向模型的委派工具已经拥有简短 `description`会将其用于一次性显示workflow 等底层调用方无需凭空构造展示元数据。面向模型的 `list_agents` 适配器会将服务结果过滤为可继续 child`inactive` 映射为其现有的 `complete` 表示UI 可以消费两种模式,并为无标签的一次性历史选择基于 id 的回退展示。描述符持久化、按 id 查找、直接 parent 鉴权和不依赖提供方的冷恢复仍归已实现的 Activation 契约负责。列表查询消费这些事实,但不能削弱它们,也不能另行发明第二种描述符表示。
每次普通的本地启动都会收到带可选、由调用方拥有之显示标签的 `one-shot` 描述符,而继续执行管理器会持久化带标签、包含附加重建字段的 `continuable` 描述符。面向模型的委派工具已经拥有简短 `description`会将其用于一次性显示workflow 等底层调用方无需凭空构造展示元数据。面向模型的 `list_agents` 适配器会将服务结果过滤为可继续 child通过在线 Agent 注册表细化状态(`running``idle``complete`UI 可以消费两种模式,并为无标签的一次性历史选择基于 id 的回退展示。描述符持久化、按 id 查找、直接 parent 鉴权和不依赖提供方的冷恢复仍归已实现的 Activation 契约负责。列表查询消费这些事实,但不能削弱它们,也不能另行发明第二种描述符表示。
### 枚举决策
@@ -52,7 +52,7 @@ subagent 服务将 `sessionQuery` 保持为可选依赖,因此没有该服务
有效描述符产生一个 child 条目,逐 child 检查失败产生一个 diagnostic 条目,缺少描述符的候选不产生条目。`mode` 是持久化创建策略;`activity` 是进程本地语料快照。活动状态既不是 `AgentStatus`、管理器内部的 Activation 状态,也不是持久化结果,结果不公开内部 `createdAt` 排序键。成功完成、失败、取消和停止原因等精确 Activation 状态与持久化结果需要单独的持久化激活记录,不在本功能范围内。
面向模型的 `list_agents` 工具接受参数,从当前正在执行 Agent 推导 `parentSessionId`,并作为 `@deepseek-ai/dsh-tool-subagent-control` 中的轻量适配器。它保留 diagnostic丢弃 `one-shot` child 条目,将可继续 child 的 `running` 活动状态映射为 `running``inactive` 活动状态映射`complete`然后按剩余的追踪顺序渲染 `<id> [<status>] — <label>``<id> [diagnostic: <reason>]`。空投影渲染为 `(no subagents)`
面向模型的 `list_agents` 工具接受一个可选的 `scope: 'children' | 'descendants'` 参数,从当前执行 Agent 推导根 id,并作为 `@deepseek-ai/dsh-tool-subagent-control` 中的轻量适配器。它保留 diagnostic丢弃 `one-shot` child 条目,状态取自在线 Agent 注册表——driver 活跃为 `running`,驻留但处于轮次之间为 `idle`,没有在线 Agent 时`complete`——然后按稳定目录顺序渲染 `<id> [<status>] — <label>``<id> [diagnostic: <reason>]``descendants` scope 读取 `SubagentService.listDescendants(rootSessionId)`:它从一份实时优先语料按稳定 pre-order 展平完整树,遍历普通与一次性中间节点以发现更深的可继续 agent依据枚举生命周期重新校验每个冷候选并为每个条目附加 `parentId``depth`。工具会在 label 之前插入 ` parent=<id> depth=<n>``parent` 是持久化直接 parent 会话 id可能指向被省略的普通会话。对于当前调用方只有 depth-1 child 条目可作为 `send_message` 候选,更深的 child 条目则可供 `interrupt_agent` 选择([中断契约](2026-08-06-continuable-subagent-interrupt.md)。发现结果只是提示——follow-up 权限仍仅属于确切直接 parent中断权限仍由服务的在线 lineage 检查决定。空投影渲染为 `(no subagents)`
diagnostic 使用三种固定原因。格式错误的事件 surface、精确加载 child 时发现的 header 冲突、读取结果中的不可变 header 与追踪到的候选不一致或不再指向请求的直接 parent、读取目标不再是先前定位的描述符事件、格式错误的描述符内容和多个描述符事件映射为 `corrupt`。未知描述符版本映射为 `unsupported`。逐 child 读取产生的 `SESSION_QUERY_SESSION_NOT_FOUND``SESSION_QUERY_EVENT_NOT_FOUND``SESSION_QUERY_PERSISTENCE_FAILED` 映射为 `unavailable`。这项阶段边界是有意为之:初始追踪期间发生持久化故障会让操作失败,而同一故障如果始于候选读取期间,可能会让每个受影响的 child 分别产生一条相同的 `unavailable` diagnostic第一版既不合并这些 diagnostic也不会把它们提升为全局失败。缺少描述符则作为非 subagent 排除,且不产生 diagnostic。配置错误、窗口错误和未识别的失败不属于 child diagnostic会作为操作失败继续向上传播。每条 diagnostic 都标识 child id 及原因,不暴露对模型隐藏的描述符内容;系统会排除该候选,而其他健康的 sibling 仍然可见。系统绝不会读取不属于追踪结果直接后代的会话,也不会为它们产生 diagnostic。
@@ -93,8 +93,8 @@ diagnostic 是瞬时查询结果,不属于会话事件或目录状态。推导
## 测试
- `packages/subagent/subagent/tests/service.spec.ts` 固定两种模式下的描述符 v2 解析,并证明无标签的底层启动会在分发给提供方之前解析出一次性描述符。`packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts` 证明本地驱动会在初始轮次内追加该描述符,在取消落入工厂到 run 的交接窗口时返回已发布 id并让结果与句柄释放失败保留在独立通道中。委派工具测试固定其现有显示说明的传递并保留相互独立的结果与 dispose diagnostic。
- `packages/subagent/subagent/tests/list-children.spec.ts` 针对由会话存储、JSONL 持久化、spawn/fork 提供方、subagent 服务与投影注册表构成的真实组合——不含查询服务——以无密钥方式钉住现行读取路径:无持久化时的仅存活列表;零 children 也响亮报 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE``SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`;三级阶梯(存活 child 从不检查、冷 child 恰好检查一次以及缓存命中、key 缺席、服务缺席、行中毒四个第二级用例);多描述符 last-wins 取末者;载荷格式错误与未知版本诊断为 `corrupt`;冷检查失败成一条 `unavailable` diagnostic 并在下次列表重试fork seed 中的祖先描述符按该身份列出;外部 unit 折叠失败在存活与冷两条路径上按 child 收纳为 `corrupt`;按 `createdAt` 再按 id 排序且不列普通 fork提供方缺失时不排除 child压缩与未压缩的孪生 child 列表结果一致;持久化列表失败使整次枚举失败;取消稳定归一化为 `CANCELLED`以及带类型的稳定错误码。一个伴随规格(已随查询式读取路径一起退役)曾在导入普通 subagent surface 时拒绝对可选 session-query 运行时的 eager 求值。
- `packages/subagent/tool-subagent-control/tests/list-agents.spec.ts` 固定 `list_agents` 的 schema无参数)、只保留可继续 child 且排除健康的一次性 sibling、同时保留 diagnostic 的投影、childdiagnostic空结果的固定文本形式、带持久化 label 的已结束 child 端到端列表、工具取消信号转发、无调用 agent 时的拒绝、收窄后的加载要求(不再注入 `sessionQuery`,以及 HMR dispose。
- `packages/subagent/subagent/tests/list-children.spec.ts` 针对由会话存储、JSONL 持久化、spawn/fork 提供方、subagent 服务与投影注册表构成的真实组合——不含查询服务——以无密钥方式钉住现行读取路径:无持久化时的仅存活列表;零 children 也响亮报 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE``SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`;三级阶梯(存活 child 从不检查、冷 child 恰好检查一次以及缓存命中、key 缺席、服务缺席、行中毒四个第二级用例);多描述符 last-wins 取末者;载荷格式错误与未知版本诊断为 `corrupt`;冷检查失败成一条 `unavailable` diagnostic 并在下次列表重试fork seed 中的祖先描述符按该身份列出;外部 unit 折叠失败在存活与冷两条路径上按 child 收纳为 `corrupt`;按 `createdAt` 再按 id 排序且不列普通 fork提供方缺失时不排除 child压缩与未压缩的孪生 child 列表结果一致;持久化列表失败使整次枚举失败;取消稳定归一化为 `CANCELLED`;带类型的稳定错误码;以及后代列表的迭代式稳定 pre-order、穿过普通与一次性中间节点、带位置 diagnostic、生命周期复验与取消。一个伴随规格(已随查询式读取路径一起退役)曾在导入普通 subagent surface 时拒绝对可选 session-query 运行时的 eager 求值。
- `packages/subagent/tool-subagent-control/tests/list-agents.spec.ts` 固定 `list_agents` 的 schema一个可选 `scope` 枚举)、只保留可继续 child 且排除健康的一次性 sibling、同时保留 diagnostic 的投影、由注册表推导的 childdiagnostic空结果文本形式、带持久化 label 的已结束 child 端到端列表、descendants scope 在在线 waiting 分支上的 pre-order parent/depth 注释、两个 scope 的取消信号转发、无调用 agent 时的拒绝、要求 `agents`不再注入 `sessionQuery` 的加载契约,以及 HMR dispose。
- 无密钥 ACP 快照场景 `subagent-list-agents`examples/acp-agent使用仅限快照的 `subagent/end` 标记为第二个 parent 轮次设置边界,随后针对 subagent 服务、投影注册表和 JSONL 持久化真实执行 `list_agents`,渲染 `<id> [complete] — <label>`
- 无密钥快照场景 `subagent-diagnostic`examples/headless-agent钉住现行列表的模型可见诊断分类包括无描述符的定局 child 以 `corrupt` diagnostic 出现。
- 无密钥 ACP 快照场景 `subagent-published-run-failure` 会发布一个真实的一次性 child注入相互独立的 run result 与 handle dispose 失败,并在 parent 工具结果中保留两项 diagnostic。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md
2026-07-27-web-subagent-conversations.md: edaaa97bbfcf0ce1751db84d8fc2d3ab07af8b03
2026-07-27-web-subagent-conversations.zh.md: 4c5e670cc46621b889d8463daa90afbf43bde30f
2026-07-27-web-subagent-conversations.md: da4de7d4bcd23f61459eba3fee8108b0af1c95f9
2026-07-27-web-subagent-conversations.zh.md: 43d9710ec2b63a09bd3db029f172746d63d91cfe

View File

@@ -20,7 +20,7 @@ Every opened child carries a catalog-derived address `{ parentSessionId, childSe
The generic Host domain preserves the same ownership boundary. `session.history` and the source side of `session.fork` read an attached Session or inspect persistence without acquiring an Agent; history folds cold projection values from that exact inspected prefix, while a fork publishes an ordinary independent session. Generic Agent-bound session, command, and goal routes return `agent-busy` for session-backed subagents, as do explicit-id `session.create` adoption and attached-only queue controls. The denial classifier accepts the coarse `origin` marker, a `subagent/descriptor` in the session's own suffix, or exact live runtime ownership by the parent; these signals only prevent generic ownership and never replace catalog mode or direct-parent authorization.
The ordinary Stop action is absent from addressed child conversations. `SubagentService.followup()` owns admission only until inbox acceptance and intentionally exposes no public child cancellation operation. A later cancellation design needs an explicit authority and lifecycle contract rather than falling through to `session.cancel`.
Stopping an addressed child never falls through to `session.cancel`. `SubagentService.followup()` owns admission only until inbox acceptance and grants no cancellation handle; a running continuable child is stopped through the dedicated `subagent.interrupt` route under the [current-turn interrupt contract](2026-08-06-continuable-subagent-interrupt.md), which parks pending work instead of discarding it. One-shot children remain uncancellable from the Web.
This decision covers Web discovery, transcript viewing, and parent-authorized human continuation. It does not make a subagent independently user-owned; that product remains [interactive side sessions](../../proposed/feature/2026-07-08-interactive-side-sessions.md).
@@ -45,7 +45,7 @@ Healthy rows reuse the standard session projections retained in the list mirror.
Selecting a row records its exact address before opening the resident client `Session`. History pagination, event folding, tool render intents, titles, and live mux reconciliation reuse the ordinary conversation machinery. Breadcrumbs use catalog labels, follow parent links only through `origin: 'subagent'` rows, include the first ordinary owner, and keep ordinary forks single-level. Forking an addressed subagent creates an ordinary fork with direct source lineage and attaches it to the nearest workspace-owning ancestor. The catalog is an ARIA tree with lazy ArrowRight/ArrowLeft disclosure, linear ArrowUp/ArrowDown navigation, Home/End, Escape, and focus restoration.
A one-shot row always replaces the composer with copy explaining that the execution record is read-only. A continuable row does so only while `parentAvailable` is false. When enabled, its Send action admits another FIFO turn even if the child is currently running; it never becomes Stop. Prompt failures retain the draft through the ordinary error behavior.
A one-shot row always replaces the composer with copy explaining that the execution record is read-only. A continuable row does so only while `parentAvailable` is false and the child is not running; a running parent-offline child keeps the ordinary composer with its input disabled so the same primary Stop stays reachable, and the read-only takeover returns once it stops. With a live parent, Enter admits another FIFO turn even while the child runs, and the single primary action follows the ordinary running convention: Send while idle, Stop while running, routed through `subagent.interrupt` ([interrupt contract](2026-08-06-continuable-subagent-interrupt.md)). Prompt failures retain the draft through the ordinary error behavior.
Agent-bound auxiliary controls are unavailable in addressed child views. In particular, the model selector and `/model` contribution do not call ordinary `session.models` or `session.selectModel`; the Host also rejects any accidental call instead of activating persisted child history outside the direct-parent continuation seam.
@@ -89,7 +89,7 @@ The shipped Web composition mounts SQLite session query beside JSONL persistence
**Auto-resume an absent parent.** Rejected because continuation requires the exact live direct parent. Child navigation must not mutate the parent lifecycle.
**Expose ordinary cancellation.** Rejected because the accepted inbox turn outlives its admission request and the continuation seam exposes no authority-safe cancellation handle.
**Expose ordinary cancellation.** Rejected because the accepted inbox turn outlives its admission request and, at this decision's time, the continuation seam exposed no authority-safe cancellation handle. The later [current-turn interrupt contract](2026-08-06-continuable-subagent-interrupt.md) added that explicit authority as a dedicated subagent route; falling through to `session.cancel` remains rejected.
**Show only continuable children.** Rejected because the durable catalog deliberately describes both session-backed modes. One-shot transcripts remain useful even though they never accept follow-ups.
@@ -103,9 +103,9 @@ The shipped Web composition mounts SQLite session query beside JSONL persistence
- Host protocol tests pin schemas including required boolean expandability, id echoing, mode verification, non-activating history, exact-parent enforcement, FIFO admission receipts, cancellation, and sanitized failure mapping.
- Generic Host tests pin attached and cold history and forks without Agent publication, cold projection folding, descriptor/origin/runtime-owner denial, explicit-id adoption denial, and the direct queue-control fence.
- Client object tests pin retained and restored addresses, one-shot read-only rejection, history routing, continuable prompt routing, no addressed cancellation, suppression of Agent-bound model controls, live activity flips including in-flight response replay and detach fallback, subagent-parent expandability flips, and membership refresh.
- Client object tests pin retained and restored addresses, one-shot read-only and cancel rejection, history routing, continuable prompt and interrupt routing, suppression of Agent-bound model controls, live activity flips including in-flight response replay and detach fallback, subagent-parent expandability flips, and membership refresh.
- jsdom tests pin the aggregate descendant count and activity, sidebar propagation across nested lineage and ordinary-fork boundaries, row-status precedence, token totals, second-precision running and frozen inactive durations, adaptive long-duration units with exact accessible text, the summary-backed root action across absent and stale-empty catalogs, known loading-row shape, mixed-mode rows, pre-click leaf disclosure, diagnostics, lazy descendant disclosure, direct-parent addresses, keyboard behavior, and both read-only reasons.
- The keyless assembled Web snapshot contains an inactive continuable child with durable usage, an inactive one-shot sibling with a deterministic long duration, and a persisted grandchild; it pins the three-descendant trigger across a stale empty catalog response, usage and timing rows, and adaptive long-duration presentation, expands without activation, opens persisted history, admits a human FIFO follow-up, reconciles child mux events, and proves one-shot history remains read-only. A separate assembled scenario holds a real child Agent turn at the model seam while it pins the aggregate running state in both the header and visible idle owner row, then cancels the turn during teardown.
- The keyless assembled Web snapshot contains an inactive continuable child with durable usage, an inactive one-shot sibling with a deterministic long duration, and a persisted grandchild; it pins the three-descendant trigger across a stale empty catalog response, usage and timing rows, adaptive long-duration presentation, and the aggregate running transition, expands without activation, opens persisted history, admits a human FIFO follow-up, reconciles child mux events, and proves one-shot history remains read-only. A separate assembled scenario holds a real child Agent turn at the model seam while it pins the aggregate running state in both the header and visible idle owner row, then cancels the turn during teardown.
- Navigation tests pin subagent-only breadcrumbs, workspace placement for forks created from subagents, and `origin: 'subagent'` sidebar filtering without hiding ordinary forks.
## Consequences
@@ -114,4 +114,4 @@ The shipped Web composition mounts SQLite session query beside JSONL persistence
- Parent availability, child activity, and `hasChildren` are snapshots. Publication, disposal, another sender, or another process may win after listing; typed prompt failure remains expected.
- A child may publish between history fetch and mux subscription, so the existing sequence reconciliation also covers the cold-to-live addressed path.
- Persisted origin adds one deliberately weak product-classification field to child headers and list projections; it cannot become an authorization shortcut.
- The UI has no child cancellation, durable outcome, Activation identity, deletion, or independently interactive offline mode, and its text must not imply those capabilities. Active-turn duration measures logged work rather than Activation residency.
- Beyond the current-turn Stop of a running continuable child ([interrupt contract](2026-08-06-continuable-subagent-interrupt.md)), the UI has no child cancellation, durable outcome, Activation identity, deletion, or independently interactive offline mode, and its text must not imply those capabilities. Active-turn duration measures logged work rather than Activation residency.

View File

@@ -20,7 +20,7 @@ Web 产品通过页头操作公开选中会话中由会话支撑的直接 subage
通用 Host 领域遵守同一所有权边界。`session.history``session.fork` 的源端会读取已附加 Session 或检查持久化存储,而不获取 Agenthistory 从所检查的确切前缀归并冷态投影值fork 则发布一个普通的独立会话。绑定到 Agent 的通用会话、命令与目标路由会对由会话支撑的 subagent 返回 `agent-busy`;显式 id 的 `session.create` 接纳与仅针对已附加会话的队列控件亦然。拒绝分类器接受粗粒度 `origin` 标记、会话自身后缀中的 `subagent/descriptor`,或 parent 对其确切的存活运行时所有权;这些信号只会阻止通用路径取得所有权,绝不取代目录 mode 或直接 parent 授权。
已寻址 child 对话不提供普通 Stop 操作`SubagentService.followup()` 只负责消息被 inbox 接受前的准入,并有意不公开任何 child 取消操作。后续取消设计需要显式的授权与生命周期契约,而不能回退到 `session.cancel`
停止一个已寻址 child 绝不回退到 `session.cancel``SubagentService.followup()` 只负责消息被 inbox 接受前的准入,不授予取消句柄;正在运行的可继续 child 通过专用的 `subagent.interrupt` 路由停止,遵循[当前轮次中断契约](2026-08-06-continuable-subagent-interrupt.md)该契约会暂停而非丢弃待处理工作。one-shot child 在 Web 端仍不可取消
本决策涵盖 Web 端发现、transcript 查看与经 parent 授权的用户继续交互。它不会让 subagent 成为用户独立所有的对象;这类产品仍然属于[交互式 side session](../../proposed/feature/2026-07-08-interactive-side-sessions.md)。
@@ -45,7 +45,7 @@ Figma 中的 [subagent 列表](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5
选择一行后,系统会先记录其确切地址,再打开常驻客户端 `Session`。历史分页、事件 fold、工具渲染意图、title 与实时 mux 归并都会复用普通对话机制。面包屑导航使用目录 label只会沿 `origin: 'subagent'` 行的父链接逐级回溯,包含第一个普通 owner并让普通 fork 保持单层。从已寻址 subagent 创建 fork 时,会生成具有直接源谱系的普通 fork并将其附加到最近拥有 Workspace 的祖先。目录是一棵 ARIA 树,支持懒加载式 ArrowRightArrowLeft 展开与折叠、线性 ArrowUpArrowDown 导航、HomeEnd、Escape 以及焦点恢复。
one-shot 行始终会用文案替代输入框,说明执行记录为只读。可继续行仅在 `parentAvailable` 为 false 时如此。启用后,即使 child 正在运行,其 Send 操作也会准入另一个 FIFO 轮次,绝不会变成 Stop。提示词失败会通过普通错误行为保留草稿。
one-shot 行始终会用文案替代输入框,说明执行记录为只读。可继续行仅在 `parentAvailable` 为 false 且 child 未在运行时如此parent 离线但仍在运行的 child 保留普通输入框,只禁用其输入区,让同一个 primary Stop 保持可达停止后只读替代恢复。parent 在线时,即使 child 正在运行,Enter 也会准入另一个 FIFO 轮次,而唯一的 primary 操作遵循普通运行约定:空闲时为 Send运行时为 Stop经由 `subagent.interrupt` 路由([中断契约](2026-08-06-continuable-subagent-interrupt.md)。提示词失败会通过普通错误行为保留草稿。
已寻址 child 视图不提供绑定到 agent 的辅助控件。具体而言,模型选择器与 `/model` contribution 不会调用普通 `session.models``session.selectModel`Host 也会拒绝任何意外调用,而不是在直接 parent 继续执行 seam 之外激活持久化 child 历史。
@@ -89,7 +89,7 @@ one-shot 行始终会用文案替代输入框,说明执行记录为只读。
**自动恢复缺失的 parent。** 不予采纳,因为继续执行要求确切的存活直接 parent。child 导航不得改变 parent 生命周期。
**公开普通取消操作。** 不予采纳,因为已获 inbox 接受的轮次会比其准入请求存续更久,继续执行 seam 不会公开具备安全授权的取消句柄。
**公开普通取消操作。** 不予采纳,因为已获 inbox 接受的轮次会比其准入请求存续更久,且在本决定当时,继续执行 seam 公开具备安全授权的取消句柄。后来的[当前轮次中断契约](2026-08-06-continuable-subagent-interrupt.md)以专用 subagent 路由补上了这项显式授权;回退到 `session.cancel` 仍被拒绝。
**只显示可继续 child。** 不予采纳,因为持久化目录有意描述由会话支撑的两种 mode。one-shot transcript 即使绝不接受后续消息,仍然有用。
@@ -103,9 +103,9 @@ one-shot 行始终会用文案替代输入框,说明执行记录为只读。
- 宿主协议测试固定 schema包括必需的布尔可展开性、id 回显、mode 校验、非激活式历史、确切 parent 强制要求、FIFO 准入回执、取消与脱敏后的失败映射。
- 通用 Host 测试固定在不发布 Agent 的情况下读取已附加与冷态历史及执行 fork、冷态投影归并、按描述符origin运行时 owner 拒绝、拒绝显式 id 接纳,以及直接队列控制栅栏。
- 客户端对象测试固定已保留与已恢复的地址、one-shot 只读拒绝、历史路由、可继续提示词路由、已寻址对话不提供取消、屏蔽绑定到 agent 的模型控件、实时活动状态翻转(包括在途响应回放与 detach 回退、subagent parent 可展开性翻转与成员刷新。
- 客户端对象测试固定已保留与已恢复的地址、one-shot 只读与取消拒绝、历史路由、可继续提示词与中断路由、屏蔽绑定到 agent 的模型控件、实时活动状态翻转(包括在途响应回放与 detach 回退、subagent parent 可展开性翻转与成员刷新。
- jsdom 测试固定后代聚合计数与活动状态、侧边栏活动在嵌套谱系中的传播与普通 fork 边界、行状态优先级、token 用量总计、精确到秒的运行中耗时与冻结后 inactive 耗时、采用自适应单位的长耗时及其精确无障碍文本、目录缺失或为陈旧空目录时由摘要支撑的根操作、已知加载行的形态、混合 mode 行、点击前的叶子展开控件、diagnostic、后代懒加载展开、直接 parent 地址、键盘行为与两种只读原因。
- 无密钥的组装 Web 快照包含一个具有持久化 token 用量的 inactive 可继续 child、一个具有确定性长耗时的 inactive one-shot sibling 和一个持久化 grandchild它会固定触发器在一次陈旧的空目录响应后仍显示三个后代并固定 token 用量与计时行自适应长耗时呈现,在不激活的情况下展开、打开持久化历史、准入一条用户 FIFO 后续消息、归并 child mux 事件,并证明 one-shot 历史仍然只读。另一个独立的组装场景会在 model seam 处保持一个真实的 child Agent 轮次进行中,同时固定页头和可见空闲 owner 行中的聚合运行状态,随后在 teardown 期间取消该轮次。
- 无密钥的组装 Web 快照包含一个具有持久化 token 用量的 inactive 可继续 child、一个具有确定性长耗时的 inactive one-shot sibling 和一个持久化 grandchild它会固定触发器在一次陈旧的空目录响应后仍显示三个后代并固定 token 用量与计时行自适应长耗时呈现及聚合 `running` 状态转换,在不激活的情况下展开、打开持久化历史、准入一条用户 FIFO 后续消息、归并 child mux 事件,并证明 one-shot 历史仍然只读。另一个独立的组装场景会在 model seam 处保持一个真实的 child Agent 轮次进行中,同时固定页头和可见空闲 owner 行中的聚合运行状态,随后在 teardown 期间取消该轮次。
- 导航测试固定仅含 subagent 的面包屑导航、从 subagent 创建 fork 时的 Workspace 归属,以及 `origin: 'subagent'` 侧边栏过滤,同时不隐藏普通 fork。
## 后果
@@ -114,4 +114,4 @@ one-shot 行始终会用文案替代输入框,说明执行记录为只读。
- parent 可用性、child 活动状态与 `hasChildren` 都是快照。列出之后发布、dispose、其他发送方或其他进程都可能抢先改变状态类型化提示词失败仍属预期行为。
- child 可能在历史获取与 mux 订阅之间发布,因此现有序号归并也涵盖从冷态转为存活的已寻址路径。
- 持久化 origin 会为 child header 与列表投影添加一个有意保持弱约束的产品分类字段;它不能变成授权捷径。
- UI 不提供 child 取消、持久化结果、Activation 身份、删除或可独立交互的离线 mode其文案不得暗示这些功能已经存在。活跃轮次耗时度量的是已记录工作而非 Activation 驻留时间。
- 除对正在运行的可继续 child 的当前轮次 Stop[中断契约](2026-08-06-continuable-subagent-interrupt.md))之外,UI 不提供 child 取消、持久化结果、Activation 身份、删除或可独立交互的离线 mode其文案不得暗示这些功能已经存在。活跃轮次耗时度量的是已记录工作而非 Activation 驻留时间。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md
2026-07-28-continuable-subagent-conversations.md: a5a900c3bb30a8d965aabc0bf498f8399ee70e7a
2026-07-28-continuable-subagent-conversations.zh.md: bde8e874ba27950dc0fab8449c6ba2ee49737d41
2026-07-28-continuable-subagent-conversations.md: 56d9abb2b09577b2fba14b9a655a941417e0c493
2026-07-28-continuable-subagent-conversations.zh.md: 77f497ceeef35dcc519242dca30c81759cc3b907

View File

@@ -131,7 +131,7 @@ Parent-originated delivery requires the parent to be live when admitted and keep
### Durability, disposal, and recovery
Without Tasks there is no `task_output`, `task_kill`, Task status, per-message result promise, or public subagent cancellation operation. The caller signal can abort start or follow-up only before inbox acceptance. After acceptance, the parent cannot cancel the message, turn, or Activation through `ctx.subagents`; `Agent.cancel()` remains a lower-level Agent capability that this version does not expose through the subagent service.
Without Tasks there is no `task_output`, `task_kill`, Task status, or per-message result promise. The caller signal can abort start or follow-up only before inbox acceptance. After acceptance, the parent cannot cancel the accepted message or dispose the Activation through `ctx.subagents`; the only public stop is the later [current-turn interrupt](2026-08-06-continuable-subagent-interrupt.md), which cancels the live target's current turn with `keepInbox` and leaves residency, pending work, and descendants intact.
Host and manager teardown remains the lifecycle stop path. Manager unload applies it globally; a host applies it only below the exact top-level Agents it owns. Each form closes the applicable admission scope, stops the selected visible Activations, awaits admitted materializations in that scope, releases child-first, and preserves the durable Sessions.
@@ -145,7 +145,7 @@ Session and descriptor persistence survive restart. Activation state, Agent inbo
This version covers continuable in-process children and leaves one-shot delegation unchanged. Remote providers require a separate Activation handle with equivalent authenticated control and child-first quiescence contracts before they can support the same behavior.
It adds no host-user continuation, subagent steering operation, durable mailbox, cross-process lease, automatic replay of interrupted inbox work, team authority, workflow authority, public subagent cancellation operation, public residency query, new live-Activation or descendant limit, or runtime cache. Existing delegation-depth policy remains unchanged. Optional child-to-parent reporting is a later consumer of this lifecycle rather than part of the base continuable capability.
It adds no host-user continuation, subagent steering operation, durable mailbox, cross-process lease, automatic replay of interrupted inbox work, team authority, workflow authority, public residency query, new live-Activation or descendant limit, or runtime cache; the later [current-turn interrupt](2026-08-06-continuable-subagent-interrupt.md) added the one public stop operation on top of this lifecycle. Existing delegation-depth policy remains unchanged. Optional child-to-parent reporting is a later consumer of this lifecycle rather than part of the base continuable capability.
## Alternatives considered
@@ -186,7 +186,7 @@ The implementation pins these behaviors:
- `followup()` accepts only the exact live direct parent and rechecks that identity at the final no-await inbox-admission boundary after any materialization; durable message provenance cannot authorize delivery.
- Continuation messages always use `Agent.followup()` and share its inbox FIFO, including when the child already has an open turn.
- `ctx.subagents.followup()` and its `send_message` adapter return only the accepted `MessageId`; the continuation layer accepts no delivery target and defines no subagent-specific route result.
- This version exposes no public subagent cancellation operation; caller signals stop start and follow-up only before inbox acceptance, while host-scoped and manager-global teardown retain child-first cleanup.
- Caller signals stop start and follow-up only before inbox acceptance, while host-scoped and manager-global teardown retain child-first cleanup; the [current-turn interrupt](2026-08-06-continuable-subagent-interrupt.md) is the one public stop and does not enter teardown.
- This version exposes no subagent steering operation or current-turn controller state.
- An idle Agent with live owned children yields a `waiting` Activation whose `AgentHandle` remains retained.
- A `next-turn` delivered to `waiting` wakes the same Activation; delivery after completed disposal cold-resumes a new Activation.

View File

@@ -131,7 +131,7 @@ activation-owner 作用域之所以存在,是因为普通 Cordis owner effect
### 持久性、dispose 与恢复
没有 Task 后,系统不再提供 `task_output``task_kill`、Task 状态逐消息结果 promise 或公开 subagent 取消操作。调用方 signal 只能在 inbox 接受消息前中止 start 或 follow-up。消息被接受后parent 不能通过 `ctx.subagents` 取消该消息、轮次或激活;`Agent.cancel()` 仍是底层 Agent 能力,但本版本不通过 subagent 服务暴露它
没有 Task 后,系统不再提供 `task_output``task_kill`、Task 状态逐消息结果 promise。调用方 signal 只能在 inbox 接受消息前中止 start 或 follow-up。消息被接受后parent 不能通过 `ctx.subagents` 取消已接受的消息或 dispose 激活;唯一的公开停止操作是后来的[当前轮次中断](2026-08-06-continuable-subagent-interrupt.md),它以 `keepInbox` 取消在线目标的当前轮次,驻留、待处理工作与后代均保持不变
宿主和管理器拆卸仍是生命周期停止路径。管理器卸载会全局应用它;宿主只会在自己确切拥有的顶层 Agent 之下应用它。两种形式都会关闭适用的准入作用域,停止选中的可见 Activation等待该作用域中已获准的物化过程按 child-first 顺序释放,并保留持久化 Session。
@@ -145,7 +145,7 @@ activation-owner 作用域之所以存在,是因为普通 Cordis owner effect
本版本覆盖可继续的进程内 child一次性委派保持不变。远程提供方必须具备单独的激活 handle以及等价的认证控制与 child-first 完全停稳契约,才能支持同样的行为。
它不新增 host-user 继续执行、subagent steering 操作、持久化邮箱、跨进程 lease、中断 inbox 工作的自动回放、团队权限、工作流权限、公开 subagent 取消操作、公开驻留查询、新的在线激活数量或后代总数限制,以及运行时缓存。现有委派深度策略保持不变。可选的 child 到 parent 报告是后续消费该生命周期的功能,不属于基础可继续能力。
它不新增 host-user 继续执行、subagent steering 操作、持久化邮箱、跨进程 lease、中断 inbox 工作的自动回放、团队权限、工作流权限、公开驻留查询、新的在线激活数量或后代总数限制,以及运行时缓存;后来的[当前轮次中断](2026-08-06-continuable-subagent-interrupt.md)在此生命周期之上补充了唯一的公开停止操作。现有委派深度策略保持不变。可选的 child 到 parent 报告是后续消费该生命周期的功能,不属于基础可继续能力。
## 曾考虑的替代方案
@@ -186,7 +186,7 @@ activation-owner 作用域之所以存在,是因为普通 Cordis owner effect
- `followup()` 只接受确切的在线直接 parent并在任何物化之后的最终无 await 的 inbox 准入边界再次检查该身份;持久化消息来源信息不能授权投递。
- 继续执行消息始终使用 `Agent.followup()` 并共享其 inbox FIFO包括 child 已有开放轮次的情况。
- `ctx.subagents.followup()` 及其 `send_message` 适配器只返回已接受的 `MessageId`;继续执行层不接受投递 target也不定义 subagent 专属路由结果。
- 本版本不暴露公开 subagent 取消操作;调用方 signal 只能在 inbox 接受消息前停止 start 和 follow-up限定到宿主的拆卸与管理器全局拆卸则保留 child-first 清理。
- 调用方 signal 只能在 inbox 接受消息前停止 start 和 follow-up限定到宿主的拆卸与管理器全局拆卸则保留 child-first 清理[当前轮次中断](2026-08-06-continuable-subagent-interrupt.md)是唯一的公开停止操作,且不进入拆卸流程
- 本版本不暴露 subagent steering 操作或当前轮次控制方状态。
- 带有在线所持 child 的空闲 Agent 会产生 `waiting` 激活,其 `AgentHandle` 继续保留。
-`waiting` 投递 `next-turn` 会唤醒同一个激活;完成 dispose 后投递消息会冷恢复新激活。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.md
2026-08-06-continuable-subagent-interrupt.md: 0f663ac6a58b9c3d658aa572ca2fea18dbef6449
2026-08-06-continuable-subagent-interrupt.zh.md: b8a5b557b73b2845e3c0b1dabdec0163f56eca48

View File

@@ -0,0 +1,48 @@
# Agent Note: Continuable subagent current-turn interrupt
Status: implemented
English | [中文](2026-08-06-continuable-subagent-interrupt.zh.md)
## Problem
A running continuable subagent could not be stopped without destroying it. The continuation manager cancels child Agents only inside whole-Activation teardown (settlement, drain, scoped drain), `send_message`/`subagent.prompt` only add work, and the Web composer's Stop button was deliberately limited to ordinary sessions. A human watching a continuable child burn tokens on a wrong path had no lever short of killing the parent tree, and when the direct parent Agent was offline the child was entirely untouchable even though its Activation stayed live. One-shot runs have holder-owned disposal and task-kill; continuable children had no analogous current-turn control.
## Decision
`ctx.subagents.interrupt(targetSessionId, authority)` stops only the live target's current turn. The manager primitive authorizes synchronously, calls the existing `Agent.cancel(cause, { keepInbox: true })`, and returns `void` — fire-and-return: the cancel signal is guaranteed issued, target quiescence is not awaited. Nothing else changes: no Activation disposal, no handle release, no descendant cascade, no inbox clearing, and no `AgentLoop` or `CancelOptions` change. Because `keepInbox` parks the unclaimed pending queue at idle, an interrupt never auto-starts the next queued follow-up; work already claimed into the interrupted turn belongs to that turn and is not requeued. Once the interrupted driver is idle, an explicit waking send resumes the preserved FIFO order.
Authority is a closed two-variant union, deliberately wider than delivery authority because stopping a turn is idempotent and delivers no content:
- `{ kind: 'user', parentSessionId }` — a human presents the durable direct-parent address. The live target's `session.header.parentSession` must match; no live parent Agent, catalog read, or persistence access is involved, which is exactly what keeps a live child stoppable while its parent Agent is offline. Cancel cause `user`.
- `{ kind: 'ancestor', agent }` — an exact live ancestor Agent (direct parent or deeper). The caller must be the registry's current entry for its id (stale callers are rejected even for absent targets), must not be the target itself, and must appear in the Activation's materialization-time `ancestry` WeakSet. Cancel cause `parent`.
Targets are resolved only in the manager's process-local Activation map. An absent id — unknown, one-shot, or naturally settled — is an accepted no-op, which uniformly covers completion races and repeat requests without leaking durable-catalog information; a target whose disposal transaction is already open is likewise an accepted no-op after authorization. One-shot lifecycle (holder `dispose()`, task-kill) is untouched. `SubagentService.interrupt()` treats a manager-less composition as an accepted no-op rather than `CONTINUATION_UNAVAILABLE`, because without a manager no manager-owned live Activation can exist.
The Host RPC `subagent.interrupt` takes the continuable `SubagentAddress` and returns `{ accepted: true }`. Its implementation calls only the core primitive with `user` authority — deliberately no `catalogChild()`, `listChildren()`, `sessionQuery`, or parent-registry lookup. A live target with a mismatched parent address maps to `subagent-unauthorized`; unexpected failures map to `internal` without leaking error text onto the wire.
## Alternatives considered
**Route human interrupts through `session.cancel`.** The generic session cancel requires an attached ordinary session and rejects subagent-owned sessions; widening it would entangle subagent authority rules with ordinary session routing. A subagent-domain RPC keeps the address-based authorization and the parent-offline guarantee explicit.
**Await target quiescence and return the turn outcome.** Cancellation is cooperative, so quiescence is unbounded; holding the RPC (and a `ChildLock` slot) open invites timeouts and convoying against delivery and disposal. Acceptance-of-signal is the only fact the caller needs, and races (natural completion, disposal) already settle idempotently.
**Reuse whole-Activation disposal for interrupt.** Disposal cancels without `keepInbox`, flushes, captures, and releases the handle — it destroys queued work and the child's residency. Interrupt is a control operation on one turn, not a lifecycle operation on the Activation.
**Extend `send_message`/`followup` authority to ancestors while at it.** Delivery injects content into a conversation and is not idempotent; its exact-direct-parent authority stays unchanged. Only interrupt gets the wider ancestor and address-based user authority.
**Auto-resume the parked queue after an interrupt.** Immediately starting queued follow-up B after aborting A would make the interrupt look ignored and steal the human's window to redirect the child. Parking until an explicit waking send keeps the stop observable and the FIFO order intact.
## Consequences
A human or ancestor can stop a runaway continuable turn without losing the child, its unclaimed queued work, or its running descendants; the cost is a deliberately weak postcondition (`accepted` means "signal issued", so a target may remain visibly `running` until it observes the signal) that clients must render honestly. The parked-queue rule means an interrupted child sits idle with retained work until a waking message arrives after the driver is idle — an intentional human-in-the-loop pause, not a scheduler defect. A waking send accepted during abort convergence currently remains queued without latching wake; Issue #1838 tracks the shared agent-loop correction.
The address-only RPC exposes one bit of live residency: an absent target is accepted while a live target under a mismatched parent returns `subagent-unauthorized`. The single-user local Host trust model accepts that observability; a future multi-principal Host must revisit both authority and response indistinguishability.
The Web surface reuses the existing primary Send/Stop toggle rather than adding a second action: the client `Session.cancel()` routes a continuable address through `subagent.interrupt` (one-shot addresses stay uncancellable, ordinary sessions keep `session.cancel`), and a running parent-offline continuable child keeps the default composer with disabled input so that same primary Stop remains reachable, returning to the read-only takeover once it stops ([Web subagent conversations](2026-07-27-web-subagent-conversations.md) owns the surrounding catalog and composer contract).
The model-facing `interrupt_agent(agent_id)` tool in `dsh-tool-subagent-control` passes `exec.agent` as the `ancestor` authority and adds none of its own: the core primitive verifies live registry identity and recorded lineage, so the tool can name a direct child or a deeper descendant with the same generic `agent_id` parameter — deliberately not `subagent_id`, which would imply direct children only. Discovery rides `list_agents({ scope: 'descendants' })` over the new `SubagentService.listDescendants()` one-trace pre-order walk with verified `parentId`/`depth` per entry ([durable catalog note](2026-07-22-durable-subagent-catalog-and-list-agents.md) owns the listing contract); discovery is a hint, never authority. `send_message` keeps its exact-direct-parent authority — only interrupt is ancestor-wide.
## Testing
Core coverage in `packages/subagent/subagent/tests/continuation.spec.ts` proves the durable `turn/end` abort, parked-then-FIFO-resumed queue, untouched descendant, both authority kinds with their cancel causes, self/sibling/stale/non-ancestor rejection, absent/one-shot/disposal-race no-ops, and the unchanged `keepInbox` loop behavior. Host coverage in `packages/host/apiproxy/tests` proves the RPC calls only the core primitive (no agents/catalog/history reads), the `subagent-unauthorized`/`internal` mappings, the wire schema's continuable-mode fence, and carrier round-trips. Client coverage pins the address-routed `Session.cancel()`, the InputBar Send/Stop toggle with the parent-offline locked-input state, and the read-only-composer selector's running exception; the keyless assembled Web scenarios (`apps/web/tests/subagent-interrupt.e2e.ts`, `subagent-interrupt-ui.e2e.ts`) hold a real child turn open with a replay hang entry and prove the interrupt transport, the aborted `turn/end`, the parked follow-up, and the FIFO resume end to end. Tool coverage in `packages/subagent/tool-subagent-control/tests` proves direct and deep ancestor interrupts with the `parent` cause and parked queue, self/sibling/stranger rejection without touching the target, absent-target no-ops without cold resume, and the descendants listing's pre-order positions; the keyless ACP snapshots pin the new tool schemas in every recorded request header.

View File

@@ -0,0 +1,48 @@
# Agent Note: Continuable subagent 当前轮次中断
Status: implemented
[English](2026-08-06-continuable-subagent-interrupt.md) | 中文
## Problem
一个正在运行的 continuable subagent 无法在不销毁它的前提下被停止。继续执行管理器只在整个 Activation 拆除结算、drain、scoped drain内部取消子 Agent`send_message``subagent.prompt` 只能增加工作,而 Web composer 的 Stop 按钮被刻意限制在普通会话。人类眼看着一个 continuable child 在错误路径上烧 token除了干掉整个 parent 树没有任何手段;当直接 parent Agent 离线时,即使 child 的 Activation 仍然在线,它也完全不可触及。一次性运行有持有方拥有的 disposal 和 task-killcontinuable child 没有对应的当前轮次控制。
## Decision
`ctx.subagents.interrupt(targetSessionId, authority)` 只停止在线目标的当前轮次。管理器原语同步完成鉴权,调用现有的 `Agent.cancel(cause, { keepInbox: true })`,然后返回 `void`——fire-and-return保证取消信号已发出但不等待目标静止。其余一切不变不 dispose Activation、不释放 handle、不级联后代、不清空 inbox也不改动 `AgentLoop``CancelOptions`。由于 `keepInbox` 让尚未领取的待处理队列停在 idle中断绝不会自动启动下一个排队的 follow-up已被领取进入中断轮次的工作属于该轮次不会重新入队。被中断的 driver 进入 idle 后,一次显式唤醒发送会按保留的 FIFO 顺序恢复。
授权是一个封闭的双变体 union刻意比投递权限更宽因为停止一个轮次是幂等的且不投递任何内容
- `{ kind: 'user', parentSessionId }`——人类出示持久化直接 parent 地址。在线目标的 `session.header.parentSession` 必须匹配;不涉及在线 parent Agent、目录读取或持久化访问这正是 parent Agent 离线时在线 child 仍可被停止的原因。取消 cause 为 `user`
- `{ kind: 'ancestor', agent }`——一个确切在线的 ancestor Agent直接 parent 或更深)。调用方必须是注册表中其 id 的当前条目(过期调用方即使目标不存在也被拒绝),不得是目标本身,并且必须出现在 Activation 物化时记录的 `ancestry` WeakSet 中。取消 cause 为 `parent`
目标只在管理器进程本地的 Activation map 中解析。不存在的 id——未知、一次性或已自然结算——是被接受的 no-op统一覆盖完成竞态和重复请求而不泄露持久化目录信息disposal 事务已打开的目标在鉴权后同样是被接受的 no-op。一次性生命周期持有方 `dispose()`、task-kill不受影响。`SubagentService.interrupt()` 把未绑定管理器的组合视为被接受的 no-op 而不是 `CONTINUATION_UNAVAILABLE`,因为没有管理器就不可能存在管理器拥有的在线 Activation。
Host RPC `subagent.interrupt` 接收 continuable 的 `SubagentAddress` 并返回 `{ accepted: true }`。它的实现只以 `user` 授权调用核心原语——刻意不调用 `catalogChild()``listChildren()``sessionQuery` 或 parent 注册表查找。parent 地址不匹配的在线目标映射为 `subagent-unauthorized`;意外失败映射为 `internal`,不把错误文本泄漏到 wire。
## Alternatives considered
**让人类中断走 `session.cancel`。** 通用会话取消要求附着的普通会话并拒绝 subagent 拥有的会话;放宽它会把 subagent 权限规则缠进普通会话路由。subagent 域的 RPC 让基于地址的鉴权和 parent 离线保证保持显式。
**等待目标静止并返回轮次结果。** 取消是协作式的,静止时间无上界;让 RPC以及一个 `ChildLock` 槽位保持打开会招致超时并与投递、disposal 形成排队。调用方需要的唯一事实是信号已被接受而竞态自然完成、disposal本就幂等收敛。
**复用整个 Activation 的 disposal 来做中断。** disposal 的取消不带 `keepInbox`,还会 flush、capture 并释放 handle——它销毁排队工作和 child 的驻留。中断是针对一个轮次的控制操作,不是针对 Activation 的生命周期操作。
**顺手把 `send_message``followup` 权限扩展到 ancestor。** 投递向对话注入内容且不幂等;其确切直接 parent 权限保持不变。只有中断获得更宽的 ancestor 与基于地址的用户授权。
**中断后自动恢复被暂停的队列。** 在中止 A 后立即启动排队的 follow-up B 会让中断看起来被忽略,并夺走人类重新引导 child 的窗口。暂停到显式唤醒发送为止,让停止可观察且 FIFO 顺序完整。
## Consequences
人类或 ancestor 可以停止一个失控的 continuable 轮次,而不丢失 child、其尚未领取的排队工作或正在运行的后代代价是一个刻意保持弱的后置条件`accepted` 表示“信号已发出”,目标在观察到信号前可能仍显示 `running`),客户端必须如实呈现。暂停队列规则意味着被中断的 child 会带着保留的工作停在 idle直到 driver 进入 idle 后收到唤醒消息——这是有意的 human-in-the-loop 暂停,不是调度器缺陷。在 abort 收敛期间被接受的唤醒发送目前会保持排队而不锁存 wakeIssue #1838 跟踪共享的 agent-loop 修正。
仅凭地址的 RPC 会暴露一位在线驻留信息:不存在的目标会被接受,而 parent 不匹配的在线目标会返回 `subagent-unauthorized`。单用户本地 Host 的信任模型接受这种可观察性;未来的多主体 Host 必须重新审视权限和响应不可区分性。
Web 侧复用现有的 primary Send/Stop 切换而不新增第二个操作:客户端 `Session.cancel()` 将 continuable 地址路由到 `subagent.interrupt`one-shot 地址保持不可取消,普通会话仍走 `session.cancel`parent 离线但仍在运行的 continuable child 保留默认 composer 并禁用其输入区,让同一个 primary Stop 保持可达,停止后恢复只读替代(周边目录与 composer 契约由 [Web subagent 对话](2026-07-27-web-subagent-conversations.md)拥有)。
`dsh-tool-subagent-control` 中面向模型的 `interrupt_agent(agent_id)` 工具把 `exec.agent` 作为 `ancestor` 授权传入,自身不增加任何权限:核心原语校验在线注册表身份与记录的 lineage因此该工具可以用同一个通用 `agent_id` 参数指定直接 child 或更深的后代——刻意不用会暗示仅限直接 child 的 `subagent_id`。发现依赖 `list_agents({ scope: 'descendants' })`,其底层是新的 `SubagentService.listDescendants()` 单次追踪 pre-order 遍历,每个条目带经校验的 `parentId``depth`(列表契约由[持久化目录 note](2026-07-22-durable-subagent-catalog-and-list-agents.md)拥有);发现只是提示,绝非权限。`send_message` 保持其确切直接 parent 权限——只有中断是 ancestor 级的。
## Testing
`packages/subagent/subagent/tests/continuation.spec.ts` 中的核心覆盖证明了持久化 `turn/end` 中止、队列先暂停后按 FIFO 恢复、后代不受影响、两种授权及其取消 cause、self/sibling/stale/非 ancestor 拒绝、absent/一次性/disposal 竞态 no-op以及 `keepInbox` 循环行为不变。`packages/host/apiproxy/tests` 中的 Host 覆盖证明 RPC 只调用核心原语(不读 agents/目录/历史)、`subagent-unauthorized``internal` 映射、wire schema 的 continuable 模式围栏以及 carrier 往返。客户端覆盖固定按地址路由的 `Session.cancel()`、InputBar 的 Send/Stop 切换及 parent 离线时锁定输入的状态,以及只读 composer selector 的运行例外keyless 组装 Web 场景(`apps/web/tests/subagent-interrupt.e2e.ts``subagent-interrupt-ui.e2e.ts`)用 replay hang 条目保持真实 child 轮次打开,端到端证明中断传输、中止的 `turn/end`、follow-up 暂停以及 FIFO 恢复。`packages/subagent/tool-subagent-control/tests` 中的工具覆盖证明直接与更深 ancestor 以 `parent` cause 中断并暂停队列、self/sibling/陌生调用方被拒绝且不触碰目标、目标不存在时 no-op 且不冷恢复,以及 descendants 列表的 pre-order 位置keyless ACP 快照把新工具 schema 固定在每个已录制请求 header 中。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-08-04-task-surface.md
2026-08-04-task-surface.md: 72fc9e12f1a02f66a02d3335ad2e8d0d4c1c3dc2
2026-08-04-task-surface.zh.md: 087c80195f75e145da93c5557afb43a421e1050d

View File

@@ -0,0 +1,289 @@
# Agent Note: Task Surface for structured session interaction
Status: proposed
English | [中文](2026-08-04-task-surface.zh.md)
## Problem
Some tasks are awkward to finish through alternating prose messages. Comparing several options, reordering a plan, reviewing a table, or filling a small set of related fields all work better as one structured interaction. Today an agent can describe such an interaction, but it cannot ask the Web client to render one without adding a permanent product component or generating executable Client Plugin code.
Those two workarounds put ownership in the wrong place. Product-specific components require a new trigger and release for every task shape. Generated code has far more authority and lifecycle cost than a one-turn form needs. It also makes the presentation, rather than the user's conclusion, the durable artifact.
The missing contract is a bounded, replayable description of a temporary UI that belongs to one Session and one tool occurrence. The product should own validation, placement, interaction mechanics, and submission. The agent should own the task-specific copy, data, and choice of supported components.
## Proposal
Add **Task Surface**, a versioned declarative model rendered by a normal Web Client Plugin. One stable model-facing tool, `show_task_surface`, publishes the model. A successful call ends the current turn. The user edits and submits the rendered panel; the Host records the submission as one ordinary visible user message and starts the next turn.
Task Surface is the default structured-UI path when all of the following hold:
- the interaction belongs to the current Session and current task;
- its behavior fits the declared component set;
- it needs no background execution or new runtime authority; and
- the useful durable result is the user's submitted conclusion, not the panel itself.
This is one trigger, not a family of product heuristics. The agent calls `show_task_surface` explicitly. A user may ask the agent to use a Task Surface in ordinary language. Products do not inspect tool names or task topics to open bespoke panels, and repeated use does not automatically turn a Task Surface into a Plugin.
Short blocking questions remain with [`ask_user_question`](../../implemented/feature/2026-07-29-ask-question-web-presentation.md). Plain explanation remains chat. Cross-Session navigation, background behavior, new services, or durable custom UI belongs to the Generated Client Plugin workflow.
## Declarative model
`TaskSurfaceModelV1` is JSON. It contains content blocks, input fields, and one submit label; it contains no code, callbacks, selectors, HTML, CSS, URLs to executable assets, or expression language. This type is unrelated to core Session's existing `SurfaceManager`/`SurfaceOp` message-reduction types; Task Surface is a product interaction protocol.
```ts
interface TaskSurfaceModelV1 {
version: 1
title: string
description?: string
sections: TaskSurfaceSection[]
fields?: TaskSurfaceField[]
submit: { label: string }
}
interface TaskSurfaceSection {
id: string
title?: string
layout?: TaskSurfaceLayout
blocks: TaskSurfaceBlock[]
}
type TaskSurfaceLayout =
| { kind: 'stack' }
| { kind: 'grid'; columns: 2 | 3 }
type TaskSurfaceBlock =
| { kind: 'markdown'; text: string }
| { kind: 'metrics'; items: { label: string; value: string; detail?: string }[] }
| { kind: 'table'; columns: { id: string; label: string }[]; rows: Record<string, string | number | boolean | null>[] }
| { kind: 'diff'; path?: string; before: string | null; after: string; language?: string }
| { kind: 'notice'; tone: 'neutral' | 'info' | 'warning'; text: string }
type TaskSurfaceField =
| { kind: 'text'; id: string; label: string; multiline?: boolean; required?: boolean; initial?: string }
| { kind: 'choice'; id: string; label: string; options: TaskSurfaceOption[]; initial?: string }
| { kind: 'multi-choice'; id: string; label: string; options: TaskSurfaceOption[]; initial?: string[] }
| { kind: 'toggle'; id: string; label: string; initial?: boolean }
| { kind: 'order'; id: string; label: string; options: TaskSurfaceOption[]; initial?: string[] }
interface TaskSurfaceOption { id: string; label: string; detail?: string }
```
The renderer controls typography, spacing, responsive layout, focus order, keyboard behavior, and theme tokens. An absent layout means `stack`; a `grid` layout owns its column count and collapses when the available width cannot support it. Unknown versions or union arms use the generic tool-result fallback instead of partial interpretation.
The `markdown` block reuses `MarkdownText` with an explicit model-URL policy. `MarkdownText` gains `remoteImages: 'render' | 'alt-only'`, preserving `render` as its ordinary default; Task Surface always passes `alt-only`, so image syntax renders only its alt text. Raw HTML and embedded media remain omitted, automatic link previews are absent, and no model-supplied URL is dereferenced without explicit user activation. Ordinary HTTP(S) links may still navigate when the user chooses them. Fixed application assets such as syntax-highlighting chunks remain under the product's normal loading policy.
Version 1 deliberately omits conditional fields, client-side data fetching, charts, file uploads, and arbitrary event handlers. A new block or field kind is a protocol change with a parser, renderer, accessibility behavior, fallback, and replay fixture in the same change.
Limits are schema-backed configuration on the Task Surface service. The initial defaults are 64 KiB for the normalized model, 64 blocks, 32 fields, 200 table rows, and 32 KiB for a submission. IDs are unique within the model; field values must match their declarations; unknown fields are rejected. The limits bound log, DOM, and prompt costs without changing the protocol.
## Tool and presentation contract
`show_task_surface` accepts `{ model: TaskSurfaceModelV1 }`. The Host parses and normalizes the complete model, rejects the call when that Session already has an open Task Surface, mints `surfaceId`, and returns canonical `{ surfaceId, model }` with the normalized model. `presentationMeta` persists `value.model`, so the projector and executor cannot disagree about normalization. The Native result names the Surface and explains that an ordinary message bypasses it when the client cannot render the panel. The tool then calls `exec.concludeTurn()` so the agent does not continue past the requested human checkpoint.
The tool definition omits `isConcurrencySafe`. Under the existing tool-registry contract, omission classifies every call as an exclusive ordering barrier; no new `ToolDefinition` field is introduced. The tool is composed only in Web profiles that mount both the Host service and Web renderer. Version 1 supports `native` and `both` tool modes; a `code`-only profile does not advertise it because Code Mode dispatch is nested and cannot carry its presentation metadata to the outer result.
The browser-safe domain package imports the type-only `Branded` primitive from `@deepseek-ai/dsh-brand` and owns all three Task Surface IDs. The canonical value is execution-local under the [canonical tool output contract](../../implemented/architecture/2026-07-20-canonical-tool-output-contract.md). Replay therefore uses `output.presentationMeta(args, value)` to persist this tagged payload with `tool/result.meta`:
```ts
import type { Branded } from '@deepseek-ai/dsh-brand'
type TaskSurfaceId = Branded<'TaskSurfaceId'>
type TaskSurfaceSubmissionId = Branded<'TaskSurfaceSubmissionId'>
type TaskSurfaceDismissalId = Branded<'TaskSurfaceDismissalId'>
interface TaskSurfacePresentationMeta {
kind: 'dsh/task-surface'
version: 1
surfaceId: TaskSurfaceId
model: TaskSurfaceModelV1
}
```
The tool keeps a generic [render intent](../../implemented/architecture/2026-07-02-tool-render-intent-union.md). The keyed Web row reads the tagged metadata already retained on `ToolResultNode`; no new render-intent arm or presentation registry is required. Clients without Task Surface support render the ordinary result content.
The Web plugin has two static Session-scoped registrations under the [toolview](../../implemented/architecture/2026-07-23-toolview-dissolution.md) and [slot registration](../../implemented/architecture/2026-07-22-slot-type-chain-implementation.md) contracts. A keyed `conversation.chat.toolview` entry for `show_task_surface` renders the durable transcript occurrence as a compact summary and read-only replay. One `TaskSurfaceDock` entry in the existing `conversation.input.dock` is the only actionable mount: it reads the active projection, calls `getActive` for the exact identity, and owns fields, drafts, submit, and dismiss. Because the Dock is independent of transcript pagination, an active Surface remains actionable when its `ToolResultNode` is outside the loaded history window.
The Dock follows the existing composer-chain fallback semantics. Any `conversation.composer` takeover hides the fallback composer stack, including `TaskSurfaceDock`, without unmounting it; the same draft owner reappears when the takeover resolves. A takeover does not receive Task Surface actions or create another editor.
The model does not choose a conversation tab, dock order, details column, modal, pixel position, or z-index. A later placement change remains a renderer decision and does not alter logged models. The transcript row never becomes a second editor, so one Surface cannot acquire competing draft or submission owners.
## Submission contract
The Task Surface domain exposes three operations through the Host transport. `submit` is the only one that admits a user message:
```ts ignore-check
type TaskSurfaceSubmissionPhase = 'queued' | 'claiming'
interface TaskSurfacePendingSubmission {
submissionId: TaskSurfaceSubmissionId
messageId: MessageId
phase: TaskSurfaceSubmissionPhase
}
interface TaskSurfaceService {
getActive(input: { sessionId: SessionId; surfaceId: TaskSurfaceId }): Promise<GetActiveTaskSurfaceResult>
submit(input: SubmitTaskSurfaceRequest): Promise<SubmitTaskSurfaceResult>
dismiss(input: DismissTaskSurfaceRequest): Promise<DismissTaskSurfaceResult>
}
interface SubmitTaskSurfaceRequest {
sessionId: SessionId
surfaceId: TaskSurfaceId
submissionId: TaskSurfaceSubmissionId
values: Record<string, JsonValue>
note?: string
}
type SubmitTaskSurfaceResult =
| { accepted: true; messageId: MessageId; phase: 'queued' }
| { accepted: false; reason: 'not-open' | 'stale' | 'invalid-submission' | 'submission-pending' }
type GetActiveTaskSurfaceResult =
| {
active: true
callId: CallId
surfaceId: TaskSurfaceId
model: TaskSurfaceModelV1
pending: TaskSurfacePendingSubmission | null
}
| { active: false; reason: 'not-open' }
interface DismissTaskSurfaceRequest {
sessionId: SessionId
surfaceId: TaskSurfaceId
dismissalId: TaskSurfaceDismissalId
}
type DismissTaskSurfaceResult =
| { dismissed: true; eventSeq: number }
| { dismissed: false; reason: 'not-open' | 'stale' | 'submission-pending' }
```
The Host resolves the exact successful `show_task_surface` occurrence, revalidates the submitted values against its persisted model, and admits the response through the normal Session queue. The response becomes a user-role message with a merge-extensible source:
```ts ignore-check
interface TaskSurfaceCorrelation {
version: 1
submissionId: TaskSurfaceSubmissionId
callId: CallId
surfaceId: TaskSurfaceId
values: Record<string, JsonValue>
}
interface TaskSurfaceUserMessageSource {
kind: 'user'
rpcId: RpcId
taskSurface: TaskSurfaceCorrelation
}
```
The `session/queue` wire item already carries the complete `Message`. The client projection is explicitly extended to retain its source instead of dropping the correlation:
```ts ignore-check
interface QueuedMessage {
id: InboxItemId
messageId: MessageId
placement: 'queued' | 'steering'
source: MessageSource
content: readonly ContentBlock[]
preview: string
text: string | null
}
```
The browser-safe domain package owns `TaskSurfaceId`, the submission and dismissal IDs, `TaskSurfaceCorrelation`, and the pending-submission shape. ApiProxy owns the transport augmentation that combines the correlation with `rpcId`. Keeping `kind: 'user'` preserves the ordinary user bubble and prompt semantics while the extra field provides durable correlation. The message content is a product-formatted readable summary: panel title, labels and submitted values, plus the optional note. The model receives that same text. The structured source is not a second hidden instruction.
The product shell owns collapse and dismiss. Collapse is local view state and sends nothing. When no submission is pending, `taskSurface.dismiss({ sessionId, surfaceId, dismissalId })` appends one `task-surface/dismissed` Session event and does not start a turn; the exact event closes the projection and updates the Dock and transcript row. Retries reuse `dismissalId` and return the original result without appending another event. Dismiss is disabled while a submission is `queued` or `claiming`, and the Host rejects such a request with `submission-pending`.
Submission is transactional at the client boundary. Acceptance returns the exact `messageId` in phase `queued`; the Dock disables every mutation through both `queued` and `claiming` and clears the persisted draft only after the matching user message becomes durable. A rejection keeps the values editable and shows the returned reason. Double clicks and transport retries reuse `submissionId` and return the first result; another submission ID receives `submission-pending` while the first is live. The Host admits one user message for one accepted Surface.
The Task Surface service records accepted submission coordination as `pending.phase: 'queued'`, while the client can correlate the still-present queue row through its retained `source`. When the Agent dequeues that occurrence for ordinary prompt admission, the service synchronously changes the same pending record to `claiming` before ApiProxy publishes the ordinary queue snapshot without the claimed row. The service keeps that process-local claim across asynchronous admission and reconnect until a matching durable `user/message` is published or the Agent reports a terminal discard.
The matching `user/message` closes the durable projection and clears the claim. Rejection, cancellation, or disposal before durability reports the discard, clears the claim, and leaves the Surface open. The Dock never interprets queue-row disappearance as either outcome: it re-reads `getActive`; `pending.phase: 'claiming'` stays disabled, `pending: null` restores the draft, and `not-open` closes the Dock. `getActive` joins the log-derived active occurrence with this one process-local pending record. The record is coordination state, not a second durable authority; after a Host restart, an uncommitted claim is absent and the still-open logged Surface becomes editable again.
`session.updateQueue` rejects `edit` and `steer` for a Task Surface-correlated row. Editing would separate formatted content from its source-carried structured values, and steering would persist a `steering/message` that does not satisfy the submission lifecycle. `remove` is allowed while the row is queued; it reports the discard and restores the open Surface. Once claimed, the row has left the generic queue and queue mutations return `queue-item-not-found`. The Task Surface service holds one single-flight pending record until commit or discard.
## Lifecycle and recovery
The Session log is the authority. A small `taskSurface` unit in the existing [Session projection system](../architecture/2026-07-27-session-projection-and-command-log.md) folds successful surface result metadata and later user-message sources into this state:
```ts ignore-check
interface TaskSurfaceProjection {
active: { callId: CallId; surfaceId: TaskSurfaceId } | null
}
```
One Session has at most one open Task Surface. A successful result opens it. A matching Task Surface user message or dismissal event closes it. A later ordinary user message also closes it as an explicit bypass; another `show_task_surface` call fails until one of those events closes the active occurrence. Rewind and fork derive their active occurrence by folding the resulting log; transient queue phase is not copied, and no separate Surface database participates.
The full model remains on its `tool/result.meta`; the projection carries only the active identity. `TaskSurfaceDock` exists independently of history rows and reacts to that identity. `taskSurface.getActive({ sessionId, surfaceId })` reads the exact occurrence from the Session log, revalidates its metadata, joins the Task Surface service's pending coordination record, and returns `{ callId, surfaceId, model, pending }`. A missing or closed occurrence returns `not-open`. Refresh and reconnect therefore recover an actionable Surface and its same-process pending phase even when the result is outside the history tail, without copying the model into every projection baseline.
The Web plugin keeps unsubmitted values in a bounded, per-Session persisted slot store keyed by `surfaceId`; they never enter the Session log, prompt, or long-term memory. Submitted values live in the accepted user message, so losing a browser draft cannot erase a conclusion.
## Package boundaries and dependencies
The capability is split where ownership changes:
| Package | Responsibility |
|---|---|
| `packages/core/agent` and `packages/core/agent-loop` | Generic terminal outcome for a claimed next-turn inbox occurrence, allowing a Host observer to distinguish durable admission from discard without Task Surface-specific types |
| `packages/task-surface/task-surface` | Browser-safe model, branded IDs, correlation and pending types, parser, limits, submission validator/formatter, Session event extension, projection unit, and Host service contract |
| `packages/task-surface/tool-task-surface` | `show_task_surface`, canonical output, presentation metadata, generic render intent, active-Surface check, and `concludeTurn()` behavior |
| `packages/client/runtime` | Generic queued-message `source` projection and Session-scoped active-projection access |
| `packages/client/ui-primitives` | Task Surface-agnostic `MarkdownText.remoteImages` policy, including the `alt-only` image branch and URL-policy tests |
| `packages/client/ui-task-surface` | Static actionable `TaskSurfaceDock`, read-only keyed transcript row, declarative Web renderer that consumes the Task Surface model and `MarkdownText` in `alt-only` mode, per-Session draft store, and submit client |
| `packages/host/apiproxy` | Typed active-read/submit/dismiss transport, user-source augmentation and carriage, queue-action restrictions, and routing of claim and terminal outcomes; delegates validation, pending coordination, and admission to the Task Surface service |
`ui-task-surface` depends on the browser-safe Task Surface domain, client connection and runtime, locale, `ui-conversation` for the declared slot contracts, `ui-slots` for registration, and `ui-primitives`; `ui-primitives` does not depend on Task Surface. ApiProxy depends on the Task Surface service contract and the generic AgentLoop terminal outcome. Core Agent packages do not import Task Surface types.
The implementation depends on the existing message log, canonical tool output, tagged render intents, Session projection, per-Session declared slot stores, and slot lifecycle. It does not depend on runtime Client Plugin creation. The generated Client Plugin workflow may use Task Surface to present a review form, but neither protocol owns or activates the other.
## Delivery stages
1. Land the model/parser, `MarkdownText` model-URL policy, projection unit, `show_task_surface`, presentation metadata, read-only Web row, static `TaskSurfaceDock`, active retrieval, and generic fallback with read-only blocks.
2. Add fields, persisted drafts, Host-validated submit/dismiss, branded correlation, client queued-source carriage, Task Surface `queued`/`claiming` coordination, claimed-occurrence terminal reporting, queue-action restrictions, and visible user-message admission.
3. Add only component kinds justified by real tasks and two consumers or a clear generic fallback. A separate explicit user action may start the generated Plugin authoring workflow, but it creates a candidate; it never promotes code directly.
## Alternatives considered
**Add product-specific triggers and panels.** Rejected because every new task shape would couple agent behavior to a shipped product component. Product code should define one admitted component vocabulary and placement policy; the agent chooses among it explicitly.
**Render arbitrary HTML, CSS, or JavaScript from the tool call.** Rejected because it turns a temporary interaction into executable Client Plugin code without the build, preview, evaluation, approval, or rollback lifecycle that code requires.
**Extend `userInteraction.ask()` with a large form.** Rejected for this contract. `ask()` is a blocking request/response operation used when a running tool cannot continue without a short answer. A Task Surface ends the turn, may remain open across refreshes, and submits its result as the next visible user turn.
**Register one dynamic `conversation.view` per call.** Rejected because the view ledger is global while its render scope is per Session, and because transient task identity would become registration identity. One static Session-scoped Dock owns interaction, and one static keyed row summarizes the logged occurrence; neither registration uses occurrence identity.
**Keep the model only in the canonical tool value.** Rejected because canonical values are not persisted. Replay requires the normalized model in `presentationMeta`.
**Store the panel in long-term memory.** Rejected because layout and draft state are not the reusable fact. Memory may retain the submitted user conclusion under existing memory policy.
## Acceptance criteria
- A real model in `native` or `both` mode can call one stable `show_task_surface` schema, the call ends its turn, and a capable Web client renders the same normalized model live and after replay; `code`-only mode does not advertise it.
- The static `TaskSurfaceDock` is the only editor and remains actionable for an active result outside the loaded history window; the keyed toolview remains a read-only transcript summary and replay. A composer takeover hides the still-mounted Dock, preserves its draft, and reveals the same owner after release.
- Submitting produces exactly one visible user message per `submissionId`, starts the next turn through normal queue admission, and retains exact branded occurrence correlation while keeping `source.kind: 'user'`; dismissing records one log event and starts no turn.
- The queued client row retains the correlated message source. `getActive` exposes `queued` or `claiming` across same-process reconnect; commit closes the projection, while explicit discard clears pending state and leaves the Surface open. Queue-row disappearance alone changes no UI state. Edit and steer are rejected, and remove succeeds only before claim.
- Refresh, reconnect, Session switching, fork, and rewind produce the lifecycle state implied by the log; `getActive` recovers the model and pending phase outside the history tail, and no panel, pending state, or draft leaks across Sessions.
- Unsupported versions, malformed metadata, and absent client capability fall back to readable tool-result content with the ordinary-message bypass; nested calls and calls made while another Surface is active fail without opening a Surface.
- Wire schemas validate ID strings and domain APIs expose the branded ID types throughout. The model parser enforces tagged layout shapes, field values, and configured byte/count limits before the panel becomes actionable. Browser tests show image syntax becomes alt text, raw HTML and embedded media do not render, and no model-supplied URL is requested before explicit user activation.
- Keyboard-only operation, focus restoration, accessible names, narrow layouts, both themes, and zh/en product chrome are covered by component tests.
- Keyless browser composition covers show, Dock and read-only-row ownership, off-window recovery, edit, retry after rejected admission, queued-to-claiming transition, discard, durable handoff without an editable gap, forbidden queue actions, dismiss, reconnect, and double-submit idempotency.
- Prefix snapshots show one stable tool definition regardless of the task-specific model; only the call arguments and later user conclusion vary.
- Unloading the Web plugin disposes its Dock, row, and draft stores through the owning Fiber without changing the durable transcript.
## Risks
The first component set may be either too small for useful tasks or broad enough to become a weak application framework. Usage evidence should decide additions; v1 has no expression language or network behavior.
The Task Surface Markdown policy gives up inline images, media, and automatic link previews. Ordinary links remain useful, but only an explicit user activation may navigate or start a request.
Large tables and Markdown can still create expensive DOM even inside byte limits. The renderer must virtualize or truncate where needed while preserving a readable fallback and explicit counts.
A product-formatted submission can become verbose when many fields are filled. The formatter needs a deterministic compact form and must preserve every submitted value without repeating the complete display model.
Holding a process-local claim until durable handoff adds a terminal-state invariant. Every admission exit must produce either the matching `user/message` or an explicit discard; otherwise a reconnect could retain a disabled Dock indefinitely.
Browser-local draft persistence can retain sensitive unsubmitted text. The store needs the stated byte bound, per-Session keys, explicit clearing after acceptance, and the same storage posture as the existing conversation draft.
The Dock and transcript row show the same occurrence in different roles. Keeping the row read-only and the Dock as the sole mutation owner prevents conflicting drafts at the cost of a second compact representation while the Surface is active.

View File

@@ -0,0 +1,289 @@
# Agent Note: 用于结构化会话交互的 Task Surface
Status: proposed
[English](2026-08-04-task-surface.md) | 中文
## 问题
有些任务很难通过交替发送文本消息来完成。比较多个选项、调整计划顺序、审阅表格或填写一小组关联字段都更适合在一次结构化交互中处理。目前agent智能体可以描述这类交互但若不增加永久的产品组件或生成可执行的客户端插件代码就无法要求 Web 客户端渲染这类交互。
这两种变通方案的职责归属都不合理。产品专用组件要求每种任务形态都新增触发方式并发布新版本。对于只需一个轮次的表单,生成代码所拥有的权限和生命周期成本都远超实际需要。这样做还会把展示界面而非用户结论变成持久产物。
目前缺少这样一份契约:用有界、可回放的描述来定义临时 UI并让它只属于一个会话和一次工具调用实例。产品应当负责校验、放置、交互机制和提交agent 应当负责特定任务的文案、数据,以及从受支持组件中作出选择。
## 提案
新增 **Task Surface**:一种由普通 Web 客户端插件渲染、带版本的声明式模型。面向模型提供一个稳定工具 `show_task_surface`用于发布该模型。调用成功后当前轮次结束。用户编辑并提交渲染出的面板Host 将提交内容记录为一条普通的可见用户消息,并开始下一轮。
同时满足以下条件时Task Surface 是默认的结构化 UI 路径:
- 交互属于当前会话和当前任务;
- 行为可以由已声明的组件集合表达;
- 不需要后台执行或新增运行时权限;
- 有价值的持久结果是用户提交的结论,而不是面板本身。
这里定义的是一个触发方式不是一组产品启发式规则。agent 会显式调用 `show_task_surface`。用户可以通过普通语言要求 agent 使用 Task Surface。产品不会根据工具名称或任务主题打开专用面板重复使用也不会自动把 Task Surface 转为插件。
简短的阻塞式问题仍由 [`ask_user_question`](../../implemented/feature/2026-07-29-ask-question-web-presentation.md) 处理。纯文本说明仍留在聊天中。跨会话导航、后台行为、新服务或持久自定义 UI 则属于 Generated Client Plugin 工作流。
## 声明式模型
`TaskSurfaceModelV1` 使用 JSON。它包含内容块、输入字段和一个提交标签不包含代码、回调、选择器、HTML、CSS、可执行产物的 URL也不包含表达式语言。该类型与核心会话中现有的 `SurfaceManager`/`SurfaceOp` 消息归约类型无关Task Surface 是一套产品交互协议。
```ts
interface TaskSurfaceModelV1 {
version: 1
title: string
description?: string
sections: TaskSurfaceSection[]
fields?: TaskSurfaceField[]
submit: { label: string }
}
interface TaskSurfaceSection {
id: string
title?: string
layout?: TaskSurfaceLayout
blocks: TaskSurfaceBlock[]
}
type TaskSurfaceLayout =
| { kind: 'stack' }
| { kind: 'grid'; columns: 2 | 3 }
type TaskSurfaceBlock =
| { kind: 'markdown'; text: string }
| { kind: 'metrics'; items: { label: string; value: string; detail?: string }[] }
| { kind: 'table'; columns: { id: string; label: string }[]; rows: Record<string, string | number | boolean | null>[] }
| { kind: 'diff'; path?: string; before: string | null; after: string; language?: string }
| { kind: 'notice'; tone: 'neutral' | 'info' | 'warning'; text: string }
type TaskSurfaceField =
| { kind: 'text'; id: string; label: string; multiline?: boolean; required?: boolean; initial?: string }
| { kind: 'choice'; id: string; label: string; options: TaskSurfaceOption[]; initial?: string }
| { kind: 'multi-choice'; id: string; label: string; options: TaskSurfaceOption[]; initial?: string[] }
| { kind: 'toggle'; id: string; label: string; initial?: boolean }
| { kind: 'order'; id: string; label: string; options: TaskSurfaceOption[]; initial?: string[] }
interface TaskSurfaceOption { id: string; label: string; detail?: string }
```
渲染器控制字体排印、间距、响应式布局、焦点顺序、键盘行为和主题 token。未指定布局时使用 `stack``grid` 布局自带列数,可用宽度无法容纳时会折叠。遇到未知版本或联合类型分支时,系统使用通用工具结果回退,而不是只解释其中一部分。
`markdown` 块复用 `MarkdownText`,并显式指定模型 URL 策略。`MarkdownText` 新增 `remoteImages: 'render' | 'alt-only'`,普通场景仍默认使用 `render`Task Surface 始终传入 `alt-only`,因此图片语法只渲染替代文本。原始 HTML 和嵌入式媒体仍会被省略,不生成自动链接预览;未经用户显式操作,不会解引用模型提供的任何 URL。普通 HTTP(S) 链接仍可在用户选择后导航。语法高亮分片等固定应用资源继续遵循产品的常规加载策略。
版本 1 有意不支持条件字段、客户端数据获取、图表、文件上传和任意事件处理器。新增任何块或字段类型都属于协议变更,必须在同一变更中加入解析器、渲染器、无障碍行为、回退方式和回放 fixture测试前置数据
Task Surface 服务通过受 schema 校验的配置定义限制。初始默认值为:规范化模型不超过 64 KiB、块不超过 64 个、字段不超过 32 个、表格行不超过 200 行、提交内容不超过 32 KiB。模型内的 ID 必须唯一字段值必须符合其声明未知字段会被拒绝。这些限制约束日志、DOM 和提示词成本,但不改变协议。
## 工具与呈现契约
`show_task_surface` 接收 `{ model: TaskSurfaceModelV1 }`。Host 解析并规范化完整模型;若该会话已有一个打开的 Task Surface则拒绝调用否则生成 `surfaceId`,并返回带规范化模型的规范值 `{ surfaceId, model }``presentationMeta` 持久化 `value.model`使投影器和执行器不会对规范化结果产生分歧。Native 结果会指明该 Surface并说明客户端无法渲染面板时可以通过普通消息绕过它。随后工具调用 `exec.concludeTurn()`,防止 agent 越过所要求的人工检查点继续执行。
工具定义省略 `isConcurrencySafe`。根据现有工具注册表契约,省略该字段会将每次调用归类为独占排序屏障,无需新增 `ToolDefinition` 字段。该工具只会组装到同时挂载 Host 服务和 Web 渲染器的 Web profile 中。版本 1 支持 `native``both` 工具模式;仅支持 `code` 的 profile 不会向模型公布该工具,因为 Code Mode 分发属于嵌套调用,无法把呈现元数据传到外层结果。
浏览器安全的领域包从 `@deepseek-ai/dsh-brand` 以仅类型方式导入 `Branded` 原语,并拥有全部三个 Task Surface ID。根据[规范工具输出契约](../../implemented/architecture/2026-07-20-canonical-tool-output-contract.md),规范值仅存在于本次执行中。因此,回放通过 `output.presentationMeta(args, value)` 将以下带标签的载荷随 `tool/result.meta` 一并持久化:
```ts
import type { Branded } from '@deepseek-ai/dsh-brand'
type TaskSurfaceId = Branded<'TaskSurfaceId'>
type TaskSurfaceSubmissionId = Branded<'TaskSurfaceSubmissionId'>
type TaskSurfaceDismissalId = Branded<'TaskSurfaceDismissalId'>
interface TaskSurfacePresentationMeta {
kind: 'dsh/task-surface'
version: 1
surfaceId: TaskSurfaceId
model: TaskSurfaceModelV1
}
```
该工具保留通用 [render intent](../../implemented/architecture/2026-07-02-tool-render-intent-union.md)。带 key 的 Web 行读取 `ToolResultNode` 上已经保留的带标签元数据,无需新增 render-intent 分支或呈现注册表。不支持 Task Surface 的客户端会渲染普通结果内容。
Web 插件按照 [toolview](../../implemented/architecture/2026-07-23-toolview-dissolution.md) 和 [slot 注册](../../implemented/architecture/2026-07-22-slot-type-chain-implementation.md)契约,提供两个静态的会话作用域注册项。一个以 `show_task_surface` 为 key 的 `conversation.chat.toolview` 条目将持久 transcript文本记录调用实例渲染为简洁摘要和只读回放。现有 `conversation.input.dock` 中的一个 `TaskSurfaceDock` 条目是唯一可操作的挂载点:它读取活动投影,针对确切身份调用 `getActive`并拥有字段、草稿、提交和关闭操作。Dock 与 transcript 分页相互独立,因此即使 `ToolResultNode` 位于已加载历史窗口之外,活动 Surface 仍可操作。
Dock 遵循现有 composer chain 的回退语义。任何 `conversation.composer` 接管都会隐藏包括 `TaskSurfaceDock` 在内的回退 composer 栈,但不会将其卸载;接管结束后,同一个草稿所有者会重新出现。接管方不会获得 Task Surface 操作,也不会创建另一个编辑器。
模型不能选择会话标签页、Dock 顺序、详情栏、模态框、像素位置或 z-index。以后即使改变放置位置也只是渲染器的决策不会改变日志中记录的模型。transcript 行绝不会成为第二个编辑器,因此同一个 Surface 不会出现相互竞争的草稿或提交所有者。
## 提交契约
Task Surface 领域通过 Host 传输层公开三个操作。只有 `submit` 会接纳用户消息:
```ts ignore-check
type TaskSurfaceSubmissionPhase = 'queued' | 'claiming'
interface TaskSurfacePendingSubmission {
submissionId: TaskSurfaceSubmissionId
messageId: MessageId
phase: TaskSurfaceSubmissionPhase
}
interface TaskSurfaceService {
getActive(input: { sessionId: SessionId; surfaceId: TaskSurfaceId }): Promise<GetActiveTaskSurfaceResult>
submit(input: SubmitTaskSurfaceRequest): Promise<SubmitTaskSurfaceResult>
dismiss(input: DismissTaskSurfaceRequest): Promise<DismissTaskSurfaceResult>
}
interface SubmitTaskSurfaceRequest {
sessionId: SessionId
surfaceId: TaskSurfaceId
submissionId: TaskSurfaceSubmissionId
values: Record<string, JsonValue>
note?: string
}
type SubmitTaskSurfaceResult =
| { accepted: true; messageId: MessageId; phase: 'queued' }
| { accepted: false; reason: 'not-open' | 'stale' | 'invalid-submission' | 'submission-pending' }
type GetActiveTaskSurfaceResult =
| {
active: true
callId: CallId
surfaceId: TaskSurfaceId
model: TaskSurfaceModelV1
pending: TaskSurfacePendingSubmission | null
}
| { active: false; reason: 'not-open' }
interface DismissTaskSurfaceRequest {
sessionId: SessionId
surfaceId: TaskSurfaceId
dismissalId: TaskSurfaceDismissalId
}
type DismissTaskSurfaceResult =
| { dismissed: true; eventSeq: number }
| { dismissed: false; reason: 'not-open' | 'stale' | 'submission-pending' }
```
Host 解析出 `show_task_surface` 的确切成功调用实例,依据其已持久化模型重新校验提交值,并通过普通会话队列接纳响应。该响应成为一条用户角色消息,并使用可合并扩展的消息来源:
```ts ignore-check
interface TaskSurfaceCorrelation {
version: 1
submissionId: TaskSurfaceSubmissionId
callId: CallId
surfaceId: TaskSurfaceId
values: Record<string, JsonValue>
}
interface TaskSurfaceUserMessageSource {
kind: 'user'
rpcId: RpcId
taskSurface: TaskSurfaceCorrelation
}
```
`session/queue` 线上的条目已经携带完整 `Message`。客户端投影会显式扩展以保留其来源,不再丢失关联信息:
```ts ignore-check
interface QueuedMessage {
id: InboxItemId
messageId: MessageId
placement: 'queued' | 'steering'
source: MessageSource
content: readonly ContentBlock[]
preview: string
text: string | null
}
```
浏览器安全的领域包拥有 `TaskSurfaceId`、提交和关闭 ID、`TaskSurfaceCorrelation`以及待处理提交的形态。ApiProxy 拥有传输扩展,负责将关联信息与 `rpcId` 组合。保留 `kind: 'user'` 可维持普通用户消息气泡和提示词语义,额外字段则提供持久关联信息。消息内容是由产品格式化的可读摘要,包括面板标题、标签和提交值,以及可选备注。模型接收相同的文本。结构化来源不是第二条隐藏指令。
产品外壳负责收起和关闭。收起属于本地视图状态,不会发送任何内容。没有待处理提交时,`taskSurface.dismiss({ sessionId, surfaceId, dismissalId })` 会追加一个 `task-surface/dismissed` 会话事件,但不启动轮次;该精确事件会关闭投影,并更新 Dock 和 transcript 行。重试会复用 `dismissalId` 并返回原始结果,不会再追加事件。提交处于 `queued` 或 `claiming` 阶段时关闭操作会被禁用Host 也会以 `submission-pending` 拒绝这类请求。
客户端边界上的提交具有事务性。接纳成功会返回处于 `queued` 阶段的确切 `messageId`;在 `queued` 和 `claiming` 两个阶段中Dock 会禁用所有变更,并且只有匹配的用户消息持久化后,才会清除已持久化的草稿。若请求被拒绝,则保留值供用户继续编辑,并显示返回的原因。双击和传输重试会复用 `submissionId` 并返回第一次调用的结果;只要第一次提交仍在处理中,另一个提交 ID 就会收到 `submission-pending`。对于一个已接受的 SurfaceHost 只会接纳一条用户消息。
Task Surface 服务将已接受提交的协调状态记录为 `pending.phase: 'queued'`,客户端则可通过仍在队列中的行所保留的 `source` 关联它。当 Agent 从队列取出该调用实例进行普通提示词接纳时,服务会先同步把同一份待处理记录改为 `claiming`,然后 ApiProxy 才发布不再包含已认领行的普通队列快照。服务会在异步接纳和重新连接期间一直保留这份进程内认领状态,直到匹配的持久 `user/message` 发布,或 Agent 报告终态丢弃。
匹配的 `user/message` 会关闭持久投影并清除认领状态。在持久化之前发生拒绝、取消或 dispose资源释放系统会报告丢弃、清除认领状态并让 Surface 保持打开。Dock 绝不会把队列行消失解读为其中任一结果,而会重新读取 `getActive``pending.phase: 'claiming'` 会维持禁用状态,`pending: null` 会恢复草稿,`not-open` 会关闭 Dock。`getActive` 会把由日志推导的活动调用实例与这唯一一份进程内待处理记录合并。该记录属于协调状态不是第二个持久权威来源Host 重启后,未提交的认领状态不复存在,日志中仍然打开的 Surface 会恢复为可编辑状态。
对于带有 Task Surface 关联信息的行,`session.updateQueue` 会拒绝 `edit` 和 `steer`。编辑会让格式化内容与消息来源所携带的结构化值脱节,而 steering中途引导会持久化一条不符合提交生命周期的 `steering/message`。该行仍在队列中时允许 `remove`;它会报告丢弃并恢复为打开的 Surface。行被认领后即已离开通用队列队列变更会返回 `queue-item-not-found`。Task Surface 服务会持有一份 single-flight 待处理记录,直至提交或丢弃。
## 生命周期与恢复
会话日志是真源。现有[会话投影系统](../architecture/2026-07-27-session-projection-and-command-log.md)中的一个小型 `taskSurface` 单元会折叠成功调用的 Surface 结果元数据和后续用户消息来源,得到以下状态:
```ts ignore-check
interface TaskSurfaceProjection {
active: { callId: CallId; surfaceId: TaskSurfaceId } | null
}
```
一个会话最多只能有一个打开的 Task Surface。成功的结果会打开它匹配的 Task Surface 用户消息或关闭事件会将其关闭。后续的普通用户消息也会将其关闭,这是一条显式的绕过路径;在以上任一事件关闭活动调用实例前,再次调用 `show_task_surface` 都会失败。回退和 fork 会通过折叠相应日志推导出活动调用实例;瞬态队列阶段不会被复制,也不会有独立的 Surface 数据库参与其中。
完整模型仍存放在对应的 `tool/result.meta` 中;投影只携带活动身份。`TaskSurfaceDock` 独立于历史行存在,并会响应该身份。`taskSurface.getActive({ sessionId, surfaceId })` 会从会话日志中读取确切调用实例,重新校验其元数据,合并 Task Surface 服务的待处理协调记录,并返回 `{ callId, surfaceId, model, pending }`。调用实例不存在或已经关闭时返回 `not-open`。因此,即使结果位于历史尾段之外,刷新和重新连接仍能恢复可操作的 Surface 及其同进程待处理阶段,而无需把模型复制到每一个投影基线中。
Web 插件将未提交值保存在一个有界、按会话持久化的 slot store 中,并以 `surfaceId` 为 key这些值永远不会进入会话日志、提示词或长期记忆。已提交值存放在接纳的用户消息中因此即使浏览器草稿丢失也不会抹去结论。
## 包边界与依赖
该能力在职责变化处拆分为多个包:
| 包 | 职责 |
|---|---|
| `packages/core/agent` 和 `packages/core/agent-loop` | 为已认领的下一轮 inbox 调用实例提供通用终态结果,让 Host 观察方无需使用 Task Surface 专用类型,即可区分持久接纳和丢弃 |
| `packages/task-surface/task-surface` | 浏览器安全的模型、带品牌类型的 ID、关联和待处理类型、解析器、限制、提交校验器格式化器、会话事件扩展、投影单元以及 Host 服务契约 |
| `packages/task-surface/tool-task-surface` | `show_task_surface`、规范输出、呈现元数据、通用 render intent、活动 Surface 检查和 `concludeTurn()` 行为 |
| `packages/client/runtime` | 通用排队消息 `source` 投影和会话作用域的活动投影访问 |
| `packages/client/ui-primitives` | 与 Task Surface 无关的 `MarkdownText.remoteImages` 策略,包括 `alt-only` 图片分支和 URL 策略测试 |
| `packages/client/ui-task-surface` | 静态且可操作的 `TaskSurfaceDock`、带 key 的只读 transcript 行、消费 Task Surface 模型并以 `alt-only` 模式使用 `MarkdownText` 的声明式 Web 渲染器、按会话划分的草稿 store以及提交客户端 |
| `packages/host/apiproxy` | 类型化的活动 Surface 读取/提交/关闭传输、用户消息来源扩展与传递、队列操作限制,以及认领和终态结果的路由;将校验、待处理协调和接纳委托给 Task Surface 服务 |
`ui-task-surface` 依赖浏览器安全的 Task Surface 领域包、客户端连接与运行时、locale、`ui-conversation` 所声明的 slot 契约、用于注册的 `ui-slots`,以及 `ui-primitives``ui-primitives` 不反向依赖 Task Surface。ApiProxy 依赖 Task Surface 服务契约和通用 AgentLoop 终态结果。核心 Agent 包不导入 Task Surface 类型。
该实现依赖现有的消息日志、规范工具输出、带标签的 render intent、会话投影、按会话作用域声明的 slot store 和 slot 生命周期不依赖在运行时创建客户端插件。Generated Client Plugin 工作流可以使用 Task Surface 展示审阅表单,但两个协议都不拥有或激活另一个协议。
## 交付阶段
1. 实现模型/解析器、`MarkdownText` 模型 URL 策略、投影单元、`show_task_surface`、呈现元数据、只读 Web 行、静态 `TaskSurfaceDock`、活动 Surface 读取,以及带只读块的通用回退。
2. 增加字段、持久化草稿、经 Host 校验的提交关闭、带品牌类型的关联信息、客户端排队来源传递、Task Surface `queued`/`claiming` 协调、已认领调用实例的终态报告、队列操作限制,以及可见用户消息接纳。
3. 只增加有实际任务依据,并且拥有至少两个消费方或明确通用回退的组件类型。一个单独的显式用户操作可以启动生成式插件编写工作流,但只会创建候选项,绝不会直接推广代码。
## 考虑过的替代方案
**增加产品专用触发方式和面板。**不予采用,因为每种新任务形态都会把 agent 行为与已发布的产品组件耦合。产品代码应当定义一套接纳的组件词汇和放置策略agent 则显式地从中选择。
**从工具调用中渲染任意 HTML、CSS 或 JavaScript。**不予采用,因为这会把临时交互变成可执行的客户端插件代码,却不具备代码所需的构建、预览、评估、批准或回滚生命周期。
**使用大型表单扩展 `userInteraction.ask()`。**本契约不采用这种做法。`ask()` 是一种阻塞式请求响应操作适用于正在运行的工具必须先获得简短答案才能继续执行的情况。Task Surface 会结束当前轮次,可以在刷新后继续保持打开,并把结果提交为下一条可见用户消息。
**每次调用都注册一个动态 `conversation.view`。**不予采用,因为视图账本是全局的,而其渲染作用域按会话划分;同时,临时任务身份会变成注册身份。一个静态的会话作用域 Dock 负责交互,一个静态带 key 的行概述已记录的调用实例;两个注册项都不使用调用实例身份。
**只在规范工具值中保留模型。**不予采用,因为规范值不会持久化。回放要求将规范化模型写入 `presentationMeta`。
**将面板存入长期记忆。**不予采用,因为布局和草稿状态不是可复用事实。现有记忆策略可以保留用户提交的结论。
## 验收标准
- 在 `native` 或 `both` 工具模式下,真实模型可以调用一个稳定的 `show_task_surface` schema调用结束当前轮次具备相应能力的 Web 客户端在实时运行和回放后都能渲染同一份规范化模型;仅支持 `code` 的模式不会向模型公布该工具。
- 静态 `TaskSurfaceDock` 是唯一的编辑器,即使活动结果位于已加载历史窗口之外也仍可操作;带 key 的 toolview 始终是 transcript 的只读摘要和回放。composer 接管会隐藏仍处于挂载状态的 Dock、保留其草稿并在接管释放后重新显示同一个所有者。
- 每个 `submissionId` 的提交操作恰好生成一条可见用户消息,通过普通队列接纳开始下一轮,并在保留 `source.kind: 'user'` 的同时维持带品牌类型的确切调用实例关联;关闭操作记录一条日志事件,且不启动轮次。
- 客户端排队行保留已关联的消息来源。`getActive` 可在同一进程的重新连接前后公开 `queued` 或 `claiming`;提交会关闭投影,显式丢弃则会清除待处理状态并让 Surface 保持打开。队列行消失本身不会改变任何 UI 状态。系统会拒绝编辑和 steering且移除操作只能在认领前成功。
- 刷新、重新连接、会话切换、fork 和回退都生成日志所决定的生命周期状态;`getActive` 可以恢复历史尾段之外的模型和待处理阶段,任何面板、待处理状态或草稿都不会泄漏到其他会话。
- 不受支持的版本、格式错误的元数据以及客户端能力缺失时,系统回退到带普通消息绕过路径的可读工具结果内容;嵌套调用以及已有另一个活动 Surface 时发起的调用都无法打开 Surface并以失败结束。
- 线上的 schema 会校验 ID 字符串,领域 API 始终公开带品牌类型的 ID。模型解析器会在面板可交互前强制校验带标签的布局形态、字段值以及配置的字节数和数量限制。浏览器测试证明图片语法会变成替代文本原始 HTML 和嵌入式媒体不会渲染,而且在用户显式操作前不会请求模型提供的 URL。
- 组件测试覆盖纯键盘操作、焦点恢复、无障碍名称、窄屏布局、两种主题,以及中英文产品界面。
- 无密钥浏览器组合测试覆盖显示、Dock 与只读行的职责归属、窗口外恢复、编辑、接纳被拒后的重试、从 `queued` 到 `claiming` 的转换、丢弃、没有可编辑空档的持久交接、禁止的队列操作、关闭、重新连接和双重提交幂等性。
- 前缀快照表明:无论任务特定模型如何变化,都只存在一个稳定的工具定义;只有调用参数和后续用户结论发生变化。
- 卸载 Web 插件时,其所属 Fiber 会对 Dock、工具行和草稿 store 执行 dispose但不会改变持久 transcript。
## 风险
第一批组件可能小到无法满足实际任务也可能大到足以演变成一个粗糙的应用框架。是否新增组件应由使用证据决定v1 不提供表达式语言或网络行为。
Task Surface 的 Markdown 策略舍弃行内图片、媒体和自动链接预览。普通链接仍有用,但只有用户显式操作后,才可以导航或发起请求。
即使设置了字节限制,大型表格和 Markdown 仍可能生成开销较高的 DOM。渲染器必须按需虚拟化或截断内容同时保留可读回退和明确计数。
填写字段较多时,由产品格式化的提交消息可能过长。格式化器需要使用确定性的紧凑格式,保留每一个提交值,同时避免重复完整显示模型。
在完成持久交接之前一直持有进程内认领状态,会新增一项终态不变量。每条接纳退出路径都必须产生匹配的 `user/message` 或显式丢弃,否则重新连接可能会让 Dock 永久处于禁用状态。
浏览器本地持久化的草稿可能保留敏感的未提交文本。store 需要遵守规定的字节上限、使用按会话划分的 key、在提交成功后显式清除并采用与现有会话草稿相同的存储策略。
Dock 和 transcript 行以不同角色展示同一个调用实例。将工具行保持为只读,并让 Dock 成为唯一的变更所有者,可以避免草稿冲突,但代价是 Surface 活动期间会出现第二份简洁表示。