From 6cd0eea52d92dc3596851316623674e4ec502fb9 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Tue, 4 Aug 2026 22:37:19 -0700 Subject: [PATCH 001/189] docs: propose Task Surface protocol --- .../feature/2026-08-04-task-surface.i18n.yaml | 6 + .../feature/2026-08-04-task-surface.md | 232 ++++++++++++++++++ .../feature/2026-08-04-task-surface.zh.md | 232 ++++++++++++++++++ 3 files changed, 470 insertions(+) create mode 100644 .agents/notes/proposed/feature/2026-08-04-task-surface.i18n.yaml create mode 100644 .agents/notes/proposed/feature/2026-08-04-task-surface.md create mode 100644 .agents/notes/proposed/feature/2026-08-04-task-surface.zh.md diff --git a/.agents/notes/proposed/feature/2026-08-04-task-surface.i18n.yaml b/.agents/notes/proposed/feature/2026-08-04-task-surface.i18n.yaml new file mode 100644 index 0000000000..36e7d8c5d6 --- /dev/null +++ b/.agents/notes/proposed/feature/2026-08-04-task-surface.i18n.yaml @@ -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: 780d5d884c83ab47535fff95efcd80b0b7f0181f +2026-08-04-task-surface.zh.md: 38e6787ee5a4d86261e79c66d99f81614bdb5871 diff --git a/.agents/notes/proposed/feature/2026-08-04-task-surface.md b/.agents/notes/proposed/feature/2026-08-04-task-surface.md new file mode 100644 index 0000000000..780d5d884c --- /dev/null +++ b/.agents/notes/proposed/feature/2026-08-04-task-surface.md @@ -0,0 +1,232 @@ +# 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 ignore-check +interface TaskSurfaceModelV1 { + version: 1 + title: string + description?: string + sections: TaskSurfaceSection[] + fields?: TaskSurfaceField[] + submit: { label: string } +} + +interface TaskSurfaceSection { + id: string + title?: string + layout?: 'stack' | 'grid' + columns?: 2 | 3 + blocks: TaskSurfaceBlock[] +} + +type TaskSurfaceBlock = + | { kind: 'markdown'; text: string } + | { kind: 'metrics'; items: { label: string; value: string; detail?: string }[] } + | { kind: 'table'; columns: { id: string; label: string }[]; rows: Record[] } + | { 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. `grid` is a layout hint: it collapses when the available width cannot support the requested columns. Markdown uses the product's supported Markdown subset. Unknown versions or union arms use the generic tool-result fallback instead of partial interpretation. + +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 sets `exclusive: true`, and 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 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 ignore-check +interface TaskSurfacePresentationMeta { + kind: 'dsh/task-surface' + version: 1 + surfaceId: string + 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 statically registers one keyed `conversation.chat.toolview` entry for `show_task_surface`, following the [toolview](../../implemented/architecture/2026-07-23-toolview-dissolution.md) and [slot registration](../../implemented/architecture/2026-07-22-slot-type-chain-implementation.md) contracts. The row renders a compact summary when settled and expands the declarative panel inline. The model does not choose a conversation tab, details column, modal, pixel position, or z-index. A later placement change remains a renderer decision and does not alter logged models. + +## 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 TaskSurfaceSubmissionId = string & { readonly __brand: 'TaskSurfaceSubmissionId' } +type TaskSurfaceDismissalId = string & { readonly __brand: 'TaskSurfaceDismissalId' } + +interface TaskSurfaceService { + getActive(input: { sessionId: SessionId; surfaceId: string }): Promise + submit(input: SubmitTaskSurfaceRequest): Promise + dismiss(input: DismissTaskSurfaceRequest): Promise +} + +interface SubmitTaskSurfaceRequest { + sessionId: SessionId + surfaceId: string + submissionId: TaskSurfaceSubmissionId + values: Record + note?: string +} + +type SubmitTaskSurfaceResult = + | { accepted: true; messageId: MessageId } + | { accepted: false; reason: 'not-open' | 'stale' | 'invalid-submission' } + +type GetActiveTaskSurfaceResult = + | { active: true; callId: CallId; surfaceId: string; model: TaskSurfaceModelV1 } + | { active: false; reason: 'not-open' } + +interface DismissTaskSurfaceRequest { + sessionId: SessionId + surfaceId: string + dismissalId: TaskSurfaceDismissalId +} + +type DismissTaskSurfaceResult = + | { dismissed: true; eventSeq: number } + | { dismissed: false; reason: 'not-open' | 'stale' } +``` + +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: string + values: Record +} + +interface TaskSurfaceUserMessageSource { + kind: 'user' + rpcId: RpcId + taskSurface: TaskSurfaceCorrelation +} +``` + +The browser-safe domain package owns `TaskSurfaceCorrelation` and its branded `submissionId`. ApiProxy owns the transport augmentation that combines it 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. `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 transcript row. Retries reuse `dismissalId` and return the original result without appending another event. + +Submission is transactional at the client boundary. The panel disables submit while admission is in flight 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`; the Host admits one user message for one accepted Surface. + +There is a short interval between queue admission and the durable `user/message`. The generic queued-message DTO therefore retains `Message.source`. A queued message with matching Task Surface correlation keeps the panel disabled; if that queue item is discarded, the pending state clears and the draft becomes editable again. The Host holds a process-local single-flight claim for the same interval, then releases it on commit, rejection, or discard. The queue is coordination state, not a second durable lifecycle record. + +## 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: string } | 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; no separate Surface database participates. + +The full model remains on its `tool/result.meta`; the projection carries only the active identity. When that result is outside the loaded history window, `taskSurface.getActive({ sessionId, surfaceId })` reads the exact occurrence from the Session log and returns `{ callId, surfaceId, model }` after revalidating the metadata. A missing or closed occurrence returns `not-open`. Refresh and reconnect therefore do not depend on the active result fitting in the history tail and do not duplicate 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/task-surface/task-surface` | Browser-safe model/types and correlation, 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/ui-task-surface` | Static keyed tool row, declarative Web renderer, per-Session draft store, and submit client | +| `packages/host/apiproxy` | Typed active-read/submit/dismiss transport, user-source augmentation, and queued-source carriage; delegates validation and admission to the Task Surface service | + +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, projection unit, `show_task_surface`, presentation metadata, static Web row, and generic fallback with read-only blocks. +2. Add fields, persisted drafts, Host-validated submit/dismiss, queued-source carriage, 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 keyed toolview keeps occurrence data in the logged call where it belongs. + +**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. +- Submitting produces exactly one visible user message per `submissionId`, starts the next turn through normal queue admission, and retains exact occurrence correlation while keeping `source.kind: 'user'`; dismissing records one log event and starts no turn. +- Refresh, reconnect, Session switching, fork, and rewind produce the lifecycle state implied by the log; `getActive` recovers a model outside the history tail, and no panel 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. +- The parser enforces IDs, union shapes, field values, and configured byte/count limits before the panel becomes actionable. +- 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, edit, retry after rejected admission, queued/discarded submission, durable submit, dismiss, refresh recovery, 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 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. + +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. + +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. diff --git a/.agents/notes/proposed/feature/2026-08-04-task-surface.zh.md b/.agents/notes/proposed/feature/2026-08-04-task-surface.zh.md new file mode 100644 index 0000000000..38e6787ee5 --- /dev/null +++ b/.agents/notes/proposed/feature/2026-08-04-task-surface.zh.md @@ -0,0 +1,232 @@ +# 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 ignore-check +interface TaskSurfaceModelV1 { + version: 1 + title: string + description?: string + sections: TaskSurfaceSection[] + fields?: TaskSurfaceField[] + submit: { label: string } +} + +interface TaskSurfaceSection { + id: string + title?: string + layout?: 'stack' | 'grid' + columns?: 2 | 3 + blocks: TaskSurfaceBlock[] +} + +type TaskSurfaceBlock = + | { kind: 'markdown'; text: string } + | { kind: 'metrics'; items: { label: string; value: string; detail?: string }[] } + | { kind: 'table'; columns: { id: string; label: string }[]; rows: Record[] } + | { 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。`grid` 是布局提示:可用宽度无法容纳所要求的列数时,渲染器会将其折叠。Markdown 使用产品支持的 Markdown 子集。遇到未知版本或联合类型分支时,系统使用通用工具结果回退,而不是只解释其中一部分。 + +版本 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 越过所要求的人工检查点继续执行。 + +工具定义设置 `exclusive: true`,并且只会组装到同时挂载 Host 服务和 Web 渲染器的 Web profile 中。版本 1 支持 `native` 和 `both` 工具模式;仅支持 `code` 的 profile 不会向模型公布该工具,因为 Code Mode 分发属于嵌套调用,无法把呈现元数据传到外层结果。 + +根据[规范工具输出契约](../../implemented/architecture/2026-07-20-canonical-tool-output-contract.md),规范值仅存在于本次执行中。因此,回放通过 `output.presentationMeta(args, value)` 将以下带标签的载荷随 `tool/result.meta` 一并持久化: + +```ts ignore-check +interface TaskSurfacePresentationMeta { + kind: 'dsh/task-surface' + version: 1 + surfaceId: string + 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` 条目。结算后,该行显示简洁摘要,并在行内展开声明式面板。模型不能选择会话标签页、详情栏、模态框、像素位置或 z-index。以后即使改变放置位置,也只是渲染器的决策,不会改变日志中记录的模型。 + +## 提交契约 + +Task Surface 领域通过 Host 传输层公开三个操作。只有 `submit` 会接纳用户消息: + +```ts ignore-check +type TaskSurfaceSubmissionId = string & { readonly __brand: 'TaskSurfaceSubmissionId' } +type TaskSurfaceDismissalId = string & { readonly __brand: 'TaskSurfaceDismissalId' } + +interface TaskSurfaceService { + getActive(input: { sessionId: SessionId; surfaceId: string }): Promise + submit(input: SubmitTaskSurfaceRequest): Promise + dismiss(input: DismissTaskSurfaceRequest): Promise +} + +interface SubmitTaskSurfaceRequest { + sessionId: SessionId + surfaceId: string + submissionId: TaskSurfaceSubmissionId + values: Record + note?: string +} + +type SubmitTaskSurfaceResult = + | { accepted: true; messageId: MessageId } + | { accepted: false; reason: 'not-open' | 'stale' | 'invalid-submission' } + +type GetActiveTaskSurfaceResult = + | { active: true; callId: CallId; surfaceId: string; model: TaskSurfaceModelV1 } + | { active: false; reason: 'not-open' } + +interface DismissTaskSurfaceRequest { + sessionId: SessionId + surfaceId: string + dismissalId: TaskSurfaceDismissalId +} + +type DismissTaskSurfaceResult = + | { dismissed: true; eventSeq: number } + | { dismissed: false; reason: 'not-open' | 'stale' } +``` + +Host 解析出 `show_task_surface` 的确切成功调用实例,依据其已持久化模型重新校验提交值,并通过普通会话队列接纳响应。该响应成为一条用户角色消息,并使用可合并扩展的消息来源: + +```ts ignore-check +interface TaskSurfaceCorrelation { + version: 1 + submissionId: TaskSurfaceSubmissionId + callId: CallId + surfaceId: string + values: Record +} + +interface TaskSurfaceUserMessageSource { + kind: 'user' + rpcId: RpcId + taskSurface: TaskSurfaceCorrelation +} +``` + +浏览器安全的领域包拥有 `TaskSurfaceCorrelation` 及其带品牌类型的 `submissionId`。ApiProxy 拥有传输扩展,负责将其与 `rpcId` 组合。保留 `kind: 'user'` 可维持普通用户消息气泡和提示词语义,额外字段则提供持久关联信息。消息内容是由产品格式化的可读摘要,包括面板标题、标签和提交值,以及可选备注。模型接收相同的文本。结构化来源不是第二条隐藏指令。 + +产品外壳负责收起和关闭。收起属于本地视图状态,不会发送任何内容。`taskSurface.dismiss({ sessionId, surfaceId, dismissalId })` 追加一个 `task-surface/dismissed` 会话事件,但不启动轮次;该精确事件会关闭投影并更新 transcript(文本记录)中的对应行。重试会复用 `dismissalId` 并返回原始结果,不会再追加一个事件。 + +客户端边界上的提交具有事务性。接纳进行期间,面板会禁用提交;只有匹配的用户消息持久化后,才会清除已持久化的草稿。若请求被拒绝,则保留值供用户继续编辑,并显示返回的原因。双击和传输重试会复用 `submissionId`;对于一个已接受的 Surface,Host 只会接纳一条用户消息。 + +队列接纳与 `user/message` 持久化之间存在一个短暂区间。因此,通用排队消息 DTO 会保留 `Message.source`。带有匹配 Task Surface 关联信息的排队消息会使面板维持禁用状态;如果该队列项被丢弃,待处理状态会清除,草稿恢复为可编辑状态。在同一区间,Host 会持有一个进程内 single-flight 占用,并在消息提交持久化、接纳被拒或队列项被丢弃时释放。队列属于协调状态,并不是第二份持久生命周期记录。 + +## 生命周期与恢复 + +会话日志是真源。现有[会话投影系统](../architecture/2026-07-27-session-projection-and-command-log.md)中的一个小型 `taskSurface` 单元会折叠成功调用的 Surface 结果元数据和后续用户消息来源,得到以下状态: + +```ts ignore-check +interface TaskSurfaceProjection { + active: { callId: CallId; surfaceId: string } | null +} +``` + +一个会话最多只能有一个打开的 Task Surface。成功的结果会打开它;匹配的 Task Surface 用户消息或关闭事件会将其关闭。后续的普通用户消息也会将其关闭,这是一条显式的绕过路径;在以上任一事件关闭活动调用实例前,再次调用 `show_task_surface` 都会失败。回退和 fork 会通过折叠相应日志推导出活动调用实例,不会使用独立的 Surface 数据库。 + +完整模型仍存放在对应的 `tool/result.meta` 中;投影只携带活动身份。当该结果超出已加载的历史窗口时,`taskSurface.getActive({ sessionId, surfaceId })` 会从会话日志中读取确切调用实例,重新校验元数据后返回 `{ callId, surfaceId, model }`。调用实例不存在或已经关闭时返回 `not-open`。因此,刷新和重新连接不要求活动结果位于历史尾段,也无需把模型复制到每一个投影基线中。 + +Web 插件将未提交值保存在一个有界、按会话持久化的 slot store 中,并以 `surfaceId` 为 key;这些值永远不会进入会话日志、提示词或长期记忆。已提交值存放在接纳的用户消息中,因此即使浏览器草稿丢失,也不会抹去结论。 + +## 包边界与依赖 + +该能力按职责变化处分包: + +| 包 | 职责 | +|---|---| +| `packages/task-surface/task-surface` | 浏览器安全的模型/类型和关联信息、解析器、限制、提交校验器/格式化器、会话事件扩展、投影单元,以及 Host 服务契约 | +| `packages/task-surface/tool-task-surface` | `show_task_surface`、规范输出、呈现元数据、通用 render intent、活动 Surface 检查和 `concludeTurn()` 行为 | +| `packages/client/ui-task-surface` | 静态带 key 的工具行、声明式 Web 渲染器、按会话划分的草稿 store,以及提交客户端 | +| `packages/host/apiproxy` | 类型化的活动 Surface 读取/提交/关闭传输、用户消息来源扩展和排队来源传递;将校验与接纳委托给 Task Surface 服务 | + +该实现依赖现有的消息日志、规范工具输出、带标签的 render intent、会话投影、按会话作用域声明的 slot store 和 slot 生命周期,不依赖在运行时创建客户端插件。Generated Client Plugin 工作流可以使用 Task Surface 展示审阅表单,但两个协议都不拥有或激活另一个协议。 + +## 交付阶段 + +1. 实现模型/解析器、投影单元、`show_task_surface`、呈现元数据、静态 Web 行,以及带只读块的通用回退。 +2. 增加字段、持久化草稿、经 Host 校验的提交/关闭、排队来源传递,以及可见用户消息接纳。 +3. 只增加有实际任务依据,并且拥有至少两个消费方或明确通用回退的组件类型。一个单独的显式用户操作可以启动生成式插件编写工作流,但只会创建候选项,绝不会直接推广代码。 + +## 考虑过的替代方案 + +**增加产品专用触发方式和面板。**不予采用,因为每种新任务形态都会把 agent 行为与已发布的产品组件耦合。产品代码应当定义一套接纳的组件词汇和放置策略;agent 则显式地从中选择。 + +**从工具调用中渲染任意 HTML、CSS 或 JavaScript。**不予采用,因为这会把临时交互变成可执行的客户端插件代码,却不具备代码所需的构建、预览、评估、批准或回滚生命周期。 + +**使用大型表单扩展 `userInteraction.ask()`。**本契约不采用这种做法。`ask()` 是一种阻塞式请求/响应操作,适用于正在运行的工具必须先获得简短答案才能继续执行的情况。Task Surface 会结束当前轮次,可以在刷新后继续保持打开,并把结果提交为下一条可见用户消息。 + +**每次调用都注册一个动态 `conversation.view`。**不予采用,因为视图账本是全局的,而其渲染作用域按会话划分;同时,临时任务身份会变成注册身份。单个静态带 key 的 toolview 会将调用实例数据保留在归属它的已记录调用中。 + +**只在规范工具值中保留模型。**不予采用,因为规范值不会持久化。回放要求将规范化模型写入 `presentationMeta`。 + +**将面板存入长期记忆。**不予采用,因为布局和草稿状态不是可复用事实。现有记忆策略可以保留用户提交的结论。 + +## 验收标准 + +- 在 `native` 或 `both` 工具模式下,真实模型可以调用一个稳定的 `show_task_surface` schema;调用结束当前轮次;具备相应能力的 Web 客户端在实时运行和回放后都能渲染同一份规范化模型;仅支持 `code` 的模式不会向模型公布该工具。 +- 每个 `submissionId` 的提交操作恰好生成一条可见用户消息,通过普通队列接纳开始下一轮,并在保留 `source.kind: 'user'` 的同时维持对确切调用实例的关联;关闭操作记录一条日志事件,且不启动轮次。 +- 刷新、重新连接、会话切换、fork 和回退都生成日志所决定的生命周期状态;`getActive` 可以恢复历史尾段之外的模型,任何面板都不会泄漏到其他会话。 +- 不受支持的版本、格式错误的元数据以及客户端能力缺失时,系统回退到带普通消息绕过路径的可读工具结果内容;嵌套调用以及已有另一个活动 Surface 时发起的调用都无法打开 Surface,并以失败结束。 +- 解析器会在面板可交互前强制校验 ID、联合类型形态、字段值以及配置的字节数和数量限制。 +- 组件测试覆盖纯键盘操作、焦点恢复、无障碍名称、窄屏布局、两种主题,以及中英文产品界面。 +- 无密钥浏览器组合测试覆盖显示、编辑、接纳被拒后的重试、排队/丢弃提交、持久提交、关闭、刷新恢复和双重提交幂等性。 +- 前缀快照表明:无论任务特定模型如何变化,都只存在一个稳定的工具定义;只有调用参数和后续用户结论发生变化。 +- 卸载 Web 插件时,其所属 Fiber 会对工具行和草稿 store 执行 dispose(资源释放),但不会改变持久 transcript。 + +## 风险 + +第一批组件可能小到无法满足实际任务,也可能大到足以演变成一个粗糙的应用框架。是否新增组件应由使用证据决定;v1 不提供表达式语言或网络行为。 + +即使设置了字节限制,大型表格和 Markdown 仍可能生成开销较高的 DOM。渲染器必须按需虚拟化或截断内容,同时保留可读回退和明确计数。 + +填写字段较多时,由产品格式化的提交消息可能过长。格式化器需要使用确定性的紧凑格式,保留每一个提交值,同时避免重复完整显示模型。 + +浏览器本地持久化的草稿可能保留敏感的未提交文本。store 需要遵守规定的字节上限、使用按会话划分的 key、在提交成功后显式清除,并采用与现有会话草稿相同的存储策略。 From 652f3b61bfa8124f3115a4827624ee8936f3c8a7 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Wed, 5 Aug 2026 00:56:18 -0700 Subject: [PATCH 002/189] docs: tighten Task Surface lifecycle contract --- .../feature/2026-08-04-task-surface.i18n.yaml | 4 +- .../feature/2026-08-04-task-surface.md | 127 ++++++++++++----- .../feature/2026-08-04-task-surface.zh.md | 129 +++++++++++++----- 3 files changed, 187 insertions(+), 73 deletions(-) diff --git a/.agents/notes/proposed/feature/2026-08-04-task-surface.i18n.yaml b/.agents/notes/proposed/feature/2026-08-04-task-surface.i18n.yaml index 36e7d8c5d6..fec93e2c36 100644 --- a/.agents/notes/proposed/feature/2026-08-04-task-surface.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-08-04-task-surface.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-08-04-task-surface.md -2026-08-04-task-surface.md: 780d5d884c83ab47535fff95efcd80b0b7f0181f -2026-08-04-task-surface.zh.md: 38e6787ee5a4d86261e79c66d99f81614bdb5871 +2026-08-04-task-surface.md: 0d79d7b830689a269d1aede937fba6aa647ea483 +2026-08-04-task-surface.zh.md: 7960c02dc3c112a8cd1ff626274dfac1f01f1bdb diff --git a/.agents/notes/proposed/feature/2026-08-04-task-surface.md b/.agents/notes/proposed/feature/2026-08-04-task-surface.md index 780d5d884c..0d79d7b830 100644 --- a/.agents/notes/proposed/feature/2026-08-04-task-surface.md +++ b/.agents/notes/proposed/feature/2026-08-04-task-surface.md @@ -44,11 +44,14 @@ interface TaskSurfaceModelV1 { interface TaskSurfaceSection { id: string title?: string - layout?: 'stack' | 'grid' - columns?: 2 | 3 + 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 }[] } @@ -66,7 +69,9 @@ type TaskSurfaceField = interface TaskSurfaceOption { id: string; label: string; detail?: string } ``` -The renderer controls typography, spacing, responsive layout, focus order, keyboard behavior, and theme tokens. `grid` is a layout hint: it collapses when the available width cannot support the requested columns. Markdown uses the product's supported Markdown subset. Unknown versions or union arms use the generic tool-result fallback instead of partial interpretation. +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. @@ -76,62 +81,83 @@ Limits are schema-backed configuration on the Task Surface service. The initial `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 sets `exclusive: true`, and 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 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 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`: +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 ignore-check +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: string + 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 statically registers one keyed `conversation.chat.toolview` entry for `show_task_surface`, following the [toolview](../../implemented/architecture/2026-07-23-toolview-dissolution.md) and [slot registration](../../implemented/architecture/2026-07-22-slot-type-chain-implementation.md) contracts. The row renders a compact summary when settled and expands the declarative panel inline. The model does not choose a conversation tab, details column, modal, pixel position, or z-index. A later placement change remains a renderer decision and does not alter logged models. +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 TaskSurfaceSubmissionId = string & { readonly __brand: 'TaskSurfaceSubmissionId' } -type TaskSurfaceDismissalId = string & { readonly __brand: 'TaskSurfaceDismissalId' } +type TaskSurfaceSubmissionPhase = 'queued' | 'claiming' + +interface TaskSurfacePendingSubmission { + submissionId: TaskSurfaceSubmissionId + messageId: MessageId + phase: TaskSurfaceSubmissionPhase +} interface TaskSurfaceService { - getActive(input: { sessionId: SessionId; surfaceId: string }): Promise + getActive(input: { sessionId: SessionId; surfaceId: TaskSurfaceId }): Promise submit(input: SubmitTaskSurfaceRequest): Promise dismiss(input: DismissTaskSurfaceRequest): Promise } interface SubmitTaskSurfaceRequest { sessionId: SessionId - surfaceId: string + surfaceId: TaskSurfaceId submissionId: TaskSurfaceSubmissionId values: Record note?: string } type SubmitTaskSurfaceResult = - | { accepted: true; messageId: MessageId } - | { accepted: false; reason: 'not-open' | 'stale' | 'invalid-submission' } + | { accepted: true; messageId: MessageId; phase: 'queued' } + | { accepted: false; reason: 'not-open' | 'stale' | 'invalid-submission' | 'submission-pending' } type GetActiveTaskSurfaceResult = - | { active: true; callId: CallId; surfaceId: string; model: TaskSurfaceModelV1 } + | { + active: true + callId: CallId + surfaceId: TaskSurfaceId + model: TaskSurfaceModelV1 + pending: TaskSurfacePendingSubmission | null + } | { active: false; reason: 'not-open' } interface DismissTaskSurfaceRequest { sessionId: SessionId - surfaceId: string + surfaceId: TaskSurfaceId dismissalId: TaskSurfaceDismissalId } type DismissTaskSurfaceResult = | { dismissed: true; eventSeq: number } - | { dismissed: false; reason: 'not-open' | 'stale' } + | { 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: @@ -141,7 +167,7 @@ interface TaskSurfaceCorrelation { version: 1 submissionId: TaskSurfaceSubmissionId callId: CallId - surfaceId: string + surfaceId: TaskSurfaceId values: Record } @@ -152,13 +178,31 @@ interface TaskSurfaceUserMessageSource { } ``` -The browser-safe domain package owns `TaskSurfaceCorrelation` and its branded `submissionId`. ApiProxy owns the transport augmentation that combines it 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 `session/queue` wire item already carries the complete `Message`. The client projection is explicitly extended to retain its source instead of dropping the correlation: -The product shell owns collapse and dismiss. Collapse is local view state and sends nothing. `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 transcript row. Retries reuse `dismissalId` and return the original result without appending another event. +```ts ignore-check +interface QueuedMessage { + id: InboxItemId + messageId: MessageId + placement: 'queued' | 'steering' + source: MessageSource + content: readonly ContentBlock[] + preview: string + text: string | null +} +``` -Submission is transactional at the client boundary. The panel disables submit while admission is in flight 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`; the Host admits one user message for one accepted Surface. +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. -There is a short interval between queue admission and the durable `user/message`. The generic queued-message DTO therefore retains `Message.source`. A queued message with matching Task Surface correlation keeps the panel disabled; if that queue item is discarded, the pending state clears and the draft becomes editable again. The Host holds a process-local single-flight claim for the same interval, then releases it on commit, rejection, or discard. The queue is coordination state, not a second durable lifecycle record. +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 @@ -166,13 +210,13 @@ The Session log is the authority. A small `taskSurface` unit in the existing [Se ```ts ignore-check interface TaskSurfaceProjection { - active: { callId: CallId; surfaceId: string } | null + 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; no separate Surface database participates. +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. When that result is outside the loaded history window, `taskSurface.getActive({ sessionId, surfaceId })` reads the exact occurrence from the Session log and returns `{ callId, surfaceId, model }` after revalidating the metadata. A missing or closed occurrence returns `not-open`. Refresh and reconnect therefore do not depend on the active result fitting in the history tail and do not duplicate the model into every projection baseline. +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. @@ -182,17 +226,22 @@ The capability is split where ownership changes: | Package | Responsibility | |---|---| -| `packages/task-surface/task-surface` | Browser-safe model/types and correlation, parser, limits, submission validator/formatter, Session event extension, projection unit, and Host service contract | +| `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/ui-task-surface` | Static keyed tool row, declarative Web renderer, per-Session draft store, and submit client | -| `packages/host/apiproxy` | Typed active-read/submit/dismiss transport, user-source augmentation, and queued-source carriage; delegates validation and admission to the Task Surface service | +| `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, projection unit, `show_task_surface`, presentation metadata, static Web row, and generic fallback with read-only blocks. -2. Add fields, persisted drafts, Host-validated submit/dismiss, queued-source carriage, and visible user-message admission. +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 @@ -203,7 +252,7 @@ The implementation depends on the existing message log, canonical tool output, t **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 keyed toolview keeps occurrence data in the logged call where it belongs. +**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`. @@ -212,21 +261,29 @@ The implementation depends on the existing message log, canonical tool output, t ## 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. -- Submitting produces exactly one visible user message per `submissionId`, starts the next turn through normal queue admission, and retains exact occurrence correlation while keeping `source.kind: 'user'`; dismissing records one log event and starts no turn. -- Refresh, reconnect, Session switching, fork, and rewind produce the lifecycle state implied by the log; `getActive` recovers a model outside the history tail, and no panel leaks across Sessions. +- 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. -- The parser enforces IDs, union shapes, field values, and configured byte/count limits before the panel becomes actionable. +- 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, edit, retry after rejected admission, queued/discarded submission, durable submit, dismiss, refresh recovery, and double-submit idempotency. +- 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 row and draft stores through the owning Fiber without changing the durable transcript. +- 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. diff --git a/.agents/notes/proposed/feature/2026-08-04-task-surface.zh.md b/.agents/notes/proposed/feature/2026-08-04-task-surface.zh.md index 38e6787ee5..7960c02dc3 100644 --- a/.agents/notes/proposed/feature/2026-08-04-task-surface.zh.md +++ b/.agents/notes/proposed/feature/2026-08-04-task-surface.zh.md @@ -44,11 +44,14 @@ interface TaskSurfaceModelV1 { interface TaskSurfaceSection { id: string title?: string - layout?: 'stack' | 'grid' - columns?: 2 | 3 + 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 }[] } @@ -66,7 +69,9 @@ type TaskSurfaceField = interface TaskSurfaceOption { id: string; label: string; detail?: string } ``` -渲染器控制字体排印、间距、响应式布局、焦点顺序、键盘行为和主题 token。`grid` 是布局提示:可用宽度无法容纳所要求的列数时,渲染器会将其折叠。Markdown 使用产品支持的 Markdown 子集。遇到未知版本或联合类型分支时,系统使用通用工具结果回退,而不是只解释其中一部分。 +渲染器控制字体排印、间距、响应式布局、焦点顺序、键盘行为和主题 token。未指定布局时使用 `stack`;`grid` 布局自带列数,可用宽度无法容纳时会折叠。遇到未知版本或联合类型分支时,系统使用通用工具结果回退,而不是只解释其中一部分。 + +`markdown` 块复用 `MarkdownText`,并显式指定模型 URL 策略。`MarkdownText` 新增 `remoteImages: 'render' | 'alt-only'`,普通场景仍默认使用 `render`;Task Surface 始终传入 `alt-only`,因此图片语法只渲染替代文本。原始 HTML 和嵌入式媒体仍会被省略,不生成自动链接预览;未经用户显式操作,不会解引用模型提供的任何 URL。普通 HTTP(S) 链接仍可在用户选择后导航。语法高亮分片等固定应用资源继续遵循产品的常规加载策略。 版本 1 有意不支持条件字段、客户端数据获取、图表、文件上传和任意事件处理器。新增任何块或字段类型都属于协议变更,必须在同一变更中加入解析器、渲染器、无障碍行为、回退方式和回放 fixture(测试前置数据)。 @@ -76,62 +81,83 @@ Task Surface 服务通过受 schema 校验的配置定义限制。初始默认 `show_task_surface` 接收 `{ model: TaskSurfaceModelV1 }`。Host 解析并规范化完整模型;若该会话已有一个打开的 Task Surface,则拒绝调用;否则生成 `surfaceId`,并返回带规范化模型的规范值 `{ surfaceId, model }`。`presentationMeta` 持久化 `value.model`,使投影器和执行器不会对规范化结果产生分歧。Native 结果会指明该 Surface,并说明客户端无法渲染面板时,可以通过普通消息绕过它。随后工具调用 `exec.concludeTurn()`,防止 agent 越过所要求的人工检查点继续执行。 -工具定义设置 `exclusive: true`,并且只会组装到同时挂载 Host 服务和 Web 渲染器的 Web profile 中。版本 1 支持 `native` 和 `both` 工具模式;仅支持 `code` 的 profile 不会向模型公布该工具,因为 Code Mode 分发属于嵌套调用,无法把呈现元数据传到外层结果。 +工具定义省略 `isConcurrencySafe`。根据现有工具注册表契约,省略该字段会将每次调用归类为独占排序屏障,无需新增 `ToolDefinition` 字段。该工具只会组装到同时挂载 Host 服务和 Web 渲染器的 Web profile 中。版本 1 支持 `native` 和 `both` 工具模式;仅支持 `code` 的 profile 不会向模型公布该工具,因为 Code Mode 分发属于嵌套调用,无法把呈现元数据传到外层结果。 -根据[规范工具输出契约](../../implemented/architecture/2026-07-20-canonical-tool-output-contract.md),规范值仅存在于本次执行中。因此,回放通过 `output.presentationMeta(args, value)` 将以下带标签的载荷随 `tool/result.meta` 一并持久化: +浏览器安全的领域包从 `@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 ignore-check +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: string + 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` 条目。结算后,该行显示简洁摘要,并在行内展开声明式面板。模型不能选择会话标签页、详情栏、模态框、像素位置或 z-index。以后即使改变放置位置,也只是渲染器的决策,不会改变日志中记录的模型。 +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 TaskSurfaceSubmissionId = string & { readonly __brand: 'TaskSurfaceSubmissionId' } -type TaskSurfaceDismissalId = string & { readonly __brand: 'TaskSurfaceDismissalId' } +type TaskSurfaceSubmissionPhase = 'queued' | 'claiming' + +interface TaskSurfacePendingSubmission { + submissionId: TaskSurfaceSubmissionId + messageId: MessageId + phase: TaskSurfaceSubmissionPhase +} interface TaskSurfaceService { - getActive(input: { sessionId: SessionId; surfaceId: string }): Promise + getActive(input: { sessionId: SessionId; surfaceId: TaskSurfaceId }): Promise submit(input: SubmitTaskSurfaceRequest): Promise dismiss(input: DismissTaskSurfaceRequest): Promise } interface SubmitTaskSurfaceRequest { sessionId: SessionId - surfaceId: string + surfaceId: TaskSurfaceId submissionId: TaskSurfaceSubmissionId values: Record note?: string } type SubmitTaskSurfaceResult = - | { accepted: true; messageId: MessageId } - | { accepted: false; reason: 'not-open' | 'stale' | 'invalid-submission' } + | { accepted: true; messageId: MessageId; phase: 'queued' } + | { accepted: false; reason: 'not-open' | 'stale' | 'invalid-submission' | 'submission-pending' } type GetActiveTaskSurfaceResult = - | { active: true; callId: CallId; surfaceId: string; model: TaskSurfaceModelV1 } + | { + active: true + callId: CallId + surfaceId: TaskSurfaceId + model: TaskSurfaceModelV1 + pending: TaskSurfacePendingSubmission | null + } | { active: false; reason: 'not-open' } interface DismissTaskSurfaceRequest { sessionId: SessionId - surfaceId: string + surfaceId: TaskSurfaceId dismissalId: TaskSurfaceDismissalId } type DismissTaskSurfaceResult = | { dismissed: true; eventSeq: number } - | { dismissed: false; reason: 'not-open' | 'stale' } + | { dismissed: false; reason: 'not-open' | 'stale' | 'submission-pending' } ``` Host 解析出 `show_task_surface` 的确切成功调用实例,依据其已持久化模型重新校验提交值,并通过普通会话队列接纳响应。该响应成为一条用户角色消息,并使用可合并扩展的消息来源: @@ -141,7 +167,7 @@ interface TaskSurfaceCorrelation { version: 1 submissionId: TaskSurfaceSubmissionId callId: CallId - surfaceId: string + surfaceId: TaskSurfaceId values: Record } @@ -152,13 +178,31 @@ interface TaskSurfaceUserMessageSource { } ``` -浏览器安全的领域包拥有 `TaskSurfaceCorrelation` 及其带品牌类型的 `submissionId`。ApiProxy 拥有传输扩展,负责将其与 `rpcId` 组合。保留 `kind: 'user'` 可维持普通用户消息气泡和提示词语义,额外字段则提供持久关联信息。消息内容是由产品格式化的可读摘要,包括面板标题、标签和提交值,以及可选备注。模型接收相同的文本。结构化来源不是第二条隐藏指令。 +`session/queue` 线上的条目已经携带完整 `Message`。客户端投影会显式扩展以保留其来源,不再丢失关联信息: -产品外壳负责收起和关闭。收起属于本地视图状态,不会发送任何内容。`taskSurface.dismiss({ sessionId, surfaceId, dismissalId })` 追加一个 `task-surface/dismissed` 会话事件,但不启动轮次;该精确事件会关闭投影并更新 transcript(文本记录)中的对应行。重试会复用 `dismissalId` 并返回原始结果,不会再追加一个事件。 +```ts ignore-check +interface QueuedMessage { + id: InboxItemId + messageId: MessageId + placement: 'queued' | 'steering' + source: MessageSource + content: readonly ContentBlock[] + preview: string + text: string | null +} +``` -客户端边界上的提交具有事务性。接纳进行期间,面板会禁用提交;只有匹配的用户消息持久化后,才会清除已持久化的草稿。若请求被拒绝,则保留值供用户继续编辑,并显示返回的原因。双击和传输重试会复用 `submissionId`;对于一个已接受的 Surface,Host 只会接纳一条用户消息。 +浏览器安全的领域包拥有 `TaskSurfaceId`、提交和关闭 ID、`TaskSurfaceCorrelation`,以及待处理提交的形态。ApiProxy 拥有传输扩展,负责将关联信息与 `rpcId` 组合。保留 `kind: 'user'` 可维持普通用户消息气泡和提示词语义,额外字段则提供持久关联信息。消息内容是由产品格式化的可读摘要,包括面板标题、标签和提交值,以及可选备注。模型接收相同的文本。结构化来源不是第二条隐藏指令。 -队列接纳与 `user/message` 持久化之间存在一个短暂区间。因此,通用排队消息 DTO 会保留 `Message.source`。带有匹配 Task Surface 关联信息的排队消息会使面板维持禁用状态;如果该队列项被丢弃,待处理状态会清除,草稿恢复为可编辑状态。在同一区间,Host 会持有一个进程内 single-flight 占用,并在消息提交持久化、接纳被拒或队列项被丢弃时释放。队列属于协调状态,并不是第二份持久生命周期记录。 +产品外壳负责收起和关闭。收起属于本地视图状态,不会发送任何内容。没有待处理提交时,`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`。对于一个已接受的 Surface,Host 只会接纳一条用户消息。 + +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 待处理记录,直至提交或丢弃。 ## 生命周期与恢复 @@ -166,33 +210,38 @@ interface TaskSurfaceUserMessageSource { ```ts ignore-check interface TaskSurfaceProjection { - active: { callId: CallId; surfaceId: string } | null + active: { callId: CallId; surfaceId: TaskSurfaceId } | null } ``` -一个会话最多只能有一个打开的 Task Surface。成功的结果会打开它;匹配的 Task Surface 用户消息或关闭事件会将其关闭。后续的普通用户消息也会将其关闭,这是一条显式的绕过路径;在以上任一事件关闭活动调用实例前,再次调用 `show_task_surface` 都会失败。回退和 fork 会通过折叠相应日志推导出活动调用实例,不会使用独立的 Surface 数据库。 +一个会话最多只能有一个打开的 Task Surface。成功的结果会打开它;匹配的 Task Surface 用户消息或关闭事件会将其关闭。后续的普通用户消息也会将其关闭,这是一条显式的绕过路径;在以上任一事件关闭活动调用实例前,再次调用 `show_task_surface` 都会失败。回退和 fork 会通过折叠相应日志推导出活动调用实例;瞬态队列阶段不会被复制,也不会有独立的 Surface 数据库参与其中。 -完整模型仍存放在对应的 `tool/result.meta` 中;投影只携带活动身份。当该结果超出已加载的历史窗口时,`taskSurface.getActive({ sessionId, surfaceId })` 会从会话日志中读取确切调用实例,重新校验元数据后返回 `{ callId, surfaceId, model }`。调用实例不存在或已经关闭时返回 `not-open`。因此,刷新和重新连接不要求活动结果位于历史尾段,也无需把模型复制到每一个投影基线中。 +完整模型仍存放在对应的 `tool/result.meta` 中;投影只携带活动身份。`TaskSurfaceDock` 独立于历史行存在,并会响应该身份。`taskSurface.getActive({ sessionId, surfaceId })` 会从会话日志中读取确切调用实例,重新校验其元数据,合并 Task Surface 服务的待处理协调记录,并返回 `{ callId, surfaceId, model, pending }`。调用实例不存在或已经关闭时返回 `not-open`。因此,即使结果位于历史尾段之外,刷新和重新连接仍能恢复可操作的 Surface 及其同进程待处理阶段,而无需把模型复制到每一个投影基线中。 Web 插件将未提交值保存在一个有界、按会话持久化的 slot store 中,并以 `surfaceId` 为 key;这些值永远不会进入会话日志、提示词或长期记忆。已提交值存放在接纳的用户消息中,因此即使浏览器草稿丢失,也不会抹去结论。 ## 包边界与依赖 -该能力按职责变化处分包: +该能力在职责变化处拆分为多个包: | 包 | 职责 | |---|---| -| `packages/task-surface/task-surface` | 浏览器安全的模型/类型和关联信息、解析器、限制、提交校验器/格式化器、会话事件扩展、投影单元,以及 Host 服务契约 | +| `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/ui-task-surface` | 静态带 key 的工具行、声明式 Web 渲染器、按会话划分的草稿 store,以及提交客户端 | -| `packages/host/apiproxy` | 类型化的活动 Surface 读取/提交/关闭传输、用户消息来源扩展和排队来源传递;将校验与接纳委托给 Task Surface 服务 | +| `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. 实现模型/解析器、投影单元、`show_task_surface`、呈现元数据、静态 Web 行,以及带只读块的通用回退。 -2. 增加字段、持久化草稿、经 Host 校验的提交/关闭、排队来源传递,以及可见用户消息接纳。 +1. 实现模型/解析器、`MarkdownText` 模型 URL 策略、投影单元、`show_task_surface`、呈现元数据、只读 Web 行、静态 `TaskSurfaceDock`、活动 Surface 读取,以及带只读块的通用回退。 +2. 增加字段、持久化草稿、经 Host 校验的提交/关闭、带品牌类型的关联信息、客户端排队来源传递、Task Surface `queued`/`claiming` 协调、已认领调用实例的终态报告、队列操作限制,以及可见用户消息接纳。 3. 只增加有实际任务依据,并且拥有至少两个消费方或明确通用回退的组件类型。一个单独的显式用户操作可以启动生成式插件编写工作流,但只会创建候选项,绝不会直接推广代码。 ## 考虑过的替代方案 @@ -203,7 +252,7 @@ Web 插件将未提交值保存在一个有界、按会话持久化的 slot stor **使用大型表单扩展 `userInteraction.ask()`。**本契约不采用这种做法。`ask()` 是一种阻塞式请求/响应操作,适用于正在运行的工具必须先获得简短答案才能继续执行的情况。Task Surface 会结束当前轮次,可以在刷新后继续保持打开,并把结果提交为下一条可见用户消息。 -**每次调用都注册一个动态 `conversation.view`。**不予采用,因为视图账本是全局的,而其渲染作用域按会话划分;同时,临时任务身份会变成注册身份。单个静态带 key 的 toolview 会将调用实例数据保留在归属它的已记录调用中。 +**每次调用都注册一个动态 `conversation.view`。**不予采用,因为视图账本是全局的,而其渲染作用域按会话划分;同时,临时任务身份会变成注册身份。一个静态的会话作用域 Dock 负责交互,一个静态带 key 的行概述已记录的调用实例;两个注册项都不使用调用实例身份。 **只在规范工具值中保留模型。**不予采用,因为规范值不会持久化。回放要求将规范化模型写入 `presentationMeta`。 @@ -212,21 +261,29 @@ Web 插件将未提交值保存在一个有界、按会话持久化的 slot stor ## 验收标准 - 在 `native` 或 `both` 工具模式下,真实模型可以调用一个稳定的 `show_task_surface` schema;调用结束当前轮次;具备相应能力的 Web 客户端在实时运行和回放后都能渲染同一份规范化模型;仅支持 `code` 的模式不会向模型公布该工具。 -- 每个 `submissionId` 的提交操作恰好生成一条可见用户消息,通过普通队列接纳开始下一轮,并在保留 `source.kind: 'user'` 的同时维持对确切调用实例的关联;关闭操作记录一条日志事件,且不启动轮次。 -- 刷新、重新连接、会话切换、fork 和回退都生成日志所决定的生命周期状态;`getActive` 可以恢复历史尾段之外的模型,任何面板都不会泄漏到其他会话。 +- 静态 `TaskSurfaceDock` 是唯一的编辑器,即使活动结果位于已加载历史窗口之外也仍可操作;带 key 的 toolview 始终是 transcript 的只读摘要和回放。composer 接管会隐藏仍处于挂载状态的 Dock、保留其草稿,并在接管释放后重新显示同一个所有者。 +- 每个 `submissionId` 的提交操作恰好生成一条可见用户消息,通过普通队列接纳开始下一轮,并在保留 `source.kind: 'user'` 的同时维持带品牌类型的确切调用实例关联;关闭操作记录一条日志事件,且不启动轮次。 +- 客户端排队行保留已关联的消息来源。`getActive` 可在同一进程的重新连接前后公开 `queued` 或 `claiming`;提交会关闭投影,显式丢弃则会清除待处理状态并让 Surface 保持打开。队列行消失本身不会改变任何 UI 状态。系统会拒绝编辑和 steering,且移除操作只能在认领前成功。 +- 刷新、重新连接、会话切换、fork 和回退都生成日志所决定的生命周期状态;`getActive` 可以恢复历史尾段之外的模型和待处理阶段,任何面板、待处理状态或草稿都不会泄漏到其他会话。 - 不受支持的版本、格式错误的元数据以及客户端能力缺失时,系统回退到带普通消息绕过路径的可读工具结果内容;嵌套调用以及已有另一个活动 Surface 时发起的调用都无法打开 Surface,并以失败结束。 -- 解析器会在面板可交互前强制校验 ID、联合类型形态、字段值以及配置的字节数和数量限制。 +- 线上的 schema 会校验 ID 字符串,领域 API 始终公开带品牌类型的 ID。模型解析器会在面板可交互前强制校验带标签的布局形态、字段值,以及配置的字节数和数量限制。浏览器测试证明:图片语法会变成替代文本,原始 HTML 和嵌入式媒体不会渲染,而且在用户显式操作前不会请求模型提供的 URL。 - 组件测试覆盖纯键盘操作、焦点恢复、无障碍名称、窄屏布局、两种主题,以及中英文产品界面。 -- 无密钥浏览器组合测试覆盖显示、编辑、接纳被拒后的重试、排队/丢弃提交、持久提交、关闭、刷新恢复和双重提交幂等性。 +- 无密钥浏览器组合测试覆盖显示、Dock 与只读行的职责归属、窗口外恢复、编辑、接纳被拒后的重试、从 `queued` 到 `claiming` 的转换、丢弃、没有可编辑空档的持久交接、禁止的队列操作、关闭、重新连接和双重提交幂等性。 - 前缀快照表明:无论任务特定模型如何变化,都只存在一个稳定的工具定义;只有调用参数和后续用户结论发生变化。 -- 卸载 Web 插件时,其所属 Fiber 会对工具行和草稿 store 执行 dispose(资源释放),但不会改变持久 transcript。 +- 卸载 Web 插件时,其所属 Fiber 会对 Dock、工具行和草稿 store 执行 dispose,但不会改变持久 transcript。 ## 风险 第一批组件可能小到无法满足实际任务,也可能大到足以演变成一个粗糙的应用框架。是否新增组件应由使用证据决定;v1 不提供表达式语言或网络行为。 +Task Surface 的 Markdown 策略舍弃行内图片、媒体和自动链接预览。普通链接仍有用,但只有用户显式操作后,才可以导航或发起请求。 + 即使设置了字节限制,大型表格和 Markdown 仍可能生成开销较高的 DOM。渲染器必须按需虚拟化或截断内容,同时保留可读回退和明确计数。 填写字段较多时,由产品格式化的提交消息可能过长。格式化器需要使用确定性的紧凑格式,保留每一个提交值,同时避免重复完整显示模型。 +在完成持久交接之前一直持有进程内认领状态,会新增一项终态不变量。每条接纳退出路径都必须产生匹配的 `user/message` 或显式丢弃,否则重新连接可能会让 Dock 永久处于禁用状态。 + 浏览器本地持久化的草稿可能保留敏感的未提交文本。store 需要遵守规定的字节上限、使用按会话划分的 key、在提交成功后显式清除,并采用与现有会话草稿相同的存储策略。 + +Dock 和 transcript 行以不同角色展示同一个调用实例。将工具行保持为只读,并让 Dock 成为唯一的变更所有者,可以避免草稿冲突,但代价是 Surface 活动期间会出现第二份简洁表示。 From fe505e1e91ce7dcd2fda1c3c3a45eaa92abb2b60 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Fri, 7 Aug 2026 03:02:10 -0700 Subject: [PATCH 003/189] feat(web): a prose mention of a produced file opens it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chatFileMentions service (provided by ui-deliverables beside its turn-tail entry, reached via ctx.get) resolves inline-code tokens in the closing message against the turn's produced locations: exact path or unique basename links, ambiguity and unknowns stay inert. MarkdownText gains the optional fileMentions seam — settled renders only, never inside anchors. --- ...6-08-07-web-inline-file-mentions.i18n.yaml | 6 + .../2026-08-07-web-inline-file-mentions.md | 28 +++ .../2026-08-07-web-inline-file-mentions.zh.md | 28 +++ apps/web/tests/produced-file-mentions.e2e.ts | 162 ++++++++++++++++++ apps/web/tsconfig.json | 1 + .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../ui-conversation/src/client/apply.ts | 1 + .../src/client/chat/AssistantMarkdown.tsx | 29 +++- .../src/client/chat/ChatView.tsx | 4 +- .../src/client/contract/slots.ts | 32 ++++ .../ui-conversation/src/client/index.ts | 1 + .../ui-conversation/tests/chat-view.spec.tsx | 41 +++++ .../client/ui-deliverables/README.i18n.yaml | 4 +- packages/client/ui-deliverables/README.md | 4 +- packages/client/ui-deliverables/README.zh.md | 4 +- .../src/client/ProducedFiles.tsx | 7 +- .../ui-deliverables/src/client/index.ts | 25 ++- .../src/client/turn-deliverables.ts | 43 +++++ .../tests/produced-files.spec.tsx | 47 ++++- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- packages/client/ui-primitives/src/index.ts | 2 +- .../src/markdown/MarkdownText.module.css | 20 +++ .../src/markdown/MarkdownText.tsx | 26 ++- .../ui-primitives/src/markdown/render.tsx | 51 +++++- .../tests/markdown-render-units.spec.tsx | 1 + .../ui-primitives/tests/markdown.spec.tsx | 43 +++++ tsconfig.host.json | 1 + 31 files changed, 584 insertions(+), 43 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.md create mode 100644 .agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.zh.md create mode 100644 apps/web/tests/produced-file-mentions.e2e.ts diff --git a/.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.i18n.yaml b/.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.i18n.yaml new file mode 100644 index 0000000000..e8fe387234 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.i18n.yaml @@ -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-07-web-inline-file-mentions.md +2026-08-07-web-inline-file-mentions.md: 79c6bddd500dc0b68bf64f3c4bea114ca41b4b62 +2026-08-07-web-inline-file-mentions.zh.md: ddc54d3b43570b19fb23c1c9c8079ef4ca65f8fa diff --git a/.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.md b/.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.md new file mode 100644 index 0000000000..79c6bddd50 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.md @@ -0,0 +1,28 @@ +# Agent Note: inline-code file mentions open the file they name + +Status: implemented + +English | [中文](2026-08-07-web-inline-file-mentions.zh.md) + +> Scope: linking inline-code tokens in the closing message's prose to the files the turn produced. Not in scope: recognizing paths in plain prose, linking files the turn did not produce, and mentions in streaming or mid-turn messages. + +## Problem + +The produced-files row lists a turn's output, but the closing message usually also *names* the file in prose — as inline code, like `` `deepseek-homepage.html` `` — and that mention was inert text. The reader's eye lands on the sentence first; the affordance sat one row below it. + +## Decision + +**A prose mention links only when it matches a produced file.** The [produced-files decision](2026-07-31-web-workspace-file-links.md) rejected linkifying the closing message because rendering must not depend on the model spelling a path recognizably; that holds. The row remains the authoritative, prose-independent account. This feature adds a second consumer of the same `locations` vocabulary: `producedFileMentions` resolves an inline-code token by exact path, or by being exactly the basename of exactly one produced path. A basename two paths share stays inert rather than guessing, and a token naming nothing the turn wrote stays inert — a mention link can never 404. + +**The renderer owns no vocabulary, and the provider is the deliverables plugin.** `MarkdownText` takes an optional `MarkdownFileMentions` resolver and consults it for inline-code tokens — after URL promotion, which wins, and never inside an anchor, where a button cannot nest. What names a file is decided behind the optional `chatFileMentions` service ui-conversation reaches via `ctx.get`: ui-deliverables provides it beside its turn-tail chain entry, so one cordis.yml line composes the row and the prose links in or out together, and ui-primitives gains no session concepts. Mentions apply to settled renders only — the streaming cache must not bake in handlers that could go stale, and the vocabulary is not final until the turn closes. The consumer memoizes the resolver on the closing seq rather than the growing transcript, so a settled message's cached parse survives stream appends. + +## Alternatives considered + +- **Path-shaped regex over all prose** — links `package.json` mentioned abstractly and examples that were never written; every false positive is a click that opens nothing or the wrong file. The vocabulary approach cannot produce a dead link. +- **Linking suffix matches (`out/index.html` mentioned as `index.html` in a subdirectory listing)** — deferred; exact path and unique basename cover the observed closing-message shapes, and a wider matcher can loosen later without breaking the seam. +- **Resolving in ui-primitives against a passed path list** — puts matching policy in the generic renderer, where other consumers would inherit it unasked. The resolver seam keeps policy with the owner. +- **Threading the vocabulary through the turn-tail chain** — the chain is a render dispatch below the message; mentions decorate markdown inside it, which only data reaching MarkdownText can do. The optional service is that data path, and its absence is the off state. + +## Consequences + +The mention and the row are two affordances for one fact, styled alike (underlined at rest, full path as `title`). `apps/web/tests/produced-file-mentions.e2e.ts` pins the assembled behavior with a built write-turn seed: unique basename links, ambiguous and unknown tokens stay inert; it does not click, for the produced-files restraint (the opener launches a real application). Mentions in mid-turn narration stay inert even for files the turn later produces, because the vocabulary attaches to the closing message only. The window-prepend edge — a window that starts mid-turn later gaining earlier same-turn writes — leaves a mention unlinked until remount, never wrongly linked. diff --git a/.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.zh.md b/.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.zh.md new file mode 100644 index 0000000000..ddc54d3b43 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.zh.md @@ -0,0 +1,28 @@ +# Agent Note:行内代码文件提及可打开其命名的文件 + +Status: implemented + +[English](2026-08-07-web-inline-file-mentions.md) | 中文 + +> 范围:把收尾消息正文中的行内代码 token 链接到本轮产出的文件。不在范围内:识别普通正文中的路径、链接本轮未产出的文件,以及流式或轮次中途消息里的提及。 + +## 问题 + +产物行列出了一轮的输出,但收尾消息通常也会在正文里*点名*文件——以行内代码形式,如 `` `deepseek-homepage.html` ``——而这个提及是死文本。读者的视线先落在句子上;可点击的交互却在下面一行。 + +## 决定 + +**正文提及只在与产出文件对得上时才成为链接。**[产物行的决定](2026-07-31-web-workspace-file-links.md)否决过"把收尾消息链接化",理由是渲染不能依赖模型把路径写得可识别;这一点不变。产物行仍是权威的、不依赖正文的记录。本特性只是给同一份 `locations` 词表增加第二个消费者:`producedFileMentions` 按精确路径解析行内代码 token,或当 token 恰好是且仅是一条产出路径的 basename 时解析。两条路径共享的 basename 保持死文本而不猜测,命名了本轮没写过的文件的 token 同样保持死文本——提及链接永远不会 404。 + +**渲染器不持有词表,提供方是 deliverables 插件。**`MarkdownText` 接受可选的 `MarkdownFileMentions` 解析器,对行内代码 token 询问它——URL 提升优先于解析器,且绝不在锚点内部(按钮不能嵌套在链接里)。什么算文件名的决定藏在 ui-conversation 经 `ctx.get` 触达的可选 `chatFileMentions` service 背后:ui-deliverables 在其 turn-tail chain 注册项旁提供该 service,因此 cordis.yml 中的一行同时把产物行和正文链接组合进来或去掉,ui-primitives 不引入任何会话概念。提及只作用于已定稿的渲染——流式缓存不能烘进可能过期的 handler,而且词表在轮次收尾前并不最终。消费方按收尾 seq 而非不断增长的 transcript 记忆化解析器,因此已定稿消息的缓存解析在流式追加中得以保留。 + +## 考虑过的替代方案 + +- **对全部正文跑路径形状的正则**——会把随口提到的 `package.json` 和从未写过的示例都链接上;每个误报都是一次打开空无或错误文件的点击。词表方案不可能产生死链。 +- **链接后缀匹配(子目录列表里把 `out/index.html` 写作 `index.html`)**——暂缓;精确路径加唯一 basename 已覆盖观察到的收尾消息形态,之后放宽匹配器不会破坏这道 seam。 +- **在 ui-primitives 里对传入的路径列表做解析**——把匹配策略放进通用渲染器,其他消费方会被动继承。解析器 seam 让策略留在持有者手里。 +- **经由 turn-tail chain 传递词表**——chain 是消息下方的渲染派发;提及要装饰的是消息内部的 markdown,只有抵达 MarkdownText 的数据才做得到。可选 service 就是那条数据通路,它的缺席即关闭态。 + +## 后果 + +提及与产物行是同一事实的两个交互面,样式一致(静止即下划线,完整路径作为 `title`)。`apps/web/tests/produced-file-mentions.e2e.ts` 用构造的写入轮 seed 钉住组装后的行为:唯一 basename 成链,歧义与未知 token 保持死文本;它不驱动点击,沿用产物行的克制(opener 会启动真实应用)。轮次中途叙述里的提及即使命名了本轮后来产出的文件也保持死文本,因为词表只挂在收尾消息上。窗口前插的边界——从轮次中途开始的窗口后来补入了同轮更早的写入——只会让提及在重挂载前暂不成链,绝不会错链。 diff --git a/apps/web/tests/produced-file-mentions.e2e.ts b/apps/web/tests/produced-file-mentions.e2e.ts new file mode 100644 index 0000000000..badd9b845e --- /dev/null +++ b/apps/web/tests/produced-file-mentions.e2e.ts @@ -0,0 +1,162 @@ +// Web e2e scenario: inline-code file mentions in the closing prose. Cold-seeds +// a built write turn (zero model calls) whose closing message names the written +// file three ways: by unique basename (links), ambiguously (stays inert), and +// as a file the turn never touched (stays inert). Package tests cover the +// resolver in isolation; only the assembled application shows a real write's +// locations reaching the prose as an opener. The click itself is not driven +// here: it hands the path to the Host's opener, which would launch a real +// application on the machine running the suite (the produced-files restraint). +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { CallId, createAssistantMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm' +import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-session-title' +import { + launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +const MODE = webSnapshotMode() +const SEED_ID = 'produced-file-mentions-web-e2e' +const DONE = 'FILE_MENTION_DONE' + +/** One-part text content for a built message. */ +function text(value: string): { type: 'text'; text: string }[] { + return [{ type: 'text', text: value }] +} + +/** The files the built turn writes; `notes.md` is named in prose but never written. */ +const WRITES = ['site/report.html', 'a/style.css', 'b/style.css'] + +/** Build a settled write turn whose closing prose mentions files in inline code. */ +function mentionFixture(): string { + const session = Session.create(SessionId('produced-file-mentions-source')) + const eventTimeOrigin = new Date().setHours(12, 0, 0, 0) + session.append('turn/start', { turn: 1 }) + const user = session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'Write the report page and both stylesheets.' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + session.append('session/title', { + title: 'Produced file mentions', + messageSeqs: [user.seq], + source: { kind: 'fallback' }, + }) + session.append('step/start', { turn: 1, step: 1 }) + const calls = WRITES.map((path, index) => ({ + path, + callId: CallId(`file-mention-${String(index)}`), + args: JSON.stringify({ file_path: path, content: `content of ${path}\n` }), + })) + session.append('assistant/message', { + turn: 1, + step: 1, + message: createAssistantMessage({ + content: calls.map(call => ({ + type: 'tool-call' as const, + id: call.callId, + name: 'write', + arguments: call.args, + })), + source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + }), + }, { surfaceOp: 'append' }) + for (const call of calls) { + const source = session.append('tool/call', { + turn: 1, + step: 1, + callId: call.callId, + name: 'write', + arguments: call.args, + }) + session.append('tool/result', { + turn: 1, + step: 1, + message: createToolResultMessage({ + callId: call.callId, + content: text(`Created ${call.path}`), + isError: false, + }), + }, { surfaceOp: 'append', sourceEventSeqs: [source.seq] }) + } + session.append('step/start', { turn: 1, step: 2 }) + session.append('assistant/message', { + turn: 1, + step: 2, + message: createAssistantMessage({ + content: [{ + type: 'text', + text: [ + 'Wrote `report.html` plus two `style.css` copies; `notes.md` untouched.', + '', + DONE, + ].join('\n'), + }], + source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + }), + }, { surfaceOp: 'append' }) + session.append('step/end', { turn: 1, step: 2 }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + + return [ + JSON.stringify({ + type: 'session', + version: SESSION_FORMAT_VERSION, + id: '{{sessionId}}', + createdAt: 0, + cwd: '{{cwd}}', + }), + ...session.events.map(event => JSON.stringify({ + ...event, + time: eventTimeOrigin + event.seq * 1_000, + })), + '', + ].join('\n') +} + +describe('web e2e: inline-code mentions of produced files', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({}) + await seedSession(scaffold, mentionFixture(), SEED_ID) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it.skipIf(MODE === 'record')('links the unique mention and leaves ambiguous and unknown code inert', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-produced-file-mentions')) + const groupRow = page.locator('[role="treeitem"]').first() + await groupRow.waitFor({ timeout: 15_000 }) + await groupRow.click() + const sessionRow = page.locator('[role="treeitem"]').nth(1) + await sessionRow.waitFor({ timeout: 10_000 }) + await sessionRow.click() + await expect.poll(() => page.getByText(DONE, { exact: true }).count(), { timeout: 15_000 }).toBe(1) + + // Exactly one prose mention links: `report.html` resolves to the written + // path; the shared `style.css` basename and unwritten `notes.md` stay code. + const mentions = page.locator('[class*="markdown"] code button') + await expect.poll(() => mentions.count(), { timeout: 10_000 }).toBe(1) + expect(await mentions.first().innerText()).toBe('report.html') + expect(await mentions.first().getAttribute('aria-label')).toBe('Open site/report.html') + expect(await mentions.first().getAttribute('title')).toBe('site/report.html') + // The turn still ends with its produced-files row (all three writes). + expect(await page.getByText('Produced', { exact: true }).count()).toBe(1) + + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 90_000) +}) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 1418cf2aa6..a147ddbcb8 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -61,6 +61,7 @@ "tests/shipped-composition.e2e.ts", "tests/startup-auto-selection.e2e.ts", "tests/produced-files.e2e.ts", + "tests/produced-file-mentions.e2e.ts", "tests/goal-bar.e2e.ts", "tests/subagent-conversation.e2e.ts", "tests/bash-abort-row.e2e.ts", diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index fdd1c41b80..2518ef28c8 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: d708621dbeb99615b0864e6977c8d2f387ae5265 -README.zh.md: e2cf9c3b32c86c2da39f09e4804c2ac69a3569f7 +README.md: 6182fa1281eec50f8898634cb466291b6ef1f5ff +README.zh.md: 8b110ec7713dcda5972d0ea0d9a8042301906d99 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index d708621dbe..6182fa1281 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -50,7 +50,7 @@ The chat stats line takes its token accounting from the generic token-meter `tok `src/client/` is organized by domain. `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations and composed props, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` directories import contract files and never each other. `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components and the store factory stay internal and reach the page through apply's slot registrations. -A finished turn ends with a turn-tail hole: the chat view renders the `conversation.chat.turnTail` list slot between the closing assistant's body and its IconActions, once per turn at the seq `assistantActionsSeqs` elects, dispatching `TurnTailOwnerProps` (the snapshot nodes, the closing seq, and the tool rows' `openFile`). This package owns only the hole; the produced-files row that fills it — derivation from the mutation tools' `locations`, the chip cap, the copy — lives in `@deepseek-ai/dsh-client-ui-deliverables`, so composing that plugin out of cordis.yml turns the surface off while the hole renders empty at zero cost. +A finished turn ends with a turn-tail hole: the chat view renders the `conversation.chat.turnTail` list slot between the closing assistant's body and its IconActions, once per turn at the seq `assistantActionsSeqs` elects, dispatching `TurnTailOwnerProps` (the snapshot nodes, the closing seq, and the tool rows' `openFile`). This package owns only the hole; the produced-files row that fills it — derivation from the mutation tools' `locations`, the chip cap, the copy — lives in `@deepseek-ai/dsh-client-ui-deliverables`, so composing that plugin out of cordis.yml turns the surface off while the hole renders empty at zero cost. The closing prose participates through the same off switch: the chat view asks the optional `chatFileMentions` service (ctx.get; provided by the same plugin) for a closing message's inline-code vocabulary and threads the result into MarkdownText's `fileMentions` seam — an absent service leaves the prose inert. ## Model Experience diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index e2cf9c3b32..8b110ec771 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -50,7 +50,7 @@ Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。Qu `src/client/` 按领域组织。`contract/` 是唯一的跨领域共享表层(`slots.ts` slot 声明与组合后的 props、`views.ts` 共享原语、`tool-call-model.ts`);`skeleton/`、`chat/` 和 `toolviews/` 目录只导入 contract 文件,彼此之间从不互相导入。`apply.ts` 是唯一允许导入全部三个领域的组装点。`/client` 导出表层只包含契约:`apply`/`inject`、两个服务类和 `contract/` 类型家族;实现组件与 store factory 保持内部,经 apply 的 slot 注册抵达页面。 -完成的一轮以一个 turn-tail 空位收尾:chat 视图在收尾 assistant 正文与其 IconActions 之间渲染 `conversation.chat.turnTail` list slot,每轮一次、位于 `assistantActionsSeqs` 选出的 seq,派发 `TurnTailOwnerProps`(快照节点、收尾 seq,以及工具行的 `openFile`)。本包只拥有空位;填充它的产物行——从改写工具 `locations` 的派生、chip 上限、文案——都在 `@deepseek-ai/dsh-client-ui-deliverables` 里,因此把那个插件从 cordis.yml 中组合掉即可关闭该交互面,空位以零成本渲染为空。 +完成的一轮以一个 turn-tail 空位收尾:chat 视图在收尾 assistant 正文与其 IconActions 之间渲染 `conversation.chat.turnTail` list slot,每轮一次、位于 `assistantActionsSeqs` 选出的 seq,派发 `TurnTailOwnerProps`(快照节点、收尾 seq,以及工具行的 `openFile`)。本包只拥有空位;填充它的产物行——从改写工具 `locations` 的派生、chip 上限、文案——都在 `@deepseek-ai/dsh-client-ui-deliverables` 里,因此把那个插件从 cordis.yml 中组合掉即可关闭该交互面,空位以零成本渲染为空。收尾正文经由同一个开关参与其中:chat 视图向可选的 `chatFileMentions` service(ctx.get;由同一插件提供)索取收尾消息的行内代码词表,并把结果接进 MarkdownText 的 `fileMentions` seam——service 缺席时正文保持死文本。 ## 模型体验 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 24325c714e..48a0558f34 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -313,6 +313,7 @@ export function apply(ctx: Context): void { actions.select(target) layout.openDetails() }, + fileMentions: owner => ctx.get('chatFileMentions')?.forClosing(owner), openFile: (path) => { const cwd = sessions.list.getSnapshot().byId[sessionId]?.cwd void workspaces.openPath(resolveToolPath(cwd, path)).catch(() => { diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx index bc1c6c7e32..8bb7f8aea4 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx @@ -15,7 +15,8 @@ import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' import { IconThinkOutline14, JsonBlock, MarkdownText, } from '@deepseek-ai/dsh-client-ui-primitives' -import type { ChatViewSlotProps, TurnTailOwnerProps } from '../contract/slots.ts' +import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ChatViewSlotProps, ChatViewInjected, TurnTailOwnerProps } from '../contract/slots.ts' import { hasContentText } from './chat-flow.ts' import { MessageIconActions } from './MessageIconActions.tsx' import { ToolRow } from './ToolRow.tsx' @@ -43,6 +44,8 @@ export interface AssistantMarkdownProps { onFork?: ((seq: number) => void) | undefined /** Turn-tail slot dispatch share and owner currency; omitted for a mid-turn assistant. */ turnTail?: (Pick, 'renderSlotChain'> & { owner: TurnTailOwnerProps }) | undefined + /** Prose file-mention factory (the injected face); omitted wherever `turnTail` is. */ + fileMentions?: ChatViewInjected['fileMentions'] | undefined /** The message is not the transcript tail of a completed turn. */ forkUnavailable?: boolean | undefined /** The owning view's locale seat, passed down as a plain prop. */ @@ -86,11 +89,25 @@ function ThinkRow({ text, running, t }: { text: string; running: boolean; t: Ass } export const AssistantMarkdown = memo(function AssistantMarkdown({ - blocks, streaming, interrupted, time, runMs, ttftMs, tokensPerSecond, seq, onFork, forkUnavailable, turnTail, t, + blocks, streaming, interrupted, time, runMs, ttftMs, tokensPerSecond, seq, onFork, forkUnavailable, turnTail, + fileMentions, t, }: AssistantMarkdownProps) { // Stable per locale revision (t identity changes on switch): a fresh object // per render would rebuild MarkdownText's component table every chunk. const codeLabels = useMemo(() => ({ copyLabel: t('copy'), copiedLabel: t('copied') }), [t]) + // Mention vocabulary for the closing prose. Keyed on the anchor seq, not the + // growing transcript: a settled turn's produced files are final, and a + // fresh identity per append would discard MarkdownText's cached parse for + // every settled closing message on every stream chunk. The window-prepend + // edge (a mid-turn window start later gaining earlier same-turn writes) + // leaves a mention unlinked until remount — never a wrong link. + const owner = turnTail?.owner + const mentions: MarkdownFileMentions | undefined = useMemo( + () => (owner === undefined ? undefined : fileMentions?.(owner)), + // Deliberately not `owner`: its identity changes per append while the + // seq-addressed vocabulary it yields does not. + [fileMentions, owner?.seq], + ) const last = blocks.length - 1 // Tool-call heads render as tool rows in the chat view's grouping pass, so // a node that is only those heads (or empty) would paint an empty root @@ -107,7 +124,13 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ {blocks.map((block, i) => { switch (block.kind) { case 'text': return ( - + ) case 'reasoning': return // Grouped into tool rows by ChatView; hasVisible above skips an empty shell. diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index b0907f5a80..c8ad485013 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -335,7 +335,8 @@ function StreamingTail({ useSession, t }: { * render through the declared keyed hole's renderSlot share). */ export function ChatView({ - useSession, useSessions, useStore, renderSlot, renderSlotChain, sessionId, openFile, loadOlder, inspectCall, chatScroll, forkAt, t, + useSession, useSessions, useStore, renderSlot, renderSlotChain, sessionId, openFile, loadOlder, inspectCall, chatScroll, forkAt, + fileMentions, t, }: ChatViewSlotProps) { const nodes = useSession(s => s.nodes) const turnTimings = useSession(s => s.turnTimings) @@ -603,6 +604,7 @@ export function ChatView({ turnTail={actionSeqs.has(node.seq) ? { renderSlotChain, owner: { nodes, seq: node.seq, openFile } } : undefined} + fileMentions={actionSeqs.has(node.seq) ? fileMentions : undefined} t={t} /> ) diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 89f7986dba..7e811da7a1 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -4,6 +4,7 @@ import type { InjectFace, MaybeSnapshotSelectorHook, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook, } from '@deepseek-ai/dsh-client-ui-slots' import type { CommandNode, ConversationNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' +import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' import type { ComposerKeyboard, EditSelection, InputActions, InputNotice, InputState } from '../input/contract.ts' import type { createChatStore } from '../stores.ts' @@ -158,6 +159,30 @@ export interface ConvViewOwnerProps { onInspectDone?: () => void } +/** + * Optional prose file-mention provider, consumed via `ctx.get('chatFileMentions')` + * (optional-service convention): the chat view asks it for a closing message's + * inline-code vocabulary and threads the result into MarkdownText. Absent + * service — the providing plugin composed out of cordis.yml — turns the + * surface off; the prose renders inert code. + */ +export interface ChatFileMentions { + /** + * Mention vocabulary for the closing message the owner currency names. + * @param owner - Turn-tail owner currency (nodes, closing seq, opener). + * @returns The resolver MarkdownText consumes, or undefined when the turn + * produced nothing worth linking. + */ + forClosing(owner: TurnTailOwnerProps): MarkdownFileMentions | undefined +} + +declare module 'cordis' { + interface Context { + /** Prose file-mention provider (ui-deliverables); reach via ctx.get — optional. */ + chatFileMentions: ChatFileMentions + } +} + /** * Owner currency of the chat view's turn-tail hole: the finalized snapshot * and the closing assistant's anchor. Registrants derive their own facts @@ -502,6 +527,13 @@ export interface ChatViewInjected { } /** Fork through the completed turn ending at the eligible message `seq`, then open the child. */ forkAt: (seq: number) => void + /** + * Prose file-mention vocabulary for one closing message, from the optional + * {@link ChatFileMentions} service (resolved lazily per call, so composing + * the provider in or out takes effect live). Undefined when the service is + * absent or the turn produced nothing worth linking. + */ + fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined } /** Full chat-view component props: runtime & the declared toolview/commandview holes' render share & store & injected & locale seat. */ diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index 725868d57a..3ce8b64053 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -13,6 +13,7 @@ export type { export type { ToolCallBlock } from './contract/tool-call-model.ts' export type { ConversationKey } from './locales.ts' export type { + ChatFileMentions, ChatStore, ChatViewInjected, ChatViewSlotProps, CommandRowOwnerProps, CommandRowProps, ComposerBarInjected, ComposerChainProps, ConversationInjected, ConversationSessionHeaderInjected, ConversationSessionInjected, ConversationSlotProps, diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index b8cd94de52..cb11a1a5fd 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -154,6 +154,8 @@ function makeHarness(init?: Partial) { inspectCall, chatScroll, forkAt, + // Absent-service default; mention tests override with a real resolver. + fileMentions: () => undefined, // Mirrors the real lookup chain (conversation namespace, then common). t: makeTranslate(zh, commonZh), } @@ -248,6 +250,45 @@ describe('chat-flow derivation', () => { expect([...assistantActionsSeqs(nodes, new Map([[1, 5]]))]).toEqual([5]) }) + it('threads the injected file-mention vocabulary into the closing prose only', () => { + const wrote = (seq: number, callId: string, path: string): ToolResultNode => ({ + ...toolResult(seq, callId, 'write'), + callView: { + card: 'diff', title: 'Write', diffs: [{ path, oldText: null, newText: 'x' }], locations: [{ path }], + }, + }) + const h = makeHarness({ + nodes: [ + user(1, 'build it'), + assistant(2, 'writing `report.html` now', 1), + wrote(3, 'w', 'site/report.html'), + assistant(4, 'Wrote `report.html`; `notes.md` untouched.', 1), + ], + turnEnds: new Map([[1, 4]]), + }) + // Stub provider mirroring the real service: only produced files resolve. + h.props.fileMentions = owner => ({ + resolve: (value) => { + if (value !== 'report.html') return undefined + return { + open: () => { h.openFile(`for-seq-${String(owner.seq)}/site/report.html`) }, + label: '打开 site/report.html', + title: 'site/report.html', + } + }, + }) + const view = render() + // Exactly one live mention: the closing message links, the mid-turn + // narration stays inert code, and the unknown file resolves to nothing. + const mentions = view.container.querySelectorAll('code button') + expect(mentions).toHaveLength(1) + const mention = view.getByRole('button', { name: '打开 site/report.html' }) + expect(mention.getAttribute('title')).toBe('site/report.html') + fireEvent.click(mention) + // The vocabulary was built from the closing message's own owner currency. + expect(h.openFile).toHaveBeenCalledWith('for-seq-4/site/report.html') + }) + it('runningTurnStartTime selects the latest turn/start without a turn/end', () => { expect(runningTurnStartTime(new Map([ [1, { startTime: 1_000, endTime: 5_000 }], diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index ee4c23c18c..3d25f13e1a 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/packages/client/ui-deliverables/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-deliverables/README.md -README.md: b8b0ea2ef1cbc9b18b905fc08b41278f403ef043 -README.zh.md: a16535b8a8d3625ca1cf90e88c6d9dca742d916b +README.md: d6695f155907e7d92b35556588687b3f95e55b88 +README.zh.md: be360a5a1fbe8d904cedf104b28f64f1b0567d6b diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index b8b0ea2ef1..d6695f1559 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -8,6 +8,8 @@ Produced-files feature owner: registers the deliverables row a finished turn end `ProducedFiles` renders the row between the closing message's body and its IconActions footer: a quiet label, up to six chips (basename text, full path as the `title`), and an explicit remainder count past the cap. Each chip opens through the owner-supplied `openFile` — the same Host opener the tool rows use, with the chat view resolving relative paths against the session cwd. Design rationale: the [workspace file links Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md). +The closing prose carries the same vocabulary. This plugin provides the `chatFileMentions` service the chat view consults per closing message: `producedFileMentions` resolves an inline-code token by exact path, or by being exactly the basename of exactly one produced path — a basename two paths share stays inert rather than guessing, so a mention link can never open the wrong file or 404. A resolved mention renders as the same underlined opener the row's chips are, with the full path as its `title`, and mentions never render inside anchors or streaming text. Decision record: the [inline file mentions Agent Note](../../../.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.md). + ## Model Experience None, as the row is a pure client derivation over already-logged tool metadata and nothing here reaches a model request. @@ -18,4 +20,4 @@ None; this package neither assembles nor sends provider requests. ## Known Limitations and Deferred Work -- **Prose mentions stay inert.** An inline-code file name in the closing message does not open the file yet; linking it to the same `locations` vocabulary is the stacked follow-up. +- **Mention matching is exact path or unique basename only.** A suffix mention (`out/index.html` written as `index.html` resolves; `deep/out/index.html` written as `out/index.html` does not) stays inert; widening the matcher is deferred until a real closing-message shape needs it. diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index a16535b8a8..be360a5a1f 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -8,6 +8,8 @@ `ProducedFiles` 在收尾消息正文与其 IconActions 之间渲染该行:一个安静的标签、至多六枚 chip(文本为文件名,完整路径作为 `title`),超出上限则显示一个明确的剩余计数。每枚 chip 经由 owner 提供的 `openFile` 打开——与工具行相同的 Host 打开器,chat 视图会把相对路径按会话 cwd 解析。设计原理:[workspace 文件链接 Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md)。 +收尾正文承载同一份词表。本插件提供 chat 视图按收尾消息查询的 `chatFileMentions` service:`producedFileMentions` 按精确路径解析行内代码 token,或当 token 恰好是且仅是一条产出路径的 basename 时解析——两条路径共享的 basename 保持死文本而不猜测,因此提及链接永远不会打开错误的文件或 404。解析成功的提及渲染为与产物行 chip 相同的下划线 opener,完整路径作为其 `title`;提及绝不会渲染在锚点内部或流式文本里。决策记录:[行内文件提及 Agent Note](../../../.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.md)。 + ## 模型体验 无。该行是对已记录工具元数据的纯客户端派生,这里没有任何内容进入模型请求。 @@ -18,4 +20,4 @@ ## 已知限制与暂缓事项 -- **正文提及仍是死文本。**收尾消息里以行内代码写出的文件名尚不能点击打开;把它接到同一份 `locations` 词表是 stacked 的后续工作。 +- **提及匹配只认精确路径或唯一 basename。**后缀式提及(`out/index.html` 写作 `index.html` 可解析;`deep/out/index.html` 写作 `out/index.html` 则不行)保持死文本;放宽匹配器等真实的收尾消息形态需要时再做。 diff --git a/packages/client/ui-deliverables/src/client/ProducedFiles.tsx b/packages/client/ui-deliverables/src/client/ProducedFiles.tsx index ab85869de2..0baff2872a 100644 --- a/packages/client/ui-deliverables/src/client/ProducedFiles.tsx +++ b/packages/client/ui-deliverables/src/client/ProducedFiles.tsx @@ -6,18 +6,13 @@ import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { basename } from './turn-deliverables.ts' import type { NS } from './locales.ts' import css from './ProducedFiles.module.css' /** Files past this stay counted but unlisted: a refactor turn must not bury the answer. */ const SHOWN = 6 -/** Trailing path segment, the part that identifies the file at a glance. */ -function basename(path: string): string { - const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) - return at === -1 ? path : path.slice(at + 1) -} - /** Matched paths plus the opener and locale seats needed to present them. */ export type ProducedFilesProps = Pick & { matched: readonly string[] diff --git a/packages/client/ui-deliverables/src/client/index.ts b/packages/client/ui-deliverables/src/client/index.ts index 6dc7bc4b84..816cdff520 100644 --- a/packages/client/ui-deliverables/src/client/index.ts +++ b/packages/client/ui-deliverables/src/client/index.ts @@ -1,16 +1,18 @@ /** * Deliverables plugin, browser half: registers the produced-files row into - * the chat view's turn-tail hole. All policy lives here — the derivation - * from the mutation tools' `locations`, the chip cap, and the copy — so - * composing this plugin out of cordis.yml removes the surface entirely; the - * owning view renders an empty hole at zero cost. + * the chat view's turn-tail chain, and provides the `chatFileMentions` + * service that links inline-code mentions of produced files in the closing + * prose. All policy lives here — the derivation from the mutation tools' + * `locations`, the mention matching, the chip cap, and the copy — so + * composing this plugin out of cordis.yml removes both surfaces entirely; + * the owning view renders an empty chain and inert prose at zero cost. */ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' -import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { ChatFileMentions } from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-client-locale/client' import { ProducedFiles } from './ProducedFiles.tsx' import { en, NS, zh, type DeliverablesKey } from './locales.ts' -import { selectProducedFiles } from './turn-deliverables.ts' +import { producedFileMentions, producedForClosing, selectProducedFiles } from './turn-deliverables.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { @@ -39,4 +41,15 @@ export function apply(ctx: ClientContext): void { locale: NS, }, ProducedFiles), ) + // The prose side of the same vocabulary: the chat view reaches this face + // via ctx.get, so its absence — this plugin composed out — is the off state. + const t = ctx.locale.bind(NS) + const mentions: ChatFileMentions = { + forClosing(owner) { + const paths = producedForClosing(owner.nodes, owner.seq) + if (paths.length === 0) return undefined + return producedFileMentions(paths, owner.openFile, path => t('produced.open', { name: path })) + }, + } + ctx.provide('chatFileMentions', mentions) } diff --git a/packages/client/ui-deliverables/src/client/turn-deliverables.ts b/packages/client/ui-deliverables/src/client/turn-deliverables.ts index c9754d1da4..4316ddf8e6 100644 --- a/packages/client/ui-deliverables/src/client/turn-deliverables.ts +++ b/packages/client/ui-deliverables/src/client/turn-deliverables.ts @@ -4,6 +4,7 @@ * own follow-along `locations`, never the closing prose. */ import type { ConversationNode, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' +import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives' import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client' /** @@ -88,3 +89,45 @@ export function selectProducedFiles(owner: TurnTailOwnerProps): readonly string[ const paths = producedForClosing(nodes, seq) return paths.length === 0 ? null : paths } + +/** + * Trailing path segment, the part that identifies the file at a glance. + * @param path - Slash- or backslash-separated path. + * @returns The final segment, or the whole string when separator-free. + */ +export function basename(path: string): string { + const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + return at === -1 ? path : path.slice(at + 1) +} + +/** + * File-mention vocabulary over one turn's produced paths, for the closing + * message's prose: an inline-code token opens the file it names. A token + * resolves by exact path, or by being exactly the basename of exactly one + * produced path — a basename two paths share stays inert rather than + * guessing, so a mention link can never open the wrong file or 404. + * @param paths - The turn's produced paths (tool order, already deduped). + * @param openFile - The chat view's file opener. + * @param label - Localizes the accessible open-label for a resolved path. + * @returns The resolver MarkdownText consumes; the full path rides `title`, + * the same disambiguator the row's chips carry. + */ +export function producedFileMentions( + paths: readonly string[], + openFile: (path: string) => void, + label: (path: string) => string, +): MarkdownFileMentions { + return { + resolve(value) { + const path = paths.includes(value) ? value : onlyPathWithBasename(paths, value) + if (path === undefined) return undefined + return { open: () => { openFile(path) }, label: label(path), title: path } + }, + } +} + +/** The single produced path whose basename is exactly `value`, else undefined. */ +function onlyPathWithBasename(paths: readonly string[], value: string): string | undefined { + const matches = paths.filter(path => basename(path) === value) + return matches.length === 1 ? matches[0] : undefined +} diff --git a/packages/client/ui-deliverables/tests/produced-files.spec.tsx b/packages/client/ui-deliverables/tests/produced-files.spec.tsx index 49e41ebd86..65af40d642 100644 --- a/packages/client/ui-deliverables/tests/produced-files.spec.tsx +++ b/packages/client/ui-deliverables/tests/produced-files.spec.tsx @@ -13,9 +13,10 @@ import type { AssistantMessageNode, ConversationNode, ToolResultNode, UserMessageNode, } from '@deepseek-ai/dsh-client-runtime/client' import { apply as applyLocale } from '@deepseek-ai/dsh-client-locale/client' +import type { ChatFileMentions } from '@deepseek-ai/dsh-client-ui-conversation/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { ProducedFiles } from '../src/client/ProducedFiles.tsx' -import { producedForClosing, selectProducedFiles } from '../src/client/turn-deliverables.ts' +import { basename, producedFileMentions, producedForClosing, selectProducedFiles } from '../src/client/turn-deliverables.ts' import { apply, inject } from '../src/client/index.ts' import { apply as applyNode } from '../src/index.ts' import { apply as applyInvariant } from '../src/invariant.ts' @@ -141,6 +142,33 @@ describe('ProducedFiles row', () => { }) }) +describe('producedFileMentions resolver', () => { + const label = (path: string) => `打开 ${path}` + + it('resolves exact paths and unique basenames; ambiguity and unknowns stay unresolved', () => { + const opened: string[] = [] + const resolver = producedFileMentions( + ['out/index.html', 'a/style.css', 'b/style.css'], + (path) => { opened.push(path) }, + label, + ) + // Unique basename resolves to its full path; the full path rides title. + const byBasename = resolver.resolve('index.html') + expect(byBasename?.label).toBe('打开 out/index.html') + expect(byBasename?.title).toBe('out/index.html') + byBasename?.open() + expect(opened).toEqual(['out/index.html']) + // An exact path resolves even when its basename is ambiguous. + const exact = resolver.resolve('a/style.css') + expect(exact?.title).toBe('a/style.css') + // A basename two paths share stays unresolved rather than guessing, + // and so does a token naming nothing the turn wrote. + expect(resolver.resolve('style.css')).toBeUndefined() + expect(resolver.resolve('notes.md')).toBeUndefined() + expect(basename('a\\b\\c.txt')).toBe('c.txt') + }) +}) + describe('package shells', () => { it('the node half mounts inert and the invariant companion registers ownership', async () => { // The node half is deliberately inert; mounting it must simply not throw. @@ -172,7 +200,24 @@ describe('plugin registration', () => { await fiber.await() expect(ctx.slots.entries('conversation.chat.turnTail')).toHaveLength(1) + // The prose face is live while the plugin is: a produced turn yields a + // resolver whose matches open through the owner-supplied opener. + const opened: string[] = [] + const owner = { + nodes: [user(1, 'go'), wrote(2, 'w', 'site/report.html'), assistant(3, 'done', 1)], + seq: 3, + openFile: (path: string) => { opened.push(path) }, + } + const service = (ctx as unknown as { get(name: string): ChatFileMentions | undefined }).get('chatFileMentions') + const mentions = service?.forClosing(owner) + mentions?.resolve('report.html')?.open() + expect(opened).toEqual(['site/report.html']) + // A turn that produced nothing yields no vocabulary at all. + expect(service?.forClosing({ ...owner, nodes: [user(1, 'hi'), assistant(2, 'ok', 1)], seq: 2 })).toBeUndefined() + await fiber.dispose() expect(ctx.slots.entries('conversation.chat.turnTail')).toHaveLength(0) + // Fiber teardown retracts the service: the consumer's ctx.get sees the off state. + expect((ctx as unknown as { get(name: string): unknown }).get('chatFileMentions')).toBeUndefined() }) }) diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 47cebf796f..f7598d7a90 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md -README.md: 7571cb48424b650a1aaa5222b33a3ee14faa69b4 -README.zh.md: fa0c3f24023ec8c1eb77553bfe191801b6698687 +README.md: a5f35c9a41008921c6a1e3a4de3fd3f38af926c0 +README.zh.md: 54c70ed29e73e196a9be60ddb794c12187dbe0c5 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 7571cb4842..a5f35c9a41 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -10,7 +10,7 @@ Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/ ## Markdown rendering -`MarkdownText` renders GFM and `$…$`, `$$…$$`, `\(…\)`, and `\[…\]` TeX math from untrusted assistant output through React elements, with math typeset by KaTeX and trusted commands disabled; block-level same-line `$$…$$` is display math, including `\tag{}`. A narrow micromark extension lets asterisk strong emphasis ending in punctuation close before adjacent CJK text, where prose normally omits the whitespace CommonMark requires; single-asterisk emphasis, non-CJK adjacency, escapes, code, and math retain upstream parsing. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders absolute HTTP(S) images without a referrer; relative paths, absolute local paths, `file:` URLs, and unsupported schemes retain their alt text. Inline code whose complete value is an absolute HTTP(S) URL keeps its code styling and gains the same safe external anchor; commands, partial URLs, other schemes, and fenced code remain inert. While a reply streams, `MarkdownText` parses incrementally: all but the trailing two blocks freeze as cached React elements and only the source tail behind them re-parses per chunk, so per-chunk work tracks the tail instead of the whole reply ([mechanism and DOM-parity contract](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md)). `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, responsive images, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars). +`MarkdownText` renders GFM and `$…$`, `$$…$$`, `\(…\)`, and `\[…\]` TeX math from untrusted assistant output through React elements, with math typeset by KaTeX and trusted commands disabled; block-level same-line `$$…$$` is display math, including `\tag{}`. A narrow micromark extension lets asterisk strong emphasis ending in punctuation close before adjacent CJK text, where prose normally omits the whitespace CommonMark requires; single-asterisk emphasis, non-CJK adjacency, escapes, code, and math retain upstream parsing. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders absolute HTTP(S) images without a referrer; relative paths, absolute local paths, `file:` URLs, and unsupported schemes retain their alt text. Inline code whose complete value is an absolute HTTP(S) URL keeps its code styling and gains the same safe external anchor; commands, partial URLs, other schemes, and fenced code remain inert. An optional `fileMentions` resolver lets the owning view link inline code that names a real file: the token keeps code styling and gains a button wired to the resolved opener, with the resolver's accessible label and full-path `title`. The renderer never guesses at what looks like a path — an unresolved token stays inert, mentions apply to settled renders only (the streaming cache must not bake in handlers that could go stale), and a token inside an anchor stays inert because a button cannot nest there. While a reply streams, `MarkdownText` parses incrementally: all but the trailing two blocks freeze as cached React elements and only the source tail behind them re-parses per chunk, so per-chunk work tracks the tail instead of the whole reply ([mechanism and DOM-parity contract](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md)). `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, responsive images, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars). ## Terminal output diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index fa0c3f2402..54c70ed29e 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -10,7 +10,7 @@ ## Markdown 渲染 -`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM 与 `$…$`、`$$…$$`、`\(…\)` 和 `\[…\]` TeX 公式,公式由 KaTeX 排版并禁用受信任命令;块级同一行 `$$…$$` 是显示公式并支持 `\tag{}`。一个小范围的 micromark 扩展允许由星号标记、以标点结尾的粗体在紧邻的 CJK 文本前闭合,以适应 CJK 文本通常省略 CommonMark 所要求空格的写法;单星号强调、紧邻非 CJK 文本的情况、转义、代码与数学公式仍沿用上游解析行为。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并在不发送 referrer 的情况下渲染采用绝对 HTTP(S) URL 的图片;相对路径、绝对本地路径、`file:` URL 与不受支持的 scheme 会保留其 alt 文本。完整内容为绝对 HTTP(S) URL 的行内代码会保留代码样式,并获得同样安全的外部链接;命令、非完整 URL、其他 scheme 与围栏代码仍不会成为链接。回复流式输出期间,`MarkdownText` 增量解析:除末尾两个块外全部冻结为缓存的 React 元素,每个分片只重新解析其后的源文本尾部,因此每分片的工作量跟随尾部而非整个回复([机制与 DOM 一致性契约](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md))。`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、响应式图片、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。 +`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM 与 `$…$`、`$$…$$`、`\(…\)` 和 `\[…\]` TeX 公式,公式由 KaTeX 排版并禁用受信任命令;块级同一行 `$$…$$` 是显示公式并支持 `\tag{}`。一个小范围的 micromark 扩展允许由星号标记、以标点结尾的粗体在紧邻的 CJK 文本前闭合,以适应 CJK 文本通常省略 CommonMark 所要求空格的写法;单星号强调、紧邻非 CJK 文本的情况、转义、代码与数学公式仍沿用上游解析行为。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并在不发送 referrer 的情况下渲染采用绝对 HTTP(S) URL 的图片;相对路径、绝对本地路径、`file:` URL 与不受支持的 scheme 会保留其 alt 文本。完整内容为绝对 HTTP(S) URL 的行内代码会保留代码样式,并获得同样安全的外部链接;命令、非完整 URL、其他 scheme 与围栏代码仍不会成为链接。可选的 `fileMentions` 解析器让持有视图把命名真实文件的行内代码变成链接:token 保留代码样式并获得接到所解析 opener 的按钮,带解析器给出的无障碍标签与完整路径 `title`。渲染器绝不猜测什么长得像路径——未解析的 token 保持原样,提及只作用于已定稿的渲染(流式缓存不能烘进可能过期的 handler),锚点内部的 token 也保持原样,因为按钮不能嵌套在链接里。回复流式输出期间,`MarkdownText` 增量解析:除末尾两个块外全部冻结为缓存的 React 元素,每个分片只重新解析其后的源文本尾部,因此每分片的工作量跟随尾部而非整个回复([机制与 DOM 一致性契约](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md))。`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、响应式图片、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。 ## 终端输出 diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index fe0235c496..30a1dae402 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -40,7 +40,7 @@ export { CodeBlock } from './markdown/CodeBlock.tsx' export type { CodeBlockProps } from './markdown/CodeBlock.tsx' export { JsonBlock } from './markdown/JsonBlock.tsx' export { MarkdownText } from './markdown/MarkdownText.tsx' -export type { MarkdownCodeLabels } from './markdown/MarkdownText.tsx' +export type { MarkdownCodeLabels, MarkdownFileMentions } from './markdown/MarkdownText.tsx' export { MessageText } from './markdown/MessageText.tsx' export { extractMarkdownPlainText } from './markdown/plain-text.ts' export type { MarkdownPlainTextMode, MarkdownPlainTextOptions } from './markdown/plain-text.ts' diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.module.css b/packages/client/ui-primitives/src/markdown/MarkdownText.module.css index 7e31857053..19375be18e 100644 --- a/packages/client/ui-primitives/src/markdown/MarkdownText.module.css +++ b/packages/client/ui-primitives/src/markdown/MarkdownText.module.css @@ -241,3 +241,23 @@ background: var(--dsw-alias-bg-base); object-fit: contain; } + +/* Inline file mention: a real file named in prose is the same affordance as a + tool row's path link, so it reads the same — underlined at rest. */ +.fileMention { + margin: 0; + padding: 0; + border: none; + background: none; + font: inherit; + color: var(--dsw-alias-label-secondary); + text-decoration: underline; + text-decoration-color: var(--dsw-alias-label-quaternary); + text-underline-offset: 3px; + cursor: pointer; +} + +.fileMention:hover { + color: var(--dsw-alias-label-primary); + text-decoration-color: currentColor; +} diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx index bb4b62514c..4dff78d784 100644 --- a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx +++ b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx @@ -19,20 +19,25 @@ import { collectReferenceTargets, createReferenceTargets, renderBlocks, renderFootnoteSection, wrapBlockChildren, } from './render.tsx' -import type { MarkdownCodeLabels, MarkdownRenderContext, ReferenceTargets } from './render.tsx' +import type { MarkdownCodeLabels, MarkdownFileMentions, MarkdownRenderContext, ReferenceTargets } from './render.tsx' import 'katex/dist/katex.min.css' import css from './MarkdownText.module.css' -export type { MarkdownCodeLabels } from './render.tsx' +export type { MarkdownCodeLabels, MarkdownFileMentions } from './render.tsx' /** One settled full render: parse with math, resolve references, append the footnote section. */ -function renderSettled(text: string, codeLabels: MarkdownCodeLabels | undefined): ReactNode[] { +function renderSettled( + text: string, + codeLabels: MarkdownCodeLabels | undefined, + fileMentions: MarkdownFileMentions | undefined, +): ReactNode[] { const root = parseGfmWithMath(text) const targets = createReferenceTargets() collectReferenceTargets(root.children, targets) const context: MarkdownRenderContext = { streaming: false, codeLabels, + fileMentions, targets, footnoteOrder: [], footnoteCounts: new Map(), @@ -96,6 +101,7 @@ class StreamingRenderer { const frozenContext: MarkdownRenderContext = { streaming: true, codeLabels: this.codeLabels, + fileMentions: undefined, targets: frameTargets, footnoteOrder: this.frozenFootnoteOrder, footnoteCounts: this.frozenFootnoteCounts, @@ -113,6 +119,7 @@ class StreamingRenderer { const tailContext: MarkdownRenderContext = { streaming: true, codeLabels: this.codeLabels, + fileMentions: undefined, targets: frameTargets, footnoteOrder: [...this.frozenFootnoteOrder], footnoteCounts: new Map(this.frozenFootnoteCounts), @@ -137,28 +144,33 @@ class StreamingRenderer { * the finalize swap) and parses incrementally across chunks; `codeLabels` * forwards localized copy-button labels to fence CodeBlocks — pass a * reference-stable object (memoized per locale revision), because a new - * identity discards the streaming render cache mid-message. + * identity discards the streaming render cache mid-message. `fileMentions` + * links inline-code tokens its resolver recognizes as real files; this is + * the single streaming gate — it applies to settled renders only, because a + * streaming message's vocabulary is not final and frozen cached elements + * must not bake in handlers that could go stale. * @returns A GFM document with TeX math rendered through KaTeX; raw HTML, * relative links, and unsafe protocols are disabled, while absolute HTTP(S) * images render directly. */ -export const MarkdownText = memo(function MarkdownText({ text, streaming = false, codeLabels }: { +export const MarkdownText = memo(function MarkdownText({ text, streaming = false, codeLabels, fileMentions }: { text: string streaming?: boolean codeLabels?: MarkdownCodeLabels | undefined + fileMentions?: MarkdownFileMentions | undefined }) { const streamRef = useRef(null) const streamLabelsRef = useRef(codeLabels) const children = useMemo(() => { if (!streaming) { streamRef.current = null - return renderSettled(text, codeLabels) + return renderSettled(text, codeLabels, fileMentions) } if (streamRef.current === null || streamLabelsRef.current !== codeLabels) { streamRef.current = new StreamingRenderer(codeLabels) streamLabelsRef.current = codeLabels } return streamRef.current.render(text) - }, [text, streaming, codeLabels]) + }, [text, streaming, codeLabels, fileMentions]) return
{children}
}) diff --git a/packages/client/ui-primitives/src/markdown/render.tsx b/packages/client/ui-primitives/src/markdown/render.tsx index 4ac8b4dbc1..452e0fc475 100644 --- a/packages/client/ui-primitives/src/markdown/render.tsx +++ b/packages/client/ui-primitives/src/markdown/render.tsx @@ -99,6 +99,21 @@ export function collectReferenceTargets( } } +/** + * File-mention affordance for inline code: the owner resolves an authored + * token to the file it names, using its own vocabulary of real files — the + * renderer never guesses at what looks like a path. + */ +export interface MarkdownFileMentions { + /** + * Resolve one inline-code token. + * @param value - The authored token, exactly as written. + * @returns The opener with its accessible label and full-path title, or + * undefined when the token names no known file — it then stays inert code. + */ + resolve(value: string): { open: () => void; label: string; title: string } | undefined +} + /** * One render pass's state: immutable options and targets plus the footnote * numbering accumulated in document order while references render. @@ -108,6 +123,10 @@ export interface MarkdownRenderContext { readonly streaming: boolean /** Localized fence copy-button labels. */ readonly codeLabels: MarkdownCodeLabels | undefined + /** Inline-code file mentions; absent wherever no opener vocabulary exists. */ + readonly fileMentions: MarkdownFileMentions | undefined + /** Inside an anchor's children: interactive mentions must not nest there. */ + readonly inLink?: boolean /** Reference targets visible to this pass. */ readonly targets: ReferenceTargets /** Footnote identifiers in first-reference order; a footnote's number is its 1-based index here. */ @@ -217,7 +236,27 @@ function renderNode(node: Md.RootContent, key: Key, context: MarkdownRenderConte // authored text, not a parsed destination, so no normalizeUri: port, // path, and query render unchanged. const href = inlineCodeHttpUrl(value) - return {href === undefined ? value : renderSafeLink(href, [value], 'link')} + if (href !== undefined) return {renderSafeLink(href, [value], 'link')} + // A token the owner's file-mention vocabulary recognizes opens that + // file; the resolver, not this renderer, decides what names a file. + // Inside an anchor the token stays inert — a button cannot nest there. + const mention = context.inLink === true ? undefined : context.fileMentions?.resolve(value) + if (mention !== undefined) { + return ( + + + + ) + } + return {value} } case 'html': // No HTML parser enters the pipeline: raw HTML stays literal text. @@ -236,7 +275,7 @@ function renderNode(node: Md.RootContent, key: Key, context: MarkdownRenderConte case 'table': return renderTable(node, key, context) case 'link': - return renderAnchor(node.url, renderChildren(node.children, context), key) + return renderAnchor(node.url, renderChildren(node.children, { ...context, inLink: true }), key) case 'linkReference': return renderLinkReference(node, key, context) case 'image': @@ -460,14 +499,14 @@ function renderLinkReference( context: MarkdownRenderContext, ): ReactNode { const definition = context.targets.definitions.get(node.identifier.toUpperCase()) - const children = renderChildren(node.children, context) if (definition === undefined) { // The grammar only emits references whose definitions exist somewhere in // the same parse, but incremental segments and hand-built trees may still - // present unresolved ones: revert to the bracketed source text. - return {'['}{children}{referenceSuffix(node)} + // present unresolved ones: revert to the bracketed source text — which is + // not an anchor, so mentions inside it stay live. + return {'['}{renderChildren(node.children, context)}{referenceSuffix(node)} } - return renderAnchor(definition.url, children, key) + return renderAnchor(definition.url, renderChildren(node.children, { ...context, inLink: true }), key) } function renderImageReference( diff --git a/packages/client/ui-primitives/tests/markdown-render-units.spec.tsx b/packages/client/ui-primitives/tests/markdown-render-units.spec.tsx index 48dd03f5c5..48f59c7479 100644 --- a/packages/client/ui-primitives/tests/markdown-render-units.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown-render-units.spec.tsx @@ -20,6 +20,7 @@ function makeContext(): MarkdownRenderContext { return { streaming: false, codeLabels: undefined, + fileMentions: undefined, targets: createReferenceTargets(), footnoteOrder: [], footnoteCounts: new Map(), diff --git a/packages/client/ui-primitives/tests/markdown.spec.tsx b/packages/client/ui-primitives/tests/markdown.spec.tsx index 5066e32cd2..89ffd32346 100644 --- a/packages/client/ui-primitives/tests/markdown.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown.spec.tsx @@ -148,6 +148,49 @@ describe('MarkdownText', () => { expect(container.querySelector('pre code a')).toBeNull() }) + it('links inline code through the file-mention resolver: URL first, settled only, never inside links', () => { + const opened: string[] = [] + const fileMentions = { + resolve: (value: string) => value === 'index.html' || value === 'out/index.html' + ? { open: () => { opened.push(value) }, label: 'Open out/index.html', title: 'out/index.html' } + : undefined, + } + const source = [ + '`index.html`', + '`other.css`', + '`https://example.com/`', + // Inside an anchor the mention stays inert code: a button cannot nest there. + '[see `out/index.html`](https://example.com/doc)', + '[ref `out/index.html`][target]', + '[target]: https://example.com/ref', + '```', + 'index.html', + '```', + ].join('\n\n') + const { container } = render() + + const mention = screen.getByRole('button', { name: 'Open out/index.html' }) + expect(mention.closest('code')).not.toBeNull() + // The full path rides title, the same disambiguator the row's chips carry. + expect(mention.getAttribute('title')).toBe('out/index.html') + fireEvent.click(mention) + expect(opened).toEqual(['index.html']) + // Exactly one live mention: the two inside anchors declined, and an + // unresolved token plus fenced code stay inert. + expect(container.querySelectorAll('code button')).toHaveLength(1) + expect(container.querySelectorAll('a code button, a button')).toHaveLength(0) + expect(screen.getByText('other.css').closest('button')).toBeNull() + // URL promotion wins before the resolver sees a token. + expect(screen.getByText('https://example.com/').closest('a')).not.toBeNull() + + // Streaming renders keep mentions off — the one gate lives here: cached + // frozen elements must not bake in handlers that could go stale. + const streamed = render( + , + ) + expect(streamed.container.querySelector('button')).toBeNull() + }) + it('exposes the CJK strong syntax as a micromark extension needing CommonMark attention markers', () => { const extension = cjkFriendlyStrong() expect(cjkFriendlyStrong()).toBe(extension) diff --git a/tsconfig.host.json b/tsconfig.host.json index ad2b5e32f1..d4f7eb8650 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -49,6 +49,7 @@ "apps/web/tests/goal-bar.e2e.ts", "apps/web/tests/startup-auto-selection.e2e.ts", "apps/web/tests/produced-files.e2e.ts", + "apps/web/tests/produced-file-mentions.e2e.ts", "apps/web/tests/subagent-conversation.e2e.ts", "apps/web/tests/bash-abort-row.e2e.ts", "apps/web/tests/skill-tool-row.e2e.ts", From 7ef39dc4b02795260c483c449b632303f95f6634 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Fri, 7 Aug 2026 05:00:47 -0700 Subject: [PATCH 004/189] refactor(deliverables): reuse the chain claim test for the mention vocabulary --- packages/client/ui-deliverables/src/client/index.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/client/ui-deliverables/src/client/index.ts b/packages/client/ui-deliverables/src/client/index.ts index 816cdff520..81b2f61b79 100644 --- a/packages/client/ui-deliverables/src/client/index.ts +++ b/packages/client/ui-deliverables/src/client/index.ts @@ -12,7 +12,7 @@ import type { ChatFileMentions } from '@deepseek-ai/dsh-client-ui-conversation/c import type {} from '@deepseek-ai/dsh-client-locale/client' import { ProducedFiles } from './ProducedFiles.tsx' import { en, NS, zh, type DeliverablesKey } from './locales.ts' -import { producedFileMentions, producedForClosing, selectProducedFiles } from './turn-deliverables.ts' +import { producedFileMentions, selectProducedFiles } from './turn-deliverables.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { @@ -46,8 +46,10 @@ export function apply(ctx: ClientContext): void { const t = ctx.locale.bind(NS) const mentions: ChatFileMentions = { forClosing(owner) { - const paths = producedForClosing(owner.nodes, owner.seq) - if (paths.length === 0) return undefined + // Same claim test the turn-tail chain entry runs: no produced files, + // no vocabulary — the two surfaces agree by construction. + const paths = selectProducedFiles(owner) + if (paths === null) return undefined return producedFileMentions(paths, owner.openFile, path => t('produced.open', { name: path })) }, } From 4c45355012bdc0d86d0fb8abe7e37c56b1e7c351 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Fri, 7 Aug 2026 05:06:21 -0700 Subject: [PATCH 005/189] style(web): mention chips take the markdown anchor language Link-blue at rest with hover underline, matching URL-promoted inline code; an at-rest underline collides with monospace descenders inside the code chip. --- ...6-08-07-web-inline-file-mentions.i18n.yaml | 4 ++-- .../2026-08-07-web-inline-file-mentions.md | 2 +- .../2026-08-07-web-inline-file-mentions.zh.md | 2 +- .../client/ui-deliverables/README.i18n.yaml | 4 ++-- packages/client/ui-deliverables/README.md | 2 +- packages/client/ui-deliverables/README.zh.md | 2 +- .../src/markdown/MarkdownText.module.css | 20 ++++++++++--------- 7 files changed, 19 insertions(+), 17 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.i18n.yaml b/.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.i18n.yaml index e8fe387234..8153420c3d 100644 --- a/.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.md -2026-08-07-web-inline-file-mentions.md: 79c6bddd500dc0b68bf64f3c4bea114ca41b4b62 -2026-08-07-web-inline-file-mentions.zh.md: ddc54d3b43570b19fb23c1c9c8079ef4ca65f8fa +2026-08-07-web-inline-file-mentions.md: 581efb5a9eb497e030d19118d52d003b37118108 +2026-08-07-web-inline-file-mentions.zh.md: 25c8ca6e106cd877a4d46a7b1338728e96771fc1 diff --git a/.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.md b/.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.md index 79c6bddd50..581efb5a9e 100644 --- a/.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.md +++ b/.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.md @@ -25,4 +25,4 @@ The produced-files row lists a turn's output, but the closing message usually al ## Consequences -The mention and the row are two affordances for one fact, styled alike (underlined at rest, full path as `title`). `apps/web/tests/produced-file-mentions.e2e.ts` pins the assembled behavior with a built write-turn seed: unique basename links, ambiguous and unknown tokens stay inert; it does not click, for the produced-files restraint (the opener launches a real application). Mentions in mid-turn narration stay inert even for files the turn later produces, because the vocabulary attaches to the closing message only. The window-prepend edge — a window that starts mid-turn later gaining earlier same-turn writes — leaves a mention unlinked until remount, never wrongly linked. +The mention and the row are two affordances for one fact (full path as `title` on both); the mention itself wears the markdown sheet's anchor language — link-blue at rest, hover underline — because an at-rest underline collides with monospace descenders inside the code chip. `apps/web/tests/produced-file-mentions.e2e.ts` pins the assembled behavior with a built write-turn seed: unique basename links, ambiguous and unknown tokens stay inert; it does not click, for the produced-files restraint (the opener launches a real application). Mentions in mid-turn narration stay inert even for files the turn later produces, because the vocabulary attaches to the closing message only. The window-prepend edge — a window that starts mid-turn later gaining earlier same-turn writes — leaves a mention unlinked until remount, never wrongly linked. diff --git a/.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.zh.md b/.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.zh.md index ddc54d3b43..25c8ca6e10 100644 --- a/.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.zh.md +++ b/.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.zh.md @@ -25,4 +25,4 @@ Status: implemented ## 后果 -提及与产物行是同一事实的两个交互面,样式一致(静止即下划线,完整路径作为 `title`)。`apps/web/tests/produced-file-mentions.e2e.ts` 用构造的写入轮 seed 钉住组装后的行为:唯一 basename 成链,歧义与未知 token 保持死文本;它不驱动点击,沿用产物行的克制(opener 会启动真实应用)。轮次中途叙述里的提及即使命名了本轮后来产出的文件也保持死文本,因为词表只挂在收尾消息上。窗口前插的边界——从轮次中途开始的窗口后来补入了同轮更早的写入——只会让提及在重挂载前暂不成链,绝不会错链。 +提及与产物行是同一事实的两个交互面(两者都以完整路径作 `title`);提及本身采用 markdown 样式表的锚点语言——静止为链接蓝、悬停出下划线——因为静止下划线在 code 胶囊里会压住等宽字的下伸部。`apps/web/tests/produced-file-mentions.e2e.ts` 用构造的写入轮 seed 钉住组装后的行为:唯一 basename 成链,歧义与未知 token 保持死文本;它不驱动点击,沿用产物行的克制(opener 会启动真实应用)。轮次中途叙述里的提及即使命名了本轮后来产出的文件也保持死文本,因为词表只挂在收尾消息上。窗口前插的边界——从轮次中途开始的窗口后来补入了同轮更早的写入——只会让提及在重挂载前暂不成链,绝不会错链。 diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index 3d25f13e1a..7c2cb356ec 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/packages/client/ui-deliverables/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-deliverables/README.md -README.md: d6695f155907e7d92b35556588687b3f95e55b88 -README.zh.md: be360a5a1fbe8d904cedf104b28f64f1b0567d6b +README.md: 189dedd88fed6914012204118ccdf9bdd0cd3bb2 +README.zh.md: bfcec3c54602533028942ed167b9526eaf3ca959 diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index d6695f1559..189dedd88f 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -8,7 +8,7 @@ Produced-files feature owner: registers the deliverables row a finished turn end `ProducedFiles` renders the row between the closing message's body and its IconActions footer: a quiet label, up to six chips (basename text, full path as the `title`), and an explicit remainder count past the cap. Each chip opens through the owner-supplied `openFile` — the same Host opener the tool rows use, with the chat view resolving relative paths against the session cwd. Design rationale: the [workspace file links Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md). -The closing prose carries the same vocabulary. This plugin provides the `chatFileMentions` service the chat view consults per closing message: `producedFileMentions` resolves an inline-code token by exact path, or by being exactly the basename of exactly one produced path — a basename two paths share stays inert rather than guessing, so a mention link can never open the wrong file or 404. A resolved mention renders as the same underlined opener the row's chips are, with the full path as its `title`, and mentions never render inside anchors or streaming text. Decision record: the [inline file mentions Agent Note](../../../.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.md). +The closing prose carries the same vocabulary. This plugin provides the `chatFileMentions` service the chat view consults per closing message: `producedFileMentions` resolves an inline-code token by exact path, or by being exactly the basename of exactly one produced path — a basename two paths share stays inert rather than guessing, so a mention link can never open the wrong file or 404. A resolved mention keeps its code chip and takes the markdown sheet's link language — link-blue at rest, underlined on hover, exactly like URL-promoted inline code — with the full path as its `title`; mentions never render inside anchors or streaming text. Decision record: the [inline file mentions Agent Note](../../../.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.md). ## Model Experience diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index be360a5a1f..bfcec3c546 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -8,7 +8,7 @@ `ProducedFiles` 在收尾消息正文与其 IconActions 之间渲染该行:一个安静的标签、至多六枚 chip(文本为文件名,完整路径作为 `title`),超出上限则显示一个明确的剩余计数。每枚 chip 经由 owner 提供的 `openFile` 打开——与工具行相同的 Host 打开器,chat 视图会把相对路径按会话 cwd 解析。设计原理:[workspace 文件链接 Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md)。 -收尾正文承载同一份词表。本插件提供 chat 视图按收尾消息查询的 `chatFileMentions` service:`producedFileMentions` 按精确路径解析行内代码 token,或当 token 恰好是且仅是一条产出路径的 basename 时解析——两条路径共享的 basename 保持死文本而不猜测,因此提及链接永远不会打开错误的文件或 404。解析成功的提及渲染为与产物行 chip 相同的下划线 opener,完整路径作为其 `title`;提及绝不会渲染在锚点内部或流式文本里。决策记录:[行内文件提及 Agent Note](../../../.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.md)。 +收尾正文承载同一份词表。本插件提供 chat 视图按收尾消息查询的 `chatFileMentions` service:`producedFileMentions` 按精确路径解析行内代码 token,或当 token 恰好是且仅是一条产出路径的 basename 时解析——两条路径共享的 basename 保持死文本而不猜测,因此提及链接永远不会打开错误的文件或 404。解析成功的提及保留 code 胶囊并采用 markdown 样式表的链接语言——静止为链接蓝、悬停出下划线,与 URL 提升的行内代码完全一致——完整路径作为其 `title`;提及绝不会渲染在锚点内部或流式文本里。决策记录:[行内文件提及 Agent Note](../../../.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.md)。 ## 模型体验 diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.module.css b/packages/client/ui-primitives/src/markdown/MarkdownText.module.css index 19375be18e..b62e66e86e 100644 --- a/packages/client/ui-primitives/src/markdown/MarkdownText.module.css +++ b/packages/client/ui-primitives/src/markdown/MarkdownText.module.css @@ -242,22 +242,24 @@ object-fit: contain; } -/* Inline file mention: a real file named in prose is the same affordance as a - tool row's path link, so it reads the same — underlined at rest. */ +/* Inline file mention: the same link language this sheet gives anchors (and + thereby URL-promoted inline code) — link-blue at rest, underline only on + hover/focus. An underline at rest reads badly inside the code chip, where + it collides with monospace descenders and the pill background. */ .fileMention { margin: 0; padding: 0; border: none; background: none; font: inherit; - color: var(--dsw-alias-label-secondary); - text-decoration: underline; - text-decoration-color: var(--dsw-alias-label-quaternary); - text-underline-offset: 3px; + color: var(--dsw-alias-state-business-primary); + text-decoration: none; cursor: pointer; } -.fileMention:hover { - color: var(--dsw-alias-label-primary); - text-decoration-color: currentColor; +.fileMention:hover, +.fileMention:focus { + outline: none; + text-decoration: underline var(--dsw-alias-state-business-primary); + text-underline-offset: 3px; } From b0eb7f8f35e385ad32b0a8af6e407de8f859c65f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 8 Aug 2026 01:23:26 +0800 Subject: [PATCH 006/189] docs: define unified GitHub label taxonomy --- ...07-25-semantic-pr-label-taxonomy.i18n.yaml | 4 +- .../2026-07-25-semantic-pr-label-taxonomy.md | 65 +++++++------------ ...026-07-25-semantic-pr-label-taxonomy.zh.md | 65 +++++++------------ AGENTS.md | 2 +- 4 files changed, 53 insertions(+), 83 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.i18n.yaml b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.i18n.yaml index 1aaa9f992b..a83236521c 100644 --- a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md -2026-07-25-semantic-pr-label-taxonomy.md: 3217b405e968d4d2c1eba1f1a5a08008b18ba514 -2026-07-25-semantic-pr-label-taxonomy.zh.md: 978f11af9402f248679e2087ff7fb513321b69b9 +2026-07-25-semantic-pr-label-taxonomy.md: 6aef5ecbb80005d1fcd90b4ee673bbbebb92cba6 +2026-07-25-semantic-pr-label-taxonomy.zh.md: 0bd3fbcaeca37fa894e92ce5a4e78c4125056ac9 diff --git a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md index 3217b405e9..6aef5ecbb8 100644 --- a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md +++ b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md @@ -6,66 +6,51 @@ English | [中文](2026-07-25-semantic-pr-label-taxonomy.zh.md) ## Problem -Pull requests need two different signals: what kind of change they make and which repository domains they affect. A flat or broadly named label set conflates those questions, hides work in distinct areas such as `session` and `llm`, and gives reviewers and automation weak inputs. +Pull request labels answer two independent questions: what kind of change the work makes and which durable repository domains it materially affects. Mixing those dimensions or keeping synonymous labels makes queries ambiguous, while a closed area inventory forces new domains into inaccurate categories. -The repository also gains new domains over time. Treating today's area labels as a closed set would force future work into inaccurate labels or a generic catch-all. +Issues already have a native Type and a separate source taxonomy. Reusing pull request kind or source labels across both object types duplicates metadata and weakens the meaning of each label family. ## Decision -Every open or merged pull request carries exactly one kind and every materially affected area. Closed pull requests that were never merged are outside the maintained historical corpus. Other operational labels may coexist, but they do not satisfy either dimension. +Every open or merged pull request carries exactly one canonical `kind/*` label and at least one materially affected `area/*` label. Closed pull requests that were never merged retain migrated historical assignments but do not receive invented missing classification. Operational labels may coexist without satisfying either dimension. ### Kinds +The kind set is closed and mutually exclusive: + | Kind | Meaning | |---|---| -| `feature` | Adds or intentionally changes behavior. | -| `bug-fix` | Corrects incorrect behavior. | -| `doc` | Makes documentation the dominant intent. | -| `testing` | Changes tests or testing infrastructure without changing product behavior. | -| `cleanup` | Preserves behavior while maintaining or simplifying the implementation or repository process. | +| `kind/feature` | Adds or intentionally changes behavior. | +| `kind/bug-fix` | Corrects incorrect behavior. | +| `kind/doc` | Makes documentation the dominant intent. | +| `kind/testing` | Changes tests or testing infrastructure without changing product behavior. | +| `kind/cleanup` | Preserves behavior while maintaining or simplifying implementation or repository process. | +| `kind/dependency` | Updates dependencies without another dominant intent. | -The kind records the change's dominant intent: accompanying tests and documentation do not turn a feature or bug fix into a testing or documentation change. +The kind records the dominant intent. Accompanying tests, documentation, cleanup, or dependency movement do not override a feature or bug fix. -Areas record semantic repository domains rather than temporary initiatives, ownership, or every path touched incidentally. Area labels are not a hierarchy: a pull request may carry several when it changes distinct contracts, but an umbrella and a narrower label do not both describe the same work. +### Areas -### Current areas +Areas name durable semantic domains rather than temporary initiatives, ownership, or every path touched incidentally. A pull request carries multiple areas when it changes distinct contracts, but it does not combine an umbrella and a narrower label for the same contract. GitHub's live `area/*` names and descriptions own the current inventory. -The 46 current areas are listed below. The group names organize the list for readability; they are not labels or another taxonomy level. +The area set is intentionally extensible. When no existing description honestly covers a durable and reusable repository domain, an agent is empowered to create a concise `area/` label without separate approval. The agent must not create an area for one pull request, an incidental path, a temporary project, a status, or a person or team, and must report the new label after applying it. Reusing an inaccurate area merely to avoid a justified addition is not acceptable. -| Group | Areas | -|---|---| -| Agent and model | `agent`, `agent-loop`, `session`, `llm`, `model-context`, `compaction`, `tools`, `persistence` | -| Orchestration | `subagent`, `workflow`, `planning`, `tasks`, `schedule`, `telemetry`, `storage`, `workspace` | -| Capabilities | `bash`, `subprocess`, `pty`, `filesystem`, `lsp`, `skills`, `web-search`, `code-mode`, `artifact`, `attachment`, `sandbox`, `mcp`, `hooks`, `cordis` | -| Interfaces | `ui`, `gui`, `tui`, `acp`, `json-rpc`, `cli`, `python-sdk`, `vscode`, `website` | -| Repository and release | `dev-infra`, `ci`, `build`, `dependencies`, `platform`, `i18n`, `release` | +Kinds are not extended this way. A new kind changes the mutually exclusive classification contract and requires an explicit taxonomy change with corresponding policy enforcement. -`gui` covers browser and Electron graphical applications, including standalone graphical developer tools; `vscode` remains the editor extension integration. `ui` covers shared cross-interface commands, approval interaction, presentation, and app boot; it coexists with `gui`, `tui`, or a protocol area only when the pull request also changes that shared contract. +### Issues and operational labels -`tasks` owns background work tied to a running process, while `schedule` owns durable time-triggered jobs. `tools` owns generic registry, schema, and execution contracts; a concrete capability receives `tools` only when it changes one of those contracts. `attachment` owns durable media references and multimodal input delivery, while `artifact` owns model-declared deliverable identity and preview lifecycle; neither borrows `tools` or `ui` for its implementation parts. +Issues use native Issue Type instead of `kind/*`; their `area/*` labels remain optional. `source/*` labels record Issue provenance and do not apply to pull requests. Priority, GitHub defaults, and workflow triggers remain independent operational metadata. -Names follow semantic ownership rather than lexical resemblance. `hooks` means the Claude Code and Codex agent bridges, not local Git hooks; `platform` means product portability, not CI runner selection; and `build` means compilation, bundling, and built package artifacts, not documentation generators. - -### Extensibility - -The area set is intentionally extensible. Add an area when a recurring, meaningful repository domain is missing; do not add a label for one pull request, a temporary project, a status, or a person or team. Rename, split, or retire an area when the domain model changes, and update this list and the affected open and merged pull requests together. - -The kind set stays narrow because kinds are mutually exclusive. A new kind requires a distinct change intent that cannot be represented by the current five; it is not a substitute for an area. +Label migrations preserve meaning before removing aliases: add the canonical replacement, verify the labelable, then remove the obsolete assignment. A label is deleted only after no pull request or Issue still uses it, and unrelated labels are never replaced as a set. ## Alternatives considered -- **One undifferentiated label set.** Rejected because kind and area answer different questions; mixing them makes the presence of one label say nothing about whether the other dimension was considered. -- **A fixed, closed area set.** Rejected because repository domains evolve. A closed set would preserve spelling at the cost of semantic accuracy. -- **One broad `core` area or package-derived labels.** Rejected because domains such as `session`, `llm`, and `agent` remain independently meaningful across package boundaries, while incidental file paths are not the scope reviewers or automation need. -- **Separate browser and desktop areas.** Rejected because browser delivery and Electron packaging expose one graphical client domain; splitting them classifies the delivery shell rather than the semantic work. -- **Broad implementation areas in place of a domain.** Rejected because a durable scheduled job is not a background task, an attachment is not merely its source interface or filesystem implementation, and an artifact is not merely its declaring tool or preview interface. -- **Umbrella and leaf areas for the same contract.** Rejected because duplicate labels inflate scope without adding information. Multiple areas remain correct when a pull request changes genuinely distinct contracts. -- **Exactly one area per pull request.** Rejected because coherent changes can legitimately span several domains, and dropping secondary areas hides affected contracts. +- **Unprefixed labels.** Rejected because a flat name does not identify whether it classifies intent, domain, source, priority, or automation, and synonymous plain and prefixed labels caused ambiguous queries. +- **A fixed area allowlist in repository policy.** Rejected because durable repository domains evolve. The `area/*` namespace remains mechanically recognizable while live descriptions carry the extensible inventory. +- **Kinds on Issues.** Rejected because native Issue Type already owns that classification; duplicating it as a label creates drift. +- **Automatic areas from paths.** Rejected because areas describe semantic impact across package boundaries, while changed paths include incidental tests, documentation, and support files. +- **Exactly one area per pull request.** Rejected because coherent changes can materially affect several independent contracts. ## Consequences -- Reviewers and automation receive one stable intent signal plus a complete semantic scope. -- `gui` queries cover browser and desktop delivery together, while `ui` queries retain only shared cross-interface contracts. -- `schedule`, `attachment`, and `artifact` queries identify those domains directly instead of approximating them through implementation dependencies. -- Selecting labels remains a judgment call: paths and title prefixes can suggest areas, but they cannot replace reading the change. -- Taxonomy changes carry maintenance work. Area additions, renames, splits, and removals update this decision record and backfill open and merged pull requests so historical queries keep their meaning. +Reviewers and automation can query intent, semantic scope, provenance, priority, and operational triggers independently. Maintainers must read the change and live label descriptions rather than infer classification from title prefixes or paths, and taxonomy migrations carry an explicit historical backfill and verification cost. diff --git a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.zh.md b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.zh.md index 978f11af94..0bd3fbcaec 100644 --- a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.zh.md +++ b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.zh.md @@ -6,66 +6,51 @@ Status: implemented ## 问题 -PR(Pull Request)需要传达两个不同的信号:它带来哪一类变更,以及会影响仓库中的哪些领域。一套扁平或命名宽泛的标签会混淆这两个问题,掩盖 `session`、`llm` 等不同领域的工作,也让评审人和自动化流程得到的输入缺乏有效信息。 +PR(Pull Request)标签回答两个相互独立的问题:工作带来哪一类变更,以及会对哪些持久的仓库领域产生实质影响。混用这两个维度或保留同义标签会使查询含义模糊,而封闭的领域清单会迫使新领域被归入不准确的类别。 -仓库还会随时间发展出新的领域。如果把当前的领域标签视为封闭集合,未来的工作就只能归入不准确的标签或通用兜底标签。 +Issue 已有原生 Issue Type 和独立的来源分类体系。在这两类对象上复用 PR 类型或来源标签会产生重复元数据,并削弱每个标签族的含义。 ## 决策 -每项开放或已合并的 PR 都带有恰好一个类型标签,以及所有受到实质影响的领域标签。未合并即关闭的 PR 不属于持续维护的历史记录集合。其他管理用途的标签可以并存,但都不能满足这两个维度中的任一个。 +每项开放或已合并的 PR 都带有恰好一个规范的 `kind/*` 标签,以及至少一个受到实质影响的 `area/*` 标签。未合并即关闭的 PR 保留经迁移的历史标签关系,但不会凭空补充缺失分类。管理用途的标签可以并存,但不能满足这两个维度中的任一个。 ### 类型 +类型集合封闭且互斥: + | 类型 | 含义 | |---|---| -| `feature` | 新增行为或有意改变行为。 | -| `bug-fix` | 修正错误行为。 | -| `doc` | 以文档变更为主要意图。 | -| `testing` | 修改测试或测试基础设施,但不改变产品行为。 | -| `cleanup` | 在保持行为不变的前提下,维护或简化实现或仓库流程。 | +| `kind/feature` | 新增行为或有意改变行为。 | +| `kind/bug-fix` | 修正错误行为。 | +| `kind/doc` | 以文档变更为主要意图。 | +| `kind/testing` | 修改测试或测试基础设施,但不改变产品行为。 | +| `kind/cleanup` | 在保持行为不变的前提下,维护或简化实现或仓库流程。 | +| `kind/dependency` | 在没有其他主要意图时更新依赖。 | -类型记录变更的主要意图:配套测试与文档并不会把一项功能或缺陷修复变成测试或文档变更。 +类型记录主要意图。配套测试、文档、清理或依赖调整不会覆盖一项功能或缺陷修复的主要意图。 -领域记录仓库中的语义领域,而不是临时项目、归属关系或偶然触及的每条路径。领域标签不构成层级:一项 PR 修改不同契约时可以带有多个领域标签,但不能用一个总括标签和一个较窄标签重复描述同一项工作。 +### 领域 -### 当前领域 +领域表示持久的语义领域,而不是临时项目、归属关系或偶然触及的每条路径。一项 PR 修改不同契约时带有多个领域标签,但不会用一个总括标签和一个较窄标签重复描述同一项契约。当前分类清单以 GitHub 上实时的 `area/*` 名称和描述为准。 -当前的 46 个领域如下。分组名称仅用于提高列表的可读性;它们既不是标签,也不是分类体系中的另一个层级。 +领域集合有意保持可扩展。当现有描述都无法准确涵盖一个持久且可复用的仓库领域时,agent(智能体)有权无须另行批准,直接创建简洁的 `area/` 标签。agent 不得为单个 PR、偶然涉及的路径、临时项目、状态、个人或团队创建领域,并且必须在应用新标签后报告。仅为避免新增一个确有必要的标签而复用不准确的领域,不可接受。 -| 分组 | 领域 | -|---|---| -| agent(智能体)与模型 | `agent`, `agent-loop`, `session`, `llm`, `model-context`, `compaction`, `tools`, `persistence` | -| 编排 | `subagent`, `workflow`, `planning`, `tasks`, `schedule`, `telemetry`, `storage`, `workspace` | -| 能力 | `bash`, `subprocess`, `pty`, `filesystem`, `lsp`, `skills`, `web-search`, `code-mode`, `artifact`, `attachment`, `sandbox`, `mcp`, `hooks`, `cordis` | -| 接口 | `ui`, `gui`, `tui`, `acp`, `json-rpc`, `cli`, `python-sdk`, `vscode`, `website` | -| 仓库与发布 | `dev-infra`, `ci`, `build`, `dependencies`, `platform`, `i18n`, `release` | +类型不以这种方式扩展。新增类型会改变互斥的分类契约,必须明确修改分类体系并同步调整策略强制校验。 -`gui` 涵盖浏览器和 Electron 图形应用,包括独立的图形化开发者工具;`vscode` 仍表示编辑器扩展集成。`ui` 涵盖共享的跨接口命令、审批交互、呈现和应用启动;只有当 PR 还修改这项共享契约时,它才与 `gui`、`tui` 或某个协议领域并用。 +### Issue 与管理用途标签 -`tasks` 负责与运行中进程绑定的后台工作,`schedule` 则负责持久化的定时作业。`tools` 负责通用的注册表契约、schema 契约和执行契约;具体能力只有在修改其中一项契约时才带有 `tools`。`attachment` 负责持久化的媒体引用和多模态输入传递,`artifact` 则负责模型声明的交付物标识和预览生命周期;二者都不会因实现包含工具或界面部分而借用 `tools` 或 `ui`。 +Issue 使用原生 Issue Type,而不是 `kind/*`;其 `area/*` 标签仍为可选。`source/*` 标签记录 Issue 来源,不适用于 PR。优先级、GitHub 默认标签和工作流触发器仍是相互独立的管理元数据。 -标签名称以语义归属为准,而不是词面相似性。`hooks` 指 Claude Code 和 Codex 的 agent 桥接,而不是本地 Git 钩子;`platform` 指产品可移植性,而不是 CI 运行器选择;`build` 指编译、打包和已构建的包产物,而不是文档生成器。 - -### 可扩展性 - -领域集合有意保持可扩展。当分类体系缺少一个会反复涉及且具有实际意义的仓库领域时,就新增领域;不要仅为一项 PR、临时项目、状态、个人或团队新增标签。当领域模型发生变化时,重命名、拆分或退役相应领域,同时更新本列表以及所有受影响的开放和已合并 PR。 - -类型集合保持精简,因为各类型互斥。新增类型的前提是存在一种当前五类无法表达的独立变更意图;类型不能用来替代领域。 +迁移标签时,须先保留语义,再移除别名:先添加规范替代标签并验证对象,再移除废弃的标签关系。只有在所有 PR 和 Issue 都不再使用某个标签后才能将其删除,且绝不整组替换无关标签。 ## 曾考虑的替代方案 -- **一套不区分维度的标签。** 不予采纳,因为类型与领域回答的是不同问题;两者混在一起时,存在一个维度的标签并不表示另一个维度也经过了考虑。 -- **一套固定、封闭的领域集合。** 不予采纳,因为仓库领域会持续演变。封闭集合会以牺牲语义准确性为代价来维持拼写不变。 -- **一个宽泛的 `core` 领域,或从包结构派生的标签。** 不予采纳,因为 `session`、`llm` 和 `agent` 等领域在跨越包边界时仍各自具有意义,而偶然涉及的文件路径并不是评审人或自动化流程所需的范围信息。 -- **为浏览器和桌面端分别设置领域。** 不予采纳,因为浏览器交付和 Electron 打包共同呈现同一个图形客户端领域;拆开二者将按交付形态而非工作的语义进行分类。 -- **以宽泛的实现领域替代语义领域。** 不予采纳,因为持久化的定时作业不是后台任务,附件不只是其来源接口或文件系统实现,产物也不只是声明它的工具或预览接口。 -- **同一项契约同时使用总括领域与细分领域。** 不予采纳,因为重复标签只会虚增范围,不会增加信息。一项 PR 确实修改不同契约时,多个领域标签仍然合理。 -- **每项 PR 恰好一个领域。** 不予采纳,因为一项内聚的变更可以合理地跨越多个领域;省略次要领域会隐藏受影响的契约。 +- **无前缀标签。** 不予采纳,因为扁平名称无法表明其表示的是意图、领域、来源、优先级还是自动化用途,而同义的无前缀和带前缀标签会使查询含义模糊。 +- **在仓库策略中使用固定的领域允许列表。** 不予采纳,因为持久的仓库领域会持续演变。`area/*` 命名空间仍可由机制识别,而实时描述负责承载可扩展的分类清单。 +- **在 Issue 上使用类型标签。** 不予采纳,因为原生 Issue Type 已负责这项分类;再用标签重复表示会产生漂移。 +- **从路径自动生成领域。** 不予采纳,因为领域描述跨越包边界的语义影响,而变更路径还会包含偶然涉及的测试、文档和支持文件。 +- **每项 PR 恰好一个领域。** 不予采纳,因为一项内聚的变更可以对多个独立契约产生实质影响。 ## 后果 -- 评审人和自动化流程获得一个稳定的意图信号,以及完整的语义范围。 -- `gui` 查询会同时覆盖浏览器与桌面端交付,`ui` 查询则只涵盖共享的跨接口契约。 -- `schedule`、`attachment` 与 `artifact` 查询直接对应各自领域,无需通过实现依赖近似归类。 -- 选择标签仍然需要判断:路径和标题前缀可以提示领域,但不能替代阅读变更内容。 -- 变更分类体系会产生维护工作。新增、重命名、拆分或移除领域时,需要更新本决策记录,并回填开放和已合并的 PR,使历史查询保持原有含义。 +评审人和自动化流程可以分别查询意图、语义范围、来源、优先级和工作流触发器。维护者必须阅读变更内容和实时标签描述,而不能根据标题前缀或路径推断分类;迁移分类体系还会产生明确的历史回填与验证成本。 diff --git a/AGENTS.md b/AGENTS.md index 64f2e944a8..90b2ab4a67 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -118,7 +118,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, - **A tool's UI render intent is part of its design**, decided up front (`generic`/`terminal`/`diff`, `locations`); presentation methods are pure functions of `args` ([cookbook](docs/cookbook/adding-a-tool.md)). - **Plan unit, e2e, and snapshot coverage** for new seams, lifecycle shapes, and transcript surfaces; missing snapshot-harness support is part of the implementation, not deferred follow-up. - **Choose PR history deliberately.** Split independent changes; fix the introducing PR before propagation. Standalone PRs and official stacks may merge-forward or rebase after review. Rewrites use `--force-with-lease`, abort on remote movement, never raw `--force`; an in-progress merge-forward preserves its checkpoint before taking a newer base ([rationale](.agents/notes/implemented/process/2026-08-02-native-github-stacks-and-optional-rebases.md)). -- **Label PRs:** one kind (`feature`/`bug-fix`/`doc`/`testing`/`cleanup`), each matching area; the [taxonomy](.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md) is extensible. +- **Labels:** one PR `kind/*`, all material `area/*`, and native Issue Type ([workflow](.agents/skills/dsh-labeling/SKILL.md)). - TODO markers: `FIXME`/`TODO`/`XXX` by urgency ([semantics](docs/development.md)). - Files end with exactly one trailing newline; `git diff --cached --check` (pre-commit) gates it. From a9abb62195f438c61b163fa2e9b8133b1523e4a9 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 8 Aug 2026 01:23:36 +0800 Subject: [PATCH 007/189] chore: enforce unified GitHub labels --- .github/dependabot.yml | 6 +- .github/issue-management/policy.mjs | 43 +++++++++- .github/issue-management/policy.test.mjs | 100 ++++++++++++++++++++--- AGENTS.md | 2 +- 4 files changed, 132 insertions(+), 19 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 524d7912e2..dd91db8bf1 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -13,7 +13,7 @@ updates: cooldown: default-days: 30 labels: - - "cleanup" + - "kind/dependency" - "area/infra" - package-ecosystem: "uv" @@ -25,7 +25,7 @@ updates: cooldown: default-days: 30 labels: - - "cleanup" + - "kind/dependency" - "area/infra" - package-ecosystem: "github-actions" @@ -37,5 +37,5 @@ updates: cooldown: default-days: 30 labels: - - "cleanup" + - "kind/dependency" - "area/infra" diff --git a/.github/issue-management/policy.mjs b/.github/issue-management/policy.mjs index 608291c4f8..18a9b03628 100644 --- a/.github/issue-management/policy.mjs +++ b/.github/issue-management/policy.mjs @@ -12,6 +12,27 @@ const AUDIT_MARKER = '' const OWNER_LINE = /^Owner: @([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?)$/ const TYPES = new Set(['Idea', 'Feature', 'Bug', 'Research', 'Task']) const PRIORITIES = ['p0', 'p1', 'p2', 'p3'] +const PR_KINDS = new Set([ + 'kind/feature', + 'kind/bug-fix', + 'kind/doc', + 'kind/testing', + 'kind/cleanup', + 'kind/dependency', +]) +const LEGACY_LABELS = new Set([ + 'kind/bug', + 'kind/documentation', + 'bug-fix', + 'doc', + 'cleanup', + 'testing', + 'dependencies', + 'ci', + 'cli', + 'llm', + 'web-search', +]) const TERMINAL_STATUSES = new Set(['Done', 'No action']) const ACTIVE_STATUS_ORDER = config.statuses.filter((status) => !TERMINAL_STATUSES.has(status)) @@ -224,8 +245,14 @@ export function retainIssueReferences(references, issues) { export function validateIssue(issue) { const errors = validateBody(issue) const status = issue.status + const invalidLabels = issue.labels.filter( + (label) => label.startsWith('kind/') || LEGACY_LABELS.has(label), + ) if (!/\p{Script=Han}/u.test(issue.title)) errors.push('Issue 标题必须包含中文') + if (invalidLabels.length > 0) { + errors.push(`Issue 不得使用 PR kind 或旧版标签:${invalidLabels.join(', ')}`) + } if ( /^\s*(?:\[(?:Idea|Feature|Bug|Research|Task|P[0-3]|Inbox|Backlog|Ready|In progress|In review|Done|No action|Owner|area\/[^\]]+)[^\]]*\]|(?:Idea|Feature|Bug|Research|Task|P[0-3]|Inbox|Backlog|Ready|In progress|In review|Done|No action|Owner|area\/[^:: ]+)\s*[::-])/iu.test( issue.title, @@ -261,12 +288,24 @@ export function validateIssue(issue) { export function validatePullRequest(input) { if (!requiresPullRequestPolicy(input)) return [] const errors = [] - const kinds = input.labels.filter((label) => label.startsWith('kind/')) + const kinds = input.labels.filter((label) => PR_KINDS.has(label)) + const unknownKinds = input.labels.filter( + (label) => label.startsWith('kind/') && !PR_KINDS.has(label) && !LEGACY_LABELS.has(label), + ) + const legacyLabels = input.labels.filter((label) => LEGACY_LABELS.has(label)) + const sourceLabels = input.labels.filter((label) => label.startsWith('source/')) const priorities = input.labels.filter((label) => PRIORITIES.includes(label)) const areas = input.labels.filter((label) => label.startsWith('area/')) if (input.references.all.length === 0) errors.push('PR 正文必须引用至少一个同仓库 Issue') - if (kinds.length !== 1) errors.push(`PR 必须恰好有一个 kind/*,当前为 ${kinds.length}`) + if (kinds.length !== 1) { + errors.push(`PR 必须恰好有一个允许的 kind/*,当前为 ${kinds.length}`) + } + if (unknownKinds.length > 0) { + errors.push(`PR 含不支持的 kind/*:${unknownKinds.join(', ')}`) + } + if (legacyLabels.length > 0) errors.push(`PR 含旧版标签:${legacyLabels.join(', ')}`) + if (sourceLabels.length > 0) errors.push(`source/* 仅用于 Issue:${sourceLabels.join(', ')}`) if (priorities.length > 1) errors.push(`PR 最多有一个 p0–p3,当前为 ${priorities.length}`) if (areas.length === 0) errors.push('PR 必须至少有一个 area/*') for (const number of input.references.all) { diff --git a/.github/issue-management/policy.test.mjs b/.github/issue-management/policy.test.mjs index 86750127a7..bcaf450afa 100644 --- a/.github/issue-management/policy.test.mjs +++ b/.github/issue-management/policy.test.mjs @@ -27,6 +27,25 @@ const legalIssue = { stateReason: null, } +const canonicalKinds = [ + 'kind/feature', + 'kind/bug-fix', + 'kind/doc', + 'kind/testing', + 'kind/cleanup', + 'kind/dependency', +] + +const reviewedPull = (labels) => ({ + isDraft: false, + authorType: 'User', + reviewRequestCount: 1, + reviewCount: 0, + labels, + references: { all: [2], resolving: [], related: [2] }, + issues: new Map([[2, { priority: null }]]), +}) + test('counts only text outside details', () => { assert.deepEqual(countVisibleUnits('支持 GitHub Project。
隐藏文字
'), { units: 4, @@ -92,6 +111,32 @@ test('rejects metadata prefixes in an Issue title', () => { assert.ok(errors.includes('Issue 标题不得带 Type、Priority、Status、area 或 Owner 前缀')) }) +test('reserves PR kind and legacy labels for pull requests', () => { + for (const label of [ + ...canonicalKinds, + 'kind/experimental', + 'kind/bug', + 'kind/documentation', + 'bug-fix', + 'doc', + 'cleanup', + 'testing', + 'dependencies', + 'ci', + 'cli', + 'llm', + 'web-search', + ]) { + assert.ok( + validateIssue({ ...legalIssue, labels: [label] }).some((error) => + error.startsWith('Issue 不得使用 PR kind 或旧版标签:'), + ), + label, + ) + } + assert.deepEqual(validateIssue({ ...legalIssue, labels: ['area/web', 'source/member'] }), []) +}) + test('keeps terminal Status aligned with the native close reason', () => { assert.deepEqual( validateIssue({ ...legalIssue, status: 'Done', state: 'closed', stateReason: 'completed' }), @@ -260,22 +305,51 @@ test('requires repository PR labels in the enforcement scope', () => { references: { all: [2], resolving: [], related: [2] }, issues: new Map([[2, { priority: null }]]), }) - assert.ok(errors.includes('PR 必须恰好有一个 kind/*,当前为 0')) + assert.ok(errors.includes('PR 必须恰好有一个允许的 kind/*,当前为 0')) assert.ok(errors.includes('PR 必须至少有一个 area/*')) }) -test('accepts repository-extensible kind labels', () => { - assert.deepEqual( - validatePullRequest({ - isDraft: false, - authorType: 'User', - reviewRequestCount: 1, - reviewCount: 0, - labels: ['kind/dependency', 'area/infra'], - references: { all: [2], resolving: [], related: [2] }, - issues: new Map([[2, { priority: null }]]), - }), - [], +test('accepts exactly the canonical kinds with extensible areas', () => { + for (const kind of canonicalKinds) { + assert.deepEqual(validatePullRequest(reviewedPull([kind, 'area/future-domain'])), [], kind) + } +}) + +test('rejects multiple, unknown, legacy, and Issue-source PR labels', () => { + assert.ok( + validatePullRequest( + reviewedPull(['kind/feature', 'kind/doc', 'area/web']), + ).includes('PR 必须恰好有一个允许的 kind/*,当前为 2'), + ) + assert.ok( + validatePullRequest(reviewedPull(['kind/experimental', 'area/web'])).includes( + 'PR 含不支持的 kind/*:kind/experimental', + ), + ) + for (const label of [ + 'kind/bug', + 'kind/documentation', + 'bug-fix', + 'doc', + 'cleanup', + 'testing', + 'dependencies', + 'ci', + 'cli', + 'llm', + 'web-search', + ]) { + assert.ok( + validatePullRequest(reviewedPull(['kind/feature', 'area/web', label])).some((error) => + error.startsWith('PR 含旧版标签:'), + ), + label, + ) + } + assert.ok( + validatePullRequest( + reviewedPull(['kind/feature', 'area/web', 'source/internal-pr']), + ).includes('source/* 仅用于 Issue:source/internal-pr'), ) }) diff --git a/AGENTS.md b/AGENTS.md index 90b2ab4a67..9344e2e6e2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -118,7 +118,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, - **A tool's UI render intent is part of its design**, decided up front (`generic`/`terminal`/`diff`, `locations`); presentation methods are pure functions of `args` ([cookbook](docs/cookbook/adding-a-tool.md)). - **Plan unit, e2e, and snapshot coverage** for new seams, lifecycle shapes, and transcript surfaces; missing snapshot-harness support is part of the implementation, not deferred follow-up. - **Choose PR history deliberately.** Split independent changes; fix the introducing PR before propagation. Standalone PRs and official stacks may merge-forward or rebase after review. Rewrites use `--force-with-lease`, abort on remote movement, never raw `--force`; an in-progress merge-forward preserves its checkpoint before taking a newer base ([rationale](.agents/notes/implemented/process/2026-08-02-native-github-stacks-and-optional-rebases.md)). -- **Labels:** one PR `kind/*`, all material `area/*`, and native Issue Type ([workflow](.agents/skills/dsh-labeling/SKILL.md)). +- **Labels:** one PR `kind/*`, all material `area/*`, and native Issue Type ([taxonomy](.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md)). - TODO markers: `FIXME`/`TODO`/`XXX` by urgency ([semantics](docs/development.md)). - Files end with exactly one trailing newline; `git diff --cached --check` (pre-commit) gates it. From 07ac5e6d4cbf06e5078e73844e38e97684788a75 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 8 Aug 2026 19:00:10 +0800 Subject: [PATCH 008/189] fix(labels): address taxonomy review findings --- .../2026-07-25-semantic-pr-label-taxonomy.md | 56 --------------- ...026-07-25-semantic-pr-label-taxonomy.zh.md | 56 --------------- ...07-27-dependabot-version-updates.i18n.yaml | 4 +- .../2026-07-27-dependabot-version-updates.md | 2 +- ...026-07-27-dependabot-version-updates.zh.md | 2 +- ...8-unified-github-label-taxonomy.i18n.yaml} | 6 +- ...026-08-08-unified-github-label-taxonomy.md | 72 +++++++++++++++++++ ...-08-08-unified-github-label-taxonomy.zh.md | 72 +++++++++++++++++++ .github/issue-management/policy.mjs | 2 + .github/issue-management/policy.test.mjs | 42 +++++------ AGENTS.md | 2 +- 11 files changed, 172 insertions(+), 144 deletions(-) delete mode 100644 .agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md delete mode 100644 .agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.zh.md rename .agents/notes/implemented/process/{2026-07-25-semantic-pr-label-taxonomy.i18n.yaml => 2026-08-08-unified-github-label-taxonomy.i18n.yaml} (56%) create mode 100644 .agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.md create mode 100644 .agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.zh.md diff --git a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md deleted file mode 100644 index 6aef5ecbb8..0000000000 --- a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md +++ /dev/null @@ -1,56 +0,0 @@ -# Agent Note: Semantic pull request label taxonomy - -Status: implemented - -English | [中文](2026-07-25-semantic-pr-label-taxonomy.zh.md) - -## Problem - -Pull request labels answer two independent questions: what kind of change the work makes and which durable repository domains it materially affects. Mixing those dimensions or keeping synonymous labels makes queries ambiguous, while a closed area inventory forces new domains into inaccurate categories. - -Issues already have a native Type and a separate source taxonomy. Reusing pull request kind or source labels across both object types duplicates metadata and weakens the meaning of each label family. - -## Decision - -Every open or merged pull request carries exactly one canonical `kind/*` label and at least one materially affected `area/*` label. Closed pull requests that were never merged retain migrated historical assignments but do not receive invented missing classification. Operational labels may coexist without satisfying either dimension. - -### Kinds - -The kind set is closed and mutually exclusive: - -| Kind | Meaning | -|---|---| -| `kind/feature` | Adds or intentionally changes behavior. | -| `kind/bug-fix` | Corrects incorrect behavior. | -| `kind/doc` | Makes documentation the dominant intent. | -| `kind/testing` | Changes tests or testing infrastructure without changing product behavior. | -| `kind/cleanup` | Preserves behavior while maintaining or simplifying implementation or repository process. | -| `kind/dependency` | Updates dependencies without another dominant intent. | - -The kind records the dominant intent. Accompanying tests, documentation, cleanup, or dependency movement do not override a feature or bug fix. - -### Areas - -Areas name durable semantic domains rather than temporary initiatives, ownership, or every path touched incidentally. A pull request carries multiple areas when it changes distinct contracts, but it does not combine an umbrella and a narrower label for the same contract. GitHub's live `area/*` names and descriptions own the current inventory. - -The area set is intentionally extensible. When no existing description honestly covers a durable and reusable repository domain, an agent is empowered to create a concise `area/` label without separate approval. The agent must not create an area for one pull request, an incidental path, a temporary project, a status, or a person or team, and must report the new label after applying it. Reusing an inaccurate area merely to avoid a justified addition is not acceptable. - -Kinds are not extended this way. A new kind changes the mutually exclusive classification contract and requires an explicit taxonomy change with corresponding policy enforcement. - -### Issues and operational labels - -Issues use native Issue Type instead of `kind/*`; their `area/*` labels remain optional. `source/*` labels record Issue provenance and do not apply to pull requests. Priority, GitHub defaults, and workflow triggers remain independent operational metadata. - -Label migrations preserve meaning before removing aliases: add the canonical replacement, verify the labelable, then remove the obsolete assignment. A label is deleted only after no pull request or Issue still uses it, and unrelated labels are never replaced as a set. - -## Alternatives considered - -- **Unprefixed labels.** Rejected because a flat name does not identify whether it classifies intent, domain, source, priority, or automation, and synonymous plain and prefixed labels caused ambiguous queries. -- **A fixed area allowlist in repository policy.** Rejected because durable repository domains evolve. The `area/*` namespace remains mechanically recognizable while live descriptions carry the extensible inventory. -- **Kinds on Issues.** Rejected because native Issue Type already owns that classification; duplicating it as a label creates drift. -- **Automatic areas from paths.** Rejected because areas describe semantic impact across package boundaries, while changed paths include incidental tests, documentation, and support files. -- **Exactly one area per pull request.** Rejected because coherent changes can materially affect several independent contracts. - -## Consequences - -Reviewers and automation can query intent, semantic scope, provenance, priority, and operational triggers independently. Maintainers must read the change and live label descriptions rather than infer classification from title prefixes or paths, and taxonomy migrations carry an explicit historical backfill and verification cost. diff --git a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.zh.md b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.zh.md deleted file mode 100644 index 0bd3fbcaec..0000000000 --- a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.zh.md +++ /dev/null @@ -1,56 +0,0 @@ -# Agent Note: 语义化 PR 标签分类体系 - -Status: implemented - -[English](2026-07-25-semantic-pr-label-taxonomy.md) | 中文 - -## 问题 - -PR(Pull Request)标签回答两个相互独立的问题:工作带来哪一类变更,以及会对哪些持久的仓库领域产生实质影响。混用这两个维度或保留同义标签会使查询含义模糊,而封闭的领域清单会迫使新领域被归入不准确的类别。 - -Issue 已有原生 Issue Type 和独立的来源分类体系。在这两类对象上复用 PR 类型或来源标签会产生重复元数据,并削弱每个标签族的含义。 - -## 决策 - -每项开放或已合并的 PR 都带有恰好一个规范的 `kind/*` 标签,以及至少一个受到实质影响的 `area/*` 标签。未合并即关闭的 PR 保留经迁移的历史标签关系,但不会凭空补充缺失分类。管理用途的标签可以并存,但不能满足这两个维度中的任一个。 - -### 类型 - -类型集合封闭且互斥: - -| 类型 | 含义 | -|---|---| -| `kind/feature` | 新增行为或有意改变行为。 | -| `kind/bug-fix` | 修正错误行为。 | -| `kind/doc` | 以文档变更为主要意图。 | -| `kind/testing` | 修改测试或测试基础设施,但不改变产品行为。 | -| `kind/cleanup` | 在保持行为不变的前提下,维护或简化实现或仓库流程。 | -| `kind/dependency` | 在没有其他主要意图时更新依赖。 | - -类型记录主要意图。配套测试、文档、清理或依赖调整不会覆盖一项功能或缺陷修复的主要意图。 - -### 领域 - -领域表示持久的语义领域,而不是临时项目、归属关系或偶然触及的每条路径。一项 PR 修改不同契约时带有多个领域标签,但不会用一个总括标签和一个较窄标签重复描述同一项契约。当前分类清单以 GitHub 上实时的 `area/*` 名称和描述为准。 - -领域集合有意保持可扩展。当现有描述都无法准确涵盖一个持久且可复用的仓库领域时,agent(智能体)有权无须另行批准,直接创建简洁的 `area/` 标签。agent 不得为单个 PR、偶然涉及的路径、临时项目、状态、个人或团队创建领域,并且必须在应用新标签后报告。仅为避免新增一个确有必要的标签而复用不准确的领域,不可接受。 - -类型不以这种方式扩展。新增类型会改变互斥的分类契约,必须明确修改分类体系并同步调整策略强制校验。 - -### Issue 与管理用途标签 - -Issue 使用原生 Issue Type,而不是 `kind/*`;其 `area/*` 标签仍为可选。`source/*` 标签记录 Issue 来源,不适用于 PR。优先级、GitHub 默认标签和工作流触发器仍是相互独立的管理元数据。 - -迁移标签时,须先保留语义,再移除别名:先添加规范替代标签并验证对象,再移除废弃的标签关系。只有在所有 PR 和 Issue 都不再使用某个标签后才能将其删除,且绝不整组替换无关标签。 - -## 曾考虑的替代方案 - -- **无前缀标签。** 不予采纳,因为扁平名称无法表明其表示的是意图、领域、来源、优先级还是自动化用途,而同义的无前缀和带前缀标签会使查询含义模糊。 -- **在仓库策略中使用固定的领域允许列表。** 不予采纳,因为持久的仓库领域会持续演变。`area/*` 命名空间仍可由机制识别,而实时描述负责承载可扩展的分类清单。 -- **在 Issue 上使用类型标签。** 不予采纳,因为原生 Issue Type 已负责这项分类;再用标签重复表示会产生漂移。 -- **从路径自动生成领域。** 不予采纳,因为领域描述跨越包边界的语义影响,而变更路径还会包含偶然涉及的测试、文档和支持文件。 -- **每项 PR 恰好一个领域。** 不予采纳,因为一项内聚的变更可以对多个独立契约产生实质影响。 - -## 后果 - -评审人和自动化流程可以分别查询意图、语义范围、来源、优先级和工作流触发器。维护者必须阅读变更内容和实时标签描述,而不能根据标题前缀或路径推断分类;迁移分类体系还会产生明确的历史回填与验证成本。 diff --git a/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.i18n.yaml b/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.i18n.yaml index 316c31771e..dea250de53 100644 --- a/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-27-dependabot-version-updates.md -2026-07-27-dependabot-version-updates.md: 5d42563788d9f1e72da65c8e9750d6b1ecba06a5 -2026-07-27-dependabot-version-updates.zh.md: 4847059944e7e35de5719a6cbfd3d5b133467ccb +2026-07-27-dependabot-version-updates.md: bba83c9e720cf87f91638131d7f9580422ea7f76 +2026-07-27-dependabot-version-updates.zh.md: 74b399778fdd06cbbe38234f7bddc5c28c4fd600 diff --git a/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.md b/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.md index 5d42563788..bba83c9e72 100644 --- a/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.md +++ b/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.md @@ -12,7 +12,7 @@ Maintained registry and GitHub Actions dependencies need a regular update path. The default branch carries [`.github/dependabot.yml`](../../../../.github/dependabot.yml) with weekly version-update checks for the root pnpm workspace, including `native/landlock-run`, the `python/sdk` uv project, and GitHub Actions. Every entry sets `cooldown.default-days` to `30`, so a version release becomes eligible only after it is at least 30 days old and is proposed on the next weekly check. The [in-repository Landlock release decision](2026-08-06-in-repository-landlock-release.md) owns the shared-workspace boundary. -The root pnpm version-update scan excludes `vendor/**`, whose source and manifests move only through the [vendoring procedure](../../../../vendor/README.md). GitHub applies `exclude-paths` only to version updates; a security pull request that touches a vendored manifest is replaced through the vendoring procedure instead of being merged as generated. Dependabot pull requests receive the repository's `cleanup` kind and `area/infra` area labels, run the normal pull-request checks, and remain subject to maintainer review; this automation does not merge them. +The root pnpm version-update scan excludes `vendor/**`, whose source and manifests move only through the [vendoring procedure](../../../../vendor/README.md). GitHub applies `exclude-paths` only to version updates; a security pull request that touches a vendored manifest is replaced through the vendoring procedure instead of being merged as generated. Dependabot pull requests receive the repository's `kind/dependency` kind and `area/infra` area labels, run the normal pull-request checks, and remain subject to maintainer review; this automation does not merge them. Repository settings enable dependency vulnerability alerts and Dependabot security updates. GitHub does not apply version-update cooldowns to those security updates, so security fixes remain eligible immediately. A generated pnpm security pull request can still fail the repository's lockfile release-age verification when dependency resolution selects unrelated fresh transitive versions; that pull request waits or is narrowed instead of weakening the policy. The repository's coordinated fresh-release exceptions are not copied into Dependabot's cooldown exclusions: automated version updates use the uniform 30-day wait, while an explicitly reviewed manual update can still follow its owning release procedure. diff --git a/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.zh.md b/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.zh.md index 4847059944..74b399778f 100644 --- a/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.zh.md +++ b/.agents/notes/implemented/process/2026-07-27-dependabot-version-updates.zh.md @@ -12,7 +12,7 @@ Status: implemented 默认分支包含 [`.github/dependabot.yml`](../../../../.github/dependabot.yml),其中为包含 `native/landlock-run` 的根 pnpm 工作区、`python/sdk` uv 项目和 GitHub Actions 配置了每周一次的版本更新检查。每个更新项都将 `cooldown.default-days` 设为 `30`,因此某个版本只有在发布至少 30 天后才符合更新条件,并会在下一次每周检查时生成更新提案。[仓库内 Landlock 发布决策](2026-08-06-in-repository-landlock-release.md)负责共享工作区边界。 -根 pnpm 工作区的版本更新扫描排除 `vendor/**`,其中的源码和 manifest(元数据清单)只能通过 [vendoring 流程](../../../../vendor/README.md)变更。GitHub 仅将 `exclude-paths` 用于版本更新;如果安全更新 PR(Pull Request)涉及随源码纳入仓库的 manifest,则改由 vendoring 流程处理,以替代自动生成的 PR,而不会将其原样合并。Dependabot PR 会获得仓库的 `cleanup` 类型标签和 `area/infra` 区域标签,运行常规 PR 检查,并且仍须由维护者评审;该自动化不会合并这些 PR。 +根 pnpm 工作区的版本更新扫描排除 `vendor/**`,其中的源码和 manifest(元数据清单)只能通过 [vendoring 流程](../../../../vendor/README.md)变更。GitHub 仅将 `exclude-paths` 用于版本更新;如果安全更新 PR(Pull Request)涉及随源码纳入仓库的 manifest,则改由 vendoring 流程处理,以替代自动生成的 PR,而不会将其原样合并。Dependabot PR 会获得仓库的 `kind/dependency` 类型标签和 `area/infra` 区域标签,运行常规 PR 检查,并且仍须由维护者评审;该自动化不会合并这些 PR。 仓库设置已启用依赖项漏洞警报和 Dependabot 安全更新。GitHub 不会对这些安全更新应用版本更新冷却期,因此安全修复仍可立即进入更新流程。如果依赖解析还选中了其他刚发布的传递依赖,pnpm 安全更新 PR 仍可能无法通过仓库的锁文件发布时长校验;此类 PR 应等待隔离期结束或缩小更新范围,不得因此放宽政策。仓库为协调刚发布版本而设置的例外,不会纳入 Dependabot 的冷却期排除项:自动版本更新统一等待 30 天;经过明确评审的手动更新仍可遵循相应的发布流程。 diff --git a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.i18n.yaml similarity index 56% rename from .agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.i18n.yaml rename to .agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.i18n.yaml index a83236521c..5ee57b914f 100644 --- a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.i18n.yaml @@ -1,6 +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/process/2026-07-25-semantic-pr-label-taxonomy.md -2026-07-25-semantic-pr-label-taxonomy.md: 6aef5ecbb80005d1fcd90b4ee673bbbebb92cba6 -2026-07-25-semantic-pr-label-taxonomy.zh.md: 0bd3fbcaeca37fa894e92ce5a4e78c4125056ac9 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.md +2026-08-08-unified-github-label-taxonomy.md: fddda6c99053e55d3e31a2f6e5c55add09772117 +2026-08-08-unified-github-label-taxonomy.zh.md: c8d727c9a56d6c7c25f73eacde42e0bef1c779b7 diff --git a/.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.md b/.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.md new file mode 100644 index 0000000000..fddda6c990 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.md @@ -0,0 +1,72 @@ +# Agent Note: Unified GitHub label taxonomy + +Status: implemented + +English | [中文](2026-08-08-unified-github-label-taxonomy.zh.md) + +## Problem + +Pull request labels answer two independent questions: what kind of change the work makes and which durable repository domains it materially affects. Mixing those dimensions or retaining synonymous plain and namespaced labels makes queries ambiguous, while a closed area inventory forces new domains into inaccurate categories. + +Issues already have a native Type and a separate source taxonomy. Reusing pull request kind or source labels across both object types duplicates metadata and weakens the meaning of each family. + +## Decision + +Every open or merged pull request carries exactly one canonical `kind/*` label and at least one materially affected `area/*` label. Closed pull requests that were never merged retain migrated historical assignments but do not receive invented missing classification. Operational labels may coexist without satisfying either dimension. + +### Kinds + +The kind set is closed and mutually exclusive: + +| Kind | Meaning | +|---|---| +| `kind/feature` | Adds or intentionally changes behavior. | +| `kind/bug-fix` | Corrects incorrect behavior. | +| `kind/doc` | Makes documentation the dominant intent. | +| `kind/testing` | Changes tests or testing infrastructure without changing product behavior. | +| `kind/cleanup` | Preserves behavior while maintaining or simplifying implementation or repository process. | +| `kind/dependency` | Updates dependencies without another dominant intent. | + +The kind records the dominant intent. Accompanying tests, documentation, cleanup, or dependency movement do not override a feature or bug fix. A new kind changes this classification contract and requires an explicit taxonomy and policy change. + +Repository policy rejects unsupported `kind/*` values and reserves every alias removed by the unification: `kind/bug`, `kind/documentation`, `feature`, `bug-fix`, `doc`, `cleanup`, `testing`, `dependencies`, `ci`, `cli`, `llm`, and `web-search`. Reserving the exact migrated set prevents an obsolete synonym from being recreated as an apparently unrelated operational label. + +### Areas + +Areas name durable semantic domains rather than temporary initiatives, ownership, or every path touched incidentally. A pull request carries multiple areas when it changes distinct contracts, but it does not combine an umbrella and a narrower label for the same contract. GitHub's live `area/*` names and descriptions own the current inventory; this record owns the selection rule and the non-obvious boundaries that cannot fit reliably in short label descriptions. + +- `area/web` covers browser and Electron graphical interfaces, `area/vscode` covers the editor extension, and `area/api` covers cross-interface protocols and language SDKs. +- `area/planning` covers goals, plans, todos, and scheduling, while `area/workflow` covers executable workflows and background task runtimes. +- `area/artifact` deliberately combines artifacts, attachments, and multimodal delivery. Split labels become justified only when those concerns again need independent review or queries. +- `area/tools` applies to generic registry, schema, and execution contracts. A concrete capability uses its own area unless it also changes that generic contract. +- `area/hooks` means the Claude Code and Codex bridges, `area/infra` covers build, release, CI, repository gates, generators, dependencies, and developer tooling, and `area/windows` covers native Windows product support rather than CI runner selection. + +The area set is intentionally extensible. When no existing description honestly covers a durable and reusable domain, an agent may create a concise `area/` label without separate approval. It must not create an area for one pull request, an incidental path, a temporary project, a status, or a person or team, and it reports the new label and rationale to the requester after applying it. Reusing an inaccurate area merely to avoid a justified addition is not acceptable. + +### Issues and migrations + +Issues use native Issue Type instead of `kind/*`; their `area/*` labels remain optional. `source/*` labels record Issue provenance and do not apply to pull requests. Priority, GitHub defaults, and workflow triggers remain independent operational metadata. + +Label migrations preserve meaning before removing aliases: add the canonical replacement, verify the labelable, then remove the obsolete assignment. A label is deleted only after no pull request or Issue still uses it, and unrelated labels are never replaced as a set. + +## Alternatives considered + +**Unprefixed labels.** Plain names reduce visual noise, but they do not identify whether a label classifies intent, domain, source, priority, or automation. Retaining both plain and namespaced synonyms also makes queries and policy enforcement ambiguous. + +**One undifferentiated label set.** A label's presence would not prove that both intent and semantic scope were considered. + +**A fixed area allowlist in repository policy.** Durable repository domains evolve. The `area/*` namespace remains mechanically recognizable while live descriptions carry the extensible inventory. + +**Package- or path-derived areas.** Areas describe semantic impact across package boundaries, while changed paths include incidental tests, documentation, and support files. + +**Separate labels for every delivery shell or media lifecycle.** Browser and Electron delivery share one graphical domain, and artifact, attachment, and multimodal delivery currently share one review/query domain. A split belongs in a later taxonomy change only when it restores useful independent classification. + +**Broad implementation labels in place of semantic domains.** A concrete capability is not merely its tool, interface, filesystem, or process implementation. Generic implementation areas apply only when their own contracts change. + +**Kinds on Issues.** Native Issue Type already owns that classification; duplicating it as a label creates drift. + +**Exactly one area per pull request.** Coherent changes can materially affect several independent contracts, and dropping secondary areas hides affected scope. + +## Consequences + +Reviewers and automation can query intent, semantic scope, provenance, priority, and operational triggers independently. Maintainers must read the change and the live label descriptions instead of inferring classification from title prefixes or paths. The live catalog, this rationale, and policy enforcement must move together when a kind or a non-obvious area boundary changes, and taxonomy migrations carry an explicit historical backfill and verification cost. diff --git a/.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.zh.md b/.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.zh.md new file mode 100644 index 0000000000..c8d727c9a5 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.zh.md @@ -0,0 +1,72 @@ +# Agent Note: 统一 GitHub 标签分类体系 + +Status: implemented + +[English](2026-08-08-unified-github-label-taxonomy.md) | 中文 + +## 问题 + +PR(Pull Request)标签回答两个相互独立的问题:工作带来哪一类变更,以及会对哪些持久的仓库领域产生实质影响。混用这两个维度,或同时保留同义的无前缀标签与带命名空间的标签,都会使查询含义模糊;封闭的领域清单则会迫使新领域归入不准确的类别。 + +Issue 已有原生 Issue Type 和独立的来源分类体系。在这两类对象上复用 PR 类型或来源标签会产生重复元数据,并削弱每个标签族的含义。 + +## 决策 + +每项开放或已合并的 PR 都带有恰好一个规范的 `kind/*` 标签,以及至少一个表示实质受影响领域的 `area/*` 标签。未合并即关闭的 PR 保留经迁移的历史标签关系,但不会凭空补充缺失分类。管理用途的标签可以并存,但不能满足这两个维度中的任一个。 + +### 变更类型 + +类型集合封闭且互斥: + +| 变更类型 | 含义 | +|---|---| +| `kind/feature` | 新增行为或有意改变行为。 | +| `kind/bug-fix` | 修正错误行为。 | +| `kind/doc` | 以文档变更为主导意图。 | +| `kind/testing` | 在不改变产品行为的前提下修改测试或测试基础设施。 | +| `kind/cleanup` | 在保持行为不变的前提下,维护或简化实现或仓库流程。 | +| `kind/dependency` | 在没有其他主导意图时更新依赖。 | + +类型记录主导意图。配套测试、文档、清理或依赖调整不会盖过功能变更或缺陷修复这一主导意图。新增类型会改变这项分类契约,因此必须明确修改分类体系和政策。 + +仓库政策会拒绝不支持的 `kind/*` 值,并将统一过程中移除的所有别名列为保留名称:`kind/bug`、`kind/documentation`、`feature`、`bug-fix`、`doc`、`cleanup`、`testing`、`dependencies`、`ci`、`cli`、`llm` 和 `web-search`。精确保留这组已迁移的名称,可以防止过时的同义名称被重新创建成看似无关的管理用途标签。 + +### 领域 + +领域表示持久的语义领域,而不是临时专项、归属关系或偶然触及的每条路径。一项 PR 修改不同契约时带有多个领域标签,但不会用一个总括标签和一个较窄标签重复描述同一项契约。GitHub 上现行的 `area/*` 名称和说明定义当前清单;本记录定义选择规则,以及简短标签说明无法可靠容纳的非显然边界。 + +- `area/web` 覆盖浏览器与 Electron 图形界面,`area/vscode` 覆盖编辑器扩展,`area/api` 覆盖跨界面协议与各语言 SDK。 +- `area/planning` 覆盖目标、计划、待办和调度,`area/workflow` 则覆盖可执行工作流与后台任务运行时。 +- `area/artifact` 有意合并产物、附件与多模态交付。只有当这些关注点再次需要独立评审或查询时,才有理由拆分标签。 +- `area/tools` 适用于通用注册表、schema 与执行契约。具体能力使用自身的领域标签,除非它还修改了这项通用契约。 +- `area/hooks` 表示 Claude Code 与 Codex 桥接,`area/infra` 覆盖构建、发布、CI、仓库门禁、生成器、依赖与开发者工具,`area/windows` 覆盖原生 Windows 产品支持,而不是 CI runner 的选型。 + +领域集合有意保持可扩展。当现有说明都无法如实涵盖一个持久且可复用的领域时,agent(智能体)无需另行批准,即可创建一个简洁的 `area/` 标签。agent 不得为单个 PR、偶然涉及的路径、临时项目、状态、个人或团队创建领域,并且必须在应用新标签后向请求者报告该标签及理由。仅为避免新增一个确有必要的领域标签而复用不准确的领域,不可接受。 + +### Issue 与迁移 + +Issue 使用原生 Issue Type,而不是 `kind/*`;其 `area/*` 标签仍然可选。`source/*` 标签记录 Issue 来源,不适用于 PR。优先级、GitHub 默认标签和工作流触发器仍是相互独立的管理元数据。 + +迁移标签时,须先保留语义,再移除别名:先添加规范替代标签,核验可加标签对象,再移除废弃的标签关系。只有在所有 PR 和 Issue 都不再使用某个标签后才能将其删除,且绝不整组替换无关标签。 + +## 考虑过的替代方案 + +**无前缀标签。** 无前缀名称可以减少视觉噪声,但无法表明标签分类的是意图、领域、来源、优先级还是自动化用途。同时保留无前缀和带命名空间的同义标签,也会使查询和政策执行含义模糊。 + +**不区分维度的单一标签集合。** 某个标签存在,并不能证明意图和语义范围都经过了考虑。 + +**仓库政策中的固定领域允许清单。** 持久的仓库领域会演进。`area/*` 命名空间仍可机械识别,而现行说明承载可扩展清单。 + +**按包或路径派生的领域。** 领域描述跨越包边界的语义影响,而变更路径会包含偶然涉及的测试、文档和支持文件。 + +**为每种交付载体或媒体生命周期单设标签。** 浏览器与 Electron 交付共用一个图形界面领域,产物、附件与多模态交付目前也共用一个评审/查询领域。只有当拆分能恢复有用的独立分类时,才应在后续分类体系变更中进行。 + +**用宽泛的实现标签取代语义领域。** 一项具体能力并不只是其工具、接口、文件系统或进程实现。通用实现领域只在其自身契约变化时适用。 + +**在 Issue 上使用类型标签。** 原生 Issue Type 已负责这项分类;再用标签复制会造成漂移。 + +**每个 PR 恰好一个领域。** 内聚的变更可能对多个独立契约产生实质影响,丢弃次要领域会隐藏受影响范围。 + +## 后果 + +评审人和自动化流程可以分别查询意图、语义范围、来源、优先级和工作流触发条件。维护者必须阅读变更内容和现行标签说明,而不能根据标题前缀或路径推断分类。当某种类型或某条非显然的领域边界发生变化时,现行标签清单、本记录中的决策依据和政策执行必须同步更新;分类体系迁移还会产生明确的历史回填和验证成本。 diff --git a/.github/issue-management/policy.mjs b/.github/issue-management/policy.mjs index 18a9b03628..09e1bbe7fd 100644 --- a/.github/issue-management/policy.mjs +++ b/.github/issue-management/policy.mjs @@ -20,9 +20,11 @@ const PR_KINDS = new Set([ 'kind/cleanup', 'kind/dependency', ]) +// Aliases removed by the unified taxonomy migration remain reserved so they cannot be recreated. const LEGACY_LABELS = new Set([ 'kind/bug', 'kind/documentation', + 'feature', 'bug-fix', 'doc', 'cleanup', diff --git a/.github/issue-management/policy.test.mjs b/.github/issue-management/policy.test.mjs index bcaf450afa..8a9b0f91e6 100644 --- a/.github/issue-management/policy.test.mjs +++ b/.github/issue-management/policy.test.mjs @@ -36,6 +36,22 @@ const canonicalKinds = [ 'kind/dependency', ] +// Keep an independent oracle rather than importing the implementation's reserved set. +const legacyLabels = [ + 'kind/bug', + 'kind/documentation', + 'feature', + 'bug-fix', + 'doc', + 'cleanup', + 'testing', + 'dependencies', + 'ci', + 'cli', + 'llm', + 'web-search', +] + const reviewedPull = (labels) => ({ isDraft: false, authorType: 'User', @@ -115,17 +131,7 @@ test('reserves PR kind and legacy labels for pull requests', () => { for (const label of [ ...canonicalKinds, 'kind/experimental', - 'kind/bug', - 'kind/documentation', - 'bug-fix', - 'doc', - 'cleanup', - 'testing', - 'dependencies', - 'ci', - 'cli', - 'llm', - 'web-search', + ...legacyLabels, ]) { assert.ok( validateIssue({ ...legalIssue, labels: [label] }).some((error) => @@ -326,19 +332,7 @@ test('rejects multiple, unknown, legacy, and Issue-source PR labels', () => { 'PR 含不支持的 kind/*:kind/experimental', ), ) - for (const label of [ - 'kind/bug', - 'kind/documentation', - 'bug-fix', - 'doc', - 'cleanup', - 'testing', - 'dependencies', - 'ci', - 'cli', - 'llm', - 'web-search', - ]) { + for (const label of legacyLabels) { assert.ok( validatePullRequest(reviewedPull(['kind/feature', 'area/web', label])).some((error) => error.startsWith('PR 含旧版标签:'), diff --git a/AGENTS.md b/AGENTS.md index 9344e2e6e2..928a118868 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -118,7 +118,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, - **A tool's UI render intent is part of its design**, decided up front (`generic`/`terminal`/`diff`, `locations`); presentation methods are pure functions of `args` ([cookbook](docs/cookbook/adding-a-tool.md)). - **Plan unit, e2e, and snapshot coverage** for new seams, lifecycle shapes, and transcript surfaces; missing snapshot-harness support is part of the implementation, not deferred follow-up. - **Choose PR history deliberately.** Split independent changes; fix the introducing PR before propagation. Standalone PRs and official stacks may merge-forward or rebase after review. Rewrites use `--force-with-lease`, abort on remote movement, never raw `--force`; an in-progress merge-forward preserves its checkpoint before taking a newer base ([rationale](.agents/notes/implemented/process/2026-08-02-native-github-stacks-and-optional-rebases.md)). -- **Labels:** one PR `kind/*`, all material `area/*`, and native Issue Type ([taxonomy](.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md)). +- **Labels:** one PR `kind/*`, all material `area/*`, and native Issue Type ([taxonomy](.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.md)). - TODO markers: `FIXME`/`TODO`/`XXX` by urgency ([semantics](docs/development.md)). - Files end with exactly one trailing newline; `git diff --cached --check` (pre-commit) gates it. From df30c62e2b062da14fa0366728d821f416d72e57 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 7 Aug 2026 14:54:31 +0800 Subject: [PATCH 009/189] fix(agent-loop): latch wakes landing in the cancel-convergence window --- ...07-16-explicit-turn-cancellation.i18n.yaml | 4 +- .../2026-07-16-explicit-turn-cancellation.md | 2 +- ...026-07-16-explicit-turn-cancellation.zh.md | 2 +- ...07-cancel-convergence-wake-latch.i18n.yaml | 6 + ...026-08-07-cancel-convergence-wake-latch.md | 29 ++++ ...-08-07-cancel-convergence-wake-latch.zh.md | 29 ++++ docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 2 +- docs/architecture.zh.md | 2 +- docs/cordis-catalog/events.md | 24 +-- docs/core-data-structures/core.i18n.yaml | 4 +- docs/core-data-structures/core.md | 6 +- docs/core-data-structures/core.zh.md | 6 +- docs/event-producer-consumer.md | 24 +-- docs/persistence-catalog.md | 2 +- packages/core/agent-loop/README.i18n.yaml | 4 +- packages/core/agent-loop/README.md | 2 +- packages/core/agent-loop/README.zh.md | 2 +- packages/core/agent-loop/src/agent.ts | 51 ++++-- packages/core/agent-loop/tests/cancel.spec.ts | 154 ++++++++++++++++-- packages/core/agent-loop/tests/loop.spec.ts | 73 ++++++++- .../core/agent-loop/tests/mock-adapter.ts | 18 +- packages/core/agent/src/types.ts | 6 +- 23 files changed, 379 insertions(+), 77 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.zh.md diff --git a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml index 820299cf2e..2f7893a09f 100644 --- a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md -2026-07-16-explicit-turn-cancellation.md: ca56c77a097e3008a50c2aec24040a4f4b6f0ba3 -2026-07-16-explicit-turn-cancellation.zh.md: bf410e5c7284a9c9914edbd14445074e71dd6943 +2026-07-16-explicit-turn-cancellation.md: 2b7cb8cc77184edf1331764d28aefe748c1614a1 +2026-07-16-explicit-turn-cancellation.zh.md: 68089e2c48d239afbff4c10cba5a202b4b6ff262 diff --git a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md index ca56c77a09..2b7cb8cc77 100644 --- a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md +++ b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md @@ -18,7 +18,7 @@ An interrupted live turn ends with the coarse durable `{ kind: 'aborted' }` outc AgentLoop privately owns one `TurnCancellation` per prospective turn. It installs the holder before notifying `agent/status = running`, retains its single `AbortController` through inbox claim, `agent/pre-step`, prompt assembly, every step, model and tool execution, and `agent/turn-stopping`, then clears the exact holder immediately before publishing `turn/end`. Terminal event observers and the following durability flush therefore cannot cancel already-completed turn work even though driver status may remain `running` until the flush settles. Every participating method, event, and request value receives that same explicit signal; the next turn receives a fresh signal. -The driver keeps only a cause-less pre-run marker for queued work cancelled before a turn is claimed. An effective `cancel()` emits the observe-only `agent/cancel-requested` notification with its resolved typed cause before clearing queued and steering work or aborting the holder; notification failures cannot veto the stop, and an idle call emits nothing. Work synchronously queued by a notification observer is included in that clear, while work queued by a later signal abort observer belongs to the next turn. If a `running` listener synchronously cancels old work and sends a replacement, the driver discards the aborted holder and creates a fresh one for the replacement. Repeated cancellation is first-wins for the active holder, while later calls may still clear newly queued pending work. +The driver keeps only a cause-less pre-run marker for queued work cancelled before a turn is claimed. An effective `cancel()` emits the observe-only `agent/cancel-requested` notification with its resolved typed cause before clearing queued and steering work or aborting the holder; notification failures cannot veto the stop, and an idle call emits nothing. Work synchronously queued by a notification observer is included in that clear, while work queued by a later signal abort observer is latched and runs when the aborted activity converges to idle — a `disposed` cancel leaves it parked ([cancel-convergence wake latch](../bug-fix/2026-08-07-cancel-convergence-wake-latch.md)). If a `running` listener synchronously cancels old work and sends a replacement, the driver discards the aborted holder and creates a fresh one for the replacement. Repeated cancellation is first-wins for the active holder, while later calls may still clear newly queued pending work. The explicit event signatures pass a single payload object: agent-scoped events carry `agent` and `signal` in the payload with `next` last, and the remaining seams keep `signal` immediately before a waterfall's final `next`. `PreStepContext` and `RequestFailureContext` are retired, with their fields folded into the `agent/pre-step` and `agent/request-error` payloads ([payload-object events](2026-08-06-agent-event-payload-objects.md)). Pre-step entry, request configuration, request-error recovery, model generation, tool execution, approval, turn stopping, and subagent or workflow requests all receive the current signal. Hook bridges must also supply `RunHookOptions.signal`, so a turn cancellation reaches the bash executor's process-group kill and join boundary. `SystemPrompt.assemble()` carries `signal?: AbortSignal` in `AssembleContext` because that object is an explicit request value that can also represent signal-less assembly outside a turn. Listeners may cooperate with the signal but must not retain it to control another turn. diff --git a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md index bf410e5c72..68089e2c48 100644 --- a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md @@ -18,7 +18,7 @@ Agent 拥有仅用于运行时的 `AgentCancelCause` 联合类型 `{ kind: 'user AgentLoop 为每个待启动轮次私有地持有一个 `TurnCancellation`。它在通知 `agent/status = running` 前安装该持有者,使其中唯一的 `AbortController` 持续覆盖 inbox 领取、`agent/pre-step`、提示词组装、每个步骤、模型与工具执行以及 `agent/turn-stopping`;随后在发布 `turn/end` 前立即清除所安装的那个持有者。因此,即使驱动器状态可能在持久化刷新结算前保持 `running`,终态事件观察者及其后的持久化刷新也无法取消已完成的轮次工作。所有参与的方法、事件和请求值都会收到同一个显式 signal;下一个轮次会收到全新的 signal。 -对于轮次被认领前已取消的排队工作,驱动器只保留一个不携带取消原因的运行前标记。实际生效的 `cancel()` 会先发出仅供观察的 `agent/cancel-requested` 通知并携带最终确定的类型化取消原因,然后才清除排队工作和 steering(中途引导)工作或中止持有者;通知失败不能阻止此次停止,空闲状态下调用则不发出任何通知。通知观察者同步加入队列的工作也会被这次清除,而稍后由 signal 中止观察者加入队列的工作属于下一个轮次。若 `running` 监听器同步取消旧工作并发送替代提示词,驱动器会丢弃已中止的持有者,并为替代提示词创建全新的持有者。同一活跃持有者上的重复取消遵循首次请求优先,后续调用仍可清除新入队的待处理工作。 +对于轮次被认领前已取消的排队工作,驱动器只保留一个不携带取消原因的运行前标记。实际生效的 `cancel()` 会先发出仅供观察的 `agent/cancel-requested` 通知并携带最终确定的类型化取消原因,然后才清除排队工作和 steering(中途引导)工作或中止持有者;通知失败不能阻止此次停止,空闲状态下调用则不发出任何通知。通知观察者同步加入队列的工作也会被这次清除,而稍后由 signal 中止观察者加入队列的工作会被锁存,并在被中止的活动收敛到空闲时执行——`disposed` 取消则将其停放([取消收敛窗口唤醒锁存](../bug-fix/2026-08-07-cancel-convergence-wake-latch.md))。若 `running` 监听器同步取消旧工作并发送替代提示词,驱动器会丢弃已中止的持有者,并为替代提示词创建全新的持有者。同一活跃持有者上的重复取消遵循首次请求优先,后续调用仍可清除新入队的待处理工作。 显式事件签名传递单个 payload 对象:agent 作用域事件在 payload 中携带 `agent` 和 `signal`,`next` 位于最后;其余 seam 保持 `signal` 紧邻 waterfall(瀑布式事件)的最终 `next` 之前。`PreStepContext` 与 `RequestFailureContext` 已退役,其字段并入 `agent/pre-step` 与 `agent/request-error` 的 payload([payload-object 事件](2026-08-06-agent-event-payload-objects.md))。pre-step 进入决策、请求配置、请求错误恢复、模型生成、工具执行、审批、轮次停止以及 subagent 或工作流请求都会收到当前 signal。钩子桥接器也必须提供 `RunHookOptions.signal`,使轮次取消能够到达 Bash 执行器终止进程组并等待其退出的边界。`SystemPrompt.assemble()` 在 `AssembleContext` 中携带 `signal?: AbortSignal`,因为该对象是显式请求值,也可表示轮次之外不携带 signal 的组装。监听器可以配合该 signal 取消,但不得保留它来控制其他轮次。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.i18n.yaml new file mode 100644 index 0000000000..d31f344c27 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.i18n.yaml @@ -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/bug-fix/2026-08-07-cancel-convergence-wake-latch.md +2026-08-07-cancel-convergence-wake-latch.md: fe00c78bdfadac0cc6c9c173fd04256f77a22051 +2026-08-07-cancel-convergence-wake-latch.zh.md: f76ad360e2d8b70f76c6baf53e2fde963a80b6c4 diff --git a/.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.md b/.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.md new file mode 100644 index 0000000000..fe00c78bdf --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.md @@ -0,0 +1,29 @@ +# Agent Note: Latch wake-ups that land in the cancel-convergence window + +Status: implemented + +English | [中文](2026-08-07-cancel-convergence-wake-latch.zh.md) + +## Problem + +`Agent.cancel(cause, { keepInbox: true })` returns immediately after firing the abort signal, but the active driver may not have converged to `idle` yet: LLM stream teardown, tool cancellation, and the `turn/end` append all unwind asynchronously after `abort()` returns. A waking send arriving in that window was placed into `next-turn` while `wakeDriver()` returned early on the still-`running` phase, and the exiting driver never replayed the wake — the message stayed parked until another waking send arrived. The same dropped-wake window existed around aborted `runMaintenance` activities. Several tests enshrined the parked behavior ("waits for another wakeup"); the bug broke both `session.cancel` and the `subagent.interrupt` composition path (issue #1838). The owning cancellation and send contracts are the [explicit turn cancellation](../architecture/2026-07-16-explicit-turn-cancellation.md) and [unified send](../architecture/2026-07-22-unified-send-and-coalesced-user-messages.md) decisions; the production `keepInbox` consumer is [web stop preserves queue](2026-07-31-web-stop-preserves-queue.md). + +## Decision + +The `running` phase carries a `wakeRequested` latch, mirroring the existing `maintenance` phase field. `wakeDriver()` latches whenever the current activity cannot deliver the wake — a maintenance task never reads the queue, and an aborted activity converges without restarting — while a live driver needs no latch because it claims queued work itself. The exiting activity replays the latch at its own convergence boundary (`kick`'s `finally` and `runMaintenance`'s `finally`): this placement guarantees `turn/end N` lands before the replayed driver opens `turn/start N+1`, and that `whenIdle()` sees the replayed driver through its `activityDone` loop. The replay sites run only while `inbox.hasPending`, so a latched wake removed from the inbox before convergence does not start an empty driver. A wake sent while the agent is already idle keeps its turn boundary even when its message is cleared before the driver claims — that `idle → running → idle` transition is an observable contract: the goal-session driver's pause/disarm fallback fires on the `idle` transition after a cancelled reservation (CI caught this when the guard was moved into `wakeDriver()` and suppressed the boundary). `cancel()` without `keepInbox` clears the latch together with the inbox. + +The `signal.aborted` discriminator is load-bearing: it separates pre-abort queued work — which `keepInbox` parks for a later wake (acceptance criterion 1) — from post-abort explicit wakes, which must run after convergence. + +## Alternatives considered + +**Have `cancel()` set the phase to `idle` immediately.** Rejected: the driver is still unwinding, so this overlaps two drivers. The replay lives in the old driver's `finally`, which then never runs — 14 of 83 tests failed, several deadlocked. Repairing it requires identity-based phase ownership plus a turn-open quiescence barrier, which is strictly more machinery and is the latch in disguise. + +**Latch unconditionally for every non-idle wake.** Rejected: pre-abort wakes would auto-start after a `keepInbox` cancel, violating acceptance criterion 1; the "parks queued work" test and the error-window steering test both failed. + +**Replay through a chained promise (`activityDone.then(...)`).** Rejected: the replay would run outside the activity's own settlement, so `whenIdle()`'s loop can resolve before the replayed driver starts; fixing that requires replacing `activityDone` at send time and depends on microtask reaction ordering — more fragile than a synchronous flag. + +**Wait for quiescence in the subagent adapter.** Rejected by the issue scope: the cancel/wake state machine owns the fix, not a consumer. + +## Consequences + +The `running` phase gains a `wakeRequested` field; `cancel()` without `keepInbox` clears it alongside the inbox, and a `disposed` cancel never latches, so a wake landing after disposal begins stays parked and `whenIdle()` does not wait on a full model turn over the session being torn down. A wake arriving in the sub-microtask gap between the driver's final `hasPending` check and its exit still parks — no latch fires because the phase is `running` and not aborted; closing that gap requires the unconditional latch and is deliberately out of scope. Between the aborted turn and the replayed driver, status transitions emit a transient `idle → running` pair. A waking send whose message is cleared before any driver claims it still opens an empty completed turn, preserving the observable wake boundary. diff --git a/.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.zh.md b/.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.zh.md new file mode 100644 index 0000000000..f76ad360e2 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 锁存取消收敛窗口内到达的唤醒请求 + +Status: implemented + +[English](2026-08-07-cancel-convergence-wake-latch.md) | 中文 + +## 问题 + +`Agent.cancel(cause, { keepInbox: true })` 在触发 abort 信号后立即返回,但活动 driver 可能尚未收敛到 `idle`:LLM 流拆除、工具取消与 `turn/end` 落盘都会在 `abort()` 返回后异步展开。在该窗口内到达的唤醒 send 被放入 `next-turn`,而 `wakeDriver()` 对仍处于 `running` 的 phase 直接返回,退出的 driver 也从不重放这次唤醒——消息会一直停放到下一条唤醒 send 到达。被中止的 `runMaintenance` 活动周围也存在同样的唤醒丢失窗口。多个测试固化了停放行为(「等待下一次唤醒」);该缺陷同时破坏了 `session.cancel` 与 `subagent.interrupt` 组合路径(issue #1838)。拥有取消与发送契约的既有决策是[显式轮次取消](../architecture/2026-07-16-explicit-turn-cancellation.md)与[统一发送](../architecture/2026-07-22-unified-send-and-coalesced-user-messages.md);生产环境中的 `keepInbox` 消费方是[Web 停止保留队列](2026-07-31-web-stop-preserves-queue.md)。 + +## 决策 + +`running` phase 携带 `wakeRequested` 锁存,与既有的 `maintenance` phase 字段对称。`wakeDriver()` 在当前活动无法投递唤醒时锁存——maintenance 任务从不读取队列,被中止的活动收敛后不会重启——而存活的 driver 不需要锁存,因为它自己会认领排队的工作。退出中的活动在其自身收敛边界(`kick` 的 `finally` 与 `runMaintenance` 的 `finally`)重放锁存:这一位置保证 `turn/end N` 先于重放 driver 打开 `turn/start N+1` 落盘,并保证 `whenIdle()` 通过其 `activityDone` 循环看到重放 driver。两个重放点仅在 `inbox.hasPending` 时执行,因此收敛前被从 inbox 移除的锁存唤醒不会启动空 driver。而 agent 已处于 idle 时发送的唤醒,即使消息在 driver 认领前被清除,仍会打开自己的 turn 边界——这趟 `idle → running → idle` 转换是可观察契约:goal-session driver 的 pause/disarm 回退依赖取消预订后的 `idle` 转换触发(把守卫放进 `wakeDriver()` 后该边界被抑制,CI 发现了这一点)。不带 `keepInbox` 的 `cancel()` 会连同 inbox 一起清除锁存。 + +`signal.aborted` 判别项是承重的:它区分「中断前已排队的工作」——`keepInbox` 将其停放以待后续唤醒(验收条件 1)——与「abort 后显式的唤醒」,后者必须在收敛后执行。 + +## 备选方案 + +**让 `cancel()` 立即把 phase 置为 `idle`。** 不予采用:driver 仍在展开收尾,这会重叠两个 driver。重放逻辑位于旧 driver 的 `finally`,而该 `finally` 此后不再执行——83 个测试中有 14 个失败,多个死锁。修复它需要基于身份的 phase 所有权外加 turn 打开时的 quiescence 屏障,机制上严格更重,而且该屏障就是换了个形态的锁存。 + +**对每个非 idle 唤醒无条件锁存。** 不予采用:中断前的唤醒会在 `keepInbox` 取消后自动启动,违反验收条件 1;「停放排队工作」测试与错误窗口的 steering 测试双双失败。 + +**通过链式 promise(`activityDone.then(...)`)重放。** 不予采用:重放会运行在活动自身结算之外,`whenIdle()` 的循环可能在重放 driver 启动前就 resolve;修复它需要在 send 时同步替换 `activityDone`,并依赖微任务反应顺序——比同步 flag 更脆弱。 + +**在 subagent adapter 中等待 quiescence。** 被 issue 范围否决:修复由取消/唤醒状态机拥有,而不是消费方。 + +## 影响 + +`running` phase 新增 `wakeRequested` 字段;不带 `keepInbox` 的 `cancel()` 会连同 inbox 一起清除它,且 `disposed` 取消从不锁存——dispose 开始后到达的唤醒保持停放,`whenIdle()` 不会在拆除中的会话上等待一个完整模型 turn。落在 driver 最后一次 `hasPending` 检查与退出之间微任务间隙的唤醒仍会停放——没有锁存触发,因为 phase 是 `running` 且未 abort;关闭该间隙需要无条件锁存,刻意留作范围外。在被中止的 turn 与重放 driver 之间,状态转换会发出一次瞬态 `idle → running` 对。唤醒 send 的消息在任何 driver 认领前被清除时,仍会打开一个空的 completed turn,保留可观察的唤醒边界。 diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index ca91a80b7e..de15631197 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/architecture.md -architecture.md: 3080d21a24310cc8851d64719d862ec719150ab6 -architecture.zh.md: 6e2bf0c155c68e84580aecc6b7783eeffa25c640 +architecture.md: ea78faa62773a6b8ac98e5baab6e181ad6a3b7f0 +architecture.zh.md: 5a46dfaf84276de5f4dc0352a529bdd4e5d8c267 diff --git a/docs/architecture.md b/docs/architecture.md index 3080d21a24..ea78faa627 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -121,7 +121,7 @@ Pruning precedes summaries; overflow retries require durable progress. `agent/re Adapter selection, dispatch, and iteration failures become terminal error or aborted `finish` chunks. `agent/request-error` receives request coordinates, normalized `LlmFailure`, available retry policy, and signal; middleware and consumer errors remain outside recovery. Failed chunks commit neither messages nor tool calls. -Other failures use `agent/error`; cancellation and disposal beat recovery. Before request-header commit, the turn signal cancels capability preparation; undispatched tools get synthetic `tool/call`/`ABORTED_BEFORE_DISPATCH` pairs. Effective `cancel(cause)` reports its cause before clearing and aborting; idle calls emit nothing. Durability distinguishes `aborted` cancellation from `disposed` teardown, which awaits quiescence ([decision](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)). +Other failures use `agent/error`; cancellation and disposal beat recovery. Before request-header commit, the turn signal cancels capability preparation; undispatched tools get synthetic `tool/call`/`ABORTED_BEFORE_DISPATCH` pairs. Effective `cancel(cause)` reports its cause before clearing and aborting; idle calls emit nothing. Waking input that lands after the abort fires but before convergence runs at the driver's convergence boundary, while a `disposed` cancel leaves it parked ([cancel-convergence wake latch](../.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.md)). Durability distinguishes `aborted` cancellation from `disposed` teardown, which awaits quiescence ([decision](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)). Turn and step events are turn-enclosed; the loop appends `user/message` events only from entered batches inside a turn. A turn opens before the initial claim and pre-step, so rejection, empty input, cancellation, or failure closes a durable turn without any step events. Standalone `compact/* { turn: null }` events consume no turn, and their lock-time markers may interleave with inbox splices. Reload synthesizes interrupted turn ends; `session/end-seed` distinguishes stale compaction orphans from live locks. After close, only `agent/error` reports failures. Each turn has one [TurnEndReason](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap). diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 6e2bf0c155..5a46dfaf84 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -121,7 +121,7 @@ idle inject: 适配器选择、分发与迭代失败会成为 error 或 aborted 类型的终止 `finish` 分片。`agent/request-error` 接收请求坐标、标准化 `LlmFailure`、可用的重试策略和信号;middleware 与消费方错误仍在恢复之外。失败分片既不提交消息,也不提交工具调用。 -其他故障使用 `agent/error`;取消和资源释放优先于恢复。在提交请求头之前,轮次信号会取消功能准备;尚未分派的工具会得到合成的 `tool/call`/`ABORTED_BEFORE_DISPATCH` 对。实际生效的 `cancel(cause)` 会在清空队列和中止前报告原因;空闲调用不发事件。持久化层以 `aborted` 区分取消,以 `disposed` 区分会等待完全停稳的拆卸([决策](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md))。 +其他故障使用 `agent/error`;取消和资源释放优先于恢复。在提交请求头之前,轮次信号会取消功能准备;尚未分派的工具会得到合成的 `tool/call`/`ABORTED_BEFORE_DISPATCH` 对。实际生效的 `cancel(cause)` 会在清空队列和中止前报告原因;空闲调用不发事件。abort 触发后、收敛前到达的唤醒输入会在 driver 的收敛边界执行,而 `disposed` 取消则将其停放([取消收敛窗口唤醒锁存](../.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.md))。持久化层以 `aborted` 区分取消,以 `disposed` 区分会等待完全停稳的拆卸([决策](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md))。 轮次和步骤事件均位于轮次边界内;loop 只会在轮次内从进入步骤的批次追加 `user/message`。轮次会在首次领取与 pre-step 之前打开,因此拒绝、空输入、取消或失败会关闭一个不包含任何步骤事件的持久轮次。独立的 `compact/* { turn: null }` 事件不占用轮次,其锁定时刻标记可以与 inbox splice 交错。重新加载会为中断的轮次合成结束事件;`session/end-seed` 区分陈旧的压缩遗留项与活跃锁。关闭后仅由 `agent/error` 报告故障。每个轮次有一个 [TurnEndReason](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap)。 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 92c2fade12..ae16d10f07 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -33,7 +33,7 @@ A fully configured agent and live session were published. Setup is composition-o Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:154`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:158`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit @@ -53,7 +53,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence and sco Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:163`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:167`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -75,7 +75,7 @@ A step or turn errored. The machine reports a failure here even when the error h Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:285`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:289`](../../packages/core/agent/src/types.ts) ### `agent/inbox/claimed` — emit @@ -97,7 +97,7 @@ One message left the inbox inside its open turn. If the proposed step is rejecte Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md) -Source: [`packages/core/agent/src/types.ts:192`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:196`](../../packages/core/agent/src/types.ts) ### `agent/inbox/discarded` — emit @@ -116,7 +116,7 @@ One message was discarded from the live inbox. Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md) -Source: [`packages/core/agent/src/types.ts:200`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:204`](../../packages/core/agent/src/types.ts) ### `agent/inbox/inserted` — emit @@ -135,7 +135,7 @@ One message entered the live inbox. Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md) -Source: [`packages/core/agent/src/types.ts:181`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:185`](../../packages/core/agent/src/types.ts) ### `agent/pre-step` — waterfall @@ -158,7 +158,7 @@ Reject a proposed step or replace the messages that enter it. Calling `next()` p Types: [Agent](../core-data-structures/core.md) · [PreStepDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md) -Source: [`packages/core/agent/src/types.ts:226`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:230`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -182,7 +182,7 @@ Replace the frozen call configuration. `await next()` yields the config the mach Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:239`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:243`](../../packages/core/agent/src/types.ts) ### `agent/request-error` — waterfall @@ -209,7 +209,7 @@ Handle one failed model-request attempt before the loop retries or closes its st Types: [Agent](../core-data-structures/core.md) · [LlmFailure](../core-data-structures/llm-streaming.md) · [RequestErrorAction](../core-data-structures/core.md) · [ResolvedRetryPolicy](../core-data-structures/llm-streaming.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:255`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:259`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -231,7 +231,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SessionStartSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:212`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:216`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -252,7 +252,7 @@ Agent status changed (`idle` ⇄ `running`). A waking delivery enters `running` Types: [Agent](../core-data-structures/core.md) · [AgentStatus](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:173`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:177`](../../packages/core/agent/src/types.ts) ### `agent/turn-stopping` — serial @@ -281,7 +281,7 @@ The turn is about to close: the model owes no response (no live tool calls, no f Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:273`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:277`](../../packages/core/agent/src/types.ts) ## `agent-loop/*` diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml index e79d5dac2f..ee870f9334 100644 --- a/docs/core-data-structures/core.i18n.yaml +++ b/docs/core-data-structures/core.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/core.md -core.md: dbd584f10b3daf873bc14210472efb6cd315717e -core.zh.md: 1fe1616a0c96abb4e8b91417cc4eae292416e42a +core.md: 8f413a7a064ad6f63e0caec31354869e51139020 +core.zh.md: d0f02f0cfc2cd30fc67aacf5b17d1daf324295c5 diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index dbd584f10b..8f413a7a06 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -685,7 +685,11 @@ interface Agent { /** * Route identified input to an inbox boundary and optionally wake the driver. - * Waking input submitted after active cancellation is queued for the next turn. + * Waking input submitted after active cancellation is queued for the next + * turn and runs when the aborted activity converges to idle; a `disposed` + * cancel leaves it parked. A wake submitted while already idle always opens + * its turn boundary, even when its message is cleared before the driver + * claims ([cancel-convergence wake latch](../../../../.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.md)). * @param message - identified content and its producer provenance. * @param target - the preferred next-turn or next-step inbox boundary. * @param wakeup - whether delivery may wake the driver. diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md index 1fe1616a0c..d0f02f0cfc 100644 --- a/docs/core-data-structures/core.zh.md +++ b/docs/core-data-structures/core.zh.md @@ -693,7 +693,11 @@ interface Agent { /** * Route identified input to an inbox boundary and optionally wake the driver. - * Waking input submitted after active cancellation is queued for the next turn. + * Waking input submitted after active cancellation is queued for the next + * turn and runs when the aborted activity converges to idle; a `disposed` + * cancel leaves it parked. A wake submitted while already idle always opens + * its turn boundary, even when its message is cleared before the driver + * claims ([cancel-convergence wake latch](../../../../.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.md)). * @param message - identified content and its producer provenance. * @param target - the preferred next-turn or next-step inbox boundary. * @param wakeup - whether delivery may wake the driver. diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 5c60be95e1..940d89eae0 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,18 +8,18 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:182`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:154`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:163`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:285`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/telemetry/session-telemetry) | -| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/types.ts:192`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | -| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/types.ts:200`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | -| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/types.ts:181`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) | -| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/types.ts:226`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:239`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:255`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:212`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:173`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `apiproxy`, [`compact-basic`](../packages/compact/compact-basic), [`goal-session`](../packages/goal/goal-session), [`headless`](../packages/bundle/headless), [`jsonrpc`](../packages/ui/jsonrpc) | -| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:273`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:158`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:167`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:289`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/telemetry/session-telemetry) | +| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/types.ts:196`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | +| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/types.ts:204`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | +| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/types.ts:185`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) | +| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/types.ts:230`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:243`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:259`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:216`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:177`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `apiproxy`, [`compact-basic`](../packages/compact/compact-basic), [`goal-session`](../packages/goal/goal-session), [`headless`](../packages/bundle/headless), [`jsonrpc`](../packages/ui/jsonrpc) | +| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:277`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` | | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:172`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy` | | `credentials/updated` | `emit` | [`packages/credentials/credentials/src/index.ts:67`](../packages/credentials/credentials/src/index.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | `apiproxy`, [`credentials`](../packages/credentials/credentials) | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 533ebd3a97..3845c2cb1a 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -100,7 +100,7 @@ Sources: [`packages/core/session/src/types.ts:308`](../packages/core/session/src } ``` -Source: [`packages/core/agent/src/types.ts:296`](../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:300`](../packages/core/agent/src/types.ts) ### `approval/*` diff --git a/packages/core/agent-loop/README.i18n.yaml b/packages/core/agent-loop/README.i18n.yaml index b25a0f18f1..2f165e76e4 100644 --- a/packages/core/agent-loop/README.i18n.yaml +++ b/packages/core/agent-loop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/agent-loop/README.md -README.md: ec1948506bbaf7a3416c2031fb472a9b513b500f -README.zh.md: 5828da301b35c95719286fb942ac239769539b67 +README.md: 2fdc60086bebc924089b5b8bd12f4b4456b1ead5 +README.zh.md: df0ba44ec5b010666737c0568bc5eeb220ff0d96 diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index ec1948506b..2fdc60086b 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -67,7 +67,7 @@ Every provider call that reaches a successful finish appends exactly one `assist After `agent/request` returns a provider/model call config, the loop asks `ctx.llm.prepareCall()` to validate adapter-owned fields and materialize configured reasoning-effort and output-token defaults under the active turn signal. The prepared call retains the exact adapter registration across this asynchronous resolution, `request/header` logging, and terminal dispatch, so HMR cannot mix one adapter's capability result with another adapter's request. The header records the effective config and which fields came from the adapter. Before the next waterfall, the loop removes those marked fields from the proposal so the current exact route rematerializes its own defaults; unmarked explicit settings persist across steps and route changes. A route with no registered adapter preserves the proposed config so an `llm/stream` listener can own and short-circuit it; unhandled terminal dispatch still fails with `NO_ADAPTER`. A new loop instance applies the same provenance rule when resuming. -Plugin failure ends the current turn, not the loop. Final adapter selection, dispatch, and iteration failures arrive from `ctx.llm` as terminal error or aborted finishes and enter `agent/request-error`; middleware, result processing, tools, and other extension failures remain thrown and close directly. Recovery receives request coordinates, immutable provider facts, the immutable retry policy captured by the prepared adapter registration, and the turn signal; the policy is absent when middleware owns an unprepared route. A handling listener returns `{ kind: 'retry' }`; an unhandled failure is terminal. AgentLoop owns one cancellation signal for the current admission or turn. An effective `cancel(cause)` clears pending work unless `keepInbox` is set and cooperatively aborts that signal; idle cancellation is a no-op. Durable `turn/end` records `aborted` for `user` and `parent`, while disposal records `disposed`; undispatched model tool calls receive synthetic `tool/call` and `ABORTED_BEFORE_DISPATCH` result pairs. The cancellation cause changes reporting, not how result context finalized after cancellation is handled. Disposal waits for signal-ignoring work before registry removal. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns the lifecycle and race contract. +Plugin failure ends the current turn, not the loop. Final adapter selection, dispatch, and iteration failures arrive from `ctx.llm` as terminal error or aborted finishes and enter `agent/request-error`; middleware, result processing, tools, and other extension failures remain thrown and close directly. Recovery receives request coordinates, immutable provider facts, the immutable retry policy captured by the prepared adapter registration, and the turn signal; the policy is absent when middleware owns an unprepared route. A handling listener returns `{ kind: 'retry' }`; an unhandled failure is terminal. AgentLoop owns one cancellation signal for the current admission or turn. An effective `cancel(cause)` clears pending work unless `keepInbox` is set and cooperatively aborts that signal; idle cancellation is a no-op. Waking input that lands after the abort fires but before the activity converges to idle is latched (`wakeRequested`) and replayed at the driver's own convergence boundary, so it runs without a further waking send; a `disposed` cancel never latches, and a wake submitted while already idle always opens its turn boundary (status shows a transient `idle → running → idle` pair even when the message was cleared). Durable `turn/end` records `aborted` for `user` and `parent`, while disposal records `disposed`; undispatched model tool calls receive synthetic `tool/call` and `ABORTED_BEFORE_DISPATCH` result pairs. The cancellation cause changes reporting, not how result context finalized after cancellation is handled. Disposal waits for signal-ignoring work before registry removal. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) and the [cancel-convergence wake latch](../../../.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.md) own the lifecycle and race contract. Within a step, exclusive calls form barriers; parallel-safe calls use a bounded rolling pool and are reclassified before start. Only dispatch/body overlaps. Policy, durable results, and result context remain model-ordered. Abort stops new calls, drains started results, and retains their finalized result context without distinguishing the cancellation cause. An internal scheduler failure stops new dispatches, waits for already-started dispatches, and reaches the turn error boundary without fabricating tool results. diff --git a/packages/core/agent-loop/README.zh.md b/packages/core/agent-loop/README.zh.md index 5828da301b..df0ba44ec5 100644 --- a/packages/core/agent-loop/README.zh.md +++ b/packages/core/agent-loop/README.zh.md @@ -67,7 +67,7 @@ interface Config { 在 `agent/request` 返回提供方/模型调用配置后,循环会调用 `ctx.llm.prepareCall()`,在活跃轮次信号的控制下校验由适配器持有的字段,并填入配置的推理(reasoning)强度和输出 token 默认值。准备完成的调用会在这次异步解析、`request/header` 日志记录和最终分派期间保留同一项确切的适配器注册,因此 HMR(热模块替换)不会把某个适配器的能力解析结果与另一适配器的请求混用。请求 header 会记录生效配置以及哪些字段来自适配器。下一次 waterfall(瀑布式事件)前,循环会从提议中移除这些带标记字段,使当前精确路由重新填入自身默认值;未带标记的显式设置会跨步骤和路由变化保留。没有已注册适配器的路由会保留原定配置,使 `llm/stream` 监听器可以接管并短路该请求;最终分派仍会以 `NO_ADAPTER` 拒绝未得到处理的路由。新循环实例在恢复时会应用同一来源规则。 -插件失败会结束当前轮次,而不是结束循环。最终适配器选择、分发与迭代失败会由 `ctx.llm` 作为终止 error 或 aborted finish 返回,并进入 `agent/request-error`;middleware、结果处理、工具及其他扩展失败仍会抛出并直接关闭轮次。恢复逻辑会接收请求坐标、不可变的提供方事实、准备完成的适配器注册所捕获的不可变重试策略以及轮次信号;middleware 接管未准备路由时,该策略缺失。处理失败的监听器返回 `{ kind: 'retry' }`;未被处理的失败是终态。AgentLoop 为当前接纳或轮次拥有一个取消信号。有效的 `cancel(cause)` 在未设置 `keepInbox` 时清除待处理工作,并以协作方式中止该信号;空闲取消是空操作。持久 `turn/end` 为 `user` 和 `parent` 记录 `aborted`,dispose(资源释放)则记录 `disposed`;未分发的模型工具调用会收到合成的 `tool/call` 与 `ABORTED_BEFORE_DISPATCH` 结果对。取消原因只改变报告方式,不改变对取消后已定案结果上下文的处理。dispose 会等待忽略信号的工作完成,然后才从注册表移除。[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)规定生命周期与竞态契约。 +插件失败会结束当前轮次,而不是结束循环。最终适配器选择、分发与迭代失败会由 `ctx.llm` 作为终止 error 或 aborted finish 返回,并进入 `agent/request-error`;middleware、结果处理、工具及其他扩展失败仍会抛出并直接关闭轮次。恢复逻辑会接收请求坐标、不可变的提供方事实、准备完成的适配器注册所捕获的不可变重试策略以及轮次信号;middleware 接管未准备路由时,该策略缺失。处理失败的监听器返回 `{ kind: 'retry' }`;未被处理的失败是终态。AgentLoop 为当前接纳或轮次拥有一个取消信号。有效的 `cancel(cause)` 在未设置 `keepInbox` 时清除待处理工作,并以协作方式中止该信号;空闲取消是空操作。abort 触发后、活动收敛到空闲前到达的唤醒输入会被锁存(`wakeRequested`),并在 driver 自身的收敛边界重放,无需再发一条唤醒 send 即可执行;`disposed` 取消从不锁存,而 agent 已处于空闲时发送的唤醒总是打开自己的 turn 边界(即使消息已被清除,状态也会显示瞬态 `idle → running → idle` 对)。持久 `turn/end` 为 `user` 和 `parent` 记录 `aborted`,dispose(资源释放)则记录 `disposed`;未分发的模型工具调用会收到合成的 `tool/call` 与 `ABORTED_BEFORE_DISPATCH` 结果对。取消原因只改变报告方式,不改变对取消后已定案结果上下文的处理。dispose 会等待忽略信号的工作完成,然后才从注册表移除。[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)与[取消收敛窗口唤醒锁存](../../../.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.md)规定生命周期与竞态契约。 在步骤内,独占调用形成屏障;并行安全调用使用有界滚动池,并在启动前重新分类。只有分发/主体会重叠。策略、持久结果和结果上下文仍保持模型顺序。中止会停止新调用,drain 已启动的结果,并保留其已定案的结果上下文,不区分取消原因。内部调度器故障会停止新的分发,等待已启动的分发,然后在不虚构工具结果的情况下到达轮次错误边界。 diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index cfac8262c2..6701777a0f 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -43,7 +43,7 @@ type Phase = lastTurn: number wakeRequested: boolean } - | { kind: 'running'; abort: AbortController; turn: number; step: number } + | { kind: 'running'; abort: AbortController; turn: number; step: number; wakeRequested: boolean } type StepEndReason = Extract @@ -112,10 +112,12 @@ export class ReactLoopAgent implements Agent { send(message: UserMessage, target: InboxTarget, wakeup: boolean): void { // Waking input cannot join an aborted activity, so it starts the next turn. + // The classification is captured BEFORE the insertion: a reentrant cancel + // from a synchronous splice observer must not reclassify this wake. const wakingAfterAbort = wakeup && this.phase.kind !== 'idle' && this.phase.abort.signal.aborted const resolvedTarget = wakingAfterAbort ? 'next-turn' : target this.inbox.splice(resolvedTarget, Infinity, 0, [message]) - if (wakeup) this.wakeDriver() + if (wakeup) this.wakeDriver(wakingAfterAbort) } followup(input: UserMessage): void { @@ -133,7 +135,7 @@ export class ReactLoopAgent implements Agent { cancel(cause: AgentCancelCause, options: CancelOptions = {}): void { if (!options.keepInbox) { this.inbox.clear() - if (this.phase.kind === 'maintenance') this.phase.wakeRequested = false + if (this.phase.kind !== 'idle') this.phase.wakeRequested = false } if (this.phase.kind !== 'idle') this.phase.abort.abort(cause) } @@ -154,22 +156,44 @@ export class ReactLoopAgent implements Agent { return await task(maintenance.abort.signal) } finally { this.setPhase({ kind: 'idle', lastTurn: maintenance.lastTurn }) - if (maintenance.wakeRequested) this.wakeDriver() + if (maintenance.wakeRequested && this.inbox.hasPending) this.wakeDriver() done.resolve() } })() } - /** Start one driver, or remember its wake behind maintenance. */ - private wakeDriver(): void { - if (this.phase.kind === 'maintenance') { - if (!this.phase.abort.signal.aborted) this.phase.wakeRequested = true + /** + * Start one driver, or latch its wake behind maintenance or an aborted + * activity. A wake sent while idle always opens its turn boundary, even + * when its message is cleared before the driver claims; only a latched + * replay is suppressed when the queue no longer holds the wake. + * @param wakeAfterAbort - the send-time classification from {@link send}: + * the wake landed after the abort fired. Captured before the inbox + * insertion so a reentrant cancel cannot reclassify it. + */ + private wakeDriver(wakeAfterAbort = false): void { + if (this.phase.kind !== 'idle') { + // The current activity cannot deliver this wake: a maintenance task + // never reads the queue, and an aborted activity converges without + // restarting — both latch for the exiting activity to replay. A live + // driver claims queued work itself, so it needs no latch. A disposal + // cancel never latches: replaying would make `whenIdle()` wait on a + // full model turn over a session being torn down. + const reason = this.phase.abort.signal.reason as AgentCancelCause | undefined + if (reason?.kind !== 'disposed' && (this.phase.kind === 'maintenance' || wakeAfterAbort)) { + this.phase.wakeRequested = true + } return } - if (this.phase.kind !== 'idle') return const driver = Promise.withResolvers() this.activityDone = driver.promise - this.setPhase({ kind: 'running', abort: new AbortController(), turn: this.phase.lastTurn, step: 0 }) + this.setPhase({ + kind: 'running', + abort: new AbortController(), + turn: this.phase.lastTurn, + step: 0, + wakeRequested: false, + }) this.loopCtx.agents.withInitiator(this, () => this.kick()).then(driver.resolve, driver.reject) } @@ -196,7 +220,9 @@ export class ReactLoopAgent implements Agent { } finally { /* v8 ignore next -- kick owns a running phase until this driver boundary */ if (this.phase.kind === 'running') { - this.setPhase({ kind: 'idle', lastTurn: this.phase.turn }) + const { turn, wakeRequested } = this.phase + this.setPhase({ kind: 'idle', lastTurn: turn }) + if (wakeRequested && this.inbox.hasPending) this.wakeDriver() } } } @@ -302,6 +328,9 @@ export class ReactLoopAgent implements Agent { } if (!this.inbox.hasPending) return false phase.abort = new AbortController() + // The driver keeps running with a fresh controller: any latch set on the + // old one is stale, and the live driver claims the queue itself. + phase.wakeRequested = false phase.step = 0 return true } diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index 5c0deed621..79bc65a105 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -126,6 +126,121 @@ describe('Agent.cancel()', () => { expect(adapter.requests).toHaveLength(3) }) + it('cancel({ keepInbox: true }) latches a waking send landing in the abort-to-idle window', async () => { + const adapter = new MockAdapter(['hang', textResponse('B reply')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('latch-window'), { provider: 'mock', model: 'mock' }) + + send(agent, 'active') + await new Promise(resolve => setTimeout(resolve, 30)) + + // The abort signal is set but the driver has not converged to idle yet: + // the waking send must be latched, not parked until another wake. + agent.cancel({ kind: 'user' }, { keepInbox: true }) + send(agent, 'B') + + await agent.whenIdle() + + expect(userTexts(agent)).toEqual(['active', 'B']) + expect(adapter.requests).toHaveLength(2) + expect(agent.inbox.nextTurn).toHaveLength(0) + expect(agent.session.events.filter(e => e.type === 'turn/end').map(e => + e.type === 'turn/end' ? e.data.reason : null)).toEqual([ + { kind: 'aborted', reason: { kind: 'user' } }, + { kind: 'completed' }, + ]) + }) + + it('cancel() without keepInbox clears a latched wake alongside the inbox', async () => { + const adapter = new MockAdapter(['hang', textResponse('C reply')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('latch-cleared'), { provider: 'mock', model: 'mock' }) + + send(agent, 'active') + await new Promise(resolve => setTimeout(resolve, 30)) + agent.cancel({ kind: 'user' }, { keepInbox: true }) + send(agent, 'B') // latched behind the aborted activity + agent.cancel({ kind: 'user' }) // drops the inbox and the latch with it + await agent.whenIdle() + + expect(userTexts(agent)).toEqual(['active']) + expect(agent.inbox.nextTurn).toHaveLength(0) + expect(adapter.requests).toHaveLength(1) + + send(agent, 'C') + await agent.whenIdle() + expect(userTexts(agent)).toEqual(['active', 'C']) + expect(adapter.requests).toHaveLength(2) + }) + + it('removing the latched wake before convergence suppresses the replay', async () => { + const adapter = new MockAdapter(['hang']) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('removed-latched-wake'), { provider: 'mock', model: 'mock' }) + + send(agent, 'active') + await new Promise(resolve => setTimeout(resolve, 30)) + + agent.cancel({ kind: 'user' }, { keepInbox: true }) + const steer = createUserMessage({ content: [{ type: 'text', text: 'steer me' }], source: { kind: 'user' } }) + agent.steer(steer) // latched behind the aborted activity + agent.inbox.remove(steer.id) // the wake is retracted before convergence + + await agent.whenIdle() + + expect(userTexts(agent)).toEqual(['active']) + expect(adapter.requests).toHaveLength(1) + expect(agent.inbox.nextTurn).toHaveLength(0) + expect(agent.status).toBe('idle') + // No replay with nothing to run: the latched message is gone, so no + // empty follow-up turn is recorded. + expect(agent.session.events.filter(e => e.type === 'turn/start')).toHaveLength(1) + }) + + it('latches a wake arriving deep into a slow abort convergence', async () => { + // The stream notices the abort only after 50ms, so the driver stays in + // the abort-to-idle window long after `cancel()` returned: the wake must + // be latched across the whole window, not just the same-tick case. + const adapter = new MockAdapter(['hang-slow', textResponse('B reply')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('slow-convergence'), { provider: 'mock', model: 'mock' }) + + send(agent, 'A') + await new Promise(resolve => setTimeout(resolve, 30)) + + agent.cancel({ kind: 'user' }, { keepInbox: true }) + await new Promise(resolve => setTimeout(resolve, 10)) + send(agent, 'B') + + await agent.whenIdle() + expect(userTexts(agent)).toEqual(['A', 'B']) + expect(adapter.requests).toHaveLength(2) + expect(agent.inbox.nextTurn).toHaveLength(0) + }) + + it('does not latch a wake landing after disposal begins', async () => { + const adapter = new MockAdapter(['hang-slow', textResponse('late reply')]) + const ctx = await harness(adapter) + const handle = await ctx.agents.create({ + sessionId: SessionId('dispose-window-wake'), + agentOptions: { provider: 'mock', model: 'mock' }, + }) + const agent = handle.agent + + send(agent, 'active') + await new Promise(resolve => setTimeout(resolve, 30)) + + // Dispose cancels with `{ kind: 'disposed' }`; a wake landing in the + // abort-to-idle window must not latch, so `whenIdle()` does not wait on + // a model turn over the session being torn down. + const disposal = handle.dispose() + setTimeout(() => { send(agent, 'late wake') }, 10) + await disposal + + expect(adapter.requests).toHaveLength(1) + expect(userTexts(agent)).toEqual(['active']) + }) + it('cancel after waking send closes its synchronously opened turn without a step', async () => { const adapter = new MockAdapter([textResponse('should not run')]) const ctx = await harness(adapter) @@ -228,7 +343,7 @@ describe('Agent.cancel()', () => { expect(userTexts(agent)).toEqual(['first', 'later']) }) - it('replacement work queued after idle-listener cancellation waits for another wakeup', async () => { + it('replacement work queued after idle-listener cancellation replays at convergence', async () => { const adapter = new MockAdapter([ textResponse('first reply'), textResponse('replacement reply'), @@ -253,9 +368,11 @@ describe('Agent.cancel()', () => { if (replacementIdle === undefined) throw new Error('idle listener did not register replacement work') await replacementIdle - expect(adapter.requests).toHaveLength(1) - expect(userTexts(agent)).toEqual(['first']) - expect(agent.inbox.nextTurn).toHaveLength(1) + // The wake sent after the cancel fired is latched: the surviving + // replacement runs at convergence without a third message. + expect(adapter.requests).toHaveLength(2) + expect(userTexts(agent)).toEqual(['first', 'surviving replacement']) + expect(agent.inbox.nextTurn).toHaveLength(0) const idle = waitForIdle(ctx, agent) send(agent, 'wake it') @@ -479,7 +596,7 @@ describe('Agent.cancel()', () => { expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(false) }) - it('a running-listener cancellation parks replacement work until another wakeup', async () => { + it('a running-listener cancellation replays replacement work at convergence', async () => { const adapter = new MockAdapter([textResponse('A reply'), textResponse('B reply')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) @@ -497,17 +614,20 @@ describe('Agent.cancel()', () => { await idle dispose() - expect(userTexts(agent)).toEqual([]) - expect(agent.inbox.nextTurn).toHaveLength(1) + // B's wake was latched behind the cancelled driver: it runs on its own. + expect(userTexts(agent)).toEqual(['B']) + expect(agent.inbox.nextTurn).toHaveLength(0) + expect(adapter.requests).toHaveLength(1) const replacementIdle = waitForIdle(ctx, agent) send(agent, 'C') await replacementIdle expect(userTexts(agent)).toEqual(['B', 'C']) + expect(adapter.requests).toHaveLength(2) expect(agent.session.events.filter(event => event.type === 'turn/end')).toHaveLength(2) }) - it('a prompt queued during pre-step cancellation waits for another wakeup', async () => { + it('a prompt queued during pre-step cancellation replays at convergence', async () => { const adapter = new MockAdapter([textResponse('A reply'), textResponse('B reply')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) @@ -518,13 +638,15 @@ describe('Agent.cancel()', () => { send(agent, 'B') await idle - expect(userTexts(agent)).toEqual([]) - expect(agent.inbox.nextTurn).toHaveLength(1) + expect(userTexts(agent)).toEqual(['B']) + expect(agent.inbox.nextTurn).toHaveLength(0) + expect(adapter.requests).toHaveLength(1) const replacementIdle = waitForIdle(ctx, agent) send(agent, 'C') await replacementIdle expect(userTexts(agent)).toEqual(['B', 'C']) + expect(adapter.requests).toHaveLength(2) expect(agent.session.events.filter(event => event.type === 'turn/end')).toHaveLength(3) }) @@ -556,7 +678,7 @@ describe('Agent.cancel()', () => { expect(flat).not.toContain('steer text') }) - it('parks replacement work queued synchronously by an abort observer', async () => { + it('replays replacement work queued synchronously by an abort observer', async () => { const adapter = new MockAdapter([ 'hang', textResponse('replacement reply'), @@ -586,13 +708,15 @@ describe('Agent.cancel()', () => { }), ]) - expect(adapter.requests).toHaveLength(1) - expect(userTexts(agent)).toEqual(['original']) - expect(agent.inbox.nextTurn).toHaveLength(1) + // The abort-observer wake was latched: replacement runs at convergence, + // so the original turn is followed by a completed replacement turn. + expect(adapter.requests).toHaveLength(2) + expect(userTexts(agent)).toEqual(['original', 'replacement']) + expect(agent.inbox.nextTurn).toHaveLength(0) const reasons = agent.session.events .filter(event => event.type === 'turn/end') .map(event => event.type === 'turn/end' ? event.data.reason : undefined) - expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'user' } }]) + expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'user' } }, { kind: 'completed' }]) const replacementIdle = waitForIdle(ctx, agent) send(agent, 'wake it') diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index c8d048ca47..1173607ae9 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -41,6 +41,14 @@ function send(agent: Agent, text: string) { agent.followup(createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } })) } +/** All user-message texts recorded in the log (to assert what actually ran). */ +function userTexts(agent: Agent): string[] { + return agent.session.events + .filter(e => e.type === 'user/message') + .flatMap(e => e.type === 'user/message' ? e.data.content : []) + .flatMap(b => b.type === 'text' ? [b.text] : []) +} + describe('agent loop', () => { it.each([0, -1, 1.5, Number.NaN, Number.MAX_SAFE_INTEGER + 1])( 'rejects invalid AgentOptions.maxTokens %s before publication', @@ -70,7 +78,7 @@ describe('agent loop', () => { }) it('cancels queued wakeup work together with an active maintenance task', async () => { - const adapter = new MockAdapter([textResponse('unused')]) + const adapter = new MockAdapter([textResponse('park reply')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('cancel-maintenance-wakeup'), { provider: 'mock', @@ -87,15 +95,68 @@ describe('agent loop', () => { }) await started.promise - send(agent, 'discard this wakeup') - agent.cancel({ kind: 'user' }) - send(agent, 'park after cancellation') + send(agent, 'discard this wakeup') // latched behind the live maintenance task + agent.cancel({ kind: 'user' }) // drops the queue and the latch, aborts maintenance + send(agent, 'park after cancellation') // newer intent: re-latched, replays at convergence await expect(maintenance).rejects.toThrow('maintenance aborted') await agent.whenIdle() - expect(agent.inbox.nextTurn).toHaveLength(1) + + // The pre-cancel wakeup is gone; the post-cancel wake replays at convergence. + expect(userTexts(agent)).toEqual(['park after cancellation']) + expect(agent.inbox.nextTurn).toHaveLength(0) + expect(adapter.requests).toHaveLength(1) + }) + + it('replays a wake latched behind maintenance at convergence', async () => { + const adapter = new MockAdapter([textResponse('wake reply')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('maintenance-wake-replay'), { + provider: 'mock', + model: 'mock', + }) + const started = Promise.withResolvers() + const finish = Promise.withResolvers() + const maintenance = agent.runMaintenance(async () => { + started.resolve(undefined) + await finish.promise + }) + await started.promise + + send(agent, 'wake behind maintenance') + finish.resolve(undefined) + await maintenance + await agent.whenIdle() + + expect(userTexts(agent)).toEqual(['wake behind maintenance']) + expect(adapter.requests).toHaveLength(1) + }) + + it('suppresses the replay when a latched maintenance wake is removed', async () => { + const adapter = new MockAdapter([]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('maintenance-wake-removed'), { + provider: 'mock', + model: 'mock', + }) + const started = Promise.withResolvers() + const finish = Promise.withResolvers() + const maintenance = agent.runMaintenance(async () => { + started.resolve(undefined) + await finish.promise + }) + await started.promise + + const wake = createUserMessage({ content: [{ type: 'text', text: 'removed wake' }], source: { kind: 'user' } }) + agent.followup(wake) + agent.inbox.remove(wake.id) + finish.resolve(undefined) + await maintenance + await agent.whenIdle() + + expect(userTexts(agent)).toEqual([]) expect(adapter.requests).toEqual([]) - agent.cancel({ kind: 'user' }) + expect(agent.session.events.filter(e => e.type === 'turn/start')).toHaveLength(0) }) it('runs a simple turn: queued message → model → idle, with ordered events', async () => { diff --git a/packages/core/agent-loop/tests/mock-adapter.ts b/packages/core/agent-loop/tests/mock-adapter.ts index 6e592d9311..69dc78990c 100644 --- a/packages/core/agent-loop/tests/mock-adapter.ts +++ b/packages/core/agent-loop/tests/mock-adapter.ts @@ -58,14 +58,16 @@ export function toolCallResponse(rawCallId: string, name: string, args: object, /** * Mock adapter driven by a script: each model call consumes the next entry. * Records every request it receives for assertions. An entry may be a - * function to compute chunks from the request, or a 'hang' marker that - * streams one chunk then waits until aborted. + * function to compute chunks from the request, a 'hang' marker that + * streams one chunk then waits until aborted, or 'hang-slow' which takes + * 50ms to notice the abort — a stand-in for slow real-world teardown + * (LLM stream cancellation, tool unwinding). */ export class MockAdapter extends LlmAdapter { requests: GenerateOptions[] = [] constructor( - private script: (StreamChunk[] | ((options: GenerateOptions) => StreamChunk[]) | 'hang')[], + private script: (StreamChunk[] | ((options: GenerateOptions) => StreamChunk[]) | 'hang' | 'hang-slow')[], private readonly reasoning?: LlmModelReasoningInfo, private readonly defaultMaxTokens?: number, ) { @@ -98,6 +100,16 @@ export class MockAdapter extends LlmAdapter { }) return } + if (entry === 'hang-slow') { + yield { type: 'block-start', index: 0, blockType: 'text' } + yield { type: 'text-delta', index: 0, text: 'partial' } + await new Promise((_resolve, reject) => { + const fail = (): void => { reject(new Error('aborted')) } + if (options.signal?.aborted) { setTimeout(fail, 50); return } + options.signal?.addEventListener('abort', () => { setTimeout(fail, 50) }, { once: true }) + }) + return + } const chunks = typeof entry === 'function' ? entry(options) : entry for (const chunk of chunks) { if (options.signal?.aborted) throw new Error('aborted') diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index fae9267347..d8cb5c4114 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -104,7 +104,11 @@ export interface Agent { /** * Route identified input to an inbox boundary and optionally wake the driver. - * Waking input submitted after active cancellation is queued for the next turn. + * Waking input submitted after active cancellation is queued for the next + * turn and runs when the aborted activity converges to idle; a `disposed` + * cancel leaves it parked. A wake submitted while already idle always opens + * its turn boundary, even when its message is cleared before the driver + * claims ([cancel-convergence wake latch](../../../../.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.md)). * @param message - identified content and its producer provenance. * @param target - the preferred next-turn or next-step inbox boundary. * @param wakeup - whether delivery may wake the driver. From 853f929704222cf5ce186cbce277c6fa298c7f59 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 7 Aug 2026 16:25:23 +0800 Subject: [PATCH 010/189] refactor(agent-loop): tighten wake-latch comments --- packages/core/agent-loop/src/agent.ts | 24 +++++++++--------------- 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 6701777a0f..6ef965e59e 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -112,8 +112,7 @@ export class ReactLoopAgent implements Agent { send(message: UserMessage, target: InboxTarget, wakeup: boolean): void { // Waking input cannot join an aborted activity, so it starts the next turn. - // The classification is captured BEFORE the insertion: a reentrant cancel - // from a synchronous splice observer must not reclassify this wake. + // Captured before the insertion so a reentrant cancel from a splice observer cannot reclassify it. const wakingAfterAbort = wakeup && this.phase.kind !== 'idle' && this.phase.abort.signal.aborted const resolvedTarget = wakingAfterAbort ? 'next-turn' : target this.inbox.splice(resolvedTarget, Infinity, 0, [message]) @@ -165,20 +164,16 @@ export class ReactLoopAgent implements Agent { /** * Start one driver, or latch its wake behind maintenance or an aborted * activity. A wake sent while idle always opens its turn boundary, even - * when its message is cleared before the driver claims; only a latched - * replay is suppressed when the queue no longer holds the wake. - * @param wakeAfterAbort - the send-time classification from {@link send}: - * the wake landed after the abort fired. Captured before the inbox - * insertion so a reentrant cancel cannot reclassify it. + * when its message was cleared; only a latched replay is suppressed when + * the queue no longer holds the wake. + * @param wakeAfterAbort - the {@link send} classification, captured before + * the inbox insertion so a reentrant cancel cannot reclassify it. */ private wakeDriver(wakeAfterAbort = false): void { if (this.phase.kind !== 'idle') { - // The current activity cannot deliver this wake: a maintenance task - // never reads the queue, and an aborted activity converges without - // restarting — both latch for the exiting activity to replay. A live - // driver claims queued work itself, so it needs no latch. A disposal - // cancel never latches: replaying would make `whenIdle()` wait on a - // full model turn over a session being torn down. + // Maintenance and aborted drivers cannot deliver the wake: latch it for + // replay at convergence. Live drivers claim queued work themselves; + // disposal never latches, so teardown waits on no model turn. const reason = this.phase.abort.signal.reason as AgentCancelCause | undefined if (reason?.kind !== 'disposed' && (this.phase.kind === 'maintenance' || wakeAfterAbort)) { this.phase.wakeRequested = true @@ -328,8 +323,7 @@ export class ReactLoopAgent implements Agent { } if (!this.inbox.hasPending) return false phase.abort = new AbortController() - // The driver keeps running with a fresh controller: any latch set on the - // old one is stale, and the live driver claims the queue itself. + // A fresh controller makes a latch set on the old one stale: the live driver claims the queue itself. phase.wakeRequested = false phase.step = 0 return true From 66a21a38b13a051af3d856cde2033e84af5a3aba Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 6 Aug 2026 11:59:19 +0800 Subject: [PATCH 011/189] feat(subagent): add current-turn interrupt RPC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ctx.subagents.interrupt() stops one live continuable child's current turn via Agent.cancel(cause, { keepInbox: true }) under either a human durable parent address or an exact live ancestor Agent. Fire-and-return: admission is synchronous, quiescence is not awaited. Pending inbox work, the Activation, and published descendants are preserved; only a later waking send resumes the parked FIFO queue. Absent, one-shot, and disposing targets are accepted no-ops. The new Host RPC subagent.interrupt calls only that primitive with user authority — no catalog, history, persistence, or parent-registry lookup — so a live child stays stoppable while its parent Agent is offline. Refs #1535 --- ...6-continuable-subagent-interrupt.i18n.yaml | 6 + ...26-08-06-continuable-subagent-interrupt.md | 42 ++++ ...08-06-continuable-subagent-interrupt.zh.md | 42 ++++ docs/cordis-catalog/events.md | 8 +- docs/cordis-catalog/services.md | 20 +- .../client/connection/src/client/fixture.ts | 2 + packages/client/connection/tests/fake-api.ts | 3 + packages/client/runtime/tests/fake-api.ts | 4 + .../cordis/tool-cordis/src/api-catalog.ts | 8 + packages/host/apiproxy/src/api-proxy.ts | 25 ++ packages/host/apiproxy/src/api/index.ts | 3 +- packages/host/apiproxy/src/api/rpc-map.ts | 1 + .../host/apiproxy/src/api/subagents.schema.ts | 12 + packages/host/apiproxy/src/api/subagents.ts | 18 ++ packages/host/apiproxy/src/fetch/client.ts | 4 + packages/host/apiproxy/src/fetch/handler.ts | 2 + .../tests/api-proxy-subagents.spec.ts | 55 ++++- .../apiproxy/tests/client-handler.spec.ts | 27 +++ .../host/apiproxy/tests/fetch-carrier.spec.ts | 8 + packages/subagent/subagent/README.i18n.yaml | 4 +- packages/subagent/subagent/README.md | 7 +- packages/subagent/subagent/README.zh.md | 7 +- .../subagent/subagent/src/continuation.ts | 71 ++++++ packages/subagent/subagent/src/index.ts | 20 ++ .../subagent/tests/continuation.spec.ts | 220 ++++++++++++++++++ .../subagent/subagent/tests/service.spec.ts | 10 + scripts/gen-cordis-catalog.ts | 1 + 27 files changed, 613 insertions(+), 17 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.md create mode 100644 .agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.zh.md diff --git a/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.i18n.yaml b/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.i18n.yaml new file mode 100644 index 0000000000..fe6e8870f5 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.i18n.yaml @@ -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: 729f1eb8259aa28aa771ed71872ebce9cadd4ed8 +2026-08-06-continuable-subagent-interrupt.zh.md: 15fe34d8b5e1d67623aabfe08710f341739e92e1 diff --git a/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.md b/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.md new file mode 100644 index 0000000000..729f1eb825 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.md @@ -0,0 +1,42 @@ +# 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 pending queue at idle, an interrupt never auto-starts the next queued follow-up; only a later 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 now stop a runaway continuable turn without losing the child, its 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 someone sends a waking message — an intentional human-in-the-loop pause, not a scheduler defect. The Web Stop action and the model-facing `interrupt_agent` tool build on this primitive in the stacked follow-up PRs for issue #1535. + +## 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. diff --git a/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.zh.md b/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.zh.md new file mode 100644 index 0000000000..15fe34d8b5 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.zh.md @@ -0,0 +1,42 @@ +# 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-kill;continuable 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;只有之后一次显式唤醒发送才按保留的 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,直到有人发送唤醒消息——这是有意的 human-in-the-loop 暂停,不是调度器缺陷。Web 的 Stop 操作和面向模型的 `interrupt_agent` 工具在 issue #1535 的后续 stacked PR 中基于此原语构建。 + +## 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 往返。 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index ae16d10f07..edfd046430 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -699,7 +699,7 @@ A published child settled. Scope-filtered dispatch uses the same delegating pare Types: [Scoped](../core-data-structures/scope.md) · [SubagentService](../core-data-structures/subagent.md) -Source: [`packages/subagent/subagent/src/index.ts:160`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:162`](../../packages/subagent/subagent/src/index.ts) ### `subagent/provider-added` — emit @@ -716,7 +716,7 @@ A provider became resolvable in the registry. Types: [SubagentProvider](../core-data-structures/subagent.md) -Source: [`packages/subagent/subagent/src/index.ts:134`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:136`](../../packages/subagent/subagent/src/index.ts) ### `subagent/provider-removed` — emit @@ -731,7 +731,7 @@ A provider left the registry. Accepted runs remain holder-owned. 'subagent/provider-removed'(name: string): void ``` -Source: [`packages/subagent/subagent/src/index.ts:140`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:142`](../../packages/subagent/subagent/src/index.ts) ### `subagent/start` — emit @@ -753,7 +753,7 @@ A provider established a published child. For in-process providers, `ctx.agents. Types: [Scoped](../core-data-structures/scope.md) · [SubagentService](../core-data-structures/subagent.md) -Source: [`packages/subagent/subagent/src/index.ts:151`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:153`](../../packages/subagent/subagent/src/index.ts) ## `system-prompt/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index a22c3464eb..caf081b5a6 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2071,6 +2071,22 @@ async startContinuable(spec: ContinuableStartSpec): Promise */ async followup( parent: Agent, childId: SessionId, content: ContentBlock[], options: SubagentFollowupOptions, ): Promise +/** + * Interrupt one live continuable child's current turn under a human parent + * address or an exact live ancestor Agent. Fire-and-return: the cancel + * signal is issued before this returns, but the target may keep running + * until it observes the signal. Pending inbox work, the Activation, and + * published descendants are preserved; only a later waking send resumes the + * parked FIFO queue. An absent target — including a one-shot or unknown id — + * is an accepted no-op, as is a manager-less composition, which cannot own a + * live Activation. + * @param targetSessionId - the durable child session id to interrupt. + * @param authority - the human parent address or exact live ancestor Agent. + * @throws {SubagentError} `UNAUTHORIZED` when the authority does not own the + * live target. + */ +interrupt(targetSessionId: SessionId, authority: SubagentInterruptAuthority): void + /** * Deliver selected content from one live continuable child to its durable * direct parent. The child is the authority credential; callers cannot name a @@ -2171,9 +2187,9 @@ list(): string[] async start(name: string, request: SubagentStartRequest): Promise ``` -Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [ContinuableSetupContribution](../core-data-structures/subagent.md) · [ContinuableStart](../core-data-structures/subagent.md) · [ContinuableStartSpec](../core-data-structures/subagent.md) · [MessageId](../core-data-structures/core.md) · [SessionId](../core-data-structures/core.md) · [SubagentFollowupOptions](../core-data-structures/subagent.md) · [SubagentListEntry](../core-data-structures/subagent.md) · [SubagentProvider](../core-data-structures/subagent.md) · [SubagentReportOptions](../core-data-structures/subagent.md) · [SubagentRun](../core-data-structures/subagent.md) · [SubagentStartRequest](../core-data-structures/subagent.md) +Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [ContinuableSetupContribution](../core-data-structures/subagent.md) · [ContinuableStart](../core-data-structures/subagent.md) · [ContinuableStartSpec](../core-data-structures/subagent.md) · [MessageId](../core-data-structures/core.md) · [SessionId](../core-data-structures/core.md) · [SubagentFollowupOptions](../core-data-structures/subagent.md) · [SubagentInterruptAuthority](../core-data-structures/subagent.md) · [SubagentListEntry](../core-data-structures/subagent.md) · [SubagentProvider](../core-data-structures/subagent.md) · [SubagentReportOptions](../core-data-structures/subagent.md) · [SubagentRun](../core-data-structures/subagent.md) · [SubagentStartRequest](../core-data-structures/subagent.md) -Source: [`packages/subagent/subagent/src/index.ts:165`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:167`](../../packages/subagent/subagent/src/index.ts) ## `ctx.subprocess` — `SubprocessService` (abstract seam) diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index ec3199ee02..2a081284b2 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -2203,6 +2203,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { prompt: request => Promise.resolve(ok(request, { messageId: `fixture-message-${request.payload.childSessionId}` as never, })), + interrupt: request => Promise.resolve(ok(request, { accepted: true as const })), }, host: { describe: request => ok(request, { version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions }), @@ -2748,6 +2749,7 @@ export class FixtureApiClient extends AbstractApiClient { case 'subagent.list': return this.api.subagents.list(request) case 'subagent.history': return this.api.subagents.history(request) case 'subagent.prompt': return this.api.subagents.prompt(request, signal) + case 'subagent.interrupt': return this.api.subagents.interrupt(request) case 'host.describe': return this.api.host.describe(request) case 'host.pickDirectory': return this.api.host.pickDirectory(request, new AbortController().signal) case 'host.listDirectory': return this.api.host.listDirectory(request, new AbortController().signal) diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index cc4504e538..ef5071316e 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -126,6 +126,9 @@ export class FakeApiClient implements IApiClient { prompt: (payload: unknown) => this.record('subagent.prompt', payload, Promise.resolve(ok({ messageId: 'fake-message' as never, }))), + interrupt: (payload: unknown) => this.record('subagent.interrupt', payload, Promise.resolve(ok({ + accepted: true as const, + }))), } readonly host: IApiClient['host'] = { diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index 2f4299ce6c..5e510c0012 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -140,10 +140,14 @@ export class FakeApiClient implements IApiClient { onSubagentPrompt: (payload: unknown) => Promise> = () => Promise.resolve(ok({ messageId: 'fake-message' as never })) + onSubagentInterrupt: (payload: unknown) => Promise> + = () => Promise.resolve(ok({ accepted: true as const })) + readonly subagents: IApiClient['subagents'] = { list: (payload: unknown) => this.record('subagent.list', payload, this.onSubagentList(payload)), history: (payload: unknown) => this.record('subagent.history', payload, this.onSubagentHistory(payload)), prompt: (payload: unknown) => this.record('subagent.prompt', payload, this.onSubagentPrompt(payload)), + interrupt: (payload: unknown) => this.record('subagent.interrupt', payload, this.onSubagentInterrupt(payload)), } readonly host: IApiClient['host'] = { diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index dec95f3b5f..acaefa5d5f 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -928,6 +928,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'async followup( parent: Agent, childId: SessionId, content: ContentBlock[], options: SubagentFollowupOptions, ): Promise', jsDoc: '/**\n * Deliver one later message to a continuable child as its next FIFO turn. A\n * resident child\'s Agent inbox accepts it directly (waking a `waiting`\n * Activation), while an absent one is cold-resumed from its persisted\n * Session. The Agent inbox is the only queue, so every accepted message has\n * one observable order.\n * @param parent - the exact live direct parent authorizing this delivery.\n * @param childId - durable child session id.\n * @param content - user-role content to deliver.\n * @param options - durable provenance and caller cancellation, which stops the\n * operation only before inbox acceptance.\n * @returns the accepted message\'s inbox id.\n * @throws when continuation services are unavailable, parent authority is\n * rejected, or the message was not admitted.\n */', }, + { + signature: 'interrupt(targetSessionId: SessionId, authority: SubagentInterruptAuthority): void', + jsDoc: '/**\n * Interrupt one live continuable child\'s current turn under a human parent\n * address or an exact live ancestor Agent. Fire-and-return: the cancel\n * signal is issued before this returns, but the target may keep running\n * until it observes the signal. Pending inbox work, the Activation, and\n * published descendants are preserved; only a later waking send resumes the\n * parked FIFO queue. An absent target — including a one-shot or unknown id —\n * is an accepted no-op, as is a manager-less composition, which cannot own a\n * live Activation.\n * @param targetSessionId - the durable child session id to interrupt.\n * @param authority - the human parent address or exact live ancestor Agent.\n * @throws {SubagentError} `UNAUTHORIZED` when the authority does not own the\n * live target.\n */', + }, { signature: 'async reportFrom( child: Agent, content: ContentBlock[], options: SubagentReportOptions, ): Promise', jsDoc: '/**\n * Deliver selected content from one live continuable child to its durable\n * direct parent. The child is the authority credential; callers cannot name a\n * recipient. Reporting does not conclude the child\'s turn or Activation.\n * @param child - exact live reporting child.\n * @param content - selected model-facing content.\n * @param options - parent scheduling and pre-acceptance cancellation.\n * @returns the stable identity of the parent-accepted message.\n * @throws when continuation services are unavailable, sender authorization\n * fails, or the direct parent is not live.\n */', @@ -2787,6 +2791,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SubagentFollowupOptions', declaration: 'export interface SubagentFollowupOptions {\n readonly source: MessageSource;\n readonly signal: AbortSignal;\n}', }, + { + name: 'SubagentInterruptAuthority', + declaration: 'export type SubagentInterruptAuthority = {\n readonly kind: \'user\';\n readonly parentSessionId: SessionId;\n} | {\n readonly kind: \'ancestor\';\n readonly agent: Agent;\n};', + }, { name: 'SubagentListEntry', declaration: 'export type SubagentListEntry = {\n readonly kind: \'child\';\n readonly id: SessionId;\n readonly activity: \'running\' | \'inactive\';\n readonly hasChildren: boolean;\n} & ({\n readonly mode: \'one-shot\';\n readonly label?: string;\n} | {\n readonly mode: \'continuable\';\n readonly label: string;\n}) | {\n readonly kind: \'diagnostic\';\n readonly id: SessionId;\n readonly reason: \'corrupt\' | \'unsupported\' | \'unavailable\';\n};', diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index cfcae423bc..52236b0f29 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -2013,6 +2013,31 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro return subagentPromptError(request, error, signal) } }, + + // Deliberately no catalog, history, persistence, or parent Agent lookup: + // the core primitive alone authorizes the durable address against the + // live Activation, which is what keeps a live child interruptible while + // its parent Agent is offline. Absent targets are accepted no-ops there. + interrupt(request) { + const { parentSessionId, childSessionId } = request.payload + try { + ctx.subagents.interrupt(childSessionId, { kind: 'user', parentSessionId }) + } catch (error: unknown) { + if (error instanceof SubagentError && error.code === 'UNAUTHORIZED') { + return Promise.resolve(err(request, { + code: 'subagent-unauthorized', + message: 'subagent does not belong to this parent', + details: { childSessionId }, + })) + } + return Promise.resolve(err(request, { + code: 'internal', + message: 'subagent interrupt failed', + details: {}, + })) + } + return Promise.resolve(ok(request, { accepted: true as const })) + }, }, workspace: { diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts index cb83c5328d..5537a0e382 100644 --- a/packages/host/apiproxy/src/api/index.ts +++ b/packages/host/apiproxy/src/api/index.ts @@ -42,7 +42,8 @@ export type { } from './sessions.ts' export type { DirectoryEntry, DirectoryListing, HostApi } from './host.ts' export type { - SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt, SubagentsApi, + SubagentAddress, SubagentCatalog, SubagentInterruptReceipt, SubagentListEntry, + SubagentPromptReceipt, SubagentsApi, } from './subagents.ts' export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts' export type { CommandsApi, CommandDescriptor } from './commands.ts' diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index 9a8750c722..deb963db07 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/packages/host/apiproxy/src/api/rpc-map.ts @@ -36,6 +36,7 @@ export interface RpcMethodMap { 'subagent.list': SubagentsApi['list'] 'subagent.history': SubagentsApi['history'] 'subagent.prompt': SubagentsApi['prompt'] + 'subagent.interrupt': SubagentsApi['interrupt'] 'host.describe': HostApi['describe'] 'host.pickDirectory': HostApi['pickDirectory'] 'host.listDirectory': HostApi['listDirectory'] diff --git a/packages/host/apiproxy/src/api/subagents.schema.ts b/packages/host/apiproxy/src/api/subagents.schema.ts index 1987d568d3..6ed8bd3263 100644 --- a/packages/host/apiproxy/src/api/subagents.schema.ts +++ b/packages/host/apiproxy/src/api/subagents.schema.ts @@ -69,6 +69,18 @@ export const subagentPromptRequestSchema = z.object({ content: z.array(contentBlockSchema), }) as unknown as z.ZodType> +/** subagent.interrupt request payload. */ +export const subagentInterruptRequestSchema = z.object({ + parentSessionId: sessionIdSchema, + childSessionId: sessionIdSchema, + mode: z.literal('continuable'), +}) satisfies z.ZodType>> + +/** subagent.interrupt response value. */ +export const subagentInterruptValueSchema = z.object({ + accepted: z.literal(true), +}) satisfies z.ZodType>> + const messageIdSchema = z.string() as unknown as z.ZodType /** subagent.prompt response value. */ diff --git a/packages/host/apiproxy/src/api/subagents.ts b/packages/host/apiproxy/src/api/subagents.ts index 4c251dca7c..8efb452c2e 100644 --- a/packages/host/apiproxy/src/api/subagents.ts +++ b/packages/host/apiproxy/src/api/subagents.ts @@ -40,6 +40,11 @@ export interface SubagentPromptReceipt { messageId: MessageId } +/** Uniform acknowledgement that one interrupt request was admitted. */ +export interface SubagentInterruptReceipt { + accepted: true +} + /** Durable parent/child address that selects subagent transport in the client. */ export type SubagentAddress = & { @@ -94,4 +99,17 @@ export interface SubagentsApi { >, signal: AbortSignal, ): Promise> + + /** + * Interrupts a live continuable child's current turn under the address's + * durable direct-parent authority, without requiring a live parent Agent, + * consulting the catalog, or resuming anything. Fire-and-return: `accepted` + * acknowledges the admitted cancel signal, not target quiescence, so the + * child may remain visibly running briefly. Queued follow-ups are kept and + * parked; an absent, idle, or already-completed target is likewise + * `accepted`. + */ + interrupt( + request: RpcRequest>, + ): Promise> } diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index 0f54d76dbc..0ce935809f 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -58,6 +58,7 @@ import { import { llmDiscoverModelsValueSchema, llmModelsValueSchema, llmProvidersValueSchema } from '../api/llm.schema.ts' import { subagentHistoryValueSchema, + subagentInterruptValueSchema, subagentListValueSchema, subagentPromptValueSchema, } from '../api/subagents.schema.ts' @@ -96,6 +97,7 @@ export interface IApiClient { list(payload: RequestPayload<'subagent.list'>, signal?: AbortSignal): Promise>> history(payload: RequestPayload<'subagent.history'>, signal?: AbortSignal): Promise>> prompt(payload: RequestPayload<'subagent.prompt'>, signal?: AbortSignal): Promise>> + interrupt(payload: RequestPayload<'subagent.interrupt'>, signal?: AbortSignal): Promise>> } host: { describe(payload: RequestPayload<'host.describe'>, signal?: AbortSignal): Promise>> @@ -171,6 +173,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType this.callUnary('subagent.list', payload, signal), history: (payload, signal) => this.callUnary('subagent.history', payload, signal), prompt: (payload, signal) => this.callUnary('subagent.prompt', payload, signal), + interrupt: (payload, signal) => this.callUnary('subagent.interrupt', payload, signal), } readonly host: IApiClient['host'] = { diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index d41b51ad6d..bd3bc3827a 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -60,6 +60,7 @@ import { import { llmDiscoverModelsRequestSchema, llmModelsRequestSchema, llmProvidersRequestSchema } from '../api/llm.schema.ts' import { subagentHistoryRequestSchema, + subagentInterruptRequestSchema, subagentListRequestSchema, subagentPromptRequestSchema, } from '../api/subagents.schema.ts' @@ -95,6 +96,7 @@ const UNARY_ROUTES: UnaryRoutes = { 'subagent.list': { schema: subagentListRequestSchema, invoke: (api, r, signal) => api.subagents.list(r, signal) }, 'subagent.history': { schema: subagentHistoryRequestSchema, invoke: (api, r, signal) => api.subagents.history(r, signal) }, 'subagent.prompt': { schema: subagentPromptRequestSchema, invoke: (api, r, signal) => api.subagents.prompt(r, signal) }, + 'subagent.interrupt': { schema: subagentInterruptRequestSchema, invoke: (api, r) => api.subagents.interrupt(r) }, 'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) }, 'host.pickDirectory': { schema: hostPickDirectoryRequestSchema, invoke: (api, r, signal) => api.host.pickDirectory(r, signal) }, 'host.listDirectory': { schema: hostListDirectoryRequestSchema, invoke: (api, r, signal) => api.host.listDirectory(r, signal) }, diff --git a/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts b/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts index feb9ecb073..62c7e653e0 100644 --- a/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts @@ -19,6 +19,7 @@ function bench(options: { childStatus?: 'idle' | 'running' entries?: object[] followupError?: Error + interruptError?: Error listError?: Error /** Persistence forgets the child entirely (the vanished-mid-read race). */ storedChild?: false @@ -53,6 +54,12 @@ function bench(options: { ) => options.followupError === undefined ? Promise.resolve('message-1') : Promise.reject(options.followupError)) + const interrupt = vi.fn(( + _targetSessionId: SessionId, + _authority: { kind: 'user'; parentSessionId: SessionId }, + ) => { + if (options.interruptError !== undefined) throw options.interruptError + }) const childHeader = { version: 0, id: CHILD, createdAt: 1, cwd: '/proj', parentSession: options.historyParent ?? PARENT, } satisfies SessionHeader @@ -72,7 +79,7 @@ function bench(options: { }) const ctx = new Context() ctx.provide('agents', { get: getAgent }) - ctx.provide('subagents', { listChildren, followup }) + ctx.provide('subagents', { listChildren, followup, interrupt }) ctx.provide('sessions', { get: (id: SessionId) => options.liveChild === true && id === CHILD ? { id: CHILD, header: childHeader, events: childEvents } @@ -90,7 +97,7 @@ function bench(options: { const api = createApiProxy(ctx, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp', }) - return { api, getAgent, listChildren, inspect, snapshot, restore, followup, parent } + return { api, getAgent, listChildren, inspect, snapshot, restore, followup, interrupt, parent } } describe('subagent gateway', () => { @@ -309,4 +316,48 @@ describe('subagent gateway', () => { error: { code: 'internal', message: 'subagent prompt failed' }, }) }) + + it('interrupts through the core primitive alone while the parent Agent is offline', async () => { + const { api, interrupt, getAgent, listChildren, inspect } = bench({ parentLive: false }) + const response = await api.subagents.interrupt(request({ + parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable' as const, + })) + expect(response.rpcId).toBe('subagent-rpc') + expect(response.result).toEqual({ ok: true, value: { accepted: true } }) + expect(interrupt).toHaveBeenCalledExactlyOnceWith(CHILD, { kind: 'user', parentSessionId: PARENT }) + // No parent-registry, catalog, or history dependency: this is what keeps a + // live child interruptible after its parent Agent went offline. + expect(getAgent).not.toHaveBeenCalled() + expect(listChildren).not.toHaveBeenCalled() + expect(inspect).not.toHaveBeenCalled() + }) + + it('maps interrupt authorization rejection without touching other services', async () => { + const { api, listChildren } = bench({ + interruptError: new SubagentError('secret lineage', 'UNAUTHORIZED'), + }) + const response = await api.subagents.interrupt(request({ + parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable' as const, + })) + expect(response.result).toEqual({ + ok: false, + error: { + code: 'subagent-unauthorized', + message: 'subagent does not belong to this parent', + details: { childSessionId: CHILD }, + }, + }) + expect(listChildren).not.toHaveBeenCalled() + }) + + it('hides unexpected interrupt failures behind the internal code', async () => { + const { api } = bench({ interruptError: new Error('secret activation state') }) + const response = await api.subagents.interrupt(request({ + parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable' as const, + })) + expect(response.result).toEqual({ + ok: false, + error: { code: 'internal', message: 'subagent interrupt failed', details: {} }, + }) + }) }) diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index ebd56ee551..476ebf344f 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -63,6 +63,7 @@ function scriptedApi(overrides: { list: r => ok(r, { entries: [], parentAvailable: false }), history: r => ok(r, { events: [], hasMore: false }), prompt: r => ok(r, { messageId: 'message-1' as never }), + interrupt: r => ok(r, { accepted: true as const }), ...overrides.subagents, }, host: { @@ -248,6 +249,32 @@ describe('unary round trip', () => { } }) + it('round-trips subagent.interrupt and rejects a one-shot or incomplete address', async () => { + const interrupt = vi.fn((r: RpcRequest) => ok(r, { accepted: true as const })) + const api = scriptedApi({ subagents: { interrupt } }) + const c = client(api) + + const accepted = await c.subagents.interrupt({ + parentSessionId: sid('parent'), childSessionId: sid('child'), mode: 'continuable', + }) + expect(accepted.result).toEqual({ ok: true, value: { accepted: true } }) + expect(interrupt).toHaveBeenCalledTimes(1) + + // The wire schema owns the mode fence: a one-shot address never reaches the impl. + const oneShot = await c.subagents.interrupt({ + parentSessionId: sid('parent'), childSessionId: sid('child'), mode: 'one-shot', + } as never) + expect(oneShot.result.ok).toBe(false) + if (!oneShot.result.ok) expect(oneShot.result.error.code).toBe('bad-request') + + const incomplete = await c.subagents.interrupt({ + parentSessionId: sid('parent'), mode: 'continuable', + } as never) + expect(incomplete.result.ok).toBe(false) + if (!incomplete.result.ok) expect(incomplete.result.error.code).toBe('bad-request') + expect(interrupt).toHaveBeenCalledTimes(1) + }) + it('rejects a method/path mismatch as bad-request', async () => { const handler = toFetchHandler(scriptedApi()) const body = { type: 'client-request', rpcId: 'r1', method: 'session.create', payload: {} } diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index 6481d75837..83ada22644 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -128,6 +128,9 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra result: { ok: true, value: { messageId: 'message-1' as never } }, } }, + async interrupt(request) { + return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } } + }, }, host: { async describe(request) { @@ -433,6 +436,11 @@ describe('unary round trip (handler ⇄ client, no network)', () => { mode: 'continuable', content: [], })).result).toEqual({ ok: true, value: { messageId: 'message-1' } }) + expect((await c.subagents.interrupt({ + parentSessionId: 'parent' as never, + childSessionId: 'child' as never, + mode: 'continuable', + })).result).toEqual({ ok: true, value: { accepted: true } }) }) it('keeps caller and connection aborts on command.execute', async () => { diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index f5ceedc743..250fd1a078 100644 --- a/packages/subagent/subagent/README.i18n.yaml +++ b/packages/subagent/subagent/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/subagent/README.md -README.md: 9d2e38c8730f7b7f26e690aa878a4466fa7c2829 -README.zh.md: 341c18617af4d040ec44814fac1ec4502d9b8902 +README.md: 7de3a5563b274e925fba931a6d5de17e68cc397c +README.zh.md: 6067555544ec0c32729beb2b4e3f773e31b747a1 diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 9d2e38c873..7de3a5563b 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -18,6 +18,7 @@ The [subagent family overview](../README.md) maps implementations and model-faci | `start(name, request)` | Validate an ordinary caller request, resolve its detached `one-shot` descriptor, then await the provider until a real one-shot child is published. Fulfillment returns a holder-owned `SubagentRun`; rejection means the provider has already cleaned every unpublished startup resource, while post-publication turn or infrastructure faults settle through the run. Continuable children never enter through this operation. | | `startContinuable(spec)` | Establish one durable continuable child and deliver its initial prompt. Resolves with `{ childId, messageId }` when the child's inbox accepts that prompt, without waiting for the turn to start or for the message to reach the Session log; any earlier failure rejects with no ids and rolls the child back entirely. Requires `ctx.agents`, session persistence, and a provider with the `prepareContinuable` capability. | | `followup(parent, childId, content, { source, signal })` | Deliver one later message from the exact live direct parent as the child's next FIFO turn, matching `Agent.followup()` terminology, and return the accepted `MessageId`. A resident child's inbox accepts it directly (waking a waiting Activation); an absent one cold-resumes from its persisted Session. Requires `ctx.agents`; cold resume also requires session persistence. | +| `interrupt(targetSessionId, authority)` | Interrupt one live continuable child's current turn under a human durable parent address (`{ kind: 'user', parentSessionId }`) or an exact live ancestor Agent (`{ kind: 'ancestor', agent }`). Admission is synchronous and the effect asynchronous: it issues `Agent.cancel(cause, { keepInbox: true })` and returns without waiting for the target to observe the signal. Pending inbox work, the Activation, and published descendants are preserved; only a later waking send resumes the parked FIFO queue. An absent target — unknown, one-shot, or already-settled id — and a manager-less composition are accepted no-ops; a wrong parent address or a stale, self-targeting, or non-ancestor caller rejects with `UNAUTHORIZED`. | | `reportFrom(child, content, { delivery, signal })` | Deliver one selected message from the exact live continuable child to its exact live direct parent and return the accepted stable `MessageId`. Quiet delivery injects context; waking delivery submits one later parent turn. | | `registerContinuableSetup(contribution)` | Compose an optional deployment capability into each continuable child's unpublished scope, with immediate revocation from resident children. | | `drainContinuableDescendants(parents)` | Close admission below exact live host-owned parent Agents, stop only their visible continuable descendants, await materializations admitted below those roots through publication or rollback, then release the selected forests child-first. The cutoff lasts until each exact parent leaves the registry; unrelated parent forests and manager-wide admission remain live. | @@ -76,7 +77,7 @@ Run events are scoped to the delegating parent. Every listener is independently Provider additions and removals also emit `subagent/provider-added` and `subagent/provider-removed`. Consumers such as the model-facing tool use those events because Cordis may load sibling plugins concurrently; configuration order does not prove registration order. -Continuable children do not create `SubagentRun` or Tasks. The continuation manager directly owns one process-local Activation and retained `AgentHandle` per resident child Session, uses the Agent inbox as the only FIFO, and cold-resumes from the durable descriptor. Exact live direct-parent identity authorizes parent-to-child delivery. Exact live child identity authorizes reports; the manager derives the recipient from durable `parentSession`, and `MessageSource` remains provenance rather than authority. +Continuable children do not create `SubagentRun` or Tasks. The continuation manager directly owns one process-local Activation and retained `AgentHandle` per resident child Session, uses the Agent inbox as the only FIFO, and cold-resumes from the durable descriptor. Exact live direct-parent identity authorizes parent-to-child delivery. Exact live child identity authorizes reports; the manager derives the recipient from durable `parentSession`, and `MessageSource` remains provenance rather than authority. Interrupt authority is deliberately wider than delivery authority: a human presents the durable direct-parent address so a live child stays stoppable while its parent Agent is offline, and any exact live ancestor recorded in the Activation's materialization lineage may stop its descendant, because stopping a turn is idempotent and delivers no content. When `ctx.sessionProjections` is available, the service registers two projection units. `subagentTiming` resets at each descriptor so a fork seed's ancestor work cannot enter the child's total, then accumulates `turn/start` → `turn/end` active time and retains same-cut `active.since` and `active.through` bounds for an open turn; while that turn remains open, `active.through` follows the latest folded event, giving an inactive consumer a conservative crash bound without mixing in newer session metadata. `subagent` folds the durable identity — mode plus creation label — from `subagent/descriptor` events with the same last-wins reset discipline, so a fork seed's ancestor descriptor stands only until the child's own overrides it; a malformed or unrecognized-version payload folds to the serializable `null` sentinel — indistinguishable from a log with no descriptor, and surviving every JSON push frame so a consumer replaces a stale identity instead of keeping it — and never throws. @@ -84,7 +85,7 @@ When `ctx.sessionProjections` is available, the service registers two projection ## Collection model -The model-facing tool collects synchronously by default: it awaits the child result and disposes the run before returning. One-shot background delegation registers a plain Task in the tool, whose generic status, collection, and cancellation tools own later interaction, and persists its model-supplied `description` as the optional display label. Continuable background delegation calls `ctx.subagents.startContinuable()` and returns only the durable child id; the child owns its own turns from inbox acceptance, so there is no Task, no result promise, and no public subagent cancellation — a caller sends later work with the `send_message` follow-up tool, and the durable child Session remains the source of the child's detailed output. The continuation manager exists only while `ctx.agents` is available, and session persistence is resolved per continuation operation. Independently, `listChildren()` enumerates the live-preferred merge of the live session store and optional session persistence — live-only when persistence is absent, since a cold child cannot be resumed then either — and serves each child's durable mode/label from the registered `subagent` projection unit: the registry's watermark snapshot for a live child; for a cold one, a durable projection-cache row when it serves an own-suffix identity — its `seq` gate proves the value postdates the fork seed, where a child's own descriptor is immutable once appended — else one bounded-concurrency persistence inspection folded through the registry, whose result must still name the enumerated lifecycle (a re-published id degrades to a `corrupt` diagnostic). A throwing cache read renders no verdict — the cache is derived data — and silently falls through to that authoritative re-fold. The projection fold is the single classification authority; listing parses no descriptor itself. A served identity produces a child row; a settled candidate whose fold served no identity is a `corrupt` diagnostic, a failed inspection is a transient `unavailable` retried on the next listing, and a running candidate without an identity yet is omitted (the creation window before its descriptor is appended). It never consults the continuation manager, Agent registrations, Activations, or providers. Each child row derives its read-time `hasChildren` hint from merged headers carrying durable `origin: 'subagent'`; it does not read descendant event logs, and the descriptor-backed child catalog remains authoritative when expanded. Service consumers such as a UI can retain both modes and choose a fallback for an unlabeled one-shot child; the model-facing `list_agents` tool projects only `continuable` entries and maps service activity to its existing `running`/`complete` vocabulary. The listing forwards the caller's signal to every persistence read, checks cancellation around each of those awaits, and reports every observed abort as `SubagentError` code `CANCELLED`; an unmounted projection registry fails loud with `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`, and a missing session store with `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`. See the [background subagent tasks Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable background subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), the [durable catalog Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md), the [merged-service Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md), the [capability-seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), and `src/types.ts` for the complete contracts. +The model-facing tool collects synchronously by default: it awaits the child result and disposes the run before returning. One-shot background delegation registers a plain Task in the tool, whose generic status, collection, and cancellation tools own later interaction, and persists its model-supplied `description` as the optional display label. Continuable background delegation calls `ctx.subagents.startContinuable()` and returns only the durable child id; the child owns its own turns from inbox acceptance, so there is no Task or result promise — a caller sends later work with the `send_message` follow-up tool, while `interrupt()` stops only the current turn without disposing the child. The durable child Session remains the source of the child's detailed output. The continuation manager exists only while `ctx.agents` is available, and session persistence is resolved per continuation operation. Independently, `listChildren()` enumerates the live-preferred merge of the live session store and optional session persistence — live-only when persistence is absent, since a cold child cannot be resumed then either — and serves each child's durable mode/label from the registered `subagent` projection unit: the registry's watermark snapshot for a live child; for a cold one, a durable projection-cache row when it serves an own-suffix identity — its `seq` gate proves the value postdates the fork seed, where a child's own descriptor is immutable once appended — else one bounded-concurrency persistence inspection folded through the registry, whose result must still name the enumerated lifecycle (a re-published id degrades to a `corrupt` diagnostic). A throwing cache read renders no verdict — the cache is derived data — and silently falls through to that authoritative re-fold. The projection fold is the single classification authority; listing parses no descriptor itself. A served identity produces a child row; a settled candidate whose fold served no identity is a `corrupt` diagnostic, a failed inspection is a transient `unavailable` retried on the next listing, and a running candidate without an identity yet is omitted (the creation window before its descriptor is appended). It never consults the continuation manager, Agent registrations, Activations, or providers. Each child row derives its read-time `hasChildren` hint from merged headers carrying durable `origin: 'subagent'`; it does not read descendant event logs, and the descriptor-backed child catalog remains authoritative when expanded. Service consumers such as a UI can retain both modes and choose a fallback for an unlabeled one-shot child; the model-facing `list_agents` tool projects only `continuable` entries and maps service activity to its existing `running`/`complete` vocabulary. The listing forwards the caller's signal to every persistence read, checks cancellation around each of those awaits, and reports every observed abort as `SubagentError` code `CANCELLED`; an unmounted projection registry fails loud with `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`, and a missing session store with `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`. See the [background subagent tasks Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable background subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), the [durable catalog Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md), the [merged-service Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md), the [capability-seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), and `src/types.ts` for the complete contracts. Continuable Activations await a best-effort final session flush without treating listener participation as durability confirmation. One-shot runs retain best-effort session checkpointing, so a completed one-shot child is discoverable after disposal only when its session actually reached persistence; the service does not invent a catalog entry from Task history when that checkpoint is absent. @@ -99,7 +100,7 @@ No direct invalidation; the named consumers own any request-prefix changes. ## Known Limitations and Deferred Work - **ACP children remain one-shot and are not trace-enumerable** — an ACP run has no local child session in the parent's session corpus. An ACP `prepareContinuable` requires persisting the remote session id in provider-specific descriptor data and a per-child continuation advertisement, since ACP `loadSession` support is negotiated per child rather than established by the method's presence. Remote providers also require a separate Activation ownership contract with equivalent authenticated control and child-first quiescence before they support continuable children. -- **No host-user continuation** — `followup()` requires the exact live direct parent. A future host adapter needs a concrete authenticated interaction before the seam gains a separate user capability. +- **No host-user continuation** — `followup()` requires the exact live direct parent. Only `interrupt()` accepts a durable parent-address user authority, because stopping a turn is idempotent and delivers no content; a future host adapter needs a concrete authenticated interaction before the seam gains a user delivery capability. - **No current-turn steering** — continuable messages and waking reports enqueue later turns; neither redirects an open turn. - **Process-local residency** — the Activation inbox and ownership graph do not coordinate two harness processes; concurrent access to one persistence store still requires a durable mailbox and cross-process lease protocol. - **No replay of accepted-but-unlogged messages** — only messages written to the child Session log are reconstructable with their admitted provenance. A crash may lose an accepted initial prompt or follow-up that never reached the log; a later authorized message can cold-resume the child, but the lost message is not replayed automatically. diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index 341c18617a..6067555544 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -18,6 +18,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 | `start(name, request)` | 校验普通调用方请求,解析其分离的 `one-shot` 描述符,然后等待提供方,直到真实的一次性子 agent 发布。兑现时返回由持有方拥有的 `SubagentRun`;拒绝表示提供方已清理所有未发布的启动资源,而发布后的轮次或基础设施故障会通过该 run 结算。可继续子 agent 绝不通过此操作进入。 | | `startContinuable(spec)` | 建立一个持久化可继续子 agent,并投递其初始提示词。子 agent 的 inbox 接受该提示词时,兑现为 `{ childId, messageId }`,无需等待轮次开始或消息写入 Session 日志;此前任何失败都会以无 id 拒绝,并完全回滚该子 agent。要求 `ctx.agents`、会话持久化以及具备 `prepareContinuable` 能力的提供方。 | | `followup(parent, childId, content, { source, signal })` | 将来自确切在线直接父级的一条后续消息作为子 agent 的下一个 FIFO 轮次投递,术语与 `Agent.followup()` 一致,并返回被接受的 `MessageId`。驻留中的子 agent 由其 inbox 直接接受(唤醒处于 waiting 的 Activation);不驻留的则从其持久化 Session 冷恢复。要求 `ctx.agents`;冷恢复还要求会话持久化。 | +| `interrupt(targetSessionId, authority)` | 以人类持久化 parent 地址(`{ kind: 'user', parentSessionId }`)或确切在线 ancestor Agent(`{ kind: 'ancestor', agent }`)为授权,中断一个在线可继续 child 的当前轮次。准入是同步的、生效是异步的:它发出 `Agent.cancel(cause, { keepInbox: true })` 后立即返回,不等待目标观察到信号。待处理的 inbox 工作、Activation 与已发布的后代均保持不变;只有之后的一次唤醒发送才会恢复被暂停的 FIFO 队列。目标不存在——未知、一次性或已结算的 id——以及未绑定管理器的组合都是被接受的 no-op;错误的 parent 地址,或过期、指向自身、非 ancestor 的调用方会以 `UNAUTHORIZED` 拒绝。 | | `reportFrom(child, content, { delivery, signal })` | 从确切在线可继续 child 向其确切在线直接 parent 投递一条选中消息,并返回已接受的稳定 `MessageId`。静默投递会注入上下文;唤醒投递会提交一个后续 parent 轮次。 | | `registerContinuableSetup(contribution)` | 把一项可选部署能力组合到每个可继续 child 尚未发布的作用域中,并支持从驻留 child 立即撤销。 | | `drainContinuableDescendants(parents)` | 在由 host 确切拥有的在线 parent Agent 之下关闭准入,只停止其可见的可继续后代,等待在这些根之下已获准的物化过程完成发布或回滚,再按 child-first 顺序释放所选森林。该截止状态会持续到每个确切 parent 离开注册表;无关的 parent 森林和管理器全局准入保持在线。 | @@ -76,7 +77,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 提供方新增和移除还会发出 `subagent/provider-added` 与 `subagent/provider-removed`。面向模型的工具等消费方使用这些事件,因为 Cordis 可能并发加载同级插件;配置顺序不能证明注册顺序。 -可继续子级不会创建 `SubagentRun` 或 Task。继续执行管理器为每个驻留子 Session 直接拥有一个仅存在于当前进程的 Activation 和一个留存的 `AgentHandle`,使用 Agent inbox 作为唯一 FIFO,并从持久化描述符冷恢复。父到子投递由确切在线的直接父级身份授权。上报则由确切在线的子级身份授权;管理器根据持久化的 `parentSession` 推导接收方,`MessageSource` 仍只表示来源,不表示权限。 +可继续子级不会创建 `SubagentRun` 或 Task。继续执行管理器为每个驻留子 Session 直接拥有一个仅存在于当前进程的 Activation 和一个留存的 `AgentHandle`,使用 Agent inbox 作为唯一 FIFO,并从持久化描述符冷恢复。父到子投递由确切在线的直接父级身份授权。上报则由确切在线的子级身份授权;管理器根据持久化的 `parentSession` 推导接收方,`MessageSource` 仍只表示来源,不表示权限。中断权限被刻意设计得比投递权限更宽:人类出示持久化直接 parent 地址,因此即使 parent Agent 离线,在线 child 仍可被停止;Activation 物化时记录的任何确切在线 ancestor 也可以停止其后代——因为停止一个轮次是幂等的,且不投递任何内容。 当 `ctx.sessionProjections` 可用时,服务会注册两个投影单元。`subagentTiming` 会在每个描述符处重置,使 fork 种子中的祖先工作不会计入 child 总量,随后累加 `turn/start` → `turn/end` 活跃时间,并为未结束的轮次保留同一切面的 `active.since` 和 `active.through` 边界;在该轮次保持未结束期间,`active.through` 会跟随最近折叠的事件,从而为 inactive 消费方提供保守的崩溃上界,又不会混入更新的会话元数据。`subagent` 以同样的 last-wins 重置纪律从 `subagent/descriptor` 事件折叠持久化身份——模式与创建标签——因此 fork 种子中的祖先描述符只在 child 自身的描述符覆盖之前有效;畸形或版本不识别的载荷折叠为可序列化的 `null` 哨兵——与没有描述符的日志不可区分,且能完好通过每个 JSON 推送帧,让消费方以之替换掉手中过时的身份而非永久滞留——绝不抛错。 @@ -84,7 +85,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 ## 收集模型 -面向模型的工具默认同步收集:先等待子 agent 结果,再 dispose 运行,然后才返回。一次性后台委派会在工具中注册普通 Task,其通用状态、收集和取消工具负责后续交互,并将模型提供的 `description` 持久化为可选显示标签。可继续后台委派会调用 `ctx.subagents.startContinuable()`,只返回持久化子 agent id;子 agent 自 inbox 接受起就拥有自己的轮次,因此没有 Task、没有结果 promise,也没有公开的子 agent 取消操作——调用方通过 `send_message` 后续操作工具发送后续工作,而持久化子 agent Session 仍是子 agent 详细输出的来源。只有 `ctx.agents` 可用时,继续执行管理器才会存在,而会话持久化按每项继续执行操作解析。与此独立,`listChildren()` 枚举在线会话存储与可选会话持久化的在线优先合并——持久化缺席时仅枚举在线 child,因为那时冷 child 本就无法恢复——并由已注册的 `subagent` 投影单元供给每个 child 的持久化模式与标签:在线 child 取注册表的水位快照;冷 child 先取可选投影缓存的持久化行,且仅当其 `seq` 门证明该值折叠自 child 自身后缀(fork 种子之后——自有描述符一经追加即不可变)才直接采用,否则经一次有界并发的持久化 inspect 再经注册表折叠,且 inspect 结果必须仍指向枚举时的生命周期(同 id 被重新发布的会话降级为 `corrupt` diagnostic)。缓存读取抛错不产生判决——缓存是派生数据——静默落到该权威重折。投影折叠是唯一的分类权威;列表自身不解析任何描述符。取得身份值即产出 child 行;已定局而折叠未产出身份的候选是 `corrupt` diagnostic,inspect 失败是瞬时的 `unavailable`(下次列表重试),运行中而暂无身份值的候选整行省略(描述符尚未追加的创建窗口)。它不查询继续执行管理器、Agent 注册信息、Activation 或提供方。每个 child 行都会根据合并结果中携带持久化 `origin: 'subagent'` 的 header 派生读取时的 `hasChildren` 提示;它不会读取后代事件日志,展开后仍以描述符支撑的 child 目录为权威依据。UI 等服务消费方可以保留两种模式,并为无标签的一次性 child 选择回退展示;面向模型的 `list_agents` 工具只投影 `continuable` 条目,并将服务活动状态映射到现有的 `running`/`complete` 词汇。列表操作会把调用方的取消信号转发到每次持久化读取,在这些 await 前后检查取消,并将每次检测到的中止报告为 `SubagentError` 错误码 `CANCELLED`;投影注册表未挂载则以 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 响亮失败,会话存储缺失则以 `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` 响亮失败。完整契约见[后台 subagent 任务 Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md)、[持久化目录 Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)、[服务合并 Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)、[能力 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)和 `src/types.ts`。 +面向模型的工具默认同步收集:先等待子 agent 结果,再 dispose 运行,然后才返回。一次性后台委派会在工具中注册普通 Task,其通用状态、收集和取消工具负责后续交互,并将模型提供的 `description` 持久化为可选显示标签。可继续后台委派会调用 `ctx.subagents.startContinuable()`,只返回持久化子 agent id;子 agent 自 inbox 接受起就拥有自己的轮次,因此没有 Task 或结果 promise——调用方通过 `send_message` 后续操作工具发送后续工作,而 `interrupt()` 只停止当前轮次,不 dispose 子 agent。持久化子 agent Session 仍是子 agent 详细输出的来源。只有 `ctx.agents` 可用时,继续执行管理器才会存在,而会话持久化按每项继续执行操作解析。与此独立,`listChildren()` 枚举在线会话存储与可选会话持久化的在线优先合并——持久化缺席时仅枚举在线 child,因为那时冷 child 本就无法恢复——并由已注册的 `subagent` 投影单元供给每个 child 的持久化模式与标签:在线 child 取注册表的水位快照;冷 child 先取可选投影缓存的持久化行,且仅当其 `seq` 门证明该值折叠自 child 自身后缀(fork 种子之后——自有描述符一经追加即不可变)才直接采用,否则经一次有界并发的持久化 inspect 再经注册表折叠,且 inspect 结果必须仍指向枚举时的生命周期(同 id 被重新发布的会话降级为 `corrupt` diagnostic)。缓存读取抛错不产生判决——缓存是派生数据——静默落到该权威重折。投影折叠是唯一的分类权威;列表自身不解析任何描述符。取得身份值即产出 child 行;已定局而折叠未产出身份的候选是 `corrupt` diagnostic,inspect 失败是瞬时的 `unavailable`(下次列表重试),运行中而暂无身份值的候选整行省略(描述符尚未追加的创建窗口)。它不查询继续执行管理器、Agent 注册信息、Activation 或提供方。每个 child 行都会根据合并结果中携带持久化 `origin: 'subagent'` 的 header 派生读取时的 `hasChildren` 提示;它不会读取后代事件日志,展开后仍以描述符支撑的 child 目录为权威依据。UI 等服务消费方可以保留两种模式,并为无标签的一次性 child 选择回退展示;面向模型的 `list_agents` 工具只投影 `continuable` 条目,并将服务活动状态映射到现有的 `running`/`complete` 词汇。列表操作会把调用方的取消信号转发到每次持久化读取,在这些 await 前后检查取消,并将每次检测到的中止报告为 `SubagentError` 错误码 `CANCELLED`;投影注册表未挂载则以 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 响亮失败,会话存储缺失则以 `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` 响亮失败。完整契约见[后台 subagent 任务 Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md)、[持久化目录 Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)、[服务合并 Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)、[能力 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)和 `src/types.ts`。 可继续 Activation 会等待 best-effort 的最终会话 flush,但不会把 listener 参与视为持久性确认。一次性运行保留尽力执行的会话检查点,因此已完成的一次性 child 只有在其会话确实进入持久化存储时,才可在 dispose 后继续被发现;如果该检查点缺失,服务不会根据 Task 历史虚构目录条目。 @@ -99,7 +100,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 ## 已知限制与暂缓事项 - **ACP 子 agent 仍为一次性,且无法通过追踪枚举**:ACP 运行在 parent 会话语料中没有本地 child 会话。ACP 的 `prepareContinuable` 需要在提供方专用描述符数据中持久化远端会话 id,以及逐子 agent 的继续执行能力声明,因为 ACP 的 `loadSession` 支持按子 agent 协商,而不是通过方法是否存在来确定。远程提供方还需要一份独立的 Activation 所有权契约,具备等效的经认证控制和子先于父的停稳保证,才能支持可继续子 agent。 -- **无 host-user 继续执行**:`followup()` 要求确切在线直接父级。未来 host 适配器需要具体的经认证交互,才能让该 seam 获得单独的用户能力。 +- **无 host-user 继续执行**:`followup()` 要求确切在线直接父级。只有 `interrupt()` 接受持久化 parent 地址形式的用户授权,因为停止一个轮次是幂等的且不投递任何内容;未来 host 适配器需要具体的经认证交互,才能让该 seam 获得用户投递能力。 - **不对当前轮次进行 steering**:可继续消息和唤醒式 report 会排入后续轮次,均不会重定向正在进行的轮次。 - **驻留仅限进程内**:Activation inbox 与所有权图不会在两个 harness 进程之间协调;对单个持久化存储的并发访问仍然需要持久化邮箱和跨进程租约协议。 - **不重放已接受但未记录的消息**:只有写入子 agent Session 日志的消息才能连同其被接受时的来源一起重建。崩溃可能丢失从未写入日志、已被接受的初始提示词或后续消息;此后一条经授权的消息可以冷恢复该子 agent,但丢失的消息不会自动重放。 diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index 212536e713..ebac0a1296 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -103,6 +103,15 @@ export interface ContinuableStart { readonly messageId: MessageId } +/** + * Authority under which one interrupt request is admitted. `user` carries the + * durable direct-parent address a human client presented; `ancestor` carries + * the exact live Agent object whose recorded lineage must contain the caller. + */ +export type SubagentInterruptAuthority = + | { readonly kind: 'user'; readonly parentSessionId: SessionId } + | { readonly kind: 'ancestor'; readonly agent: Agent } + /** Options for following up with one continuable child. */ export interface SubagentFollowupOptions { /** Durable attribution retained on the delivered message; it grants no authority. */ @@ -412,6 +421,68 @@ export class SubagentContinuationManager { } } + /** + * Interrupt one live continuable child's current turn. Admission is + * synchronous and the effect is asynchronous: this authorizes the caller, + * requests `Agent.cancel(cause, { keepInbox: true })` on the target, and + * returns without waiting for the target to observe the signal or reach + * quiescence. The Activation, its handle, accepted pending inbox work, and + * already-published descendants are untouched; the parked queue resumes only + * on a later waking send. + * + * An absent target is an accepted no-op, which uniformly covers natural + * completion races, repeated requests, one-shot ids, and unknown ids without + * consulting the durable catalog. A target whose disposal transaction is + * already open is likewise an accepted no-op after authorization. + * @param targetSessionId - the durable child session id to interrupt. + * @param authority - the human parent address or exact live ancestor Agent. + * @throws {SubagentError} `UNAUTHORIZED` when the presented authority does + * not own the live target: a stale or self-targeting ancestor caller, a + * parent address that is not the live target's durable direct parent, or + * an ancestor outside the target's recorded live lineage. + */ + interrupt(targetSessionId: SessionId, authority: SubagentInterruptAuthority): void { + if (authority.kind === 'ancestor') { + const caller = authority.agent + // A stale caller is rejected even when the target is absent, so a + // replaced same-id Agent can never probe this manager's state. + if (this.ctx.agents.get(caller.id) !== caller) { + throw new SubagentError( + `interrupting "${targetSessionId}" requires the exact live ancestor agent`, + 'UNAUTHORIZED', + ) + } + if (caller.id === targetSessionId) { + throw new SubagentError( + `agent "${caller.id}" cannot interrupt itself`, + 'UNAUTHORIZED', + ) + } + } + const activation = this.activations.get(targetSessionId) + if (activation === undefined) return + if (authority.kind === 'user') { + if (activation.handle.agent.session.header.parentSession !== authority.parentSessionId) { + throw new SubagentError( + `subagent "${targetSessionId}" belongs to another parent session`, + 'UNAUTHORIZED', + ) + } + } else if (!activation.ancestry.has(authority.agent)) { + throw new SubagentError( + `subagent "${targetSessionId}" is not a live descendant of agent "${authority.agent.id}"`, + 'UNAUTHORIZED', + ) + } + // Disposal already stopped the target with a whole-Activation teardown; + // a second cancel would be a redundant signal on a closing handle. + if (activation.disposal !== undefined) return + activation.handle.agent.cancel( + authority.kind === 'user' ? { kind: 'user' } : { kind: 'parent' }, + { keepInbox: true }, + ) + } + /** * Deliver explicitly selected content from one resident continuable child to * its durable direct parent. Sender authorization, parent resolution, and diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 5bcd53d6a5..e179bfd8b4 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -58,6 +58,7 @@ import type { ContinuableStart, ContinuableStartSpec, SubagentFollowupOptions, + SubagentInterruptAuthority, SubagentReportOptions, } from './continuation.ts' import SubagentActivationSetupRegistry from './activation-setup-registry.ts' @@ -111,6 +112,7 @@ export type { ContinuableStartSpec, CoordinatorMessageSource, SubagentFollowupOptions, + SubagentInterruptAuthority, SubagentReportDelivery, SubagentReportMessageSource, SubagentReportOptions, @@ -231,6 +233,24 @@ export class SubagentService extends Service { return this.requireContinuations().followup(parent, childId, content, options) } + /** + * Interrupt one live continuable child's current turn under a human parent + * address or an exact live ancestor Agent. Fire-and-return: the cancel + * signal is issued before this returns, but the target may keep running + * until it observes the signal. Pending inbox work, the Activation, and + * published descendants are preserved; only a later waking send resumes the + * parked FIFO queue. An absent target — including a one-shot or unknown id — + * is an accepted no-op, as is a manager-less composition, which cannot own a + * live Activation. + * @param targetSessionId - the durable child session id to interrupt. + * @param authority - the human parent address or exact live ancestor Agent. + * @throws {SubagentError} `UNAUTHORIZED` when the authority does not own the + * live target. + */ + interrupt(targetSessionId: SessionId, authority: SubagentInterruptAuthority): void { + this.continuations?.interrupt(targetSessionId, authority) + } + /** * Deliver selected content from one live continuable child to its durable * direct parent. The child is the authority credential; callers cannot name a diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index dca06add38..9370676f76 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -1717,3 +1717,223 @@ describe('continuable errors', () => { expect(ctx.agents.get(started.childId)).toBeUndefined() }) }) + +describe('SubagentService.interrupt', () => { + it('aborts the current turn durably, parks accepted follow-ups, and resumes them only on a waking send', async () => { + const releaseFirst = Promise.withResolvers() + const adapter = new GatedAdapter([ + { chunks: textResponse('first'), gate: releaseFirst.promise }, + { chunks: textResponse('second') }, + { chunks: textResponse('third') }, + { chunks: textResponse('fourth') }, + ]) + const { ctx, parent } = await setupWith(adapter) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) + const child = ctx.agents.get(started.childId)! + await followup(ctx, parent, started.childId, message('parked B')) + await followup(ctx, parent, started.childId, message('parked C')) + const cancelSpy = vi.spyOn(child, 'cancel') + + ctx.subagents.interrupt(started.childId, { kind: 'user', parentSessionId: parent.id }) + + expect(cancelSpy).toHaveBeenCalledTimes(1) + expect(cancelSpy).toHaveBeenCalledWith({ kind: 'user' }, { keepInbox: true }) + // Cancellation is cooperative: the held model call observes it on release. + releaseFirst.resolve(undefined) + await child.whenIdle() + // Parked, not resumed: no second model request follows the abort, the + // accepted follow-ups stay pending, and the same Activation stays resident. + expect(adapter.requests).toHaveLength(1) + expect(child.inbox.nextTurn).toHaveLength(2) + expect(child.status).toBe('idle') + expect(ctx.agents.get(started.childId)).toBe(child) + + // Only an explicit waking send restores the driver; the parked items then + // run before it in the existing FIFO order. + await followup(ctx, parent, started.childId, message('waking D')) + await waitNoActivation(ctx, started.childId) + const loaded = await ctx.sessionPersistence.load(started.childId) + expect(userTexts(loaded.events)).toEqual(['child task', 'parked B', 'parked C', 'waking D']) + const turnEnds = loaded.events + .filter(event => event.type === 'turn/end') + .map(event => (event).data.reason.kind) + expect(turnEnds).toEqual(['aborted', 'completed', 'completed', 'completed']) + }) + + it('interrupts only the target while its resident descendant keeps running', async () => { + const releaseChild = Promise.withResolvers() + const releaseGrandchild = Promise.withResolvers() + const adapter = new GatedAdapter([ + { chunks: textResponse('child'), gate: releaseChild.promise }, + { chunks: textResponse('grandchild'), gate: releaseGrandchild.promise }, + ]) + const { ctx, parent } = await setupWith(adapter) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) + const child = ctx.agents.get(started.childId)! + const grandchild = await ctx.subagents.startContinuable(startSpec(child)) + await vi.waitFor(() => { expect(adapter.requests).toHaveLength(2) }) + const grandchildAgent = ctx.agents.get(grandchild.childId)! + const childCancel = vi.spyOn(child, 'cancel') + const grandchildCancel = vi.spyOn(grandchildAgent, 'cancel') + + ctx.subagents.interrupt(started.childId, { kind: 'user', parentSessionId: parent.id }) + + expect(childCancel).toHaveBeenCalledTimes(1) + releaseChild.resolve(undefined) + await child.whenIdle() + // The target parks as a waiting owner; the published descendant was never + // signalled and keeps its own turn open. + expect(grandchildCancel).not.toHaveBeenCalled() + expect(ctx.agents.get(started.childId)).toBe(child) + expect(ctx.agents.get(grandchild.childId)).toBe(grandchildAgent) + + releaseGrandchild.resolve(undefined) + await waitNoActivation(ctx, grandchild.childId) + await waitNoActivation(ctx, started.childId) + const loaded = await ctx.sessionPersistence.load(grandchild.childId) + const turnEnds = loaded.events + .filter(event => event.type === 'turn/end') + .map(event => (event).data.reason.kind) + expect(turnEnds).toEqual(['completed']) + }) + + it('authorizes the human address against the live target\'s durable direct parent', async () => { + const hold = Promise.withResolvers() + const adapter = new GatedAdapter([{ chunks: textResponse('working'), gate: hold.promise }]) + const { ctx, parent } = await setupWith(adapter) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) + const child = ctx.agents.get(started.childId)! + const cancelSpy = vi.spyOn(child, 'cancel') + + expect(() => { ctx.subagents.interrupt(started.childId, { + kind: 'user', + parentSessionId: SessionId('stranger'), + }) }).toThrow(/belongs to another parent session/) + expect(cancelSpy).not.toHaveBeenCalled() + + ctx.subagents.interrupt(started.childId, { kind: 'user', parentSessionId: parent.id }) + expect(cancelSpy).toHaveBeenCalledWith({ kind: 'user' }, { keepInbox: true }) + hold.resolve(undefined) + await waitNoActivation(ctx, started.childId) + }) + + it('lets a deep exact live ancestor interrupt its descendant with the parent cause', async () => { + const releaseChild = Promise.withResolvers() + const releaseGrandchild = Promise.withResolvers() + const adapter = new GatedAdapter([ + { chunks: textResponse('child'), gate: releaseChild.promise }, + { chunks: textResponse('grandchild'), gate: releaseGrandchild.promise }, + ]) + const { ctx, parent } = await setupWith(adapter) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) + const child = ctx.agents.get(started.childId)! + const grandchild = await ctx.subagents.startContinuable(startSpec(child)) + await vi.waitFor(() => { expect(adapter.requests).toHaveLength(2) }) + const grandchildAgent = ctx.agents.get(grandchild.childId)! + const childCancel = vi.spyOn(child, 'cancel') + const grandchildCancel = vi.spyOn(grandchildAgent, 'cancel') + + // Deep ancestor: the top-level parent interrupts the grandchild. + ctx.subagents.interrupt(grandchild.childId, { kind: 'ancestor', agent: parent }) + expect(grandchildCancel).toHaveBeenCalledWith({ kind: 'parent' }, { keepInbox: true }) + // Direct ancestor: the same authority kind covers the immediate parent. + ctx.subagents.interrupt(started.childId, { kind: 'ancestor', agent: parent }) + expect(childCancel).toHaveBeenCalledWith({ kind: 'parent' }, { keepInbox: true }) + + releaseChild.resolve(undefined) + releaseGrandchild.resolve(undefined) + await waitNoActivation(ctx, grandchild.childId) + await waitNoActivation(ctx, started.childId) + }) + + it('rejects self, sibling, stale, and unrelated ancestor callers without touching the target', async () => { + const releaseA = Promise.withResolvers() + const releaseB = Promise.withResolvers() + const adapter = new GatedAdapter([ + { chunks: textResponse('a'), gate: releaseA.promise }, + { chunks: textResponse('b'), gate: releaseB.promise }, + ]) + const { ctx, parent } = await setupWith(adapter) + const targetStart = await ctx.subagents.startContinuable(startSpec(parent)) + await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) + const target = ctx.agents.get(targetStart.childId)! + const siblingStart = await ctx.subagents.startContinuable(startSpec(parent)) + await vi.waitFor(() => { expect(adapter.requests).toHaveLength(2) }) + const sibling = ctx.agents.get(siblingStart.childId)! + const stranger = ctx.agentLoop.create(SessionId('stranger'), { provider: 'mock', model: 'mock' }) + const stale = { ...parent, id: parent.id } as unknown as Agent + const cancelSpy = vi.spyOn(target, 'cancel') + + expect(() => { ctx.subagents.interrupt(targetStart.childId, { kind: 'ancestor', agent: target }) }) + .toThrow(/cannot interrupt itself/) + expect(() => { ctx.subagents.interrupt(targetStart.childId, { kind: 'ancestor', agent: sibling }) }) + .toThrow(/not a live descendant/) + expect(() => { ctx.subagents.interrupt(targetStart.childId, { kind: 'ancestor', agent: stranger }) }) + .toThrow(/not a live descendant/) + expect(() => { ctx.subagents.interrupt(targetStart.childId, { kind: 'ancestor', agent: stale }) }) + .toThrow(/exact live ancestor/) + // A stale caller is rejected before target lookup, even for an absent id. + expect(() => { ctx.subagents.interrupt(SessionId('missing'), { kind: 'ancestor', agent: stale }) }) + .toThrow(/exact live ancestor/) + expect(cancelSpy).not.toHaveBeenCalled() + + releaseA.resolve(undefined) + releaseB.resolve(undefined) + await waitNoActivation(ctx, targetStart.childId) + await waitNoActivation(ctx, siblingStart.childId) + }) + + it('accepts absent and one-shot ids as no-ops without touching the one-shot Agent', async () => { + const { ctx, parent } = await setup([textResponse('one shot')]) + ctx.subagents.interrupt(SessionId('missing'), { kind: 'user', parentSessionId: parent.id }) + ctx.subagents.interrupt(SessionId('missing'), { kind: 'ancestor', agent: parent }) + + const run = await ctx.subagents.start('spawn', { + label: 'one-shot work', + prompt: message('one-shot work'), + parent, + signal: testSignal, + }) + const oneShot = run.localAgent! + const cancelSpy = vi.spyOn(oneShot, 'cancel') + ctx.subagents.interrupt(run.id, { kind: 'user', parentSessionId: parent.id }) + ctx.subagents.interrupt(run.id, { kind: 'ancestor', agent: parent }) + expect(cancelSpy).not.toHaveBeenCalled() + await run.result + await run.dispose() + }) + + it('accepts an interrupt after natural completion', async () => { + const { ctx, parent } = await setup([textResponse('done')]) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await waitNoActivation(ctx, started.childId) + ctx.subagents.interrupt(started.childId, { kind: 'user', parentSessionId: parent.id }) + ctx.subagents.interrupt(started.childId, { kind: 'ancestor', agent: parent }) + }) + + it('accepts an interrupt that lost the race with disposal without signalling twice', async () => { + const hold = Promise.withResolvers() + const adapter = new GatedAdapter([{ chunks: textResponse('working'), gate: hold.promise }]) + const { ctx, parent } = await setupWith(adapter) + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) + const child = ctx.agents.get(started.childId)! + const cancelSpy = vi.spyOn(child, 'cancel') + + // Scoped teardown opens the disposal transaction synchronously and issues + // its own whole-Activation cancel before this call returns. + const drained = ctx.subagents.drainContinuableDescendants([parent]) + expect(cancelSpy).toHaveBeenCalledTimes(1) + + // Interrupt after the cutoff: accepted no-op, no second signal, no waiting. + ctx.subagents.interrupt(started.childId, { kind: 'user', parentSessionId: parent.id }) + expect(cancelSpy).toHaveBeenCalledTimes(1) + + hold.resolve(undefined) + await drained + }) +}) diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index 8b90719227..a50696cf2a 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -133,6 +133,16 @@ describe('SubagentService', () => { await expect(subagents.drainContinuableDescendants([])).resolves.toBeUndefined() }) + it('treats interrupt as an accepted no-op when no manager was bound', async () => { + const { subagents } = await service() + // Without a continuation manager no live Activation can exist, so there is + // nothing to stop and nothing to authorize against. + expect(() => { subagents.interrupt(SessionId('child'), { + kind: 'user', + parentSessionId: SessionId('parent-1'), + }) }).not.toThrow() + }) + it('rejects continuable operations when their runtime services are absent', async () => { const { subagents } = await service() await expect(subagents.startContinuable({ diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 54581a8605..c8ddb487ef 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -173,6 +173,7 @@ export const LINK_MAP: Readonly> = { ContinuableStartSpec: 'subagent.md', CoordinatorMessageSource: 'subagent.md', SubagentFollowupOptions: 'subagent.md', + SubagentInterruptAuthority: 'subagent.md', SubagentListEntry: 'subagent.md', SubagentProvider: 'subagent.md', SubagentReportDelivery: 'subagent.md', From 82019134b1c3ff0cdcb7b4edf7d1f6a031b4ad7c Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 6 Aug 2026 12:09:09 +0800 Subject: [PATCH 012/189] test(web): verify subagent.interrupt over the real composition A browserless keyless-replay e2e boots the shipped web composition, holds a continuable child's turn open with a replay hang entry, queues a follow-up and interrupts over plain HTTP, and proves from the real session state that the turn aborted, the follow-up parked without a new turn, and a waking send resumed the preserved FIFO order. Refs #1535 --- apps/web/tests/subagent-interrupt.e2e.ts | 176 +++++++++++++++++++++++ apps/web/tsconfig.json | 1 + tsconfig.host.json | 1 + 3 files changed, 178 insertions(+) create mode 100644 apps/web/tests/subagent-interrupt.e2e.ts diff --git a/apps/web/tests/subagent-interrupt.e2e.ts b/apps/web/tests/subagent-interrupt.e2e.ts new file mode 100644 index 0000000000..e1e35e7981 --- /dev/null +++ b/apps/web/tests/subagent-interrupt.e2e.ts @@ -0,0 +1,176 @@ +// Web e2e scenario (browserless): the subagent.interrupt RPC against the real +// composition. A live continuable child holds its model turn open through a +// replay hang entry; plain HTTP queues a follow-up, interrupts the turn, and +// proves from the real session state that the turn aborted, the follow-up +// parked without auto-starting a new turn, and a later waking send resumed the +// preserved FIFO order. No browser: the RPC surface is the product surface +// under test, and PR-stacked UI coverage owns the composer interaction. +import { existsSync } from 'node:fs' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { SessionId as sessionId, type SessionId } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-agent' +import { launchWebScaffold, webSnapshotMode, type WebScaffold } from './scaffold.ts' + +const MODE = webSnapshotMode() +const INITIAL = 'Explain event sourcing in one sentence.' +const FOLLOWUP = 'Now give the same explanation to a human reader.' +const WAKING = 'And add one concrete example.' + +type RpcResult = { ok: true; value: T } | { ok: false; error: { code: string; message: string } } + +/** POST one unary RPC through the real HTTP carrier and unwrap its result. */ +async function rpc(baseUrl: string, method: string, payload: unknown): Promise> { + const response = await fetch(`${baseUrl}/api/${method}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + type: 'client-request', + rpcId: `interrupt-e2e-${method}-${crypto.randomUUID()}`, + method, + payload, + }), + }) + if (!response.ok) throw new Error(`${method} failed over HTTP ${response.status}: ${await response.text()}`) + return (await response.json() as { result: RpcResult }).result +} + +/** Poll a synchronous condition (hook-safe; expect.poll is test-body only). */ +async function waitFor(predicate: () => boolean, what: string, timeoutMs = 30_000): Promise { + const deadline = Date.now() + timeoutMs + while (!predicate()) { + if (Date.now() >= deadline) throw new Error(`timed out waiting for ${what}`) + await new Promise(resolve => setTimeout(resolve, 10)) + } +} + +/** One text-only scripted model completion (no tool calls: real tools are mounted). */ +function textCompletion(text: string): object { + return { + kind: 'chunks', + chunks: [ + { type: 'block-start', index: 0, blockType: 'text' }, + { type: 'text-delta', index: 0, text }, + { type: 'block-end', index: 0, block: { type: 'text', text } }, + { type: 'usage', usage: { inputTokens: 20, outputTokens: 8 } }, + { type: 'finish', reason: { kind: 'stop' } }, + ], + } +} + +describe.skipIf(MODE === 'record')('web e2e: subagent.interrupt over the real composition', () => { + let scaffold: WebScaffold + let sidecarRoot: string + let readyFile: string + let parentId: SessionId + let childId: SessionId + + beforeAll(async () => { + sidecarRoot = await mkdtemp(join(tmpdir(), 'dsh-web-subagent-interrupt-')) + readyFile = join(sidecarRoot, 'hang-ready') + // Whole-script replacement: the child's three model calls are the hang + // (turn 1, interrupted), the parked follow-up's turn, and the waking turn. + // The parent never runs a turn, so the child claims this primary script. + await writeFile(join(sidecarRoot, 'replay.override.json'), JSON.stringify([ + { kind: 'hang', readyFile }, + textCompletion('resumed response one'), + textCompletion('resumed response two'), + ])) + // Header-only primary fixture: the bare-array override replaces the + // derived script entirely; the path only anchors replay installation. + await writeFile( + join(sidecarRoot, 'session.jsonl'), + '{"type":"session","version":0,"id":"primary","createdAt":0}\n', + ) + scaffold = await launchWebScaffold({ + replayFixture: join(sidecarRoot, 'session.jsonl'), + replayOverride: join(sidecarRoot, 'replay.override.json'), + }) + + // A live parent Agent through the real API; no workspace or browser. + const created = await rpc<{ sessionId: string }>(scaffold.baseUrl, 'session.create', { + cwd: scaffold.workspaceCwd, + }) + if (!created.ok) throw new Error(`session.create failed: ${created.error.code}`) + parentId = sessionId(created.value.sessionId) + const parent = scaffold.ctx.agents.get(parentId) + if (parent === undefined) throw new Error('created parent session did not publish a live Agent') + + const started = await scaffold.ctx.subagents.startContinuable({ + provider: 'spawn', + label: 'event-sourcing researcher', + signal: new AbortController().signal, + request: { prompt: [{ type: 'text', text: INITIAL }], parent }, + }) + childId = started.childId + // The hang entry writes readyFile after its prefix chunks, immediately + // before waiting for cancellation: the deterministic "turn is open" gate. + await waitFor(() => existsSync(readyFile), 'the held child turn to open') + }, 120_000) + + afterAll(async () => { + const failures: unknown[] = [] + await scaffold?.close().catch((error: unknown) => failures.push(error)) + await rm(sidecarRoot, { recursive: true, force: true }).catch((error: unknown) => failures.push(error)) + if (failures.length === 1) throw failures[0] + if (failures.length > 1) throw new AggregateError(failures, 'subagent interrupt teardown failed') + }) + + it('parks a queued follow-up on interrupt and resumes it FIFO on a waking send', async () => { + // Queue the follow-up while the turn is still open, then interrupt. + const queued = await rpc<{ messageId: string }>(scaffold.baseUrl, 'subagent.prompt', { + parentSessionId: parentId, + childSessionId: childId, + mode: 'continuable', + content: [{ type: 'text', text: FOLLOWUP }], + }) + expect(queued).toMatchObject({ ok: true }) + + const settled = scaffold.whenTurnSettled() + const interrupted = await rpc<{ accepted: true }>(scaffold.baseUrl, 'subagent.interrupt', { + parentSessionId: parentId, + childSessionId: childId, + mode: 'continuable', + }) + expect(interrupted).toMatchObject({ ok: true, value: { accepted: true } }) + // accepted acknowledges the admitted cancel, not quiescence: wait for the + // aborted turn/end (the composition's first turn/end) before asserting. + expect(await settled).toBe(childId) + + // Parked, not resumed: the Activation stays resident with an idle driver, + // the follow-up is retained, and no second turn opened. + const child = scaffold.ctx.agents.get(childId) + expect(child).toBeDefined() + expect(child!.status).toBe('idle') + expect(child!.inbox.nextTurn).toHaveLength(1) + expect(child!.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1) + const lastEnd = child!.session.events.filter(event => event.type === 'turn/end').at(-1) + expect((lastEnd)?.data.reason.kind).toBe('aborted') + + // Only an explicit waking send resumes the parked queue, FIFO, then the + // child runs both turns to completion and settles. + const waking = await rpc<{ messageId: string }>(scaffold.baseUrl, 'subagent.prompt', { + parentSessionId: parentId, + childSessionId: childId, + mode: 'continuable', + content: [{ type: 'text', text: WAKING }], + }) + expect(waking).toMatchObject({ ok: true }) + await expect.poll(() => scaffold.ctx.agents.get(childId), { timeout: 60_000 }).toBeUndefined() + + const loaded = await scaffold.ctx.sessionPersistence.load(childId) + // Human-origin messages only: the real composition also injects + // runtime-context snapshots as non-user-source messages. + const userTexts = loaded.events.flatMap(event => event.type === 'user/message' + && event.data.source.kind === 'user' + ? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : []) + : []) + expect(userTexts).toEqual([INITIAL, FOLLOWUP, WAKING]) + const turnEndKinds = loaded.events + .filter(event => event.type === 'turn/end') + .map(event => (event).data.reason.kind) + expect(turnEndKinds).toEqual(['aborted', 'completed', 'completed']) + }, 120_000) +}) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 7fb84f1507..3fa6ba8905 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -67,6 +67,7 @@ "tests/produced-file-mentions.e2e.ts", "tests/goal-bar.e2e.ts", "tests/subagent-conversation.e2e.ts", + "tests/subagent-interrupt.e2e.ts", "tests/sidebar-subagent-activity.e2e.ts", "tests/bash-abort-row.e2e.ts", "tests/skill-tool-row.e2e.ts", diff --git a/tsconfig.host.json b/tsconfig.host.json index 81f660c317..1997f0ea47 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -54,6 +54,7 @@ "apps/web/tests/produced-files.e2e.ts", "apps/web/tests/produced-file-mentions.e2e.ts", "apps/web/tests/subagent-conversation.e2e.ts", + "apps/web/tests/subagent-interrupt.e2e.ts", "apps/web/tests/sidebar-subagent-activity.e2e.ts", "apps/web/tests/bash-abort-row.e2e.ts", "apps/web/tests/skill-tool-row.e2e.ts", From 57e9e6977c3065c461d3c5da38a2b841d6bb1ea1 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 6 Aug 2026 12:50:44 +0800 Subject: [PATCH 013/189] docs(subagent): record the public interrupt in the continuation contract docs The continuable-subagent core-data page and the continuation lifecycle Agent Note claimed no public subagent cancellation existed; interrupt() is now that one public stop. Both pages and their Chinese counterparts state the shipped contract and link the owning interrupt note. Refs #1535 --- ...8-continuable-subagent-conversations.i18n.yaml | 4 ++-- ...26-07-28-continuable-subagent-conversations.md | 6 +++--- ...07-28-continuable-subagent-conversations.zh.md | 6 +++--- docs/core-data-structures/subagent.i18n.yaml | 4 ++-- docs/core-data-structures/subagent.md | 15 ++++++++++++++- docs/core-data-structures/subagent.zh.md | 15 ++++++++++++++- scripts/type-equiv.manifest.json | 5 +++++ 7 files changed, 43 insertions(+), 12 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml index 99629a74c0..82639d4aa8 100644 --- a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-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 diff --git a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md index a5a900c3bb..56d9abb2b0 100644 --- a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md +++ b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md @@ -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. diff --git a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md index bde8e874ba..77f497ceee 100644 --- a/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md @@ -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 后投递消息会冷恢复新激活。 diff --git a/docs/core-data-structures/subagent.i18n.yaml b/docs/core-data-structures/subagent.i18n.yaml index eb708f33da..8c3aa46364 100644 --- a/docs/core-data-structures/subagent.i18n.yaml +++ b/docs/core-data-structures/subagent.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/subagent.md -subagent.md: 4d7552eb5749284d90e31e62d8ac02e3d7a21b1d -subagent.zh.md: ebcb1ce445c164352109d6613028c7a36c10d6d4 +subagent.md: fba934233fb7e890d6a68bd4cc7d37f90ab913ed +subagent.zh.md: 70651397f82e2cf4c0fe18aa26ff8247484ca0aa diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index 4d7552eb57..fba934233f 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -139,7 +139,20 @@ The Agent inbox is the only queue. Every continuation message becomes one `Agent Follow-up authority comes from an exact live Agent tool context. The authenticated Agent must be the durable child's direct parent recorded in `SessionHeader.parentSession`. `MessageSource` and `senderSessionId` are durable provenance after admission and grant no authority; the optional model-facing tool uses `CoordinatorMessageSource`. -For both operations the caller signal owns lookup, materialization, and admission only until inbox acceptance. Afterwards the manager owns the Activation independently: later caller cancellation neither cancels the accepted turn nor disposes the child, and the seam exposes no public subagent cancellation or steering operation. +For both operations the caller signal owns lookup, materialization, and admission only until inbox acceptance. Afterwards the manager owns the Activation independently: later caller cancellation neither cancels the accepted turn nor disposes the child, and the seam exposes no steering operation. + +`SubagentService.interrupt(targetSessionId, authority)` is the one public stop: it authorizes synchronously, issues `Agent.cancel(cause, { keepInbox: true })` on the live target, and returns without awaiting quiescence. The Activation, its pending inbox work, and published descendants are untouched; only a later waking send resumes the parked FIFO queue. An absent target — unknown, one-shot, or already settled — and a manager-less composition are accepted no-ops; a wrong parent address or a stale, self-targeting, or non-ancestor caller rejects with `UNAUTHORIZED`. + +```ts type-equiv +/** + * Authority under which one interrupt request is admitted. `user` carries the + * durable direct-parent address a human client presented; `ancestor` carries + * the exact live Agent object whose recorded lineage must contain the caller. + */ +type SubagentInterruptAuthority = + | { readonly kind: 'user'; readonly parentSessionId: SessionId } + | { readonly kind: 'ancestor'; readonly agent: Agent } +``` Every Activation owns its `AgentHandle` and an `ownedChildren: Set`; because one Session has at most one live Activation, the child Session id identifies the live child without another runtime-incarnation reference. Starting a child or submitting parent-originated work registers the child in a continuation-managed parent's set before the child can run, and that parent cannot settle while the set is non-empty. A top-level or other non-continuation Agent has no Activation and stays outside the waiting graph. Child release happens only after the child Agent is quiescent, every child of that child is disposed, the best-effort final session flush settles, and the child's `AgentHandle` completes disposal. diff --git a/docs/core-data-structures/subagent.zh.md b/docs/core-data-structures/subagent.zh.md index ebcb1ce445..70651397f8 100644 --- a/docs/core-data-structures/subagent.zh.md +++ b/docs/core-data-structures/subagent.zh.md @@ -139,7 +139,20 @@ Agent 收件箱是唯一的队列。每条继续执行消息都会成为一个 ` 后续操作的权限来自确切的在线 Agent 工具上下文。已认证的 Agent 必须是持久化子 agent 在 `SessionHeader.parentSession` 中记录的直接父级。`MessageSource` 与 `senderSessionId` 在准入之后是持久的来源凭据,不授予任何权限;可选的面向模型工具使用 `CoordinatorMessageSource`。 -对于这两种操作,调用方 signal 仅在收件箱接受之前掌管查找、物化与准入。此后管理器独立掌管该 Activation:之后的调用方取消既不会取消已接受的轮次,也不会 dispose 子 agent,并且该 seam 不对外暴露任何 subagent 取消或 steering(中途引导)操作。 +对于这两种操作,调用方 signal 仅在收件箱接受之前掌管查找、物化与准入。此后管理器独立掌管该 Activation:之后的调用方取消既不会取消已接受的轮次,也不会 dispose 子 agent,并且该 seam 不对外暴露任何 steering(中途引导)操作。 + +`SubagentService.interrupt(targetSessionId, authority)` 是唯一的公开停止操作:它同步完成鉴权,对在线目标发出 `Agent.cancel(cause, { keepInbox: true })`,然后不等待静止即返回。Activation、其待处理的 inbox 工作与已发布的后代均不受影响;只有之后的一次唤醒发送才会恢复被暂停的 FIFO 队列。不存在的目标——未知、一次性或已结算——以及未绑定管理器的组合是被接受的 no-op;错误的 parent 地址,或过期、指向自身、非 ancestor 的调用方会以 `UNAUTHORIZED` 拒绝。 + +```ts type-equiv +/** + * Authority under which one interrupt request is admitted. `user` carries the + * durable direct-parent address a human client presented; `ancestor` carries + * the exact live Agent object whose recorded lineage must contain the caller. + */ +type SubagentInterruptAuthority = + | { readonly kind: 'user'; readonly parentSessionId: SessionId } + | { readonly kind: 'ancestor'; readonly agent: Agent } +``` 每个 Activation 都拥有自己的 `AgentHandle` 和一个 `ownedChildren: Set`;由于一份会话至多有一个存活 Activation,子会话 id 无需另一个运行时化身引用即可标识存活的子 agent。启动子 agent 或提交源自 parent 的工作,会在子 agent 能够运行之前将其注册到受继续执行管理的父级集合中;只要该集合非空,该父级就无法 settle。顶层或其他非继续执行的 Agent 没有 Activation,处于 waiting 图之外。只有当子 Agent 已停稳、该子 agent 的每个子级都已 dispose、best-effort 的最终会话 flush 结算完毕,且子 agent 的 `AgentHandle` 完成 dispose 之后,才会释放子 agent。 diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 095fa25b4f..5c2f3eb454 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1150,6 +1150,11 @@ "symbol": "SubagentFollowupOptions", "source": "packages/subagent/subagent/src/continuation.ts" }, + { + "doc": "docs/core-data-structures/subagent.md", + "symbol": "SubagentInterruptAuthority", + "source": "packages/subagent/subagent/src/continuation.ts" + }, { "doc": "docs/core-data-structures/subagent.md", "symbol": "ContinuableStart", From 336c84baf0ead4e5fefbd7d40725de982e05c1ae Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 7 Aug 2026 10:27:43 +0800 Subject: [PATCH 014/189] docs(subagent): clarify interrupt edge cases --- .../2026-08-06-continuable-subagent-interrupt.i18n.yaml | 4 ++-- .../feature/2026-08-06-continuable-subagent-interrupt.md | 8 ++++++-- .../2026-08-06-continuable-subagent-interrupt.zh.md | 8 ++++++-- docs/cordis-catalog/services.md | 7 ++++--- docs/core-data-structures/subagent.i18n.yaml | 4 ++-- docs/core-data-structures/subagent.md | 2 +- docs/core-data-structures/subagent.zh.md | 2 +- docs/event-producer-consumer.md | 8 ++++---- packages/cordis/tool-cordis/src/api-catalog.ts | 2 +- packages/host/apiproxy/src/api/subagents.ts | 4 ++-- packages/subagent/subagent/README.i18n.yaml | 4 ++-- packages/subagent/subagent/README.md | 3 ++- packages/subagent/subagent/README.zh.md | 3 ++- packages/subagent/subagent/src/continuation.ts | 7 ++++--- packages/subagent/subagent/src/index.ts | 7 ++++--- 15 files changed, 43 insertions(+), 30 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.i18n.yaml b/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.i18n.yaml index fe6e8870f5..643bb9f9ac 100644 --- a/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.md -2026-08-06-continuable-subagent-interrupt.md: 729f1eb8259aa28aa771ed71872ebce9cadd4ed8 -2026-08-06-continuable-subagent-interrupt.zh.md: 15fe34d8b5e1d67623aabfe08710f341739e92e1 +2026-08-06-continuable-subagent-interrupt.md: 7bbc6ecf6736e9e34924113be984a3bf7436b9b9 +2026-08-06-continuable-subagent-interrupt.zh.md: 22a71bf4cd3d9b949ae8cf458a3c83b65739d396 diff --git a/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.md b/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.md index 729f1eb825..7bbc6ecf67 100644 --- a/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.md +++ b/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.md @@ -10,7 +10,7 @@ A running continuable subagent could not be stopped without destroying it. The c ## 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 pending queue at idle, an interrupt never auto-starts the next queued follow-up; only a later explicit waking send resumes the preserved FIFO order. +`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: @@ -35,7 +35,11 @@ The Host RPC `subagent.interrupt` takes the continuable `SubagentAddress` and re ## Consequences -A human or ancestor can now stop a runaway continuable turn without losing the child, its 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 someone sends a waking message — an intentional human-in-the-loop pause, not a scheduler defect. The Web Stop action and the model-facing `interrupt_agent` tool build on this primitive in the stacked follow-up PRs for issue #1535. +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 Stop action and the model-facing `interrupt_agent` tool build on this primitive in the stacked follow-up PRs for issue #1535. ## Testing diff --git a/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.zh.md b/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.zh.md index 15fe34d8b5..22a71bf4cd 100644 --- a/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.zh.md +++ b/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 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;只有之后一次显式唤醒发送才按保留的 FIFO 顺序恢复。 +`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,刻意比投递权限更宽,因为停止一个轮次是幂等的且不投递任何内容: @@ -35,7 +35,11 @@ Host RPC `subagent.interrupt` 接收 continuable 的 `SubagentAddress` 并返回 ## Consequences -人类或 ancestor 现在可以停止一个失控的 continuable 轮次,而不丢失 child、其排队工作或正在运行的后代;代价是一个刻意保持弱的后置条件(`accepted` 表示"信号已发出",目标在观察到信号前可能仍显示 `running`),客户端必须如实呈现。暂停队列规则意味着被中断的 child 会带着保留的工作停在 idle,直到有人发送唤醒消息——这是有意的 human-in-the-loop 暂停,不是调度器缺陷。Web 的 Stop 操作和面向模型的 `interrupt_agent` 工具在 issue #1535 的后续 stacked PR 中基于此原语构建。 +人类或 ancestor 可以停止一个失控的 continuable 轮次,而不丢失 child、其尚未领取的排队工作或正在运行的后代;代价是一个刻意保持弱的后置条件(`accepted` 表示“信号已发出”,目标在观察到信号前可能仍显示 `running`),客户端必须如实呈现。暂停队列规则意味着被中断的 child 会带着保留的工作停在 idle,直到 driver 进入 idle 后收到唤醒消息——这是有意的 human-in-the-loop 暂停,不是调度器缺陷。在 abort 收敛期间被接受的唤醒发送目前会保持排队而不锁存 wake;Issue #1838 跟踪共享的 agent-loop 修正。 + +仅凭地址的 RPC 会暴露一位在线驻留信息:不存在的目标会被接受,而 parent 不匹配的在线目标会返回 `subagent-unauthorized`。单用户本地 Host 的信任模型接受这种可观察性;未来的多主体 Host 必须重新审视权限和响应不可区分性。 + +Web 的 Stop 操作和面向模型的 `interrupt_agent` 工具在 issue #1535 的后续 stacked PR 中基于此原语构建。 ## Testing diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index caf081b5a6..59a9cf527d 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2075,9 +2075,10 @@ async followup( parent: Agent, childId: SessionId, content: ContentBlock[], opti * Interrupt one live continuable child's current turn under a human parent * address or an exact live ancestor Agent. Fire-and-return: the cancel * signal is issued before this returns, but the target may keep running - * until it observes the signal. Pending inbox work, the Activation, and - * published descendants are preserved; only a later waking send resumes the - * parked FIFO queue. An absent target — including a one-shot or unknown id — + * until it observes the signal. Unclaimed pending inbox work, the Activation, + * and published descendants are preserved; claimed work is not requeued. + * Once the interrupted driver is idle, a waking send resumes the parked FIFO + * queue. An absent target — including a one-shot or unknown id — * is an accepted no-op, as is a manager-less composition, which cannot own a * live Activation. * @param targetSessionId - the durable child session id to interrupt. diff --git a/docs/core-data-structures/subagent.i18n.yaml b/docs/core-data-structures/subagent.i18n.yaml index 8c3aa46364..15d5e19578 100644 --- a/docs/core-data-structures/subagent.i18n.yaml +++ b/docs/core-data-structures/subagent.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/subagent.md -subagent.md: fba934233fb7e890d6a68bd4cc7d37f90ab913ed -subagent.zh.md: 70651397f82e2cf4c0fe18aa26ff8247484ca0aa +subagent.md: 4de77c6232d73d4b4e9dd87e9afc7a077e61f1a0 +subagent.zh.md: 3310bb4bf8cad1314ec26eef9042955959aba5fe diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index fba934233f..4de77c6232 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -141,7 +141,7 @@ Follow-up authority comes from an exact live Agent tool context. The authenticat For both operations the caller signal owns lookup, materialization, and admission only until inbox acceptance. Afterwards the manager owns the Activation independently: later caller cancellation neither cancels the accepted turn nor disposes the child, and the seam exposes no steering operation. -`SubagentService.interrupt(targetSessionId, authority)` is the one public stop: it authorizes synchronously, issues `Agent.cancel(cause, { keepInbox: true })` on the live target, and returns without awaiting quiescence. The Activation, its pending inbox work, and published descendants are untouched; only a later waking send resumes the parked FIFO queue. An absent target — unknown, one-shot, or already settled — and a manager-less composition are accepted no-ops; a wrong parent address or a stale, self-targeting, or non-ancestor caller rejects with `UNAUTHORIZED`. +`SubagentService.interrupt(targetSessionId, authority)` is the one public stop: it authorizes synchronously, issues `Agent.cancel(cause, { keepInbox: true })` on the live target, and returns without awaiting quiescence. The Activation, its unclaimed pending inbox work, and published descendants are untouched; work already claimed into the interrupted turn is not requeued. Once the interrupted driver is idle, a waking send resumes the parked FIFO queue. An absent target — unknown, one-shot, or already settled — and a manager-less composition are accepted no-ops. For a live target, a mismatched parent address or caller outside its live ancestry rejects with `UNAUTHORIZED`; stale ancestor objects and self-targeting ancestor requests reject before target lookup. ```ts type-equiv /** diff --git a/docs/core-data-structures/subagent.zh.md b/docs/core-data-structures/subagent.zh.md index 70651397f8..3310bb4bf8 100644 --- a/docs/core-data-structures/subagent.zh.md +++ b/docs/core-data-structures/subagent.zh.md @@ -141,7 +141,7 @@ Agent 收件箱是唯一的队列。每条继续执行消息都会成为一个 ` 对于这两种操作,调用方 signal 仅在收件箱接受之前掌管查找、物化与准入。此后管理器独立掌管该 Activation:之后的调用方取消既不会取消已接受的轮次,也不会 dispose 子 agent,并且该 seam 不对外暴露任何 steering(中途引导)操作。 -`SubagentService.interrupt(targetSessionId, authority)` 是唯一的公开停止操作:它同步完成鉴权,对在线目标发出 `Agent.cancel(cause, { keepInbox: true })`,然后不等待静止即返回。Activation、其待处理的 inbox 工作与已发布的后代均不受影响;只有之后的一次唤醒发送才会恢复被暂停的 FIFO 队列。不存在的目标——未知、一次性或已结算——以及未绑定管理器的组合是被接受的 no-op;错误的 parent 地址,或过期、指向自身、非 ancestor 的调用方会以 `UNAUTHORIZED` 拒绝。 +`SubagentService.interrupt(targetSessionId, authority)` 是唯一的公开停止操作:它同步完成鉴权,对在线目标发出 `Agent.cancel(cause, { keepInbox: true })`,然后不等待静止即返回。Activation、其尚未领取的待处理 inbox 工作与已发布的后代均不受影响;已被领取进入中断轮次的工作不会重新入队。被中断的 driver 进入 idle 后,一次唤醒发送会恢复被暂停的 FIFO 队列。不存在的目标——未知、一次性或已结算——以及未绑定管理器的组合是被接受的 no-op。对在线目标,错误的 parent 地址或不在其在线祖先链中的调用方会以 `UNAUTHORIZED` 拒绝;过期的 ancestor 对象和指向自身的 ancestor 请求会在查找目标前拒绝。 ```ts type-equiv /** diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 940d89eae0..acd2bc5e91 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -37,10 +37,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `settings/document-updated` | `emit` | [`packages/settings/settings/src/index.ts:170`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` | | `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:157`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:283`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | -| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:160`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) | -| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:134`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:140`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:151`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) | +| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:162`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) | +| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:136`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:142`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:153`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `telemetry/record` | `waterfall` | [`packages/telemetry/session-telemetry/src/index.ts:43`](../packages/telemetry/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/telemetry/session-telemetry) (`waterfall`) | - | diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index acaefa5d5f..1fd08a5c95 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -930,7 +930,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'interrupt(targetSessionId: SessionId, authority: SubagentInterruptAuthority): void', - jsDoc: '/**\n * Interrupt one live continuable child\'s current turn under a human parent\n * address or an exact live ancestor Agent. Fire-and-return: the cancel\n * signal is issued before this returns, but the target may keep running\n * until it observes the signal. Pending inbox work, the Activation, and\n * published descendants are preserved; only a later waking send resumes the\n * parked FIFO queue. An absent target — including a one-shot or unknown id —\n * is an accepted no-op, as is a manager-less composition, which cannot own a\n * live Activation.\n * @param targetSessionId - the durable child session id to interrupt.\n * @param authority - the human parent address or exact live ancestor Agent.\n * @throws {SubagentError} `UNAUTHORIZED` when the authority does not own the\n * live target.\n */', + jsDoc: '/**\n * Interrupt one live continuable child\'s current turn under a human parent\n * address or an exact live ancestor Agent. Fire-and-return: the cancel\n * signal is issued before this returns, but the target may keep running\n * until it observes the signal. Unclaimed pending inbox work, the Activation,\n * and published descendants are preserved; claimed work is not requeued.\n * Once the interrupted driver is idle, a waking send resumes the parked FIFO\n * queue. An absent target — including a one-shot or unknown id —\n * is an accepted no-op, as is a manager-less composition, which cannot own a\n * live Activation.\n * @param targetSessionId - the durable child session id to interrupt.\n * @param authority - the human parent address or exact live ancestor Agent.\n * @throws {SubagentError} `UNAUTHORIZED` when the authority does not own the\n * live target.\n */', }, { signature: 'async reportFrom( child: Agent, content: ContentBlock[], options: SubagentReportOptions, ): Promise', diff --git a/packages/host/apiproxy/src/api/subagents.ts b/packages/host/apiproxy/src/api/subagents.ts index 8efb452c2e..a85f4bc750 100644 --- a/packages/host/apiproxy/src/api/subagents.ts +++ b/packages/host/apiproxy/src/api/subagents.ts @@ -105,8 +105,8 @@ export interface SubagentsApi { * durable direct-parent authority, without requiring a live parent Agent, * consulting the catalog, or resuming anything. Fire-and-return: `accepted` * acknowledges the admitted cancel signal, not target quiescence, so the - * child may remain visibly running briefly. Queued follow-ups are kept and - * parked; an absent, idle, or already-completed target is likewise + * child may remain visibly running briefly. Unclaimed queued follow-ups are + * kept and parked; an absent, idle, or already-completed target is likewise * `accepted`. */ interrupt( diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index 250fd1a078..9b9843af87 100644 --- a/packages/subagent/subagent/README.i18n.yaml +++ b/packages/subagent/subagent/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/subagent/README.md -README.md: 7de3a5563b274e925fba931a6d5de17e68cc397c -README.zh.md: 6067555544ec0c32729beb2b4e3f773e31b747a1 +README.md: eb1baa39231a2c058b3ada1a23b7b87e2bb2e384 +README.zh.md: 8402df3e4ff2559898dfd4dd512ab1601c9ec61d diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 7de3a5563b..eb1baa3923 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -18,7 +18,7 @@ The [subagent family overview](../README.md) maps implementations and model-faci | `start(name, request)` | Validate an ordinary caller request, resolve its detached `one-shot` descriptor, then await the provider until a real one-shot child is published. Fulfillment returns a holder-owned `SubagentRun`; rejection means the provider has already cleaned every unpublished startup resource, while post-publication turn or infrastructure faults settle through the run. Continuable children never enter through this operation. | | `startContinuable(spec)` | Establish one durable continuable child and deliver its initial prompt. Resolves with `{ childId, messageId }` when the child's inbox accepts that prompt, without waiting for the turn to start or for the message to reach the Session log; any earlier failure rejects with no ids and rolls the child back entirely. Requires `ctx.agents`, session persistence, and a provider with the `prepareContinuable` capability. | | `followup(parent, childId, content, { source, signal })` | Deliver one later message from the exact live direct parent as the child's next FIFO turn, matching `Agent.followup()` terminology, and return the accepted `MessageId`. A resident child's inbox accepts it directly (waking a waiting Activation); an absent one cold-resumes from its persisted Session. Requires `ctx.agents`; cold resume also requires session persistence. | -| `interrupt(targetSessionId, authority)` | Interrupt one live continuable child's current turn under a human durable parent address (`{ kind: 'user', parentSessionId }`) or an exact live ancestor Agent (`{ kind: 'ancestor', agent }`). Admission is synchronous and the effect asynchronous: it issues `Agent.cancel(cause, { keepInbox: true })` and returns without waiting for the target to observe the signal. Pending inbox work, the Activation, and published descendants are preserved; only a later waking send resumes the parked FIFO queue. An absent target — unknown, one-shot, or already-settled id — and a manager-less composition are accepted no-ops; a wrong parent address or a stale, self-targeting, or non-ancestor caller rejects with `UNAUTHORIZED`. | +| `interrupt(targetSessionId, authority)` | Interrupt one live continuable child's current turn under a human durable parent address (`{ kind: 'user', parentSessionId }`) or an exact live ancestor Agent (`{ kind: 'ancestor', agent }`). Admission is synchronous and the effect asynchronous: it issues `Agent.cancel(cause, { keepInbox: true })` and returns without waiting for the target to observe the signal. Unclaimed pending inbox work, the Activation, and published descendants are preserved; work already claimed into the interrupted turn is not requeued. Once the interrupted driver is idle, a waking send resumes the parked FIFO queue. An absent target — unknown, one-shot, or already-settled id — and a manager-less composition are accepted no-ops. For a live target, a mismatched parent address or caller outside its live ancestry rejects with `UNAUTHORIZED`; stale ancestor objects and self-targeting ancestor requests reject before target lookup. | | `reportFrom(child, content, { delivery, signal })` | Deliver one selected message from the exact live continuable child to its exact live direct parent and return the accepted stable `MessageId`. Quiet delivery injects context; waking delivery submits one later parent turn. | | `registerContinuableSetup(contribution)` | Compose an optional deployment capability into each continuable child's unpublished scope, with immediate revocation from resident children. | | `drainContinuableDescendants(parents)` | Close admission below exact live host-owned parent Agents, stop only their visible continuable descendants, await materializations admitted below those roots through publication or rollback, then release the selected forests child-first. The cutoff lasts until each exact parent leaves the registry; unrelated parent forests and manager-wide admission remain live. | @@ -102,6 +102,7 @@ No direct invalidation; the named consumers own any request-prefix changes. - **ACP children remain one-shot and are not trace-enumerable** — an ACP run has no local child session in the parent's session corpus. An ACP `prepareContinuable` requires persisting the remote session id in provider-specific descriptor data and a per-child continuation advertisement, since ACP `loadSession` support is negotiated per child rather than established by the method's presence. Remote providers also require a separate Activation ownership contract with equivalent authenticated control and child-first quiescence before they support continuable children. - **No host-user continuation** — `followup()` requires the exact live direct parent. Only `interrupt()` accepts a durable parent-address user authority, because stopping a turn is idempotent and delivers no content; a future host adapter needs a concrete authenticated interaction before the seam gains a user delivery capability. - **No current-turn steering** — continuable messages and waking reports enqueue later turns; neither redirects an open turn. +- **Wake gap during cancellation convergence** — a waking follow-up accepted after the interrupt signal is issued but before the active driver becomes idle remains queued until another waking send. Issue #1838 owns the agent-loop wake latch, which also affects ordinary session cancellation. - **Process-local residency** — the Activation inbox and ownership graph do not coordinate two harness processes; concurrent access to one persistence store still requires a durable mailbox and cross-process lease protocol. - **No replay of accepted-but-unlogged messages** — only messages written to the child Session log are reconstructable with their admitted provenance. A crash may lose an accepted initial prompt or follow-up that never reached the log; a later authorized message can cold-resume the child, but the lost message is not replayed automatically. - **No durable report mailbox** — reports require a live direct parent and provide acceptance identity rather than exactly-once delivery or a read receipt. diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index 6067555544..8402df3e4f 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -18,7 +18,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 | `start(name, request)` | 校验普通调用方请求,解析其分离的 `one-shot` 描述符,然后等待提供方,直到真实的一次性子 agent 发布。兑现时返回由持有方拥有的 `SubagentRun`;拒绝表示提供方已清理所有未发布的启动资源,而发布后的轮次或基础设施故障会通过该 run 结算。可继续子 agent 绝不通过此操作进入。 | | `startContinuable(spec)` | 建立一个持久化可继续子 agent,并投递其初始提示词。子 agent 的 inbox 接受该提示词时,兑现为 `{ childId, messageId }`,无需等待轮次开始或消息写入 Session 日志;此前任何失败都会以无 id 拒绝,并完全回滚该子 agent。要求 `ctx.agents`、会话持久化以及具备 `prepareContinuable` 能力的提供方。 | | `followup(parent, childId, content, { source, signal })` | 将来自确切在线直接父级的一条后续消息作为子 agent 的下一个 FIFO 轮次投递,术语与 `Agent.followup()` 一致,并返回被接受的 `MessageId`。驻留中的子 agent 由其 inbox 直接接受(唤醒处于 waiting 的 Activation);不驻留的则从其持久化 Session 冷恢复。要求 `ctx.agents`;冷恢复还要求会话持久化。 | -| `interrupt(targetSessionId, authority)` | 以人类持久化 parent 地址(`{ kind: 'user', parentSessionId }`)或确切在线 ancestor Agent(`{ kind: 'ancestor', agent }`)为授权,中断一个在线可继续 child 的当前轮次。准入是同步的、生效是异步的:它发出 `Agent.cancel(cause, { keepInbox: true })` 后立即返回,不等待目标观察到信号。待处理的 inbox 工作、Activation 与已发布的后代均保持不变;只有之后的一次唤醒发送才会恢复被暂停的 FIFO 队列。目标不存在——未知、一次性或已结算的 id——以及未绑定管理器的组合都是被接受的 no-op;错误的 parent 地址,或过期、指向自身、非 ancestor 的调用方会以 `UNAUTHORIZED` 拒绝。 | +| `interrupt(targetSessionId, authority)` | 以人类持久化 parent 地址(`{ kind: 'user', parentSessionId }`)或确切在线 ancestor Agent(`{ kind: 'ancestor', agent }`)为授权,中断一个在线可继续 child 的当前轮次。准入是同步的、生效是异步的:它发出 `Agent.cancel(cause, { keepInbox: true })` 后立即返回,不等待目标观察到信号。尚未领取的待处理 inbox 工作、Activation 与已发布的后代均保持不变;已被领取进入中断轮次的工作不会重新入队。被中断的 driver 进入 idle 后,一次唤醒发送会恢复被暂停的 FIFO 队列。目标不存在——未知、一次性或已结算的 id——以及未绑定管理器的组合都是被接受的 no-op。对在线目标,错误的 parent 地址或不在其在线祖先链中的调用方会以 `UNAUTHORIZED` 拒绝;过期的 ancestor 对象和指向自身的 ancestor 请求会在查找目标前拒绝。 | | `reportFrom(child, content, { delivery, signal })` | 从确切在线可继续 child 向其确切在线直接 parent 投递一条选中消息,并返回已接受的稳定 `MessageId`。静默投递会注入上下文;唤醒投递会提交一个后续 parent 轮次。 | | `registerContinuableSetup(contribution)` | 把一项可选部署能力组合到每个可继续 child 尚未发布的作用域中,并支持从驻留 child 立即撤销。 | | `drainContinuableDescendants(parents)` | 在由 host 确切拥有的在线 parent Agent 之下关闭准入,只停止其可见的可继续后代,等待在这些根之下已获准的物化过程完成发布或回滚,再按 child-first 顺序释放所选森林。该截止状态会持续到每个确切 parent 离开注册表;无关的 parent 森林和管理器全局准入保持在线。 | @@ -102,6 +102,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 - **ACP 子 agent 仍为一次性,且无法通过追踪枚举**:ACP 运行在 parent 会话语料中没有本地 child 会话。ACP 的 `prepareContinuable` 需要在提供方专用描述符数据中持久化远端会话 id,以及逐子 agent 的继续执行能力声明,因为 ACP 的 `loadSession` 支持按子 agent 协商,而不是通过方法是否存在来确定。远程提供方还需要一份独立的 Activation 所有权契约,具备等效的经认证控制和子先于父的停稳保证,才能支持可继续子 agent。 - **无 host-user 继续执行**:`followup()` 要求确切在线直接父级。只有 `interrupt()` 接受持久化 parent 地址形式的用户授权,因为停止一个轮次是幂等的且不投递任何内容;未来 host 适配器需要具体的经认证交互,才能让该 seam 获得用户投递能力。 - **不对当前轮次进行 steering**:可继续消息和唤醒式 report 会排入后续轮次,均不会重定向正在进行的轮次。 +- **取消收敛期间存在唤醒缺口**:中断信号发出后、活动 driver 进入 idle 前被接受的唤醒型 follow-up 会保持排队,直到另一条唤醒发送到达。Issue #1838 负责 agent-loop 的唤醒锁存;普通会话取消也受此影响。 - **驻留仅限进程内**:Activation inbox 与所有权图不会在两个 harness 进程之间协调;对单个持久化存储的并发访问仍然需要持久化邮箱和跨进程租约协议。 - **不重放已接受但未记录的消息**:只有写入子 agent Session 日志的消息才能连同其被接受时的来源一起重建。崩溃可能丢失从未写入日志、已被接受的初始提示词或后续消息;此后一条经授权的消息可以冷恢复该子 agent,但丢失的消息不会自动重放。 - **没有持久化的上报 mailbox**:上报需要实时直接父级,提供的是接受标识,不保证恰好一次投递,也不提供已读回执。 diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index ebac0a1296..67cc177fe1 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -426,9 +426,10 @@ export class SubagentContinuationManager { * synchronous and the effect is asynchronous: this authorizes the caller, * requests `Agent.cancel(cause, { keepInbox: true })` on the target, and * returns without waiting for the target to observe the signal or reach - * quiescence. The Activation, its handle, accepted pending inbox work, and - * already-published descendants are untouched; the parked queue resumes only - * on a later waking send. + * quiescence. The Activation, its handle, accepted unclaimed inbox work, and + * already-published descendants are untouched; work already claimed into the + * interrupted turn is not requeued. Once the interrupted driver is idle, a + * waking send resumes the parked queue. * * An absent target is an accepted no-op, which uniformly covers natural * completion races, repeated requests, one-shot ids, and unknown ids without diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index e179bfd8b4..8bf4efb5d0 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -237,9 +237,10 @@ export class SubagentService extends Service { * Interrupt one live continuable child's current turn under a human parent * address or an exact live ancestor Agent. Fire-and-return: the cancel * signal is issued before this returns, but the target may keep running - * until it observes the signal. Pending inbox work, the Activation, and - * published descendants are preserved; only a later waking send resumes the - * parked FIFO queue. An absent target — including a one-shot or unknown id — + * until it observes the signal. Unclaimed pending inbox work, the Activation, + * and published descendants are preserved; claimed work is not requeued. + * Once the interrupted driver is idle, a waking send resumes the parked FIFO + * queue. An absent target — including a one-shot or unknown id — * is an accepted no-op, as is a manager-less composition, which cannot own a * live Activation. * @param targetSessionId - the durable child session id to interrupt. From 5793614b866de1e7af82a9627728d8b733da6c1b Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 6 Aug 2026 12:45:37 +0800 Subject: [PATCH 015/189] feat(web): expose continuable subagent interrupt The composer's single primary action now stops a running continuable child: Session.cancel() routes a continuable address through subagent.interrupt (ordinary sessions keep session.cancel; one-shot addresses stay uncancellable), and InputBar switches the same Send button to Stop while a continuable child runs. A running parent-offline child keeps the default composer with disabled input so that Stop stays reachable; the read-only takeover returns once it stops. Refs #1535 --- ...07-27-web-subagent-conversations.i18n.yaml | 4 +- .../2026-07-27-web-subagent-conversations.md | 12 +- ...026-07-27-web-subagent-conversations.zh.md | 12 +- ...6-continuable-subagent-interrupt.i18n.yaml | 4 +- ...26-08-06-continuable-subagent-interrupt.md | 4 +- ...08-06-continuable-subagent-interrupt.zh.md | 4 +- .../offline-composer.expected.md | 25 ++ apps/web/tests/subagent-interrupt-ui.e2e.ts | 271 ++++++++++++++++++ apps/web/tsconfig.json | 1 + .../runtime/src/client/sessions/session.ts | 15 +- packages/client/runtime/tests/session.spec.ts | 30 +- .../ui-conversation/src/client/locales.ts | 2 + .../src/client/skeleton/InputBar.tsx | 25 +- .../ui-conversation/tests/input-bar.spec.tsx | 46 ++- packages/client/ui-subagent/README.i18n.yaml | 4 +- packages/client/ui-subagent/README.md | 4 +- packages/client/ui-subagent/README.zh.md | 4 +- .../client/ui-subagent/src/client/index.ts | 6 +- .../ui-subagent/tests/browser-plugin.spec.ts | 9 +- tsconfig.host.json | 1 + 20 files changed, 433 insertions(+), 50 deletions(-) create mode 100644 apps/web/tests/snapshots/subagent-interrupt/offline-composer.expected.md create mode 100644 apps/web/tests/subagent-interrupt-ui.e2e.ts diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml index 56952f8b69..4ec9a77d72 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md -2026-07-27-web-subagent-conversations.md: 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 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md index edaaa97bbf..da4de7d4bc 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md @@ -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. diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md index 4c5e670cc4..43d9710ec2 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md @@ -20,7 +20,7 @@ Web 产品通过页头操作公开选中会话中由会话支撑的直接 subage 通用 Host 领域遵守同一所有权边界。`session.history` 与 `session.fork` 的源端会读取已附加 Session 或检查持久化存储,而不获取 Agent;history 从所检查的确切前缀归并冷态投影值,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 树,支持懒加载式 ArrowRight/ArrowLeft 展开与折叠、线性 ArrowUp/ArrowDown 导航、Home/End、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 驻留时间。 diff --git a/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.i18n.yaml b/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.i18n.yaml index 643bb9f9ac..5cf5601fc0 100644 --- a/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.md -2026-08-06-continuable-subagent-interrupt.md: 7bbc6ecf6736e9e34924113be984a3bf7436b9b9 -2026-08-06-continuable-subagent-interrupt.zh.md: 22a71bf4cd3d9b949ae8cf458a3c83b65739d396 +2026-08-06-continuable-subagent-interrupt.md: 1792d4c6d25858c861be0ff920206d0d4e684f41 +2026-08-06-continuable-subagent-interrupt.zh.md: ff9887481d1ded2743c7c8f9f162413fb6581d70 diff --git a/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.md b/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.md index 7bbc6ecf67..1792d4c6d2 100644 --- a/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.md +++ b/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.md @@ -39,8 +39,8 @@ A human or ancestor can stop a runaway continuable turn without losing the child 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 Stop action and the model-facing `interrupt_agent` tool build on this primitive in the stacked follow-up PRs for issue #1535. +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` tool builds on this primitive in the stacked follow-up PR for issue #1535. ## 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. +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. diff --git a/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.zh.md b/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.zh.md index 22a71bf4cd..ff9887481d 100644 --- a/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.zh.md +++ b/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.zh.md @@ -39,8 +39,8 @@ Host RPC `subagent.interrupt` 接收 continuable 的 `SubagentAddress` 并返回 仅凭地址的 RPC 会暴露一位在线驻留信息:不存在的目标会被接受,而 parent 不匹配的在线目标会返回 `subagent-unauthorized`。单用户本地 Host 的信任模型接受这种可观察性;未来的多主体 Host 必须重新审视权限和响应不可区分性。 -Web 的 Stop 操作和面向模型的 `interrupt_agent` 工具在 issue #1535 的后续 stacked PR 中基于此原语构建。 +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)拥有)。面向模型的 `interrupt_agent` 工具在 issue #1535 的后续 stacked PR 中基于此原语构建。 ## 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 往返。 +`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 恢复。 diff --git a/apps/web/tests/snapshots/subagent-interrupt/offline-composer.expected.md b/apps/web/tests/snapshots/subagent-interrupt/offline-composer.expected.md new file mode 100644 index 0000000000..6ea7b83bff --- /dev/null +++ b/apps/web/tests/snapshots/subagent-interrupt/offline-composer.expected.md @@ -0,0 +1,25 @@ +- banner: + - navigation "Session hierarchy": + - button "Ask a research subagent to" + - text: / + - button "event-sourcing researcher" [disabled] + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- text: Explain event sourcing in one sentence. {{clock}} +- button "Copy": + - img +- button "Branch into a new conversation" [disabled]: + - img +- text: Available only on the last message of a completed turn +- button "Context injection @deepseek-ai/dsh-system-prompt": + - img + - img + - text: Context injection @deepseek-ai/dsh-system-prompt +- paragraph: partial +- status: Deep diving... +- textbox "Parent session offline; sending is unavailable but you can still stop the run" [disabled] +- button "Commands" [disabled]: + - img +- 'button "Access mode, current: Workspace Write" [disabled]': Workspace Write +- button "Stop generating" diff --git a/apps/web/tests/subagent-interrupt-ui.e2e.ts b/apps/web/tests/subagent-interrupt-ui.e2e.ts new file mode 100644 index 0000000000..be72f6546b --- /dev/null +++ b/apps/web/tests/subagent-interrupt-ui.e2e.ts @@ -0,0 +1,271 @@ +// Web e2e scenario: the composer's primary action interrupts a running +// continuable child. The child holds its model turn open through a replay +// hang entry; the browser proves the single primary Send/Stop toggle, the +// parent-offline disabled-input-with-Stop composer, the subagent.interrupt +// (never session.cancel) transport, the parked follow-up, and the FIFO resume +// on a waking send. +// +// Replay-binding note: only the PRIMARY script can hang, and scripts bind by +// first-call order, so the child issues the composition's first model call +// (claiming the overridden primary) and the parent's one UI prompt — needed +// so the non-blank parent renders its header catalog — binds to a derived +// child fixture afterwards. +import { existsSync } from 'node:fs' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-agent' +import { + acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, + launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' + +const BASE_FIXTURE = fileURLToPath(new URL('./snapshots/live-interactions/session.jsonl', import.meta.url)) + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/subagent-interrupt', import.meta.url)) +const OFFLINE_COMPOSER_EXPECTED = join(SNAPSHOT_DIR, 'offline-composer.expected.md') +const MODE = webSnapshotMode() +const LABEL = 'event-sourcing researcher' +const INITIAL = 'Explain event sourcing in one sentence.' +const FOLLOWUP = 'Now give the same explanation to a human reader.' +const WAKING = 'And add one concrete example.' +const PARKED_ANSWER = 'parked follow-up answer' +const WAKING_ANSWER = 'waking answer' + +/** Poll a synchronous condition (hook-safe; expect.poll is test-body only). */ +async function waitFor(predicate: () => boolean, what: string, timeoutMs = 30_000): Promise { + const deadline = Date.now() + timeoutMs + while (!predicate()) { + if (Date.now() >= deadline) throw new Error(`timed out waiting for ${what}`) + await new Promise(resolve => setTimeout(resolve, 10)) + } +} + +/** One text-only scripted model completion (no tool calls: real tools are mounted). */ +function textCompletion(text: string): object { + return { + kind: 'chunks', + chunks: [ + { type: 'block-start', index: 0, blockType: 'text' }, + { type: 'text-delta', index: 0, text }, + { type: 'block-end', index: 0, block: { type: 'text', text } }, + { type: 'usage', usage: { inputTokens: 20, outputTokens: 8 } }, + { type: 'finish', reason: { kind: 'stop' } }, + ], + } +} + +describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running continuable child', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let sidecarRoot: string + let childId: SessionId + let tripwire: ReturnType + const apiCalls: string[] = [] + + beforeAll(async () => { + sidecarRoot = await mkdtemp(join(tmpdir(), 'dsh-web-subagent-interrupt-ui-')) + const readyFile = join(sidecarRoot, 'hang-ready') + // The child claims this whole-script replacement: held turn 1, then the + // parked follow-up and waking turns. + await writeFile(join(sidecarRoot, 'replay.override.json'), JSON.stringify([ + { kind: 'hang', readyFile }, + textCompletion(PARKED_ANSWER), + textCompletion(WAKING_ANSWER), + ])) + await writeFile( + join(sidecarRoot, 'session.jsonl'), + '{"type":"session","version":0,"id":"primary","createdAt":0}\n', + ) + // The parent's one prompted turn replays this recorded single text-only + // call (binding is positional, not lineage-aware). + const parentTurnPath = join(sidecarRoot, 'parent-turn.jsonl') + const base = await readFile(BASE_FIXTURE, 'utf8') + const [header, ...events] = base.trimEnd().split('\n') + if (header === undefined) throw new Error('base replay fixture has no header') + await writeFile(parentTurnPath, [ + header + .replace('"id":"{{sessionId}}"', '"id":"recorded-parent-turn"') + .replace(/"createdAt":\d+/, '"createdAt":1784998084442'), + ...events, + '', + ].join('\n')) + scaffold = await launchWebScaffold({ + replayFixture: join(sidecarRoot, 'session.jsonl'), + replayOverride: join(sidecarRoot, 'replay.override.json'), + replayChildFixtures: [parentTurnPath], + }) + browser = await chromium.launch() + page = await newEnglishPage(browser) + page.on('request', (request) => { + const path = new URL(request.url()).pathname + if (path.startsWith('/api/')) apiCalls.push(path) + }) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + + const parent = scaffold.ctx.agents.roots()[0] + if (parent === undefined) throw new Error('fresh workspace did not publish its parent Agent') + // The child's first model call claims the primary override and holds. + const started = await scaffold.ctx.subagents.startContinuable({ + provider: 'spawn', + label: LABEL, + signal: new AbortController().signal, + request: { prompt: [{ type: 'text', text: INITIAL }], parent }, + }) + childId = started.childId + await waitFor(() => existsSync(readyFile), 'the held child turn to open') + + // One prompted parent turn makes the parent non-blank so the session + // header (and its subagent catalog action) renders. + const parentSettled = scaffold.whenTurnSettled() + const parentInput = page.locator('textarea:enabled').first() + await parentInput.fill('Ask a research subagent to explain event sourcing.') + await parentInput.press('Enter') + expect(await parentSettled).toBe(parent.id) + + // Reload onto the restart baseline (the proven route to a freshly + // discovered catalog), with the child still live and running host-side. + const warningStart = tripwire.warnings.length + await page.reload({ waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await page.getByRole('button', { name: /1 subagent/ }).waitFor({ timeout: 15_000 }) + acknowledgeReloadConnectionLoss(tripwire, warningStart) + expect(scaffold.ctx.agents.get(childId)?.status).toBe('running') + }, 120_000) + + afterAll(async () => { + const failures: unknown[] = [] + await browser?.close().catch((error: unknown) => failures.push(error)) + await scaffold?.close().catch((error: unknown) => failures.push(error)) + if (sidecarRoot !== undefined) { + await rm(sidecarRoot, { recursive: true, force: true }) + .catch((error: unknown) => failures.push(error)) + } + if (failures.length === 1) throw failures[0] + if (failures.length > 1) throw new AggregateError(failures, 'subagent interrupt UI teardown failed') + }) + + it('locks input but keeps the same primary Stop when the parent is offline', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-interrupt-offline')) + // Simulate a parent that went offline: the catalog delivers + // parentAvailable: false while the child Activation stays live (the + // interrupt RPC itself needs no live parent — PR 1's host coverage). + const pattern = '**/api/subagent.list' + await page.route(pattern, async (route) => { + const response = await route.fetch() + const body = await response.json() as { + result: { ok: true; value: { parentAvailable: boolean } } | { ok: false } + } + if (body.result.ok) body.result.value.parentAvailable = false + await route.fulfill({ response, json: body }) + }) + try { + await page.getByRole('button', { name: /1 subagent/ }).click() + await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click() + const input = page.getByRole('textbox', { + name: 'Parent session offline; sending is unavailable but you can still stop the run', + }) + await input.waitFor({ timeout: 15_000 }) + expect(await input.isDisabled()).toBe(true) + // Still exactly one primary action, and it is an enabled Stop. + const stop = page.getByRole('button', { name: 'Stop generating' }) + expect(await stop.count()).toBe(1) + expect(await stop.isEnabled()).toBe(true) + expect(await page.getByRole('button', { name: 'Send message' }).count()).toBe(0) + await compareOrRefreshGolden( + OFFLINE_COMPOSER_EXPECTED, + await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd), + MODE, + ) + } finally { + await page.unroute(pattern) + } + }, 60_000) + + it('interrupts through subagent.interrupt, parks the follow-up, and resumes it FIFO', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-interrupt-flow')) + // Reselect the child with the truthful catalog: parent available again. + await page.getByRole('navigation', { name: 'Session hierarchy' }) + .getByRole('button').first().click() + await page.getByRole('button', { name: /1 subagent/ }).click() + await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click() + const input = page.getByRole('textbox', { name: 'Message the agent' }) + await input.waitFor({ timeout: 15_000 }) + expect(await input.isDisabled()).toBe(false) + + // Queue a follow-up while the turn is open; the primary stays Stop. + const promptResponse = page.waitForResponse(response => + new URL(response.url()).pathname === '/api/subagent.prompt') + await input.fill(FOLLOWUP) + await input.press('Enter') + expect(((await (await promptResponse).json()) as { result: { ok: boolean } }).result) + .toMatchObject({ ok: true }) + + const aborted = new Promise((resolve, reject) => { + const timer = setTimeout(() => { + off() + reject(new Error('interrupt did not reach an aborted turn/end')) + }, 30_000) + const off = scaffold.ctx.on('session/event', (session: { id: SessionId }, event: SessionEvent) => { + if (session.id !== childId || event.type !== 'turn/end') return + clearTimeout(timer) + off() + if (event.data.reason.kind === 'aborted') resolve() + else reject(new Error(`expected an aborted turn/end, got ${event.data.reason.kind}`)) + }) + }) + const stop = page.getByRole('button', { name: 'Stop generating' }) + expect(await stop.count()).toBe(1) + const interruptResponse = page.waitForResponse(response => + new URL(response.url()).pathname === '/api/subagent.interrupt') + await stop.click() + expect(((await (await interruptResponse).json()) as { + result: { ok: boolean; value?: { accepted: boolean } } + }).result).toMatchObject({ ok: true, value: { accepted: true } }) + // The addressed child stops through its own RPC, never the generic one. + expect(apiCalls.filter(path => path === '/api/session.cancel')).toEqual([]) + await aborted + + // Parked: the Activation stays resident and idle with the retained + // follow-up; the primary returns to Send without a new turn starting. + await expect.poll(() => scaffold.ctx.agents.get(childId)?.status, { timeout: 15_000 }).toBe('idle') + const child = scaffold.ctx.agents.get(childId) + expect(child).toBeDefined() + expect(child!.inbox.nextTurn).toHaveLength(1) + expect(child!.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1) + await page.getByRole('button', { name: 'Send message' }).waitFor({ timeout: 15_000 }) + + // Only the waking send resumes the parked queue, FIFO, to settlement. + await input.fill(WAKING) + await input.press('Enter') + await expect.poll(() => page.getByText(PARKED_ANSWER, { exact: true }).count(), { timeout: 30_000 }).toBe(1) + await expect.poll(() => page.getByText(WAKING_ANSWER, { exact: true }).count(), { timeout: 30_000 }).toBe(1) + await expect.poll(() => scaffold.ctx.agents.get(childId), { timeout: 60_000 }).toBeUndefined() + + const loaded = await scaffold.ctx.sessionPersistence.load(childId) + const userTexts = loaded.events.flatMap(event => event.type === 'user/message' + && event.data.source.kind === 'user' + ? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : []) + : []) + expect(userTexts).toEqual([INITIAL, FOLLOWUP, WAKING]) + const turnEndKinds = loaded.events + .filter(event => event.type === 'turn/end') + .map(event => event.data.reason.kind) + expect(turnEndKinds).toEqual(['aborted', 'completed', 'completed']) + expect(tripwire.pageErrors).toEqual([]) + }, 120_000) + + it('keeps its snapshot inventory closed', async () => { + await assertFixtureInventory(SNAPSHOT_DIR, ['offline-composer.expected.md']) + }) +}) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 3fa6ba8905..606f257e56 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -68,6 +68,7 @@ "tests/goal-bar.e2e.ts", "tests/subagent-conversation.e2e.ts", "tests/subagent-interrupt.e2e.ts", + "tests/subagent-interrupt-ui.e2e.ts", "tests/sidebar-subagent-activity.e2e.ts", "tests/bash-abort-row.e2e.ts", "tests/skill-tool-row.e2e.ts", diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 075fd686ea..659afcac4a 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -281,17 +281,22 @@ export class Session implements SessionFace { /** * Stop the active turn while the Host preserves pending inbox work; failures - * land in promptError (same error-strip display slot). + * land in promptError (same error-strip display slot). A continuable + * subagent address routes through `subagent.interrupt`, whose durable + * parent-address authority works without a live parent Agent; a one-shot + * address stays uncancellable (the UI offers no stop action, so this arm is + * defensive). * @returns the cancel result. */ async cancel(): Promise> { - if (this.address !== undefined) { + const address = this.address + if (address !== undefined && address.mode === 'one-shot') { const result: RpcResult<{ accepted: true }> = { ok: false, error: { code: 'subagent-delivery-unavailable', message: 'subagent activation cancellation is unavailable', - details: { childSessionId: this.address.childSessionId }, + details: { childSessionId: address.childSessionId }, }, } this.promptError = { op: 'stop', error: result.error } @@ -300,7 +305,9 @@ export class Session implements SessionFace { } let result: RpcResult<{ accepted: true }> try { - result = (await this.api.sessions.cancel({ sessionId: this.sessionId })).result + result = address !== undefined + ? (await this.api.subagents.interrupt(address)).result + : (await this.api.sessions.cancel({ sessionId: this.sessionId })).result } catch (error) { result = transportError(error) } diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index b5400ecff9..5d5f292f92 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -639,7 +639,7 @@ describe('paging', () => { }) describe('prompt and cancel errors', () => { - it('routes an addressed child through non-activating history and continuation prompt only', async () => { + it('routes an addressed child through non-activating history, continuation prompt, and interrupt only', async () => { const api = new FakeApiClient() const session = new Session(SID, api, { address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' }, @@ -650,7 +650,7 @@ describe('prompt and cancel errors', () => { const cancelled = await session.cancel() expect(prompted).toEqual({ ok: true, value: { accepted: true } }) - expect(cancelled).toMatchObject({ ok: false, error: { code: 'subagent-delivery-unavailable' } }) + expect(cancelled).toEqual({ ok: true, value: { accepted: true } }) expect(api.callsOf('subagent.history')).toEqual([ { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable', maxMessages: 50 }, ]) @@ -660,15 +660,37 @@ describe('prompt and cancel errors', () => { content: [{ type: 'text', text: '继续' }], }, ]) + expect(api.callsOf('subagent.interrupt')).toEqual([ + { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' }, + ]) expect(api.callsOf('session.history')).toEqual([]) expect(api.callsOf('session.prompt')).toEqual([]) expect(api.callsOf('session.cancel')).toEqual([]) + // A successful interrupt leaves no stop error behind. + expect(session.getSnapshot().promptError).toBeNull() expect(session.getSnapshot().subagent).toEqual({ address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' }, parentAvailable: true, }) }) + it('lands an interrupt business failure in promptError with op=stop', async () => { + const api = new FakeApiClient() + api.onSubagentInterrupt = () => Promise.resolve(err({ + code: 'subagent-unauthorized', message: 'nope', details: { childSessionId: SID }, + }) as never) + const session = new Session(SID, api, { + address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' }, + parentAvailable: true, + }) + await session.open() + const cancelled = await session.cancel() + expect(cancelled).toMatchObject({ ok: false, error: { code: 'subagent-unauthorized' } }) + expect(session.getSnapshot().promptError).toMatchObject({ + op: 'stop', error: { code: 'subagent-unauthorized' }, + }) + }) + it('keeps one-shot history readable without exposing prompt or cancel transport', async () => { const api = new FakeApiClient() const session = new Session(SID, api, { @@ -676,12 +698,16 @@ describe('prompt and cancel errors', () => { }) await session.open() const prompted = await session.prompt([{ type: 'text', text: '继续' }], 'queue') + const cancelled = await session.cancel() expect(prompted).toMatchObject({ ok: false, error: { code: 'subagent-not-resumable' } }) + expect(cancelled).toMatchObject({ ok: false, error: { code: 'subagent-delivery-unavailable' } }) expect(api.callsOf('subagent.history')).toEqual([ { parentSessionId: PARENT, childSessionId: SID, mode: 'one-shot', maxMessages: 50 }, ]) expect(api.callsOf('subagent.prompt')).toEqual([]) + expect(api.callsOf('subagent.interrupt')).toEqual([]) + expect(api.callsOf('session.cancel')).toEqual([]) }) it('sends content through session.prompt; composerPhase steps blank → engaging synchronously at send entry', async () => { diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 11e852a8b6..a67b816012 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -17,6 +17,7 @@ export const zh = { 'placeholder.plan': PLAN_NEXT_ACTION_ZH, 'placeholder.default': '给智能体发消息', 'placeholder.unavailable': '会话不可用', + 'placeholder.parentOffline': '父会话已离线,无法继续发送;仍可停止当前运行', 'placeholder.hero': '描述你想要构建的内容', 'placeholder.workspace': '选择一个工作区开始', 'input.commands': '命令', @@ -159,6 +160,7 @@ export const en = { 'placeholder.plan': PLAN_NEXT_ACTION_EN, 'placeholder.default': 'Message the agent', 'placeholder.unavailable': 'Session unavailable', + 'placeholder.parentOffline': 'Parent session offline; sending is unavailable but you can still stop the run', 'placeholder.hero': 'Describe what you want to build', 'placeholder.workspace': 'Choose a workspace to start', 'input.commands': 'Commands', diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 7b24c09684..21abce0e38 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -83,11 +83,15 @@ export function InputBar({ // (undefined = capability absent → the chip renders nothing). const permissions = useProjection('permissions') + // A continuable child without its live parent cannot accept human input, + // but its primary Stop below stays available while it runs. + const continuable = subagent?.address.mode === 'continuable' + const parentOffline = continuable && !subagent.parentAvailable // Queue cut 1: running input stays free; locked = session removed, the - // inert no-workspace state, or the machine faces absent (no session). The - // transient machine locks (adjudicating pending / submitting) render - // read-only — the draft stays visible and focused, keystrokes drop. - const disabled = removed || inert || !live || blocked !== undefined + // inert no-workspace state, the machine faces absent (no session), or a + // parent-offline continuable child. An owner block also disables input; + // adjudicating and submitting render read-only so the draft stays visible. + const disabled = removed || inert || !live || blocked !== undefined || parentOffline const locked = disabled // The model seat is the ONE control a block leaves live: every block this // contract has is cleared by choosing a model, so locking it too would leave @@ -350,8 +354,9 @@ export function InputBar({ if (el !== null) toggleCommandMenu?.(selectionOf(el)) } - const ordinary = subagent === null - const stopping = running && ordinary + // Ordinary sessions and continuable children stop through the same primary + // action; one-shot children stay send-and-stop-free (read-only takeover). + const stopping = running && (subagent === null || continuable) const primaryLabel = stopping ? t('input.stop') : t('input.send') const onPrimary = (): void => { if (stopping) { @@ -478,9 +483,11 @@ export function InputBar({ disabled={locked} readOnly={machineBusy} data-phase={input?.phase ?? 'inert'} - placeholder={placeholder ?? (disabled - ? t('placeholder.unavailable') - : planActive ? t('placeholder.plan') : t('placeholder.default'))} + placeholder={placeholder ?? (parentOffline + ? t('placeholder.parentOffline') + : disabled + ? t('placeholder.unavailable') + : planActive ? t('placeholder.plan') : t('placeholder.default'))} rows={2} onChange={onChange} onKeyDown={onKeyDown} diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index 47791a6090..89f0d0a5f4 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -143,7 +143,8 @@ function bench(over?: BenchOptions) { } const view = render() const textarea = view.container.querySelector('textarea')! - const stopping = over?.running === true && over.subagent === undefined + const stopping = over?.running === true + && (over.subagent === undefined || over.subagent.address.mode === 'continuable') const button = view.container.querySelector( `button[aria-label="${stopping ? '停止生成' : '发送消息'}"]`, )! @@ -250,8 +251,8 @@ describe('running and lock semantics (queue cut 1)', () => { expect(ctrl.sink).toHaveBeenCalledWith('also queue', 'queue') }) - it('running subagent primary admits a follow-up instead of exposing Stop', () => { - const { button, sink, stop } = bench({ + it('running continuable subagent turns the same primary into Stop while typing stays free', () => { + const { button, textarea, sink, stop } = bench({ running: true, draft: '后续消息', subagent: { @@ -263,23 +264,54 @@ describe('running and lock semantics (queue cut 1)', () => { parentAvailable: true, }, }) - expect(button.getAttribute('aria-label')).toBe('发送消息') + // One primary action only: Send switched to Stop, no side-by-side interrupt. + expect(button.getAttribute('aria-label')).toBe('停止生成') + expect(textarea.disabled).toBe(false) fireEvent.click(button) + expect(stop).toHaveBeenCalledTimes(1) + expect(sink).not.toHaveBeenCalled() + // Enter still queues the follow-up while the primary offers Stop. + fireEvent.keyDown(textarea, { key: 'Enter' }) expect(sink).toHaveBeenCalledWith('后续消息', 'queue') - expect(stop).not.toHaveBeenCalled() + }) - const empty = bench({ + it('parent-offline running continuable locks the input but keeps the same primary Stop usable', () => { + const { button, textarea, stop, view } = bench({ running: true, + draft: '', subagent: { address: { parentSessionId: 'parent' as SessionId, childSessionId: SID, mode: 'continuable', }, + parentAvailable: false, + }, + }) + expect(textarea.disabled).toBe(true) + expect(textarea.placeholder).toBe('父会话已离线,无法继续发送;仍可停止当前运行') + expect((view.getByLabelText('命令') as HTMLButtonElement).disabled).toBe(true) + expect(button.getAttribute('aria-label')).toBe('停止生成') + expect(button.disabled).toBe(false) + fireEvent.click(button) + expect(stop).toHaveBeenCalledTimes(1) + }) + + it('running one-shot subagent never exposes Stop', () => { + const { button, stop } = bench({ + running: true, + draft: '不可停止', + subagent: { + address: { + parentSessionId: 'parent' as SessionId, + childSessionId: SID, + mode: 'one-shot', + }, parentAvailable: true, }, }) - expect(empty.button.disabled).toBe(true) + expect(button.getAttribute('aria-label')).toBe('发送消息') + expect(stop).not.toHaveBeenCalled() }) it('keeps both running subagent Enter gestures on Queue transport', () => { diff --git a/packages/client/ui-subagent/README.i18n.yaml b/packages/client/ui-subagent/README.i18n.yaml index 8bb19b2ba2..d1b0852953 100644 --- a/packages/client/ui-subagent/README.i18n.yaml +++ b/packages/client/ui-subagent/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-subagent/README.md -README.md: cb210b219a8c66985eb4e1370468372eed9614b4 -README.zh.md: 857e92d05a7ed2d0df9398acc9698db13b0c6eb2 +README.md: de55963bbe356e18a4e2b653be823cc5015c1bb7 +README.zh.md: ce4244db094bb8b602b854289ec000477bf45f0b diff --git a/packages/client/ui-subagent/README.md b/packages/client/ui-subagent/README.md index cb210b219a..de55963bbe 100644 --- a/packages/client/ui-subagent/README.md +++ b/packages/client/ui-subagent/README.md @@ -6,7 +6,7 @@ Web subagent feature owner: contributes the lazily expandable catalog tree to `c The header action reads `subagentsByParent` and session summaries through the standard `useSessions` hook. After a non-empty direct catalog arrives, its trigger counts the complete subagent-only descendant lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. The compact tree remains direct-catalog authoritative: continuable and one-shot rows display mode plus `running`/`inactive` activity and an optional log-backed title, while the trailing column stacks total durable provider usage above active-turn duration. Token totals sum the four disjoint `tokenUsage` buckets. Visual duration stays exact to the second below one day, then uses at most two adjacent units—days/hours, approximate months/days, or approximate years/months—while hover and the accessible name retain the exact day/hour/minute/second value. Duration sums completed `subagentTiming` turns, advances once per second only for an open turn on a running child, and freezes after the child becomes inactive; an interrupted open turn is bounded by its same-cut `active.through`, never by newer session metadata. An unlabeled one-shot row falls back to its session id, while corrupt, unsupported, or unavailable rows remain readable but disabled. Each healthy row's `hasChildren` hint determines disclosure before interaction, so known leaves never show an arrow; a catalog level reserves the disclosure column only when at least one healthy row is a branch, allowing branchless levels to start at the leading status marker. Expanding a branch immediately reserves one disabled loading row per known direct descendant, then lazily replaces them with that child's authoritative catalog. Every visible branch is reported to the runtime so membership frames cause a debounced refresh only where the tree is being consumed. Selecting any depth calls `SessionsService.openSubagent()` with the row's exact `{parentSessionId, childSessionId, mode}` address. Component-local state owns tree visibility, expanded branches, keyboard focus, and the running-duration clock. ArrowRight/ArrowLeft expand and collapse branches; ArrowUp/ArrowDown, Home, End, and Escape navigate or close the tree; closing returns focus to the trigger. Styling uses tokens only. -A one-shot child always elects a read-only composer that identifies the transcript as a completed execution record. A continuable child does so only when its exact parent is unavailable, with copy explaining the recovery path. A continuable child with a live parent keeps the ordinary input chrome, whose Session routes through `subagent.prompt`; running input remains Send because every follow-up joins the child's FIFO inbox, and addressed sessions never expose Stop. This package never receives host context or calls a model-facing tool. The catalog and composer behavior are specified by the [Web subagent conversations Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md). +A one-shot child always elects a read-only composer that identifies the transcript as a completed execution record. A continuable child does so only when its exact parent is unavailable and the child is not running, with copy explaining the recovery path; while such a child still runs, the selector yields to the ordinary composer, whose input is disabled but whose primary Stop stays usable, and the takeover returns once it stops. A continuable child with a live parent keeps the ordinary input chrome, whose Session routes prompts through `subagent.prompt`: typing stays free while the child runs because every follow-up joins the child's FIFO inbox, and the single primary action follows the ordinary convention — Send while idle, Stop while running, routed through `subagent.interrupt`. This package never receives host context or calls a model-facing tool. The catalog and composer behavior are specified by the [Web subagent conversations Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md) and the [current-turn interrupt Agent Note](../../../.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.md). Subagent-origin Session rows are omitted from the ordinary sidebar, so the parent header catalog is their navigation entry point. Ordinary forks remain in the sidebar. @@ -30,5 +30,5 @@ Append-only. This package never edits earlier request tokens. ## Known Limitations and Deferred Work -- **The catalog has no durable outcome** — activity and timing do not distinguish completion, failure, or cancellation, and the UI exposes neither Activation identity nor an authority-safe cancel button. +- **The catalog has no durable outcome** — activity and timing do not distinguish completion, failure, or cancellation, and the UI exposes no Activation identity; stopping is limited to the composer's current-turn Stop for a running continuable child. - **`@` references remain display-title text** — duplicate or renamed labels are ambiguous, so they intentionally do not acquire continuation semantics. diff --git a/packages/client/ui-subagent/README.zh.md b/packages/client/ui-subagent/README.zh.md index 857e92d05a..ce4244db09 100644 --- a/packages/client/ui-subagent/README.zh.md +++ b/packages/client/ui-subagent/README.zh.md @@ -6,7 +6,7 @@ Web subagent 功能 owner:向 `conversation.session.header.actions` 贡献可 页头操作通过标准 `useSessions` 钩子读取 `subagentsByParent` 与会话摘要。非空直接目录到达后,其触发器会统计仅含 subagent 的完整后代谱系,在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。紧凑树仍以直接目录为权威依据:可继续和 one-shot 行会显示 mode、`running`/`inactive` 活动状态和由日志支撑的可选 title,尾随列则在上行显示提供方的持久化 token 用量总计,在下行显示活跃轮次耗时。token 用量总计为四个互不重叠的 `tokenUsage` 桶之和。视觉耗时在不足一天时精确到秒,达到一天后则最多使用两个相邻单位——天/小时、近似月份/天或近似年份/月份——而悬停信息与无障碍名称会保留精确的天/小时/分钟/秒数值。耗时会累加已完成的 `subagentTiming` 轮次,仅在运行中 child 存在未结束轮次时每秒递增一次,并在 child 变为 inactive 后冻结;被中断的未结束轮次以其同一切面的 `active.through` 为上界,绝不使用更新的会话元数据。没有 label 的 one-shot 行会回退到其会话 id,而损坏、不受支持或不可用的行仍保持可读但禁用。每个健康行的 `hasChildren` 提示会在交互前决定是否显示展开控件,因此已知叶子节点从不显示箭头;每层目录仅在其中至少一个健康行是分支时才预留展开列,使完全不含分支的层级能从最前面的状态标记开始。展开分支时,会立即为每个已知直接后代预留一行禁用的加载行,随后再用该 child 的权威目录懒加载结果替换这些占位行。每个可见分支都会上报给运行时,使成员帧只在树正被消费的位置触发去抖动刷新。选择任意深度的条目都会使用该行的确切地址 `{parentSessionId, childSessionId, mode}` 调用 `SessionsService.openSubagent()`。组件局部状态负责树的可见性、已展开分支、键盘焦点与运行中耗时时钟。ArrowRight/ArrowLeft 展开和折叠分支;ArrowUp/ArrowDown、Home、End 与 Escape 用于导航或关闭树;关闭后焦点返回触发器。样式只使用 token。 -one-shot child 始终选用只读编辑器,并将 transcript(文本记录)说明为已完成的执行记录。可继续 child 仅在其确切 parent 不可用时选用只读编辑器,并以文案说明恢复路径。确切 parent 存活时,可继续 child 保留普通输入 chrome,其会话会通过 `subagent.prompt` 路由;child 运行期间,输入操作仍为 Send,因为每条后续消息都会进入 child 的 FIFO inbox,且已寻址会话绝不公开 Stop。本包绝不接收宿主上下文,也不调用面向模型的工具。目录与编辑器行为由 [Web subagent 对话 Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md) 规定。 +one-shot child 始终选用只读编辑器,并将 transcript(文本记录)说明为已完成的执行记录。可继续 child 仅在其确切 parent 不可用且 child 未在运行时选用只读编辑器,并以文案说明恢复路径;此类 child 仍在运行期间,selector 会让位给普通编辑器——其输入区被禁用,但 primary Stop 保持可用,停止后只读替代恢复。确切 parent 存活时,可继续 child 保留普通输入 chrome,其会话通过 `subagent.prompt` 路由提示词:child 运行期间输入保持自由,因为每条后续消息都会进入 child 的 FIFO inbox,而唯一的 primary 操作遵循普通约定——空闲时为 Send,运行时为 Stop,经由 `subagent.interrupt` 路由。本包绝不接收宿主上下文,也不调用面向模型的工具。目录与编辑器行为由 [Web subagent 对话 Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md) 与[当前轮次中断 Agent Note](../../../.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.md) 规定。 普通侧边栏会省略带 subagent origin 的会话行,因此 parent 页头目录是它们的导航入口。普通 fork 仍保留在侧边栏中。 @@ -30,5 +30,5 @@ one-shot child 始终选用只读编辑器,并将 transcript(文本记录) ## 已知限制与暂缓事项 -- **目录没有持久化结果**:活动状态与计时无法区分完成、失败或取消,且 UI 既不公开 Activation 身份,也不公开符合授权边界的取消按钮。 +- **目录没有持久化结果**:活动状态与计时无法区分完成、失败或取消,且 UI 不公开 Activation 身份;停止能力仅限编辑器上针对运行中可继续 child 的当前轮次 Stop。 - **`@` 引用仍是显示标题文本**:重复或改名后的 label 会有歧义,因此它们刻意不获得继续执行语义。 diff --git a/packages/client/ui-subagent/src/client/index.ts b/packages/client/ui-subagent/src/client/index.ts index 95626cbaf1..73c7f9a6cc 100644 --- a/packages/client/ui-subagent/src/client/index.ts +++ b/packages/client/ui-subagent/src/client/index.ts @@ -43,7 +43,11 @@ function selectReadOnlySubagent(owner: ComposerChainProps): SubagentReadOnlyMatc const subagent = owner.session?.subagent if (subagent === undefined || subagent === null) return null if (subagent.address.mode === 'one-shot') return { reason: 'one-shot' } - return subagent.parentAvailable ? null : { reason: 'parent-unavailable' } + if (subagent.parentAvailable) return null + // A RUNNING parent-offline continuable child keeps the default composer: + // its input is disabled there, but the same primary Stop stays available so + // the child can be interrupted. Once it stops, this takeover returns. + return owner.session?.running === true ? null : { reason: 'parent-unavailable' } } /** diff --git a/packages/client/ui-subagent/tests/browser-plugin.spec.ts b/packages/client/ui-subagent/tests/browser-plugin.spec.ts index 09221e1e94..861546c19c 100644 --- a/packages/client/ui-subagent/tests/browser-plugin.spec.ts +++ b/packages/client/ui-subagent/tests/browser-plugin.spec.ts @@ -161,19 +161,26 @@ describe('apply', () => { const select = composerEntry.select as (owner: ComposerChainProps) => SubagentReadOnlyMatch | null const owner = ( subagent: ConversationSnapshot['subagent'] | undefined, + running = false, ): ComposerChainProps => ({ interactions: [], session: subagent === undefined ? undefined - : ({ subagent } as unknown as ConversationSnapshot), + : ({ subagent, running } as unknown as ConversationSnapshot), }) expect(select(owner(undefined))).toBeNull() expect(select(owner(null))).toBeNull() expect(select(owner({ address: { ...address, mode: 'one-shot' }, parentAvailable: true }))) .toEqual({ reason: 'one-shot' }) + // One-shot stays read-only even while running: it has no stop action. + expect(select(owner({ address: { ...address, mode: 'one-shot' }, parentAvailable: true }, true))) + .toEqual({ reason: 'one-shot' }) expect(select(owner({ address, parentAvailable: true }))).toBeNull() expect(select(owner({ address, parentAvailable: false }))) .toEqual({ reason: 'parent-unavailable' }) + // A RUNNING parent-offline continuable yields the default composer, whose + // disabled input still carries the primary Stop; stopped, it takes back over. + expect(select(owner({ address, parentAvailable: false }, true))).toBeNull() }) }) diff --git a/tsconfig.host.json b/tsconfig.host.json index 1997f0ea47..1a8ef6508a 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -55,6 +55,7 @@ "apps/web/tests/produced-file-mentions.e2e.ts", "apps/web/tests/subagent-conversation.e2e.ts", "apps/web/tests/subagent-interrupt.e2e.ts", + "apps/web/tests/subagent-interrupt-ui.e2e.ts", "apps/web/tests/sidebar-subagent-activity.e2e.ts", "apps/web/tests/bash-abort-row.e2e.ts", "apps/web/tests/skill-tool-row.e2e.ts", From 4b29f9ca7a7a72f1f6289717d5460ffff8b690cb Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 7 Aug 2026 16:13:54 +0800 Subject: [PATCH 016/189] test(web): refresh interrupt UI golden after master merge --- .../snapshots/subagent-interrupt/offline-composer.expected.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/apps/web/tests/snapshots/subagent-interrupt/offline-composer.expected.md b/apps/web/tests/snapshots/subagent-interrupt/offline-composer.expected.md index 6ea7b83bff..a0baa9a44f 100644 --- a/apps/web/tests/snapshots/subagent-interrupt/offline-composer.expected.md +++ b/apps/web/tests/snapshots/subagent-interrupt/offline-composer.expected.md @@ -9,9 +9,6 @@ - text: Explain event sourcing in one sentence. {{clock}} - button "Copy": - img -- button "Branch into a new conversation" [disabled]: - - img -- text: Available only on the last message of a completed turn - button "Context injection @deepseek-ai/dsh-system-prompt": - img - img From d769d3cbb7c32cc4f1a4ff32cf5b30af96ea9f34 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 6 Aug 2026 13:46:55 +0800 Subject: [PATCH 017/189] feat(subagent): let ancestors interrupt descendants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit interrupt_agent(agent_id) passes the calling agent as the ancestor authority for ctx.subagents.interrupt(); the service verifies live registry identity and recorded lineage, so a direct child or deeper descendant stops with the same generic parameter while send_message keeps its exact-direct-parent authority. Discovery: list_agents gains an optional scope. descendants walks the new SubagentService.listDescendants() — one lineage trace flattened in stable pre-order across ordinary and one-shot intermediates, each entry carrying its verified parentId and depth — and every status now comes from the live Agent registry (running/idle/complete). Refs #1535 --- ...subagent-catalog-and-list-agents.i18n.yaml | 4 +- ...urable-subagent-catalog-and-list-agents.md | 8 +- ...ble-subagent-catalog-and-list-agents.zh.md | 8 +- ...6-continuable-subagent-interrupt.i18n.yaml | 4 +- ...26-08-06-continuable-subagent-interrupt.md | 6 +- ...08-06-continuable-subagent-interrupt.zh.md | 6 +- apps/web/tests/shipped-composition.e2e.ts | 1 + docs/cordis-catalog/services.md | 19 +- docs/core-data-structures/subagent.i18n.yaml | 4 +- docs/core-data-structures/subagent.md | 23 +- docs/core-data-structures/subagent.zh.md | 23 +- .../system-prompt.expected.md | 21 +- .../tool-schemas.expected.json | 31 ++- .../both-mode-turn/tool-schemas.expected.json | 31 ++- .../code-mode-turn/system-prompt.expected.md | 21 +- .../lsp-definition/tool-schemas.expected.json | 31 ++- .../pty-tools/tool-schemas.expected.json | 31 ++- .../tool-schemas.expected.json | 31 ++- .../tool-schemas.1.expected.json | 31 ++- .../tool-schemas.1.expected.json | 31 ++- .../tool-schemas.1.expected.json | 31 ++- .../text-turn/tool-schemas.expected.json | 31 ++- .../web-fetch/tool-schemas.expected.json | 31 ++- .../cordis/tool-cordis/src/api-catalog.ts | 8 + .../core/tools/tests/gen-tool-catalog.spec.ts | 3 +- packages/subagent/subagent/README.i18n.yaml | 4 +- packages/subagent/subagent/README.md | 5 +- packages/subagent/subagent/README.zh.md | 7 +- packages/subagent/subagent/src/index.ts | 31 ++- .../subagent/subagent/src/list-children.ts | 159 ++++++++++++-- .../subagent/tests/list-children.spec.ts | 194 +++++++++++++++++ .../tool-subagent-control/README.i18n.yaml | 4 +- .../subagent/tool-subagent-control/README.md | 28 ++- .../tool-subagent-control/README.zh.md | 28 ++- .../tool-subagent-control/src/index.ts | 56 ++++- .../tool-subagent-control/src/list-agents.ts | 124 ++++++++--- .../tests/list-agents.spec.ts | 165 +++++++++++++- .../tests/tool-subagent-control.spec.ts | 202 +++++++++++++++++- scripts/gen-cordis-catalog.ts | 1 + scripts/gen-tool-catalog.ts | 5 +- scripts/type-equiv.manifest.json | 5 + 41 files changed, 1325 insertions(+), 162 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.i18n.yaml b/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.i18n.yaml index 2acc3dd5b5..c525cc8f43 100644 --- a/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-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 diff --git a/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md b/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md index b96d6e1dd3..8337a92623 100644 --- a/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md +++ b/.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md @@ -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 ` [] —