From 83ae7046f2fc9e8ea953f392feffeb5fefa0391e Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 30 Jul 2026 17:54:48 +0800 Subject: [PATCH 01/19] fix(install): preserve pnpm version errors --- scripts/install.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/install.sh b/scripts/install.sh index 41d5c749c1..e4e8c175da 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -186,7 +186,7 @@ fi # pnpm is the only dependency we offer to install for you. if command -v pnpm >/dev/null 2>&1; then - info "pnpm $(pnpm --version 2>/dev/null) ... ok" + info "pnpm $(pnpm --version) ... ok" else warn "pnpm is not installed." if confirm "Install pnpm now?" Y; then From 7aa9e5108480eb2aa6cf2f5145d41e9eaf12b3f6 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Sat, 1 Aug 2026 06:15:16 -0700 Subject: [PATCH 02/19] fix(cli): give shipped web search a 60s budget --- .../feature/2026-07-31-web-default-search.i18n.yaml | 4 ++-- .../implemented/feature/2026-07-31-web-default-search.md | 6 ++++-- .../feature/2026-07-31-web-default-search.zh.md | 6 ++++-- apps/cli/config/base.cordis.yml | 5 ++++- apps/cli/tests/built-bin.e2e.ts | 7 +++++++ 5 files changed, 21 insertions(+), 7 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-31-web-default-search.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-web-default-search.i18n.yaml index 2c8f3eea48..1c04bc1d55 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-default-search.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-web-default-search.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-31-web-default-search.md -2026-07-31-web-default-search.md: 121a1dff5fffd4223eefcc7475fff874276658aa -2026-07-31-web-default-search.zh.md: ac98f4806413cb6050a08354a553942437866fe1 +2026-07-31-web-default-search.md: 8f2cc6f5d1a9ec9f33279ae8e5c992bdc660e7fa +2026-07-31-web-default-search.zh.md: 7d528617143cb9756d0a4532bb1ed6a3a3e70ca8 diff --git a/.agents/notes/implemented/feature/2026-07-31-web-default-search.md b/.agents/notes/implemented/feature/2026-07-31-web-default-search.md index 121a1dff5f..8f2cc6f5d1 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-default-search.md +++ b/.agents/notes/implemented/feature/2026-07-31-web-default-search.md @@ -10,7 +10,7 @@ The harness had a complete Web capability family—provider registry, DeepSeek/E ## Decision -`apps/cli/config/base.cordis.yml` explicitly mounts `dsh-web` with `searchProvider: deepseek-official`, `dsh-web-search-deepseek`, and `dsh-tool-web` with `fetch: false`. It does not mount `dsh-web-fetch-local` or select a fetch provider. The shared base makes only `web_search` a default for TUI, browser, and headless sessions. The explicit search provider id keeps selection independent of registration order and leaves personal or `--config` overlays able to replace or disable the rows. +`apps/cli/config/base.cordis.yml` explicitly mounts `dsh-web` with `searchProvider: deepseek-official`, `dsh-web-search-deepseek`, and `dsh-tool-web` with `fetch: false` and `searchTimeoutMs: 60000`. It does not mount `dsh-web-fetch-local` or select a fetch provider. The shared base makes only `web_search` a default for TUI, browser, and headless sessions. The explicit search provider id keeps selection independent of registration order and leaves personal or `--config` overlays able to replace or disable the rows. The one-minute shipped budget covers an auxiliary DeepSeek Messages request plus server-side retrieval while leaving `dsh-tool-web`'s provider-neutral 30-second default unchanged for custom compositions. DeepSeek search uses the same `DEEPSEEK_API_KEY` credential reference as the official conversation adapter. The provider resolves that reference inside every search through the optional `ctx.credentials` service; only a composition without the seam falls back to the launching process environment, and a non-empty literal `apiKey` remains the programmatic last resort. A stored or rotated Web Models key therefore reaches the next search without restarting or retaining the value on the provider. Because `WebSearchProvider.available()` is synchronous, it treats an installed resolver as locally usable and missing dynamic credentials fail the operation with the provider-specific `WEB_PROVIDER_CREDENTIAL_MISSING` code while the stable tool schema stays registered. @@ -28,8 +28,10 @@ The default mount does not create a Web-specific permission policy. `web_search` **Keep Web tools in `web.cordis.yml`.** Rejected because it preserves an unexplained tool-roster difference between TUI and Web/headless. The rows are not surface-specific, so `base.cordis.yml` is their one home; the [tool-roster decision](2026-07-31-even-out-shipped-tool-rosters.md) records the shared composition. +**Raise `dsh-tool-web`'s provider-neutral timeout.** Rejected because custom providers and deployments own different latency expectations; the shipped DeepSeek composition owns this deployment budget. + **Enable search and fetch together.** Rejected because default `web_fetch` would allow model-selected anonymous outbound HTTP(S) retrieval to arbitrary URLs. Search covers discovery; deployments that accept broader retrieval can opt into `dsh-web-fetch-local` and set `dsh-tool-web`'s `fetch` option to `true` in their overlay. ## Consequences -Native model requests on every shipped surface carry only the `web_search` schema and search-only prompt guidance; Web/headless Code Mode exposes the same search capability beneath `run_code`. The prompt tells the model to use returned snippets and never advertises the disabled `web_fetch` tool. Search adds a complete auxiliary model call and may use the native server tool multiple times; its exact secret-free request remains reconstructable from the initiating session log. The default offers search-result snippets and source metadata but no arbitrary page retrieval; deployments that need full-page fetch must opt in. The Web snapshot lane boots the shipped tree, drives a replayed `web_search` call through the real DeepSeek provider against a local Messages fixture, asserts the durable auxiliary request and structured result, and pins the settled browser presentation. The TUI/Web composition smokes pin the shared `web_search` roster and absence of `web_fetch`; provider tests pin missing, stored, and rotated credential behavior plus literal and ambient compatibility. +Native model requests on every shipped surface carry only the `web_search` schema and search-only prompt guidance; Web/headless Code Mode exposes the same search capability beneath `run_code`. The prompt tells the model to use returned snippets and never advertises the disabled `web_fetch` tool. Search adds a complete auxiliary model call and may use the native server tool multiple times; its exact secret-free request remains reconstructable from the initiating session log. The default offers search-result snippets and source metadata but no arbitrary page retrieval; deployments that need full-page fetch must opt in. The Web snapshot lane boots the shipped tree, drives a replayed `web_search` call through the real DeepSeek provider against a local Messages fixture, asserts the durable auxiliary request and structured result, and pins the settled browser presentation. The TUI/Web composition smokes pin the shared `web_search` roster and absence of `web_fetch`; the built composition dump pins the one-minute shipped search budget; provider tests pin missing, stored, and rotated credential behavior plus literal and ambient compatibility. diff --git a/.agents/notes/implemented/feature/2026-07-31-web-default-search.zh.md b/.agents/notes/implemented/feature/2026-07-31-web-default-search.zh.md index ac98f48064..7d52861714 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-default-search.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-web-default-search.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -`apps/cli/config/base.cordis.yml` 明确挂载 `dsh-web`,配置 `searchProvider: deepseek-official`,同时挂载 `dsh-web-search-deepseek`,并以 `fetch: false` 挂载 `dsh-tool-web`。它不挂载 `dsh-web-fetch-local`,也不选择抓取提供方。共享 base 只将 `web_search` 设为 TUI、浏览器与无头会话的默认工具。显式搜索提供方 id 使选择不受注册顺序影响,同时个人覆盖层或 `--config` 覆盖层仍可替换或禁用这些配置项。 +`apps/cli/config/base.cordis.yml` 明确挂载 `dsh-web`,配置 `searchProvider: deepseek-official`,同时挂载 `dsh-web-search-deepseek`,并以 `fetch: false` 和 `searchTimeoutMs: 60000` 挂载 `dsh-tool-web`。它不挂载 `dsh-web-fetch-local`,也不选择抓取提供方。共享 base 只将 `web_search` 设为 TUI、浏览器与无头会话的默认工具。显式搜索提供方 id 使选择不受注册顺序影响,同时个人覆盖层或 `--config` 覆盖层仍可替换或禁用这些配置项。已交付的一分钟预算用于覆盖一次辅助 DeepSeek Messages 请求及服务端检索,同时保持 `dsh-tool-web` 提供方无关的 30 秒默认值不变,以供自定义组合使用。 DeepSeek 搜索使用与官方会话适配器相同的 `DEEPSEEK_API_KEY` 凭据引用。提供方在每次搜索内部通过可选的 `ctx.credentials` 服务解析该引用;只有未挂载该 seam 的组合才会回退到启动进程的环境变量,非空的 `apiKey` 字面值仍作为程序化配置的最后兜底。因此,由 Web 的 Models 页存储或轮换的密钥无需重启即可用于下一次搜索,提供方也无需保留该值。由于 `WebSearchProvider.available()` 是同步方法,它会将已安装解析器视为本地可用;若动态凭据缺失,操作会以提供方专属错误码 `WEB_PROVIDER_CREDENTIAL_MISSING` 失败,而稳定的工具 schema 仍保持注册。 @@ -28,8 +28,10 @@ DeepSeek 搜索使用与官方会话适配器相同的 `DEEPSEEK_API_KEY` 凭据 **将 Web 工具保留在 `web.cordis.yml` 中。** 不予采纳:这会保留 TUI 与 Web/无头界面之间无法解释的工具清单差异。这些配置行并非界面特有,因此其唯一归属是 `base.cordis.yml`;[工具清单决策](2026-07-31-even-out-shipped-tool-rosters.md)记录了这一共享组合。 +**提高 `dsh-tool-web` 的提供方无关超时。** 不予采纳:自定义提供方和部署有各自不同的延迟预期;这一部署预算应归已交付的 DeepSeek 组合所有。 + **同时启用搜索和抓取。** 不予采纳:默认启用 `web_fetch` 会允许模型自行选择任意 URL,执行匿名出站 HTTP(S) 抓取。搜索负责发现信息;接受更广泛抓取范围的部署可以在覆盖层中选择启用 `dsh-web-fetch-local`,并将 `dsh-tool-web` 的 `fetch` 选项设为 `true`。 ## 后果 -每个已交付界面的原生模型请求都只会携带 `web_search` schema,以及仅用于搜索的提示词指引;Web/无头 Code Mode 通过 `run_code` 公开相同的搜索能力。该提示词要求模型使用返回的 snippet,且绝不会向模型提及已禁用的 `web_fetch` 工具。搜索会增加一次完整的辅助模型调用,并可能多次使用原生服务器工具;发起会话的日志仍可精确重建其不含密钥的请求。默认配置会提供搜索结果 snippet 与来源元数据,但不支持任意页面抓取;需要抓取完整页面的部署必须自行选择启用抓取。Web 快照通道会启动已交付配置树,使用本地 Messages fixture(测试前置数据),经由真实 DeepSeek 提供方驱动一次回放的 `web_search` 调用,断言持久化的辅助请求与结构化结果,并固定最终浏览器呈现。TUI/Web 组合冒烟测试固定了共享的 `web_search` 清单及不提供 `web_fetch` 这一事实;提供方测试固定缺失、已存储及已轮换凭据的行为,以及字面值与环境变量的兼容性。 +每个已交付界面的原生模型请求都只会携带 `web_search` schema,以及仅用于搜索的提示词指引;Web/无头 Code Mode 通过 `run_code` 公开相同的搜索能力。该提示词要求模型使用返回的 snippet,且绝不会向模型提及已禁用的 `web_fetch` 工具。搜索会增加一次完整的辅助模型调用,并可能多次使用原生服务器工具;发起会话的日志仍可精确重建其不含密钥的请求。默认配置会提供搜索结果 snippet 与来源元数据,但不支持任意页面抓取;需要抓取完整页面的部署必须自行选择启用抓取。Web 快照通道会启动已交付配置树,使用本地 Messages fixture(测试前置数据),经由真实 DeepSeek 提供方驱动一次回放的 `web_search` 调用,断言持久化的辅助请求与结构化结果,并固定最终浏览器呈现。TUI/Web 组合冒烟测试固定了共享的 `web_search` 清单及不提供 `web_fetch` 这一事实;构建后组合配置的转储固定了已交付的一分钟搜索预算;提供方测试固定缺失、已存储及已轮换凭据的行为,以及字面值与环境变量的兼容性。 diff --git a/apps/cli/config/base.cordis.yml b/apps/cli/config/base.cordis.yml index e88cbdb39d..b1810215e4 100644 --- a/apps/cli/config/base.cordis.yml +++ b/apps/cli/config/base.cordis.yml @@ -332,7 +332,9 @@ # chat, at each search; its Messages endpoint is separate from the # chat-completions endpoint, so it takes its own base-URL override. Fetch stays # disabled and no fetch provider is mounted: that provider defers SSRF -# protection and the model would choose the request target. +# protection and the model would choose the request target. Search is a full +# auxiliary model request with server-side retrieval, so this shipped DeepSeek +# route gets 60s while the provider-neutral tool default remains 30s. - id: web name: '@deepseek-ai/dsh-web' config: @@ -348,6 +350,7 @@ name: '@deepseek-ai/dsh-tool-web' config: fetch: false + searchTimeoutMs: 60000 # ── rows every surface mounts, whose values each overlay states ────────────── diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index 9591ca3105..3592d438dd 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -94,6 +94,13 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', expect(stdout).toContain('model: deepseek-v4-pro') expect(stdout).toContain('cwd: !!js process.cwd()') expect(stdout).toContain("name: '@deepseek-ai/dsh-tui'") + expect(stdout).toContain([ + '- id: tool-web', + " name: '@deepseek-ai/dsh-tool-web'", + ' config:', + ' fetch: false', + ' searchTimeoutMs: 60000', + ].join('\n')) // Provenance comment separators name each section's source file. expect(stdout).toContain('# == base.cordis.yml') expect(stdout).toContain('# == base.cordis.yml, patched by tui.cordis.yml') From cd121c636a554a2a07b6469cef0e8700d723787e Mon Sep 17 00:00:00 2001 From: kingwl Date: Sun, 2 Aug 2026 12:45:36 +0800 Subject: [PATCH 03/19] fix(web): align composer context stack --- ...-30-composer-context-stack-order.i18n.yaml | 4 +- ...2026-07-30-composer-context-stack-order.md | 6 +- ...6-07-30-composer-context-stack-order.zh.md | 6 +- ...odo-first-composer-context-order.i18n.yaml | 6 ++ ...08-02-todo-first-composer-context-order.md | 33 ++++++++ ...02-todo-first-composer-context-order.zh.md | 33 ++++++++ .../2026-07-22-docked-web-goal-bar.i18n.yaml | 4 +- .../feature/2026-07-22-docked-web-goal-bar.md | 2 +- .../2026-07-22-docked-web-goal-bar.zh.md | 2 +- apps/web/tests/queue-actions.e2e.ts | 82 ++++++++++++++++++- .../queue-actions/layout.expected.md | 42 ++++++++++ .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../src/client/skeleton/TodoPanel.module.css | 8 +- .../src/client/skeleton/TodoPanel.tsx | 4 +- .../ui-conversation/tests/todo-panel.spec.tsx | 4 +- packages/client/ui-goal/README.i18n.yaml | 4 +- packages/client/ui-goal/README.md | 2 +- packages/client/ui-goal/README.zh.md | 2 +- .../ui-goal/src/client/GoalBar.module.css | 4 +- packages/client/ui-goal/src/client/index.ts | 2 +- .../ui-goal/tests/browser-plugin.spec.tsx | 2 +- 23 files changed, 228 insertions(+), 32 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-02-todo-first-composer-context-order.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-02-todo-first-composer-context-order.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-02-todo-first-composer-context-order.zh.md create mode 100644 apps/web/tests/snapshots/queue-actions/layout.expected.md diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-composer-context-stack-order.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-30-composer-context-stack-order.i18n.yaml index a6db678efa..fa83836e46 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-composer-context-stack-order.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-30-composer-context-stack-order.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/bug-fix/2026-07-30-composer-context-stack-order.md -2026-07-30-composer-context-stack-order.md: 9c269bcaf7fa360b6a5d0e16fd8cda48aa285d66 -2026-07-30-composer-context-stack-order.zh.md: 47288141ea4591b29adde0f85e810fc797740488 +2026-07-30-composer-context-stack-order.md: 150225ce27fa8459703170622cef754b7694e2fc +2026-07-30-composer-context-stack-order.zh.md: f13ffc3c1136707a86ba5636a5b69304a132df01 diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-composer-context-stack-order.md b/.agents/notes/implemented/bug-fix/2026-07-30-composer-context-stack-order.md index 9c269bcaf7..150225ce27 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-composer-context-stack-order.md +++ b/.agents/notes/implemented/bug-fix/2026-07-30-composer-context-stack-order.md @@ -10,7 +10,7 @@ Goal, Todo, and Queue contribute independently to the same `conversation.input.d ## Decision -The composer context stack has one canonical ascending order: Goal at `0`, Todo at `10`, and Queue at `20`, followed by the composer bar outside the list. The gaps leave room for future entries to declare their intended position without relying on plugin activation order. +The [Todo-first alignment decision](2026-08-02-todo-first-composer-context-order.md) owns the current ascending order. This note retains the stack contract around that order: numeric gaps leave room for future entries to declare their intended position without relying on plugin activation order, and the composer bar follows the list. `ConversationRoot` owns the 6px space between independent context cards. Goal is a standalone 752×36px card and collapsed Todo is a standalone 752×44px card. Queue is the terminal dock entry: its 776px wrapper contains the same 752px panel column and subtracts the shared gap plus a named 5px layout overlap, so the later composer card paints over only the queue edge. Empty entries render null and consume no gap. @@ -18,7 +18,7 @@ The order and overlap are separate contracts. Registration order establishes sem ## Verification -Registration tests pin all three order values. Browser screenshots cover the full Goal/Todo/Queue matrix, Goal+Todo without Queue, and Queue alone; together they exercise every adjacency: Goal–Todo, Todo–Queue, and Queue–Composer. +Registration tests pin all three order values. The keyless Queue browser scenario renders Todo, Goal, and Queue together, pins their accessibility order, and checks their visible card edges; focused Goal and Queue scenarios cover their independent states. ## Alternatives considered @@ -30,4 +30,4 @@ Registration tests pin all three order values. Browser screenshots cover the ful ## Consequences -The visual hierarchy is stable for every presence combination, and Queue is the only context surface joined to the composer. New input-dock plugins must choose an order relative to Goal `0`, Todo `10`, and Queue `20`; an entry after Queue also requires an explicit decision about which surface owns the composer boundary. +The visual hierarchy is stable for every presence combination, and Queue is the only context surface joined to the composer. New input-dock plugins must choose an order relative to Todo `0`, Goal `10`, and Queue `20`; an entry after Queue also requires an explicit decision about which surface owns the composer boundary. diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-composer-context-stack-order.zh.md b/.agents/notes/implemented/bug-fix/2026-07-30-composer-context-stack-order.zh.md index 47288141ea..f13ffc3c11 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-composer-context-stack-order.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-30-composer-context-stack-order.zh.md @@ -10,7 +10,7 @@ Goal、Todo 与 Queue 独立注册到同一个 `conversation.input.dock` 列表 ## 决策 -composer 上下文堆栈采用唯一规范的升序排列:Goal 为 `0`,Todo 为 `10`,Queue 为 `20`,随后是位于列表外的 composer bar。顺序值之间的空档使未来条目可以声明预期位置,不必依赖插件激活顺序。 +[Todo 优先的对齐决策](2026-08-02-todo-first-composer-context-order.md)规定当前的升序排列。本记录保留围绕该顺序的堆栈契约:数值间隔使未来条目可以声明预期位置,不必依赖插件激活顺序;composer bar 位于列表之后。 `ConversationRoot` 负责独立上下文卡片之间的 6px 间距。Goal 是一张独立的 752×36px 卡片,折叠后的 Todo 是一张独立的 752×44px 卡片。Queue 是末端 dock 条目:其 776px 包装层包含相同的 752px 面板列,并减去共享间距与具名的 5px 布局重叠量,因此后渲染的 composer 卡片只覆盖 Queue 边缘。空条目渲染为 null,不占用间距。 @@ -18,7 +18,7 @@ composer 上下文堆栈采用唯一规范的升序排列:Goal 为 `0`,Todo ## 验证 -注册测试固定了三个顺序值。浏览器截图覆盖完整的 Goal/Todo/Queue 组合矩阵、没有 Queue 的 Goal+Todo,以及仅有 Queue 的情况;这些场景共同覆盖全部相邻关系:Goal–Todo、Todo–Queue 与 Queue–Composer。 +注册测试固定了三个顺序值。无密钥 Queue 浏览器场景同时渲染 Todo、Goal 和 Queue,固定它们的无障碍顺序,并检查其可见卡片边缘;聚焦的 Goal 与 Queue 场景覆盖各自的独立状态。 ## 考虑过的替代方案 @@ -30,4 +30,4 @@ composer 上下文堆栈采用唯一规范的升序排列:Goal 为 `0`,Todo ## 后果 -所有存在组合下的视觉层级都保持稳定,Queue 是唯一与 composer 相接的上下文界面。新的 input-dock 插件必须相对于 Goal `0`、Todo `10` 与 Queue `20` 选择顺序;若条目位于 Queue 之后,还必须明确决定由哪个界面负责 composer 边界。 +所有存在组合下的视觉层级都保持稳定,Queue 是唯一与 composer 相接的上下文界面。新的 input-dock 插件必须相对于 Todo `0`、Goal `10` 与 Queue `20` 选择顺序;若条目位于 Queue 之后,还必须明确决定由哪个界面负责 composer 边界。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-02-todo-first-composer-context-order.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-02-todo-first-composer-context-order.i18n.yaml new file mode 100644 index 0000000000..9e28778e02 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-02-todo-first-composer-context-order.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-02-todo-first-composer-context-order.md +2026-08-02-todo-first-composer-context-order.md: 1d8bd9cfc30069530420e802f935b88b0b2f7cef +2026-08-02-todo-first-composer-context-order.zh.md: 9324ed666fa33cdbdb324eb732a979344ae4d8a2 diff --git a/.agents/notes/implemented/bug-fix/2026-08-02-todo-first-composer-context-order.md b/.agents/notes/implemented/bug-fix/2026-08-02-todo-first-composer-context-order.md new file mode 100644 index 0000000000..1d8bd9cfc3 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-02-todo-first-composer-context-order.md @@ -0,0 +1,33 @@ +# Agent Note: Todo-first composer context order + +Status: implemented + +English | [中文](2026-08-02-todo-first-composer-context-order.zh.md) + +## Problem + +The composer context stack rendered Goal before Todo even though the Harness design orders the current task plan before its ongoing goal and pending Queue. Todo also used the Queue wrapper's 776px width as its visible card width, while Goal and the Queue panel rendered on the shared 752px card column. The result inverted the intended information hierarchy and left Todo wider than both adjacent panels. + +## Decision + +The `conversation.input.dock` list uses one ascending product order: Todo at `0`, Goal at `10`, and Queue at `20`, followed by the composer bar outside the list. Registration order remains the semantic source of truth; the renderer does not hardcode known component ids or repair their order with CSS. + +Todo, Goal, and the visible Queue panel share the 752px card column inside the 800px composer cap. Queue retains a 776px wrapper with 12px transparent inset on each side because that wrapper owns the composer overlap. Todo is a standalone card rather than a wrapper, so its maximum width subtracts both inset layers directly and exposes the same left and right edges as Goal and the Queue panel. + +The [composer stack contract](2026-07-30-composer-context-stack-order.md) continues to own inter-card spacing and Queue's exclusive overlap with the composer. This decision supersedes only that note's Goal-first order. + +## Verification + +Todo and Goal registration tests pin orders `0` and `10`; Queue remains pinned at `20`. The keyless Queue browser scenario renders all three panels concurrently, records their Todo–Goal–Queue accessibility order, and compares their visible bounding boxes at the desktop viewport before exercising Queue mutations. + +## Alternatives considered + +**Reorder the known panels inside `ConversationRoot`.** Rejected because `conversation.input.dock` is an extensible ordered list; a hardcoded component inventory would make plugin activation order and rendered order disagree. + +**Use CSS `order` to move Todo visually.** Rejected because accessibility and keyboard order must match the visual hierarchy, and the slot ledger already owns semantic order. + +**Keep Todo at the Queue wrapper width.** Rejected because the Queue wrapper's transparent inset is layout infrastructure for its composer overlap, not part of the visible panel column. + +## Consequences + +The standing task plan appears before the ongoing goal, pending Queue work remains closest to the composer, and all three visible cards share one horizontal edge. Future input-dock plugins choose an explicit position relative to Todo `0`, Goal `10`, and Queue `20`; only Queue owns the terminal wrapper overlap. diff --git a/.agents/notes/implemented/bug-fix/2026-08-02-todo-first-composer-context-order.zh.md b/.agents/notes/implemented/bug-fix/2026-08-02-todo-first-composer-context-order.zh.md new file mode 100644 index 0000000000..9324ed666f --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-02-todo-first-composer-context-order.zh.md @@ -0,0 +1,33 @@ +# Agent Note: Todo 优先的 composer 上下文顺序 + +Status: implemented + +[English](2026-08-02-todo-first-composer-context-order.md) | 中文 + +## 问题 + +composer 上下文堆栈将 Goal 渲染在 Todo 之前,但 Harness 设计稿把当前任务计划排在进行中的目标和待处理 Queue 之前。Todo 还把 Queue 包装层的 776px 宽度用作自身的可见卡片宽度,而 Goal 和 Queue 面板则渲染在共享的 752px 卡片列上。结果既颠倒了预期的信息层级,也让 Todo 比相邻两个面板更宽。 + +## 决策 + +`conversation.input.dock` 列表采用统一的产品顺序,升序依次为 Todo `0`、Goal `10`、Queue `20`,随后是位于列表外的 composer bar。注册顺序仍是语义真源;渲染器不会硬编码已知组件 id,也不会使用 CSS 修正它们的顺序。 + +Todo、Goal 与可见的 Queue 面板共用 800px composer 宽度上限内的 752px 卡片列。Queue 保留 776px 包装层,并在两侧各留 12px 透明内缩,因为该包装层负责与 composer 重叠。Todo 是独立卡片,而非包装层,因此其最大宽度会直接扣除两层内缩,使左右边缘与 Goal 和 Queue 面板一致。 + +[composer 堆栈契约](2026-07-30-composer-context-stack-order.md)继续规定卡片间距,以及仅限 Queue 与 composer 重叠。本决策只取代该记录中 Goal 优先的顺序。 + +## 验证 + +Todo 与 Goal 的注册测试分别固定顺序 `0` 和 `10`;Queue 仍固定为 `20`。无密钥 Queue 浏览器场景同时渲染三个面板,记录 Todo–Goal–Queue 的无障碍顺序,并在桌面视口下比较其可见边界框,随后再执行 Queue 变更。 + +## 考虑过的替代方案 + +**在 `ConversationRoot` 内重新排列已知面板。** 不予采纳,因为 `conversation.input.dock` 是可扩展的有序列表;硬编码的组件清单会使插件激活顺序与渲染顺序不一致。 + +**使用 CSS `order` 移动 Todo 的视觉位置。** 不予采纳,因为无障碍顺序和键盘顺序必须与视觉层级一致,而 slot 账本已经负责语义顺序。 + +**让 Todo 保持 Queue 包装层的宽度。** 不予采纳,因为 Queue 包装层的透明内缩是其与 composer 重叠所需的布局基础设施,不属于可见面板列。 + +## 后果 + +当前有效的任务计划显示在进行中的目标之前,待处理 Queue 工作仍最靠近 composer,三张可见卡片共用相同的横向边缘。未来的 input-dock 插件必须相对于 Todo `0`、Goal `10` 和 Queue `20` 选择明确位置;仅 Queue 负责末端包装层与 composer 的重叠。 diff --git a/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.i18n.yaml b/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.i18n.yaml index 7a233e76ec..e1999f49cb 100644 --- a/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.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-docked-web-goal-bar.md -2026-07-22-docked-web-goal-bar.md: 30f1d45e80cb2759175948f5683b499720ab50f0 -2026-07-22-docked-web-goal-bar.zh.md: 4c8481e64d9e5177a962f10ab1d043e761d07545 +2026-07-22-docked-web-goal-bar.md: 21f200f165acdf370a5896acb8f54afe33a4ee99 +2026-07-22-docked-web-goal-bar.zh.md: eb8e3edda54207b06e25812a38f969e74f342c34 diff --git a/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.md b/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.md index 30f1d45e80..21f200f165 100644 --- a/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.md +++ b/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.md @@ -10,7 +10,7 @@ The web UI had no goal surface at all: the goal stack shipped with model tools, ## Decision -`GoalBar` (`packages/client/ui-goal/src/client/GoalBar.tsx`) is a props-driven, self-contained component registered first in the composer's input-dock list. Its standalone 752px card follows the composer's horizontal geometry, and every visible state shares one fixed 36px height so switching phases never resizes it. Loading (`goal === undefined`), absent (`goal === null`), and `phase === 'complete'` render nothing — a completed goal is history, not chrome. +`GoalBar` (`packages/client/ui-goal/src/client/GoalBar.tsx`) is a props-driven, self-contained component registered second in the composer's input-dock list, after Todo and before Queue. Its standalone 752px card follows the composer's horizontal geometry, and every visible state shares one fixed 36px height so switching phases never resizes it. Loading (`goal === undefined`), absent (`goal === null`), and `phase === 'complete'` render nothing — a completed goal is history, not chrome. Visibility drives the label and actions: active shows "Ongoing Goal" with pause/edit/clear; paused shows "Paused Goal" and swaps pause for a resume icon button; blocked shows "Blocked Goal" and carries `blockedReason.message` as the strip's `title` tooltip. Goal creation lives on the `/goal` command, not in the bar. The pencil swaps the strip for an inline edit form prefilled with the current objective: Enter or the check button saves through `GoalBarActions.onEdit(objective)`, Esc cancels, and an all-whitespace objective keeps save disabled. The form closes only when the edit succeeds; a failure preserves the draft and displays the error in the bar. Resume and clear failures are displayed there as well. Clear otherwise calls `onClear` directly with no confirmation — a clear keeps a durable tombstone, so nothing is unrecoverable. Every mutation first acquires a synchronous component-local single-flight latch because React's pending-state render cannot close the same-frame click window. A successful clear also suppresses that exact goal id immediately while the authoritative null projection catches up, so an acknowledged tombstone cannot leave a stale clear control that submits `GOAL_NOT_FOUND`; a failure releases the latch and remains retryable. An effect keyed on the goal's id resets this transient state and drops the edit form when the goal's identity changes, so neither a cleared marker nor a surviving draft can affect the replacement goal. diff --git a/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.zh.md b/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.zh.md index 4c8481e64d..eb8e3edda5 100644 --- a/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.zh.md +++ b/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.zh.md @@ -10,7 +10,7 @@ Web UI 此前没有任何目标相关的界面:目标栈已随模型工具、T ## 决策 -`GoalBar`(`packages/client/ui-goal/src/client/GoalBar.tsx`)是一个由 props 驱动的自包含组件,在 composer 的 input-dock 列表中注册为第一个条目。它采用独立的 752px 卡片,遵循 composer 的水平几何;所有可见状态均使用固定的 36px 高度,切换阶段不会改变尺寸。加载中(`goal === undefined`)、无目标(`goal === null`)和 `phase === 'complete'` 时不渲染任何内容:已完成的目标是历史记录,不是常驻界面元素。 +`GoalBar`(`packages/client/ui-goal/src/client/GoalBar.tsx`)是一个由 props 驱动的自包含组件,在 composer 的 input-dock 列表中注册为第二个条目,位于 Todo 之后、Queue 之前。它采用独立的 752px 卡片,遵循 composer 的水平几何;所有可见状态均使用固定的 36px 高度,切换阶段不会改变尺寸。加载中(`goal === undefined`)、无目标(`goal === null`)和 `phase === 'complete'` 时不渲染任何内容:已完成的目标是历史记录,不是常驻界面元素。 可见性决定标签和操作:active 状态显示 "Ongoing Goal" 并提供暂停/编辑/清除;paused 状态显示 "Paused Goal",把暂停换成一个恢复图标按钮;blocked 状态显示 "Blocked Goal",并把 `blockedReason.message` 作为横条的 `title` 悬浮提示。创建目标的入口在 `/goal` 命令上,不在横条里。铅笔图标把横条切换为内联编辑表单,预填当前目标内容:Enter 或勾选按钮通过 `GoalBarActions.onEdit(objective)` 保存,Esc 取消,目标内容全为空白字符时保存按钮保持禁用。编辑成功后表单才会关闭;编辑失败时保留草稿,并在横条中显示错误。恢复和清除失败也显示在横条中。除此之外,清除直接调用 `onClear`,不做确认——清除会保留 durable 墓碑,没有不可恢复的损失。每次变更都会先取得一个同步的组件内 single-flight 锁,因为 React 的 pending 状态渲染无法关闭同一帧内的点击窗口。清除成功后还会立即抑制该 goal id,直到权威的 null 投影追上,因此已确认的墓碑不会留下陈旧的清除控件并再次提交 `GOAL_NOT_FOUND`;失败则释放锁,并且仍可重试。一个以目标 id 为键的 effect 会在目标身份变化时重置瞬态状态并丢弃编辑表单,因此无论已清除标记还是存留草稿,都不会影响替换目标。 diff --git a/apps/web/tests/queue-actions.e2e.ts b/apps/web/tests/queue-actions.e2e.ts index 006b2816cd..0def7d9fa2 100644 --- a/apps/web/tests/queue-actions.e2e.ts +++ b/apps/web/tests/queue-actions.e2e.ts @@ -22,6 +22,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/queue-actions', import.m const FIXTURE = fileURLToPath(new URL('./snapshots/live-interactions/session.jsonl', import.meta.url)) const COLLAPSED_EXPECTED = join(SNAPSHOT_DIR, 'collapsed.expected.md') const EDITING_EXPECTED = join(SNAPSHOT_DIR, 'editing.expected.md') +const LAYOUT_EXPECTED = join(SNAPSHOT_DIR, 'layout.expected.md') const PRESERVED_EXPECTED = join(SNAPSHOT_DIR, 'preserved.expected.md') const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md') const MODE = webSnapshotMode() @@ -172,10 +173,89 @@ describe('web e2e: queue row actions', () => { await expect.poll(() => page.locator('[data-queue-dock]').count()).toBe(0) }, 120_000) + it.skipIf(MODE === 'record')('orders Todo before Goal and Queue on one desktop card column', async () => { + overrideDir = await mkdtemp(join(tmpdir(), 'dsh-web-context-layout-')) + const readyFile = join(overrideDir, '.hang-ready') + const overridePath = join(overrideDir, 'replay.override.json') + await writeFile(overridePath, JSON.stringify([{ kind: 'hang', readyFile } satisfies ReplayEntry])) + + const sessionEvents: SessionEvent[] = [] + scaffold = await launchWebScaffold({ replayFixture: FIXTURE, replayOverride: overridePath }) + scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) }) + browser = await chromium.launch() + page = await newEnglishPage(browser) + const tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + onTestFailed(() => saveFailureShot(page, 'web-e2e-context-layout')) + + const input = page.locator('textarea').first() + const settled = scaffold.whenTurnSettled() + await input.fill('/goal Keep the composer context panels aligned') + await input.press('Enter') + await expect.poll(() => existsSync(readyFile), { timeout: 15_000 }).toBe(true) + await page.locator('[data-goal-bar]').waitFor({ timeout: 10_000 }) + + const sessions = scaffold.ctx.sessions.list() + expect(sessions).toHaveLength(1) + sessions[0]!.append('todo/write', { + todos: [ + { content: 'Confirm the panel order', status: 'completed' }, + { content: 'Align the panel widths', status: 'in_progress' }, + ], + }) + await page.locator('[data-testid="todo-panel"]').waitFor({ timeout: 10_000 }) + + for (const text of ['Layout queue first', 'Layout queue second']) { + await input.fill(text) + await input.press('Enter') + } + const queueHeader = page.getByRole('button', { name: '2 queued messages' }) + await expect.poll(() => queueHeader.getAttribute('aria-expanded'), { timeout: 10_000 }) + .toBe('false') + + const layoutSnapshot = await captureStableAria( + page, + '[class*="centerCol"]', + scaffold.workspaceCwd, + ) + await compareOrRefreshGolden(LAYOUT_EXPECTED, layoutSnapshot, MODE) + + const queuePanelBox = await page.locator('[data-queue-dock] > div').boundingBox() + const todoBox = await page.locator('[data-testid="todo-panel"]').boundingBox() + const goalBox = await page.locator('[data-goal-bar] > div').boundingBox() + expect(queuePanelBox).not.toBeNull() + expect(todoBox).not.toBeNull() + expect(goalBox).not.toBeNull() + expect(todoBox!.y).toBeLessThan(goalBox!.y) + expect(goalBox!.y).toBeLessThan(queuePanelBox!.y) + expect(todoBox!.x).toBeCloseTo(goalBox!.x, 1) + expect(todoBox!.x).toBeCloseTo(queuePanelBox!.x, 1) + expect(todoBox!.width).toBeCloseTo(goalBox!.width, 1) + expect(todoBox!.width).toBeCloseTo(queuePanelBox!.width, 1) + + await queueHeader.click() + const removeButtons = page.getByRole('button', { name: 'Remove queued message' }) + await expect.poll(() => removeButtons.count(), { timeout: 10_000 }).toBe(2) + await removeButtons.first().click() + await expect.poll(() => removeButtons.count(), { timeout: 10_000 }).toBe(1) + await removeButtons.first().click() + await expect.poll(() => page.locator('[data-queue-dock]').count(), { timeout: 10_000 }).toBe(0) + await page.getByRole('button', { name: 'Clear goal' }).click() + await expect.poll(() => page.locator('[data-goal-bar]').count(), { timeout: 10_000 }).toBe(0) + await page.getByRole('button', { name: 'Stop generating' }).click() + await settled + + expect(turnEndReasons(sessionEvents)).toEqual(['aborted']) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 120_000) + it.skipIf(MODE === 'record')('keeps its snapshot inventory closed', async () => { await assertFixtureInventory( SNAPSHOT_DIR, - ['collapsed.expected.md', 'editing.expected.md', 'preserved.expected.md', 'ui.expected.md'], + ['collapsed.expected.md', 'editing.expected.md', 'layout.expected.md', 'preserved.expected.md', 'ui.expected.md'], ) }) }) diff --git a/apps/web/tests/snapshots/queue-actions/layout.expected.md b/apps/web/tests/snapshots/queue-actions/layout.expected.md new file mode 100644 index 0000000000..a50c996de3 --- /dev/null +++ b/apps/web/tests/snapshots/queue-actions/layout.expected.md @@ -0,0 +1,42 @@ +- banner: + - heading "workspace" [level=1] + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- 'button "goal Goal created Status: active Objective: Keep the composer context panels aligned Rounds: 0/256 Activation: armed Commands: /goal edit , /goal pause, /goal clear"': + - img + - img + - text: "goal Goal created Status: active Objective: Keep the composer context panels aligned Rounds: 0/256 Activation: armed Commands: /goal edit , /goal pause, /goal clear" +- button "Context injection": + - img + - img + - text: Context injection +- button "Context injection": + - img + - img + - text: Context injection +- button "Context injection": + - img + - img + - text: Context injection +- paragraph: partial +- status: Deep diving... +- region "To-dos": + - button "To-dos 1/2 tasks · 1 in progress" +- img +- text: Ongoing Goal Keep the composer context panels aligned +- button "Pause goal": + - img +- button "Edit goal": + - img +- button "Clear goal": + - img +- button "2 queued messages" +- textbox "Message the agent" +- button "Commands": + - img +- 'button "Access mode, current: Workspace Write"': Workspace Write +- button "Select model, current DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash + - img +- button "Stop generating" diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 9809f132b8..f9e31b01e6 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: 845e12d760326b97a7e1fbffbd1655a73c5b5174 -README.zh.md: eab8d6663848b1130e41e2d581f13ad93a50b545 +README.md: a1704e66129bbca983785ec145cd584cc44f8bfd +README.zh.md: a08dd14c6fa23fc43ffe7fb17bc1041f71aece0a diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 845e12d760..a1704e6612 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -30,7 +30,7 @@ A `grep`/`glob` call declaring the `search` render intent renders its result inl Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openFile`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); the bash sample is the third-party-posture exemplar. Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders). -The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`/ 已完成 · ` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: -1` — above the queue rows — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and starts collapsed as a header of title plus `"/ tasks · in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the standing list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included. +The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`/ 已完成 · ` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: 0` — before Goal and Queue — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and starts collapsed as a header of title plus `"/ tasks · in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the standing list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included. `QueueDock` is the terminal input-dock entry at `order: 20`. It hides while empty, renders one pending row directly, and defaults two or more rows to a collapsed `" 条排队消息"` header whose button expands or collapses the complete list. The header exposes `aria-expanded` and `aria-controls`; the expanded list scrolls within a 180px height bound. An active edit or mutation keeps its rows visible, and emptying the queue restores the collapsed default for the next queue. Each visible row remains a single-line preview with its exact-occurrence edit and delete actions. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index eab8d66638..a08dd14c6f 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -30,7 +30,7 @@ 审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。侧边栏通过 manager 跟踪的 `waitingApproval` 列表位(未实例化会话同样点亮)镜像该阻塞状态,其优先级高于运行中圆环,直至问题解决。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,其中 kebab-case 预设名渲染为 Title Case 标签;普通安全预设会立即经输入栏注入的 `command` 回调提交 `/permission `,而 `danger-full-access` 在界面中显示为 `Full access`,选择后先打开页面内的 Modal 风险确认。用户勾选确认项前启用按钮始终不可用;取消、Escape、关闭按钮与点击遮罩都不会提交命令。 -todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: -1` 占用 `'conversation.input.dock'` 列表 slot(位于队列行之上),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加 `"<已完成>/<总数> tasks · in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。 +todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: 0` 占用 `'conversation.input.dock'` 列表 slot(位于 Goal 与 Queue 之前),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加 `"<已完成>/<总数> tasks · in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。 `QueueDock` 是 `order: 20` 的末端 input-dock 条目。队列为空时隐藏;只有一个待处理项时直接渲染该行;存在两个或更多待处理项时,默认收起为 `" 条排队消息"` 表头,其按钮可展开或收起完整列表。表头暴露 `aria-expanded` 和 `aria-controls`;展开后的列表以 180px 为高度上限,并可滚动。存在进行中的编辑或变更时,列表行会保持可见;队列清空后,下一次出现队列时会恢复默认收起状态。每条可见行仍是单行预览,并提供针对精确单次入队项的编辑和删除操作。 diff --git a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css index 1e557a0c0a..3df55879bc 100644 --- a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css @@ -1,6 +1,6 @@ -/* Todo strip in the composer context stack (Figma 9:959): tip surface, - 14px radius, status icons + secondary item labels. It shares the composer - card geometry and adds the dock inset on both sides. */ +/* Todo strip in the composer context stack (Figma 1236:32276): tip surface, + 14px radius, status icons + secondary item labels. Its visible card aligns + with the GoalBar and the Queue panel inside their shared dock column. */ .root { box-sizing: border-box; @@ -17,6 +17,8 @@ max-width: calc( var(--dsh-composer-card-max-width) - var(--dsh-composer-dock-inset) - + var(--dsh-composer-dock-inset) - + var(--dsh-composer-dock-inset) - var(--dsh-composer-dock-inset) ); border: 1px solid var(--dsw-alias-border-l1); diff --git a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx index 011f4df5cd..c820902ef5 100644 --- a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx @@ -138,10 +138,10 @@ export const todoDockEntry = { name: 'conversation-todo-dock', inject: ['slots', 'conversation'], /** - * Register the plan strip between the goal and queue entries (order 10). + * Register the plan strip before the goal and queue entries (order 0). * @param ctx - registrant context (disposal rides ctx.effect inside slots.register). */ apply(ctx: Context): void { - ctx.slots.register({ name: 'conversation.input.dock', id: 'todo', order: 10, locale: NS }, TodoDock) + ctx.slots.register({ name: 'conversation.input.dock', id: 'todo', order: 0, locale: NS }, TodoDock) }, } diff --git a/packages/client/ui-conversation/tests/todo-panel.spec.tsx b/packages/client/ui-conversation/tests/todo-panel.spec.tsx index e03cd04af7..fdaad9ab4a 100644 --- a/packages/client/ui-conversation/tests/todo-panel.spec.tsx +++ b/packages/client/ui-conversation/tests/todo-panel.spec.tsx @@ -99,12 +99,12 @@ describe('TodoDock', () => { expect(screen.queryByTestId('todo-panel')).toBeNull() }) - it('registers between the goal and queue entries', () => { + it('registers before the goal and queue entries', () => { expect(todoDockEntry.name).toBe('conversation-todo-dock') expect(todoDockEntry.inject).toEqual(['slots', 'conversation']) const register = vi.fn() todoDockEntry.apply({ slots: { register } } as never) - expect(register).toHaveBeenCalledWith({ name: 'conversation.input.dock', id: 'todo', order: 10, locale: NS }, TodoDock) + expect(register).toHaveBeenCalledWith({ name: 'conversation.input.dock', id: 'todo', order: 0, locale: NS }, TodoDock) }) }) diff --git a/packages/client/ui-goal/README.i18n.yaml b/packages/client/ui-goal/README.i18n.yaml index d8611b7695..5a426916bd 100644 --- a/packages/client/ui-goal/README.i18n.yaml +++ b/packages/client/ui-goal/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-goal/README.md -README.md: cfb54fd28044ed80e6ec05de0be057f5d4cfaf46 -README.zh.md: fd999cf1c4c9695d15cfaab3e83afdf475f40448 +README.md: 3da9d97c801a0a742de2601e5261c09ba193cf33 +README.zh.md: c2474fc6ef8d0c990da4b4eaff79d56baf3180cf diff --git a/packages/client/ui-goal/README.md b/packages/client/ui-goal/README.md index cfb54fd280..3da9d97c80 100644 --- a/packages/client/ui-goal/README.md +++ b/packages/client/ui-goal/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Goal surface plugin, browser half: the `GoalBar` strip is the first standalone card in the `conversation.input.dock` composer-context stack (order 0, before Todo and Queue). The live goal arrives through `useProjection('goal')` — the host-computed whole value seeded by the history tail page and updated by `session/projection` frames — so the plugin owns no domain store, refresh chain, or event listener. The slot inject face carries only the four mutation verbs (edit / pause / resume / clear over the `goal.*` wire domain — an active goal offers the pause action, a paused one resume); each reads the CAS ref from the session's current projected value at call time and surfaces the settled RPC error inline. The strip single-flights mutations synchronously because React's pending render cannot fence same-frame clicks; after a successful clear it immediately suppresses that exact goal id while the authoritative null projection catches up. Goal creation stays on the `/goal` host command; loading, absent, completed, and successfully cleared goals render nothing. +Goal surface plugin, browser half: the `GoalBar` strip is the second standalone card in the `conversation.input.dock` composer-context stack (order 10, after Todo and before Queue). The live goal arrives through `useProjection('goal')` — the host-computed whole value seeded by the history tail page and updated by `session/projection` frames — so the plugin owns no domain store, refresh chain, or event listener. The slot inject face carries only the four mutation verbs (edit / pause / resume / clear over the `goal.*` wire domain — an active goal offers the pause action, a paused one resume); each reads the CAS ref from the session's current projected value at call time and surfaces the settled RPC error inline. The strip single-flights mutations synchronously because React's pending render cannot fence same-frame clicks; after a successful clear it immediately suppresses that exact goal id while the authoritative null projection catches up. Goal creation stays on the `/goal` host command; loading, absent, completed, and successfully cleared goals render nothing. The `/client` export surface is the plugin body (`apply`/`inject`), the `GoalBar`/`GoalDock` components, and the injected verb face types. diff --git a/packages/client/ui-goal/README.zh.md b/packages/client/ui-goal/README.zh.md index fd999cf1c4..c2474fc6ef 100644 --- a/packages/client/ui-goal/README.zh.md +++ b/packages/client/ui-goal/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -Goal 表面插件(浏览器半件):`GoalBar` 条带是 `conversation.input.dock` composer 上下文堆栈中的第一张独立卡片(order 0,位于 Todo 和 Queue 之前)。活值经 `useProjection('goal')` 到达——host 计算的全量值由历史尾页播种、由 `session/projection` 帧更新——因此本插件不持有领域 store、不设刷新链、不挂事件监听。slot 注入面只携带四个变更动词(edit / pause / resume / clear,走 `goal.*` 协议域——active 的 goal 提供暂停动作,paused 的提供恢复);每个动词在调用时从会话当前投影值读取 CAS ref,并把结算后的 RPC 错误内联呈现。由于 React 的 pending 渲染无法拦住同一帧内的点击,横条会同步为变更建立 single-flight 防护;清除成功后,会立即抑制该 goal id 对应的目标显示,直到权威的 null 投影追上。goal 的创建仍归 `/goal` host 命令;加载中、无 goal、已完成和已成功清除的 goal 一律不渲染。 +Goal 表面插件(浏览器半件):`GoalBar` 条带是 `conversation.input.dock` composer 上下文堆栈中的第二张独立卡片(order 10,位于 Todo 之后、Queue 之前)。活值经 `useProjection('goal')` 到达——host 计算的全量值由历史尾页播种、由 `session/projection` 帧更新——因此本插件不持有领域 store、不设刷新链、不挂事件监听。slot 注入面只携带四个变更动词(edit / pause / resume / clear,走 `goal.*` 协议域——active 的 goal 提供暂停动作,paused 的提供恢复);每个动词在调用时从会话当前投影值读取 CAS ref,并把结算后的 RPC 错误内联呈现。由于 React 的 pending 渲染无法拦住同一帧内的点击,横条会同步为变更建立 single-flight 防护;清除成功后,会立即抑制该 goal id 对应的目标显示,直到权威的 null 投影追上。goal 的创建仍归 `/goal` host 命令;加载中、无 goal、已完成和已成功清除的 goal 一律不渲染。 `/client` 出口面为插件本体(`apply`/`inject`)、`GoalBar`/`GoalDock` 组件与注入动词面类型。 diff --git a/packages/client/ui-goal/src/client/GoalBar.module.css b/packages/client/ui-goal/src/client/GoalBar.module.css index 261c4bfa0a..a1b515f2d3 100644 --- a/packages/client/ui-goal/src/client/GoalBar.module.css +++ b/packages/client/ui-goal/src/client/GoalBar.module.css @@ -1,5 +1,5 @@ -/* GoalBar: the first standalone card in the composer context stack (Figma - 9:939). Its 752px column matches Todo and the Queue panel. */ +/* GoalBar: the second standalone card in the composer context stack (Figma + 1236:32276). Its 752px column matches Todo and the Queue panel. */ .dock { box-sizing: border-box; diff --git a/packages/client/ui-goal/src/client/index.ts b/packages/client/ui-goal/src/client/index.ts index 17b52044e5..3cd5c05711 100644 --- a/packages/client/ui-goal/src/client/index.ts +++ b/packages/client/ui-goal/src/client/index.ts @@ -75,7 +75,7 @@ export function apply(ctx: ClientContext): void { scope.effect(() => scope.slots.register({ name: 'conversation.input.dock', id: 'goal', - order: 0, + order: 10, locale: NS, inject: (sessionId): GoalBarActions => ({ onEdit: async (objective) => { diff --git a/packages/client/ui-goal/tests/browser-plugin.spec.tsx b/packages/client/ui-goal/tests/browser-plugin.spec.tsx index 47fd786e41..796f0fd927 100644 --- a/packages/client/ui-goal/tests/browser-plugin.spec.tsx +++ b/packages/client/ui-goal/tests/browser-plugin.spec.tsx @@ -97,7 +97,7 @@ describe('ui-goal browser plugin', () => { it('registers the GoalBar dock entry with the documented id and order', async () => { const b = bench() await b.fiber.await() - expect(b.entry()).toMatchObject({ id: 'goal', order: 0, locale: 'goal' }) + expect(b.entry()).toMatchObject({ id: 'goal', order: 10, locale: 'goal' }) expect(b.entry()?.inject).toBeTypeOf('function') }) From 6332b5b952d7a931fe4c69ec2547f1f214591972 Mon Sep 17 00:00:00 2001 From: kingwl Date: Sun, 2 Aug 2026 13:26:08 +0800 Subject: [PATCH 04/19] fix(web): align context cards on narrow screens --- ...odo-first-composer-context-order.i18n.yaml | 4 +-- ...08-02-todo-first-composer-context-order.md | 4 +-- ...02-todo-first-composer-context-order.zh.md | 4 +-- apps/web/tests/queue-actions.e2e.ts | 32 +++++++++++-------- .../src/client/skeleton/TodoPanel.module.css | 2 ++ .../ui-goal/src/client/GoalBar.module.css | 12 +++++-- 6 files changed, 37 insertions(+), 21 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-02-todo-first-composer-context-order.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-02-todo-first-composer-context-order.i18n.yaml index 9e28778e02..eeac8a251c 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-02-todo-first-composer-context-order.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-02-todo-first-composer-context-order.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/bug-fix/2026-08-02-todo-first-composer-context-order.md -2026-08-02-todo-first-composer-context-order.md: 1d8bd9cfc30069530420e802f935b88b0b2f7cef -2026-08-02-todo-first-composer-context-order.zh.md: 9324ed666fa33cdbdb324eb732a979344ae4d8a2 +2026-08-02-todo-first-composer-context-order.md: 26962426c763c6911fc61acab09cdb96725922b1 +2026-08-02-todo-first-composer-context-order.zh.md: a81401ffeb8df5855b584a5727e24067d9fb1e70 diff --git a/.agents/notes/implemented/bug-fix/2026-08-02-todo-first-composer-context-order.md b/.agents/notes/implemented/bug-fix/2026-08-02-todo-first-composer-context-order.md index 1d8bd9cfc3..26962426c7 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-02-todo-first-composer-context-order.md +++ b/.agents/notes/implemented/bug-fix/2026-08-02-todo-first-composer-context-order.md @@ -12,13 +12,13 @@ The composer context stack rendered Goal before Todo even though the Harness des The `conversation.input.dock` list uses one ascending product order: Todo at `0`, Goal at `10`, and Queue at `20`, followed by the composer bar outside the list. Registration order remains the semantic source of truth; the renderer does not hardcode known component ids or repair their order with CSS. -Todo, Goal, and the visible Queue panel share the 752px card column inside the 800px composer cap. Queue retains a 776px wrapper with 12px transparent inset on each side because that wrapper owns the composer overlap. Todo is a standalone card rather than a wrapper, so its maximum width subtracts both inset layers directly and exposes the same left and right edges as Goal and the Queue panel. +Todo, Goal, and the visible Queue panel share the 752px card column inside the 800px composer cap. Queue retains a 776px wrapper with 12px transparent inset on each side because that wrapper owns the composer overlap. Todo is a standalone card rather than a wrapper, so its responsive width and maximum width subtract both inset layers directly. Goal uses the same responsive column and caps its inner bar at 752px, preserving matching edges below the desktop cap. The [composer stack contract](2026-07-30-composer-context-stack-order.md) continues to own inter-card spacing and Queue's exclusive overlap with the composer. This decision supersedes only that note's Goal-first order. ## Verification -Todo and Goal registration tests pin orders `0` and `10`; Queue remains pinned at `20`. The keyless Queue browser scenario renders all three panels concurrently, records their Todo–Goal–Queue accessibility order, and compares their visible bounding boxes at the desktop viewport before exercising Queue mutations. +Todo and Goal registration tests pin orders `0` and `10`; Queue remains pinned at `20`. The keyless Queue browser scenario renders all three panels concurrently, records their Todo–Goal–Queue accessibility order, and compares their visible bounding boxes at the 1680px desktop baseline and a 640px sub-cap viewport before exercising Queue mutations. ## Alternatives considered diff --git a/.agents/notes/implemented/bug-fix/2026-08-02-todo-first-composer-context-order.zh.md b/.agents/notes/implemented/bug-fix/2026-08-02-todo-first-composer-context-order.zh.md index 9324ed666f..a81401ffeb 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-02-todo-first-composer-context-order.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-02-todo-first-composer-context-order.zh.md @@ -12,13 +12,13 @@ composer 上下文堆栈将 Goal 渲染在 Todo 之前,但 Harness 设计稿 `conversation.input.dock` 列表采用统一的产品顺序,升序依次为 Todo `0`、Goal `10`、Queue `20`,随后是位于列表外的 composer bar。注册顺序仍是语义真源;渲染器不会硬编码已知组件 id,也不会使用 CSS 修正它们的顺序。 -Todo、Goal 与可见的 Queue 面板共用 800px composer 宽度上限内的 752px 卡片列。Queue 保留 776px 包装层,并在两侧各留 12px 透明内缩,因为该包装层负责与 composer 重叠。Todo 是独立卡片,而非包装层,因此其最大宽度会直接扣除两层内缩,使左右边缘与 Goal 和 Queue 面板一致。 +Todo、Goal 与可见的 Queue 面板共用 800px composer 宽度上限内的 752px 卡片列。Queue 保留 776px 包装层,并在两侧各留 12px 透明内缩,因为该包装层负责与 composer 重叠。Todo 是独立卡片,而非包装层,因此其响应式宽度和最大宽度都会直接扣除两层内缩。Goal 使用相同的响应式卡片列,并将内层横条的宽度上限设为 752px,从而在低于桌面宽度上限时也保持边缘对齐。 [composer 堆栈契约](2026-07-30-composer-context-stack-order.md)继续规定卡片间距,以及仅限 Queue 与 composer 重叠。本决策只取代该记录中 Goal 优先的顺序。 ## 验证 -Todo 与 Goal 的注册测试分别固定顺序 `0` 和 `10`;Queue 仍固定为 `20`。无密钥 Queue 浏览器场景同时渲染三个面板,记录 Todo–Goal–Queue 的无障碍顺序,并在桌面视口下比较其可见边界框,随后再执行 Queue 变更。 +Todo 与 Goal 的注册测试分别固定顺序 `0` 和 `10`;Queue 仍固定为 `20`。无密钥 Queue 浏览器场景同时渲染三个面板,记录 Todo–Goal–Queue 的无障碍顺序,并在 1680px 桌面基线和低于宽度上限的 640px 视口下比较其可见边界框,随后再执行 Queue 变更。 ## 考虑过的替代方案 diff --git a/apps/web/tests/queue-actions.e2e.ts b/apps/web/tests/queue-actions.e2e.ts index 0def7d9fa2..4f0c52d9c6 100644 --- a/apps/web/tests/queue-actions.e2e.ts +++ b/apps/web/tests/queue-actions.e2e.ts @@ -173,7 +173,7 @@ describe('web e2e: queue row actions', () => { await expect.poll(() => page.locator('[data-queue-dock]').count()).toBe(0) }, 120_000) - it.skipIf(MODE === 'record')('orders Todo before Goal and Queue on one desktop card column', async () => { + it.skipIf(MODE === 'record')('orders Todo before Goal and Queue on one responsive card column', async () => { overrideDir = await mkdtemp(join(tmpdir(), 'dsh-web-context-layout-')) const readyFile = join(overrideDir, '.hang-ready') const overridePath = join(overrideDir, 'replay.override.json') @@ -222,18 +222,24 @@ describe('web e2e: queue row actions', () => { ) await compareOrRefreshGolden(LAYOUT_EXPECTED, layoutSnapshot, MODE) - const queuePanelBox = await page.locator('[data-queue-dock] > div').boundingBox() - const todoBox = await page.locator('[data-testid="todo-panel"]').boundingBox() - const goalBox = await page.locator('[data-goal-bar] > div').boundingBox() - expect(queuePanelBox).not.toBeNull() - expect(todoBox).not.toBeNull() - expect(goalBox).not.toBeNull() - expect(todoBox!.y).toBeLessThan(goalBox!.y) - expect(goalBox!.y).toBeLessThan(queuePanelBox!.y) - expect(todoBox!.x).toBeCloseTo(goalBox!.x, 1) - expect(todoBox!.x).toBeCloseTo(queuePanelBox!.x, 1) - expect(todoBox!.width).toBeCloseTo(goalBox!.width, 1) - expect(todoBox!.width).toBeCloseTo(queuePanelBox!.width, 1) + const expectAlignedContextPanels = async () => { + const queuePanelBox = await page.locator('[data-queue-dock] > div').boundingBox() + const todoBox = await page.locator('[data-testid="todo-panel"]').boundingBox() + const goalBox = await page.locator('[data-goal-bar] > div').boundingBox() + expect(queuePanelBox).not.toBeNull() + expect(todoBox).not.toBeNull() + expect(goalBox).not.toBeNull() + expect(todoBox!.y).toBeLessThan(goalBox!.y) + expect(goalBox!.y).toBeLessThan(queuePanelBox!.y) + expect(todoBox!.x).toBeCloseTo(goalBox!.x, 1) + expect(todoBox!.x).toBeCloseTo(queuePanelBox!.x, 1) + expect(todoBox!.width).toBeCloseTo(goalBox!.width, 1) + expect(todoBox!.width).toBeCloseTo(queuePanelBox!.width, 1) + } + await expectAlignedContextPanels() + await page.setViewportSize({ width: 640, height: 1000 }) + await expectAlignedContextPanels() + await page.setViewportSize({ width: 1680, height: 1000 }) await queueHeader.click() const removeButtons = page.getByRole('button', { name: 'Remove queued message' }) diff --git a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css index 3df55879bc..56e7244d21 100644 --- a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css @@ -12,6 +12,8 @@ var(--dsh-composer-side-clearance) - var(--dsh-composer-side-clearance) - var(--dsh-composer-dock-inset) - + var(--dsh-composer-dock-inset) - + var(--dsh-composer-dock-inset) - var(--dsh-composer-dock-inset) ); max-width: calc( diff --git a/packages/client/ui-goal/src/client/GoalBar.module.css b/packages/client/ui-goal/src/client/GoalBar.module.css index a1b515f2d3..62249947c7 100644 --- a/packages/client/ui-goal/src/client/GoalBar.module.css +++ b/packages/client/ui-goal/src/client/GoalBar.module.css @@ -3,8 +3,16 @@ .dock { box-sizing: border-box; - width: 100%; - padding: 0 44px; + width: calc( + 100% - + var(--dsh-composer-side-clearance) - + var(--dsh-composer-side-clearance) - + var(--dsh-composer-dock-inset) - + var(--dsh-composer-dock-inset) - + var(--dsh-composer-dock-inset) - + var(--dsh-composer-dock-inset) + ); + margin: 0 auto; } .bar { From 955a12cca4921a36bacf856bcccc240073c13667 Mon Sep 17 00:00:00 2001 From: kingwl Date: Thu, 30 Jul 2026 03:17:54 +0800 Subject: [PATCH 05/19] feat(web): steer queued messages into active turns --- ...-29-addressable-queue-operations.i18n.yaml | 4 +- ...2026-07-29-addressable-queue-operations.md | 10 +- ...6-07-29-addressable-queue-operations.zh.md | 10 +- ...026-07-30-web-queue-steer-action.i18n.yaml | 6 ++ .../2026-07-30-web-queue-steer-action.md | 67 +++++++++++++ .../2026-07-30-web-queue-steer-action.zh.md | 67 +++++++++++++ .../queue-actions/editing.expected.md | 2 + .../snapshots/queue-actions/ui.expected.md | 2 + apps/web/tests/steering.e2e.ts | 69 +++++-------- docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 2 + docs/architecture.zh.md | 2 + docs/cordis-catalog/events.md | 32 +++---- docs/core-data-structures/core.i18n.yaml | 4 +- docs/core-data-structures/core.md | 14 ++- docs/core-data-structures/core.zh.md | 14 ++- docs/event-producer-consumer.md | 32 +++---- packages/client/runtime/README.i18n.yaml | 4 +- packages/client/runtime/README.md | 2 +- packages/client/runtime/README.zh.md | 2 +- .../runtime/src/client/contract/session.ts | 4 +- .../client/runtime/tests/queue-store.spec.ts | 19 +++- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 4 +- packages/client/ui-conversation/README.zh.md | 4 +- .../ui-conversation/src/client/locales.ts | 6 ++ .../src/client/queue/QueueDock.tsx | 19 +++- .../ui-conversation/src/client/service.ts | 4 +- .../ui-conversation/tests/queue-dock.spec.tsx | 45 ++++++++- .../cordis/tool-cordis/src/api-catalog.ts | 4 +- 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 | 16 ++++ .../tests/contract-regressions.spec.ts | 96 +++++++++++++++++++ packages/core/agent/README.i18n.yaml | 4 +- packages/core/agent/README.md | 2 +- packages/core/agent/README.zh.md | 2 +- packages/core/agent/src/types.ts | 12 ++- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- packages/host/apiproxy/src/api-proxy.ts | 17 +++- packages/host/apiproxy/src/api/rpc.schema.ts | 1 + packages/host/apiproxy/src/api/rpc.ts | 1 + .../host/apiproxy/src/api/sessions.schema.ts | 1 + packages/host/apiproxy/src/api/sessions.ts | 3 +- .../apiproxy/tests/api-proxy-commands.spec.ts | 18 +++- .../host/apiproxy/tests/rpc-schemas.spec.ts | 4 + 49 files changed, 508 insertions(+), 147 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.md create mode 100644 .agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.zh.md diff --git a/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.i18n.yaml index 99b5efd70f..217bcd32db 100644 --- a/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.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-29-addressable-queue-operations.md -2026-07-29-addressable-queue-operations.md: 02519f8fe8be60823ac773ac4cceecb9f12f88b2 -2026-07-29-addressable-queue-operations.zh.md: 998fc49ae7289b07c97312d4814d85b5cc84387b +2026-07-29-addressable-queue-operations.md: e5fbacfa7de244daa1c5f8e504ee98a4a7730393 +2026-07-29-addressable-queue-operations.zh.md: a532d18d1b29fcebc7e9d8222da9eb5a162b8ec6 diff --git a/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.md b/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.md index 02519f8fe8..e5fbacfa7d 100644 --- a/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.md +++ b/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.md @@ -12,13 +12,13 @@ The Web queue rendered pending messages but could not edit or delete one row. `M **Each accepted FIFO occurrence has its own identity.** AgentLoop mints an opaque `InboxItemId` and publishes an `InboxItem` containing that id, the identified `UserMessage`, and its acceptance-time `queued | steering` placement. Reusing one `MessageId` creates distinct inbox identities. Injection bypasses the FIFOs and receives no inbox identity. -**Mutation ends at driver claim.** `Agent.updateInbox(id, action)` synchronously searches the pending queued FIFO. Edit replaces frozen content while preserving `InboxItemId`, `MessageId`, source, wake policy, and position. Remove emits the occurrence’s terminal discard. Steering and driver-claimed occurrences return `not-found`, so queue operations never rewrite active-turn input or durable history. +**Mutation ends at driver claim.** `Agent.updateInbox(id, action)` synchronously searches the pending queued FIFO. Edit replaces frozen content while preserving `InboxItemId`, `MessageId`, source, wake policy, and position. Remove emits the occurrence’s terminal discard. Strict steer transfers the message into an open next-step window as a new steering occurrence; a closed window returns `steer-unavailable` without changing the queued item. Pending steering and driver-claimed occurrences return `not-found`, so later mutations never rewrite active-turn input or durable history. **The live ledger is authoritative.** `agent/inbox/enqueue`, `update`, `dequeue`, and `discard` maintain a Host mirror of queued occurrences. A synchronously re-entrant update or terminal event may reach the mirror before its outer enqueue listener; the mirror retains that unseen outcome for the current dispatch and folds it into the enqueue, so listener registration order cannot publish stale content or a ghost row. The wire sends complete `session/queue` snapshots rather than incremental guesses. Reconnect sends the current baseline, and every queued mutation or terminal event replaces it. The client applies no optimistic edit and never retires a row from durable turn events or status changes. **Queue addresses require a live ordinary-session Agent.** `session.updateQueue` queries only the mounted Agent registry and never resumes a cold session: an `InboxItemId` is process-local and cannot name work after restart or disposal. A session-backed subagent returns `agent-busy` before inbox access and retains its continuation owner; for ordinary sessions, a missing Agent and a driver-claimed occurrence both return `queue-item-not-found`. -**Web actions address Queue only.** The Host excludes pending steering from `session/queue`; steering retains its existing durable transcript path after consumption. QueueDock hides while empty, renders one pending occurrence directly, and defaults two or more occurrences to a collapsed `" 条排队消息"` header that expands or collapses the complete list. The header exposes `aria-expanded` and `aria-controls`; the expanded list scrolls within a 180px height bound. An active edit or mutation keeps its rows visible, and emptying the queue restores the collapsed default for the next queue. Visible rows expose edit and delete, but no send-now control. The UI derives queue row and mutation types from the runtime `SessionFace` contract rather than importing the connection plugin, so plugin cooperation continues through services and snapshots. Edit is available only when all content blocks are text; the editor cannot silently drop non-text blocks. An editing row exposes only save and cancel, with Enter and Escape as their keyboard equivalents. Delete removes the exact occurrence. The Web stop action preserves pending Queue work; AgentLoop claims the next waking occurrence only after the interrupted turn reaches quiescence, and its dequeue event retires that row without a browser resend. +**Web actions address Queue only.** The Host excludes pending steering from `session/queue`; steering retains its existing durable transcript path after consumption. QueueDock hides while empty, renders one pending occurrence directly, and defaults two or more occurrences to a collapsed `" 条排队消息"` header that expands or collapses the complete list. The header exposes `aria-expanded` and `aria-controls`; the expanded list scrolls within a 180px height bound. An active edit or mutation keeps its rows visible, and emptying the queue restores the collapsed default for the next queue. Visible rows expose edit, delete, and a running-only strict-steer action. The UI derives queue row and mutation types from the runtime `SessionFace` contract rather than importing the connection plugin, so plugin cooperation continues through services and snapshots. Edit is available only when all content blocks are text; the editor cannot silently drop non-text blocks. An editing row exposes only save and cancel, with Enter and Escape as their keyboard equivalents. Delete removes the exact occurrence, while strict steer preserves every content block and retires the row only through the authoritative snapshot. The Web stop action preserves pending Queue work; AgentLoop claims the next waking occurrence only after the interrupted turn reaches quiescence, and its dequeue event retires that row without a browser resend. The [Web Queue steer action](2026-07-30-web-queue-steer-action.md) owns the strict transfer contract. ## Alternatives considered @@ -26,7 +26,7 @@ The Web queue rendered pending messages but could not edit or delete one row. `M **Apply optimistic browser mutations.** Rejected because driver claim and another client can win before the Host action. Waiting for the authoritative snapshot makes the ownership boundary visible and lets `queue-item-not-found` report a real race. -**Include pending steering in the queue mutation protocol.** Rejected because QueueDock has no steering interaction, and editing or deleting active-turn input would widen this feature beyond its current consumer. A dedicated steering interaction owns that delivery contract. +**Allow editing or removal of pending steering.** Rejected because QueueDock only addresses independent queued turns. Once strict steer succeeds, the new steering occurrence belongs to the active turn and remains outside this mutation surface. **Expose a protocol-only promotion operation.** Rejected because no product interaction reorders Queue. A public operation without a current consumer would add ordering semantics and tests for speculative use. @@ -34,10 +34,10 @@ The Web queue rendered pending messages but could not edit or delete one row. `M ## Verification -AgentLoop contract tests hold prompt admission while editing and removing exact queued occurrences, reject mutations of steering occurrences, and verify the resulting independent turn and terminal lifecycle events. Host schema and proxy tests cover queued-only authoritative snapshots, synchronous re-entrant mutation order, reconnect, cold-Agent rejection, typed not-found errors, and the RPC transport. Client runtime and QueueDock tests cover non-optimistic projection, single-row presentation, default multi-row collapse, interaction-forced visibility, reset after emptying, expansion, text-only editing, save and cancel affordances, removal, retirement races, and disabled mixed-content editing. A keyless browser scenario captures the default collapsed header, drives edit and delete through the built Web composition and real HTTP/SSE wire, then stops consecutive active turns to prove the preserved FIFO advances without clearing its tail. +AgentLoop contract tests hold prompt admission while editing, removing, and strictly steering exact queued occurrences; they reject mutations of steering occurrences and verify the resulting independent turn and terminal lifecycle events. Host schema and proxy tests cover queued-only authoritative snapshots, synchronous re-entrant mutation order, reconnect, cold-Agent rejection, typed race errors, and the RPC transport. Client runtime and QueueDock tests cover non-optimistic projection, single-row presentation, default multi-row collapse, interaction-forced visibility, reset after emptying, expansion, text-only editing, save and cancel affordances, removal, strict steer, retirement races, and disabled mixed-content editing. Keyless browser scenarios drive all three exposed actions through the built Web composition and real HTTP/SSE wire, then stop consecutive active turns to prove the preserved FIFO advances without clearing its tail. ## Consequences -Queued work gains precise row operations without becoming durable session history. Occurrence identity is a live process-local capability and disappears at claim, broad cancellation, disposal, or restart; the Web stop action preserves it until a later claim, while reconnect recovers only queued items still held by the live Agent. Editing excludes mixed content until an editor can preserve every block, while pending steering remains outside this operation surface. +Queued work gains precise row operations without becoming durable session history. Occurrence identity is a live process-local capability and disappears at claim, strict transfer, broad cancellation, disposal, or restart; the Web stop action preserves queued occurrences until a later claim, while reconnect recovers only queued items still held by the live Agent. Editing excludes mixed content until an editor can preserve every block, while pending steering remains outside the projection and operation surface. The protocol now carries full queue snapshots on each change. Queues are expected to remain short, so deterministic recovery and multi-client convergence are preferred over an incremental mutation protocol. diff --git a/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.zh.md b/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.zh.md index 998fc49ae7..a532d18d1b 100644 --- a/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.zh.md +++ b/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.zh.md @@ -12,13 +12,13 @@ Web 队列能够渲染待处理消息,但无法编辑或删除其中某一行 **每次获准进入 FIFO 的项都有独立标识。** AgentLoop 会铸造不透明的 `InboxItemId`,并发布一个 `InboxItem`,其中包含该 id、已有标识的 `UserMessage`,以及接受时确定的 `queued | steering` 放置方式。复用同一个 `MessageId` 会创建不同的 inbox 标识。注入绕过 FIFO,因此不会获得 inbox 标识。 -**变更边界止于驱动器认领。** `Agent.updateInbox(id, action)` 会同步搜索待处理的 queued FIFO。编辑会替换已冻结的内容,同时保留 `InboxItemId`、`MessageId`、来源、唤醒策略和位置。移除会发出该次入队项的终态 discard。steering(中途引导)项和已被驱动器认领的项会返回 `not-found`,因此队列操作绝不会改写活动轮次输入或持久历史。 +**变更边界止于驱动器认领。** `Agent.updateInbox(id, action)` 会同步搜索待处理的 queued FIFO。编辑会替换已冻结的内容,同时保留 `InboxItemId`、`MessageId`、来源、唤醒策略和位置。移除会发出该次入队项的终态 discard。严格 steering(中途引导)会把消息作为新的 steering 单次入队项转移到开放的 next-step 窗口;窗口关闭时返回 `steer-unavailable`,且不改变 queued 项。待处理 steering 和已被驱动器认领的项会返回 `not-found`,因此后续变更绝不会改写活动轮次输入或持久历史。 **实时账本是权威状态。** `agent/inbox/enqueue`、`update`、`dequeue` 和 `discard` 共同维护 queued 入队项的 Host 镜像。同步可重入的 update 或终态事件可能先于外层 enqueue 监听器到达镜像;镜像会在当前分发期间保留这一尚不可见的结果,并在处理 enqueue 时把它合并进去,因此监听器注册顺序不会导致系统发布陈旧内容或不存在的行。协议发送完整的 `session/queue` 快照,而非增量猜测。重连会发送当前基线,每次 queued 变更或终态事件都会整体替换它。客户端不会进行乐观编辑,也绝不根据持久轮次事件或状态变化退役队列行。 **Queue 寻址要求普通会话的 Agent 存活。** `session.updateQueue` 只查询已挂载的 Agent 注册表,绝不恢复冷会话:`InboxItemId` 属于进程本地标识,无法在重启或资源释放后继续指向工作。由会话支撑的 subagent 会在访问 inbox 前返回 `agent-busy`,并保留其继续执行 owner;对于普通会话,Agent 缺失和单次入队项已被驱动器认领这两种情况都返回 `queue-item-not-found`。 -**Web 操作只面向 Queue。** Host 从 `session/queue` 中排除待处理 steering;steering 消费后仍沿用既有的持久 transcript(文本记录)路径。QueueDock 在队列为空时隐藏,只有一个待处理项时直接渲染该行,存在两个或更多待处理项时则默认收起为可展开或收起完整列表的 `" 条排队消息"` 表头。表头暴露 `aria-expanded` 和 `aria-controls`;展开后的列表以 180px 为高度上限,并可滚动。存在进行中的编辑或变更时,列表行会保持可见;队列清空后,下一次出现队列时会恢复默认收起状态。可见行暴露编辑和删除操作,不提供立即发送控件。UI 从运行时 `SessionFace` 契约派生队列行与变更类型,而不是导入连接插件,因此插件仍通过服务和快照协作。仅当所有内容块都是文本时才提供编辑功能;编辑器不能静默丢弃非文本块。编辑中的行只展示保存和取消操作,对应的键盘操作分别是 Enter 和 Escape。删除会移除对应的精确入队项。Web 停止操作会保留待处理 Queue 工作;只有在被中断轮次达到完全停稳后,AgentLoop 才认领下一个可唤醒入队项,其出队事件会退役该行,无需浏览器重发。 +**Web 操作只面向 Queue。** Host 从 `session/queue` 中排除待处理 steering;steering 消费后仍沿用既有的持久 transcript(文本记录)路径。QueueDock 在队列为空时隐藏,只有一个待处理项时直接渲染该行,存在两个或更多待处理项时则默认收起为可展开或收起完整列表的 `" 条排队消息"` 表头。表头暴露 `aria-expanded` 和 `aria-controls`;展开后的列表以 180px 为高度上限,并可滚动。存在进行中的编辑或变更时,列表行会保持可见;队列清空后,下一次出现队列时会恢复默认收起状态。可见行暴露编辑、删除以及仅在运行期间可用的严格 steering 操作。UI 从运行时 `SessionFace` 契约派生队列行与变更类型,而不是导入连接插件,因此插件仍通过服务和快照协作。仅当所有内容块都是文本时才提供编辑功能;编辑器不能静默丢弃非文本块。编辑中的行只展示保存和取消操作,对应的键盘操作分别是 Enter 和 Escape。删除会移除对应的精确入队项;严格 steering 会保留每个内容块,并且只通过权威快照退役该行。Web 停止操作会保留待处理 Queue 工作;只有在被中断轮次达到完全停稳后,AgentLoop 才认领下一个可唤醒入队项,其出队事件会退役该行,无需浏览器重发。[Web Queue steering 操作](2026-07-30-web-queue-steer-action.md)负责定义严格转移契约。 ## 考虑过的替代方案 @@ -26,7 +26,7 @@ Web 队列能够渲染待处理消息,但无法编辑或删除其中某一行 **在浏览器中进行乐观变更。** 不予采纳,因为驱动器认领或另一个客户端可能先于 Host 操作完成。等待权威快照可以显式呈现所有权边界,并让 `queue-item-not-found` 报告真实竞态。 -**将待处理 steering 纳入队列变更协议。** 不予采纳,因为 QueueDock 没有 steering 交互,而编辑或删除活动轮次输入会把此功能扩展到当前消费方之外。应由专用 steering 交互负责该投递契约。 +**允许编辑或移除待处理 steering。** 不予采纳,因为 QueueDock 只寻址独立的 queued 轮次。严格 steering 一旦成功,新的 steering 单次入队项就属于活动轮次,并且不再位于此变更接口内。 **暴露仅协议层的前移操作。** 不予采纳,因为当前没有产品交互会重新排序 Queue。公开一个没有当前消费方的操作,会为了推测性用途引入排序语义和测试。 @@ -34,10 +34,10 @@ Web 队列能够渲染待处理消息,但无法编辑或删除其中某一行 ## 验证 -AgentLoop 契约测试会在编辑和移除精确 queued 入队项时阻塞提示词接纳,拒绝对 steering 入队项的变更,并验证所得独立轮次及终态生命周期事件。Host schema 与代理测试覆盖仅含 queued 项的权威快照、同步可重入变更顺序、重连、拒绝冷 Agent、类型化 not-found 错误和 RPC 传输。客户端运行时和 QueueDock 测试覆盖非乐观投影、单行呈现、多行默认收起、交互期间强制保持可见、清空后重置、展开、仅文本编辑、保存与取消入口、移除、退役竞态,以及禁用混合内容编辑。一个无密钥浏览器场景会捕获默认收起的表头,通过构建后的 Web 组合和真实 HTTP/SSE 协议执行编辑和删除,随后连续停止活动轮次,证明保留的 FIFO 会继续推进且不清空队尾。 +AgentLoop 契约测试会在编辑、移除和严格 steering 精确 queued 入队项时保持提示词接纳窗口打开,拒绝对 steering 入队项的变更,并验证所得独立轮次及终态生命周期事件。Host schema 与代理测试覆盖仅含 queued 项的权威快照、同步可重入变更顺序、重连、拒绝冷 Agent、类型化竞态错误和 RPC 传输。客户端运行时和 QueueDock 测试覆盖非乐观投影、单行呈现、多行默认收起、交互期间强制保持可见、清空后重置、展开、仅文本编辑、保存与取消入口、移除、严格 steering、退役竞态,以及禁用混合内容编辑。无密钥浏览器场景会通过构建后的 Web 组合和真实 HTTP/SSE 协议执行公开的全部三项操作,随后连续停止活动轮次,证明保留的 FIFO 会继续推进且不清空队尾。 ## 后果 -queued 工作获得精确的行操作,但不会因此成为持久会话历史。单次入队标识是进程本地的实时寻址凭据,会在认领、广义取消、dispose 或重启时消失;Web 停止操作会将其保留到后续认领,而重连只能恢复仍由活跃 Agent 持有的 queued 项。编辑会排除混合内容,直至编辑器能够保留每个块;待处理 steering 则不属于此操作接口。 +queued 工作获得精确的行操作,但不会因此成为持久会话历史。单次入队标识是进程本地的实时寻址凭据,会在认领、严格转移、广义取消、dispose 或重启时消失;Web 停止操作会将 queued 入队项保留到后续认领,而重连只能恢复仍由活跃 Agent 持有的 queued 项。编辑会排除混合内容,直至编辑器能够保留每个块;待处理 steering 则不属于此投影和操作接口。 现在,协议会在每次变更时携带完整队列快照。队列预期保持较短,因此系统优先选择确定性恢复和多客户端收敛,而非增量变更协议。 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.i18n.yaml new file mode 100644 index 0000000000..780858e1d8 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.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-07-30-web-queue-steer-action.md +2026-07-30-web-queue-steer-action.md: 4a8a04c056748d5c607c5ec868de4f2dd8342f2c +2026-07-30-web-queue-steer-action.zh.md: 92225f3a61e24b9eb687c50400aa6fe5e6ff5623 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.md b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.md new file mode 100644 index 0000000000..4a8a04c056 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.md @@ -0,0 +1,67 @@ +# Agent Note: Steer a queued Web message into the active turn + +Status: implemented + +English | [中文](2026-07-30-web-queue-steer-action.zh.md) + +## Problem + +The Web composer deliberately queues Enter submissions while an agent runs. QueueDock already gives each pending message an addressable row, and the durable transcript already renders consumed `steering/message` events with an interjection badge, but Web has no action connecting those two surfaces. + +Implementing the row action as a client-side delete followed by `session.prompt(mode: 'steer')` would split one user intent across two RPCs. Driver claim could win between them, the steer could fail after deletion, or the existing best-effort `agent.steer()` fallback could silently append a new Queue item after the original occurrence was removed. A send-now action must therefore distinguish current-turn steering from Queue promotion and preserve the original row when steering is no longer possible. + +## Decision + +### Product contract + +Each non-editing QueueDock row exposes the upward-arrow action as “插话发送”. The action is enabled only while the session reports a running agent; mixed-content messages remain eligible because steering forwards the complete immutable `UserMessage` rather than the row's text projection. Edit and delete keep their existing behavior, and the composer continues to submit Enter as Queue. + +Activating the action requests strict current-turn steering for that exact `InboxItemId`. Success removes the Queue row through the authoritative Host snapshot. When AgentLoop drains it, the existing durable `steering/message` event and transcript badge render the message without a new chat presentation path. + +The running bit is only an interaction hint. AgentLoop's `acceptsNextStep` value is authoritative at the synchronous mutation boundary. If that window has closed, the operation leaves the Queue occurrence unchanged and returns a typed `steer-unavailable` error; if the driver already claimed the occurrence, it returns the existing `queue-item-not-found` error. The UI reports either race without optimistically removing the row. + +### Agent and lifecycle boundary + +`InboxAction` gains a consumer-backed `{ kind: 'steer' }` operation alongside edit and remove. `Agent.updateInbox()` handles it only after locating the queued occurrence and proving `acceptsNextStep`; it never delegates to the best-effort `agent.steer()` alias. + +An applied action ends the queued occurrence and accepts the same immutable `UserMessage` as a new steering occurrence. The steering occurrence receives a new `InboxItemId` and truthful `placement: 'steering'`, while the message retains its `MessageId`, content, and source. AgentLoop installs the new outbox entry before publishing lifecycle events, then emits its enqueue before the old occurrence's discard so re-entrant cancellation cannot observe or retire an unannounced item. The existing inbox conservation invariant therefore continues to require one enqueue and one terminal dequeue or discard for each occurrence. + +The action does not run `agent/prompt-submit`: choosing steering intentionally changes delivery from an independently admitted turn to current-turn next-step input. It neither cancels current work nor reorders the remaining Queue. + +### Host and client boundary + +`session.updateQueue` carries the `steer` action and maps the two negative outcomes to typed RPC errors. The conversion is one synchronous Agent operation; the Host never reconstructs it by combining remove and prompt calls. + +The Host's transient `session/queue` projection remains Queue-only. It ignores the new pending steering occurrence and removes the old row when its discard arrives. Pending steering does not gain edit, delete, or reconnect presentation in this cut. A later dedicated pending-steering projection may add that observability without widening Queue mutation semantics. + +The existing `session.prompt(mode: 'steer')` contract remains best-effort for new input: outside the next-step window it may become a waking follow-up. Only the Queue row action is strict, because failure can safely leave its already-pending message untouched. + +### Verification + +AgentLoop contract coverage holds prompt admission open, converts one exact queued occurrence, and proves the replacement steering occurrence keeps the message value, drains as `steering/message`, and never starts its former independent turn. It also pins unavailable-window retention, claimed-address rejection, and re-entrant cancellation lifecycle conservation. + +Host schema and proxy tests cover the new action, both typed errors, authoritative Queue snapshots, and the absence of pending steering from reconnect snapshots. QueueDock tests cover running-state enablement, complete-content eligibility, failure retention, and authoritative success retirement. + +The keyless Web steering scenario queues a message through the real composer while the first response streams, activates the row arrow, then uses `ask_user_question` as a stable pending-steering barrier. After the answer, it proves one badged interjection becomes durable and the next model request obeys it. Queue edit/delete scenarios continue to prove those actions are unchanged. + +## Alternatives considered + +**Delete the row, then call `session.prompt(mode: 'steer')` from Web.** Rejected because two RPCs cannot make deletion and steering atomic; failure and driver-claim races can lose or duplicate the user's message. + +**Restore Queue promotion under the upward arrow.** Rejected because moving an item to the front still creates an independent admitted turn. The control promises current-turn steering, not priority within Queue. + +**Use the existing best-effort `agent.steer()` behavior.** Rejected for this action because a closed next-step window would silently turn the selected row back into queued work, possibly at a different position and identity. Strict failure preserves the original occurrence and makes the semantic race visible. + +**Change `agent.steer()` to be strict for every caller.** Rejected because TUI and plugin callers use its safe follow-up fallback for newly submitted input. A queued row has recoverable state that those callers do not. + +**Preserve the same `InboxItemId` while changing placement.** Rejected because `InboxItemId` identifies one FIFO acceptance and `placement` records that acceptance's resolved delivery. Ending one queued occurrence and accepting one steering occurrence keeps lifecycle facts truthful and leaves the conservation invariant unchanged. + +**Expose pending steering in `session/queue`.** Deferred because the existing product design provides no pending-steering row state or operations. Authoritative Queue retirement plus the durable consumed bubble is sufficient for the first interaction cut; reconnect visibility can be added through a dedicated projection if product testing shows the gap matters. + +**Cancel the active turn and run the selected Queue item.** Rejected because it destroys unrelated in-flight work and starts a new turn rather than steering the current one. + +## Consequences + +A successful action can be pending but absent from the Web after its Queue row retires and before `steering/message` commits; a refresh during that interval has no pending-steering indication. The running bit can also remain true briefly after the strict next-step window closes, so the button may be enabled for an operation that correctly returns `steer-unavailable`. + +The explicit action changes delivery from an independently admitted turn to current-turn steering, so prompt-admission plugins do not process the converted message. Enqueue-before-discard lifecycle publication remains required for re-entrant cancellation safety; focused regression coverage protects that ordering. diff --git a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.zh.md new file mode 100644 index 0000000000..92225f3a61 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.zh.md @@ -0,0 +1,67 @@ +# Agent Note(agent 决策记录):将 Web 已排队消息转为活动轮次的 steering(中途引导) + +Status: implemented + +[English](2026-07-30-web-queue-steer-action.md) | 中文 + +## 问题 + +Web composer 会在 agent 运行期间有意把 Enter 提交作为 Queue 入队。QueueDock 已经为每条待处理消息提供可寻址的行,持久 transcript(文本记录)也已能把消费后的 `steering/message` 事件渲染为带插话徽标的消息,但 Web 没有连接这两个界面的操作。 + +如果 Web 先在客户端删除该行,再调用 `session.prompt(mode: 'steer')`,就会把用户的一次意图拆分到两个 RPC 中。驱动器可能在两次调用之间先认领该项,steering 投递也可能在删除后失败;现有尽力而为的 `agent.steer()` 回退还可能在原单次入队项被移除后,静默追加一个新的 Queue 项。因此,立即发送操作必须区分当前轮次 steering 与 Queue 前移,并在 steering 已不可用时保留原行。 + +## 决策 + +### 产品契约 + +每个非编辑态的 QueueDock 行都会提供名为“插话发送”的向上箭头操作。仅当会话报告 agent 正在运行时,该操作才会启用;包含混合内容的消息仍可使用,因为 steering 会转发完整且不可变的 `UserMessage`,而非该行的文本投影。编辑和删除保持现有行为,composer 也继续把 Enter 提交为 Queue。 + +触发该操作会针对对应的 `InboxItemId` 请求严格的当前轮次 steering。操作成功后,权威 Host 快照会移除 Queue 行。AgentLoop 排空该项时,现有持久 `steering/message` 事件与 transcript 插话徽标会渲染这条消息,无需新增聊天展示路径。 + +running 标志位只用于提示交互状态。在同步变更边界上,AgentLoop 的 `acceptsNextStep` 值才是权威依据。如果该窗口已经关闭,操作会保持 Queue 单次入队项不变,并返回类型化的 `steer-unavailable` 错误;如果驱动器已经认领该项,则返回现有的 `queue-item-not-found` 错误。UI 会报告任一竞态,不会乐观地移除该行。 + +### Agent 与生命周期边界 + +`InboxAction` 会在编辑和移除之外,新增由实际消费方支撑的 `{ kind: 'steer' }` 操作。`Agent.updateInbox()` 只有在找到 queued 单次入队项并确认 `acceptsNextStep` 后才会处理该操作,绝不会委托给尽力而为的 `agent.steer()` 别名。 + +操作成功应用后,系统会结束 queued 单次入队项,并把同一个不可变 `UserMessage` 接受为新的 steering 单次入队项。steering 单次入队项会获得新的 `InboxItemId` 和如实反映投递方式的 `placement: 'steering'`,消息则保留其 `MessageId`、内容和来源。AgentLoop 会先安装新的 outbox 项,再发布生命周期事件;随后先发出新单次入队项的 enqueue,再发出旧单次入队项的 discard,确保可重入取消无法观察或退役一个尚未公布的项。因此,现有 inbox 守恒不变量仍然要求每个单次入队项恰好对应一个 enqueue,以及一个终态 dequeue 或 discard。 + +该操作不会运行 `agent/prompt-submit`:选择 steering 会有意把投递方式从经独立接纳的轮次改为当前轮次的 next-step 输入。它既不会取消当前工作,也不会重新排序 Queue 中的剩余项。 + +### Host 与客户端边界 + +`session.updateQueue` 会携带 `steer` 操作,并把两种负面结果映射为类型化 RPC 错误。这项转换是一次同步 Agent 操作;Host 绝不会通过组合移除和提示词调用来重建它。 + +Host 的瞬态 `session/queue` 投影仍然只包含 Queue。它会忽略新的待处理 steering 单次入队项,并在收到旧项的 discard 时移除原行。本阶段不会为待处理 steering 增加编辑、删除或重连展示。未来可以用专用的待处理 steering 投影补充这种可观测性,而无需扩大 Queue 变更语义。 + +现有 `session.prompt(mode: 'steer')` 对新输入仍采用尽力而为的契约:在 next-step 窗口之外,它可能变为会唤醒 agent 的后续轮次。只有 Queue 行操作采用严格语义,因为失败时可以安全地保留其已经待处理的消息。 + +### 验证 + +AgentLoop 契约覆盖保持提示词接纳窗口打开,转换一个精确的 queued 单次入队项,并证明替代它的 steering 单次入队项保留消息值、以 `steering/message` 的形式排空,且绝不启动原本的独立轮次。该覆盖还钉住窗口不可用时保留原项、拒绝已被认领的地址,以及可重入取消下的生命周期守恒。 + +Host schema 和代理测试覆盖新操作、两种类型化错误、权威 Queue 快照,以及重连快照不包含待处理 steering。QueueDock 测试覆盖按运行状态启用、混合内容消息仍可完整投递、失败时保留原行,以及成功后由权威快照退役。 + +无密钥 Web steering 场景在第一次响应流式输出期间,通过真实 composer 排队一条消息并触发行上的箭头,再用 `ask_user_question` 作为稳定的待处理 steering 屏障。回答问题后,该场景证明一条带徽标的插话成为持久记录,并且下一次模型请求遵循它。Queue 编辑/删除场景继续证明这些操作没有变化。 + +## 考虑过的替代方案 + +**在 Web 中删除该行,再调用 `session.prompt(mode: 'steer')`。** 不予采纳,因为两个 RPC 无法让删除和 steering 成为原子操作;失败和驱动器认领竞态可能丢失或重复用户消息。 + +**恢复向上箭头对应的 Queue 前移操作。** 不予采纳,因为把某个项移到队首仍然会创建一个独立接纳的轮次。该控件承诺的是当前轮次 steering,而不是 Queue 内的优先级。 + +**使用现有尽力而为的 `agent.steer()` 行为。** 不予采纳,因为关闭的 next-step 窗口会静默地把选中行重新变成 queued 工作,而且位置和标识可能不同。严格失败会保留原单次入队项,并让这项语义竞态明确可见。 + +**让每个调用方使用的 `agent.steer()` 都采用严格语义。** 不予采纳,因为 TUI 和插件调用方会针对新提交的输入使用其安全的后续轮次回退。queued 行具有这些调用方不具备的可恢复状态。 + +**改变投递方式时保留同一个 `InboxItemId`。** 不予采纳,因为 `InboxItemId` 标识一次 FIFO 接受,而 `placement` 记录该次接受解析出的投递方式。结束一个 queued 单次入队项并接受一个 steering 单次入队项,能够使生命周期事实保持如实,并让守恒不变量保持不变。 + +**在 `session/queue` 中暴露待处理 steering。** 暂缓,因为现有产品设计没有为待处理 steering 提供行状态或操作。权威的 Queue 退役加上持久的已消费气泡,足以支撑首个交互阶段;如果产品测试表明这一缺口影响显著,可以通过专用投影增加重连可见性。 + +**取消活动轮次并运行选中的 Queue 项。** 不予采纳,因为这会破坏无关的进行中工作,并且会启动新轮次,而不是 steering 当前轮次。 + +## 后果 + +操作成功后,从 Queue 行退役到 `steering/message` 提交之间,对应消息可能仍处于待处理状态,却不会出现在 Web 中;如果在此期间刷新,界面不会显示待处理 steering。严格 next-step 窗口关闭后,running 标志位仍可能短暂保持为 true,因此按钮可能会为一个最终正确返回 `steer-unavailable` 的操作保持启用。 + +这项显式操作会把投递方式从经独立接纳的轮次改为当前轮次 steering,因此提示词接纳插件不会处理转换后的消息。为保证可重入取消安全,生命周期事件仍必须先发布 enqueue 再发布 discard;有针对性的回归覆盖会保护这一顺序。 diff --git a/apps/web/tests/snapshots/queue-actions/editing.expected.md b/apps/web/tests/snapshots/queue-actions/editing.expected.md index 3a70840713..3f6ebab8a2 100644 --- a/apps/web/tests/snapshots/queue-actions/editing.expected.md +++ b/apps/web/tests/snapshots/queue-actions/editing.expected.md @@ -23,6 +23,8 @@ - img - button "Remove queued message": - img + - button "插话发送": + - img - listitem: - textbox "Edit queued message": Edited queue item - button "Save queued message": diff --git a/apps/web/tests/snapshots/queue-actions/ui.expected.md b/apps/web/tests/snapshots/queue-actions/ui.expected.md index 24edb57417..db860c30d1 100644 --- a/apps/web/tests/snapshots/queue-actions/ui.expected.md +++ b/apps/web/tests/snapshots/queue-actions/ui.expected.md @@ -22,6 +22,8 @@ - img - button "Remove queued message": - img + - button "Steer queued message": + - img - textbox "Message the agent" - button "Commands": - img diff --git a/apps/web/tests/steering.e2e.ts b/apps/web/tests/steering.e2e.ts index a329f2c02e..bfa8aae162 100644 --- a/apps/web/tests/steering.e2e.ts +++ b/apps/web/tests/steering.e2e.ts @@ -1,15 +1,7 @@ -// Web e2e scenario: mid-turn steering over the host wire. The Web UI has no -// steer entry, so the steer is POSTed from the page over the same -// same-origin /api transport the client uses. Everything downstream is -// product: the gateway routes mode:'steer' to Agent.steer, the loop drains -// it at the step boundary into a durable steering/message event, the SSE mux -// pushes it, and the transcript shows the text as a plain bubble (no -// interjection chrome). The question composer supplies the deterministic -// mid-turn window: while ask_user_question blocks, the turn is provably -// running, so record and replay perform the identical steer-then-answer -// sequence with zero timing dependence — and the recorded final reply proves -// the steer reached the MODEL (it obeys an instruction that only the -// steering message carries). +// Web e2e scenario: queue a message while the first response streams, strictly +// transfer that exact occurrence to steering through QueueDock, then prove it +// is logged, rendered, and obeyed. The following question tool supplies a +// deterministic pending-steering snapshot before the step can drain. import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { join } from 'node:path' @@ -56,15 +48,12 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => { let browser: Browser let page: Page let tripwire: ReturnType - let liveSessionId: string | undefined const sessionEvents: SessionEvent[] = [] beforeAll(async () => { - scaffold = await launchWebScaffold(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 }) - scaffold.ctx.on('session/event', (session, event) => { - liveSessionId ??= session.id - sessionEvents.push(event) - }) + // The slower replay keeps the Queue action available until the recorded question barrier arrives. + scaffold = await launchWebScaffold(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 100 }) + scaffold.ctx.on('session/event', (_session, event) => { sessionEvents.push(event) }) browser = await chromium.launch() page = await newEnglishPage(browser) tripwire = watchConsole(page) @@ -79,7 +68,7 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => { await scaffold?.close() }) - it('steers during the blocked step; the message is logged, rendered, and obeyed', async () => { + it('strictly steers one queued row; the interjection is logged, rendered, and obeyed', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-steering')) if (MODE !== 'record') { // The steer must NOT be a user/message — it lands as steering/message. @@ -91,35 +80,27 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => { await input.fill(PROMPT) await input.press('Enter') - // The blocked composer is the mid-turn barrier: its presence proves the - // ask_user_question step is executing, i.e. the turn is running NOW. + // Enter remains the Queue gesture. The row action then atomically moves + // this exact occurrence into the current turn's steering outbox. + await input.fill(STEER) + await input.press('Enter') + const queued = page.getByText(STEER, { exact: true }) + await queued.waitFor({ timeout: 10_000 }) + const queuedRow = page.getByRole('listitem').filter({ hasText: STEER }) + const steerButton = queuedRow.getByRole('button', { name: 'Steer queued message' }) + await expect.poll(() => steerButton.isEnabled(), { timeout: 10_000 }).toBe(true) + await steerButton.click({ timeout: 10_000 }) + await expect.poll(() => page.getByText(STEER, { exact: true }).count(), { timeout: 10_000 }).toBe(0) + + // The blocked composer is the mid-turn barrier: the tool cannot finish + // this step, so the accepted steering remains pending and invisible. const composer = page.locator('[data-question-key]') await composer.waitFor({ timeout: MODE === 'record' ? 120_000 : 30_000 }) - // Steer through the real wire from the page (same envelope + endpoint the - // web client's session.prompt uses). accepted:true is the transport proof. - expect(liveSessionId).toBeDefined() - const reply = await page.evaluate(async ({ sessionId, text }) => { - const response = await fetch('/api/session.prompt', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - type: 'client-request', - rpcId: crypto.randomUUID(), - method: 'session.prompt', - payload: { sessionId, mode: 'steer', content: [{ type: 'text', text }] }, - }), - }) - return await response.json() as { result?: { ok?: boolean } } - }, { sessionId: liveSessionId!, text: STEER }) - expect(reply.result?.ok).toBe(true) - if (MODE !== 'record') { - // Mid-turn golden: the ACCEPTED steer is durable in the inbox but the - // loop drains steering only at the step boundary, so no steering/message - // exists yet and no steer text renders — the composer still blocks, - // alone. The DOM is stable here (no further SSE frames can arrive until - // the question is answered), making this state capturable. + // Mid-turn golden: the converted steer is pending in the loop but the + // loop drains steering only at the step boundary, so no Queue row or + // steering/message bubble renders while the question still blocks. expect(await page.getByText(STEER, { exact: true }).count()).toBe(0) expect(await page.getByRole('button', { name: 'Edit queued message' }).count()).toBe(0) const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 576ef05d32..f0e2a9892c 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: 6aa942ba2702d8d30ae94d9968f07abb5e1fe88d -architecture.zh.md: c8aaa68527f34f4879f882a08260a4e0bd4f4c5f +architecture.md: 4bb0cb1f29bb48adf89c97af7c85c90219d0558a +architecture.zh.md: 4c6ff721d894aaee4cc14ff321967580997aba56 diff --git a/docs/architecture.md b/docs/architecture.md index 6aa942ba27..4bb0cb1f29 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -123,6 +123,8 @@ Each step assembles ordered stable system sections, cache-safe dynamic contexts, Admission-time and active-turn `inject()` stage for the next step; tool-time injection and post-tool `additionalContexts` settle after results. Steering shares the outbox but remains provisional until a request admits it. `steer()` returns a message-owned receipt: after `agent/step` and asynchronous prompt assembly succeed, the loop commits the stable batch, snapshots request history, opens `step/start`, then resolves its receipts as admitted with the turn and step; later arrivals wait. A turn-concluding tool result, broad cancellation, disposal, or a claimed idle-steering turn that never opens a step rejects affected receipts, while `cancel(..., { keepInbox: true })` and non-terminal routing preserve pending delivery. Idle `inject()` appends immediately without changing turn numbers; persistence drains eagerly. +Before driver claim, `updateInbox()` may edit or remove a queued occurrence, or strictly transfer its immutable message into an open next-step window. That transfer ends the queued occurrence and accepts a new steering occurrence; a closed window leaves Queue unchanged. Direct `steer()` remains best-effort for newly submitted input and falls back to a waking follow-up outside the window ([decision](../.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.md)). + Pruning precedes summaries; overflow retries require durable progress. `agent/request-error` may authorize one retry turn between failed-step and turn close; cancellation wins. Adapter-owned `retryPolicy` makes normal mode bounded; always mode delegates specialized recovery before retrying until success or cancellation ([compaction](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md), [retry foundation](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md), [provider policy](../.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md)). ### Failure Boundaries diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index c8aaa68527..4c6ff721d8 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -123,6 +123,8 @@ idle inject: 接纳期间和活跃轮次内的 `inject()` 会为下一步骤暂存;工具执行期间的注入和工具执行后的 `additionalContexts` 会在结果记录完毕后落定。steering 与其共用 outbox,但在请求接纳前始终处于待准入状态。`steer()` 会返回归属于该消息的回执:`agent/step` 和异步提示词组装成功后,循环提交稳定批次、捕获请求历史并开启 `step/start`,再将其回执解析为已准入并附带轮次与步骤;后续消息继续等待。结束轮次的工具结果、广义取消、dispose(资源释放),以及已领取 idle-steering 消息却从未开启步骤的轮次,都会拒绝受影响的回执;`cancel(..., { keepInbox: true })` 和非终止型路由则保留待处理投递。空闲状态下的 `inject()` 会立即追加,且不改变轮次编号;持久化层会尽快排空。 +驱动器认领之前,`updateInbox()` 可以编辑或移除 queued 单次入队项,也可以严格地把其不可变消息转移到开放的 next-step 窗口。该转移会结束 queued 单次入队项,并接受一个新的 steering 单次入队项;窗口关闭时 Queue 保持不变。直接调用 `steer()` 时,对新提交的输入仍采用尽力而为的语义,并在窗口之外回退为会唤醒 agent 的后续轮次([决策](../.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.md))。 + 裁剪先于摘要;溢出重试必须取得持久进展。`agent/request-error` 可以在失败步骤与轮次关闭之间授权一个重试轮次;取消优先。适配器拥有的 `retryPolicy` 使 normal mode 保持有界;always mode 先委托专门恢复,再持续重试直至成功或取消([压缩](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md)、[重试基础](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md)、[提供方策略](../.agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md))。 ### 失败边界 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 55785ad536..521a2fa735 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -32,7 +32,7 @@ Effective broad cancellation was requested, before queued/outbox work is cleared Types: [Agent](../core-data-structures/core.md) · [AgentCancelCause](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:349`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:353`](../../packages/core/agent/src/types.ts) ### `agent/created` — emit @@ -54,7 +54,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:280`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:284`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit @@ -74,7 +74,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:289`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:293`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -96,7 +96,7 @@ A step or turn errored. The machine reports a failure here (plus the logger) eve Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:463`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:467`](../../packages/core/agent/src/types.ts) ### `agent/inbox/dequeue` — emit @@ -117,7 +117,7 @@ The driver claimed one item out of the inbox: a queued item at a turn boundary, Types: [Agent](../core-data-structures/core.md) · [InboxItem](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:327`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:331`](../../packages/core/agent/src/types.ts) ### `agent/inbox/discard` — emit @@ -140,7 +140,7 @@ Pending inbox items were dropped without delivering them, so every enqueue occur Types: [Agent](../core-data-structures/core.md) · [InboxItem](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:339`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:343`](../../packages/core/agent/src/types.ts) ### `agent/inbox/enqueue` — emit @@ -161,7 +161,7 @@ An item entered the queued or steering inbox. `placement` is the acceptance-time Types: [Agent](../core-data-structures/core.md) · [InboxItem](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:308`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:312`](../../packages/core/agent/src/types.ts) ### `agent/inbox/update` — emit @@ -181,7 +181,7 @@ A still-pending queued item changed content. The item id, placement, and positio Types: [Agent](../core-data-structures/core.md) · [InboxItem](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:317`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:321`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall @@ -204,7 +204,7 @@ Allow, rewrite, or block one claimed prompt before it becomes a user message or Types: [Agent](../core-data-structures/core.md) · [PromptDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md) -Source: [`packages/core/agent/src/types.ts:376`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:380`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -228,7 +228,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:402`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:406`](../../packages/core/agent/src/types.ts) ### `agent/request-error` — waterfall @@ -258,7 +258,7 @@ Handle a model-request failure after its failed step has closed but before the f Types: [Agent](../core-data-structures/core.md) · [LlmFailure](../core-data-structures/llm-streaming.md) · [RequestError](../core-data-structures/core.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:421`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:425`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -280,7 +280,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:362`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:366`](../../packages/core/agent/src/types.ts) ### `agent/settled` — emit @@ -305,7 +305,7 @@ One drain chain reached its terminal turn: that turn's `turn/end` is already com Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SettleReason](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:450`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:454`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -325,7 +325,7 @@ Agent status changed (`idle` ⇄ `running`). `send()` does not enter `running` s 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:298`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:302`](../../packages/core/agent/src/types.ts) ### `agent/step` — serial @@ -349,7 +349,7 @@ Awaited serial checkpoint before EVERY request of a turn is built (the first as Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:389`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:393`](../../packages/core/agent/src/types.ts) ### `agent/turn-stopping` — serial @@ -375,7 +375,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:436`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:440`](../../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 a156394d97..415d74e6f6 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: 795256a2b30e44771baf8bcb7c1d541134692a02 -core.zh.md: 5cebff049aae53df9f0494da1fadc8dfa5d9ad09 +core.md: 1b5704384157688b45ae0900bf2d9924426bbd6b +core.zh.md: 05802039920163ac8185703ab483c5603087ed96 diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 795256a2b3..1b57043841 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -516,11 +516,12 @@ interface InboxItem { type InboxAction = | { readonly kind: 'edit'; readonly content: ContentBlock[] } | { readonly kind: 'remove' } + | { readonly kind: 'steer' } ``` ```ts type-equiv /** Result of applying an inbox action at the synchronous ownership boundary. */ -type InboxActionResult = 'applied' | 'not-found' +type InboxActionResult = 'applied' | 'not-found' | 'steer-unavailable' ``` ```ts type-equiv @@ -546,7 +547,7 @@ interface SendOptions { } ``` -The fixed-preset aliases own `target` and `wakeup`; their already identified `UserMessage` carries role, content, and provenance. Its `MessageId` remains stable when an edit replaces the message content, while the enclosing `InboxItemId` identifies one accepted occurrence across `agent/inbox/enqueue`, `agent/inbox/update`, and its terminal dequeue or discard. Injection bypasses the FIFOs and never appears on those events. +The fixed-preset aliases own `target` and `wakeup`; their already identified `UserMessage` carries role, content, and provenance. Its `MessageId` remains stable when an edit replaces content or strict steer transfers the immutable message. The original queued occurrence ends and strict steer accepts a new steering occurrence with a distinct `InboxItemId`. Injection bypasses the FIFOs and never appears on inbox lifecycle events. ```ts type-equiv /** Options for {@link Agent.cancel}. */ @@ -633,10 +634,13 @@ interface Agent { /** * Mutate one still-pending queued occurrence synchronously. Editing preserves * the message identity and queue position; removal publishes its terminal - * discard. Steering occurrences and driver-claimed items return `not-found`. + * discard. Steer strictly transfers the message into the current next-step + * window, or returns `steer-unavailable` without changing the queued + * occurrence. Steering occurrences and driver-claimed items return + * `not-found`. * @param id - independently addressable queued occurrence. - * @param action - edit or remove operation. - * @returns whether the pending occurrence was found and updated. + * @param action - edit, remove, or strict steer operation. + * @returns the applied outcome or the reason no mutation occurred. */ updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md index 5cebff049a..0580203992 100644 --- a/docs/core-data-structures/core.zh.md +++ b/docs/core-data-structures/core.zh.md @@ -524,11 +524,12 @@ interface InboxItem { type InboxAction = | { readonly kind: 'edit'; readonly content: ContentBlock[] } | { readonly kind: 'remove' } + | { readonly kind: 'steer' } ``` ```ts type-equiv /** Result of applying an inbox action at the synchronous ownership boundary. */ -type InboxActionResult = 'applied' | 'not-found' +type InboxActionResult = 'applied' | 'not-found' | 'steer-unavailable' ``` ```ts type-equiv @@ -554,7 +555,7 @@ interface SendOptions { } ``` -固定预设的别名方法自带 `target` 与 `wakeup`;其已有标识的 `UserMessage` 会携带角色、内容与 provenance。编辑替换消息内容时,其 `MessageId` 保持稳定;外层 `InboxItemId` 则在 `agent/inbox/enqueue`、`agent/inbox/update` 及终态 dequeue 或 discard 之间标识同一次入队。注入绕过两个 FIFO,从不出现在这些事件中。 +固定预设的别名方法自带 `target` 与 `wakeup`;其已有标识的 `UserMessage` 会携带角色、内容与 provenance。编辑替换内容或严格 steering(中途引导)转移不可变消息时,其 `MessageId` 都保持稳定。原 queued 单次入队项会结束,严格 steering 则接受一个具有不同 `InboxItemId` 的新 steering 单次入队项。注入绕过两个 FIFO,从不出现在 inbox 生命周期事件中。 ```ts type-equiv /** Options for {@link Agent.cancel}. */ @@ -641,10 +642,13 @@ interface Agent { /** * Mutate one still-pending queued occurrence synchronously. Editing preserves * the message identity and queue position; removal publishes its terminal - * discard. Steering occurrences and driver-claimed items return `not-found`. + * discard. Steer strictly transfers the message into the current next-step + * window, or returns `steer-unavailable` without changing the queued + * occurrence. Steering occurrences and driver-claimed items return + * `not-found`. * @param id - independently addressable queued occurrence. - * @param action - edit or remove operation. - * @returns whether the pending occurrence was found and updated. + * @param action - edit, remove, or strict steer operation. + * @returns the applied outcome or the reason no mutation occurred. */ updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 1c4309004b..d1457a75af 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,22 +8,22 @@ 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:157`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) | -| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:349`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal-session`](../packages/goal/goal-session) | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:280`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:289`](../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), [`tui`](../packages/ui/tui) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:463`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tui`](../packages/ui/tui) | -| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:327`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`subagent`](../packages/subagent/subagent), [`tui`](../packages/ui/tui) | -| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:339`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`subagent`](../packages/subagent/subagent), [`tui`](../packages/ui/tui) | -| `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:308`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session) | -| `agent/inbox/update` | `emit` | [`packages/core/agent/src/types.ts:317`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy` | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:376`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`tui`](../packages/ui/tui) | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:402`](../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:421`](../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:362`](../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), [`workspace-context`](../packages/context/workspace-context) | -| `agent/settled` | `emit` | [`packages/core/agent/src/types.ts:450`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`compact-basic`](../packages/compact/compact-basic) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:298`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/step` | `serial` | [`packages/core/agent/src/types.ts:389`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`plan-mode`](../packages/plan/plan-mode), [`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/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:436`](../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/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:353`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal-session`](../packages/goal/goal-session) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:284`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:293`](../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), [`tui`](../packages/ui/tui) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:467`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tui`](../packages/ui/tui) | +| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:331`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`subagent`](../packages/subagent/subagent), [`tui`](../packages/ui/tui) | +| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:343`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`subagent`](../packages/subagent/subagent), [`tui`](../packages/ui/tui) | +| `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:312`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session) | +| `agent/inbox/update` | `emit` | [`packages/core/agent/src/types.ts:321`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy` | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:380`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`tui`](../packages/ui/tui) | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:406`](../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:425`](../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:366`](../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), [`workspace-context`](../packages/context/workspace-context) | +| `agent/settled` | `emit` | [`packages/core/agent/src/types.ts:454`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`compact-basic`](../packages/compact/compact-basic) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:302`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/step` | `serial` | [`packages/core/agent/src/types.ts:393`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`plan-mode`](../packages/plan/plan-mode), [`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/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:440`](../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:154`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy`, [`tui`](../packages/ui/tui) | | `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/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index f20967ee2f..0bbb962352 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/runtime/README.md -README.md: f956be22384a42e9ed30e8aa5f25fe8173cc9f9c -README.zh.md: 49449c51d89d957b5bd39798c9167607f72a5c3a +README.md: a98e97d796ca4e4be07d8d8d25ebc0a24d066a8b +README.zh.md: a973c4fbbf16633fed11d137ae548604516d8a51 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index f956be2238..a98e97d796 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -22,7 +22,7 @@ SlotsService gives the renderer separate bare observables for `useSessions` and ## Pending queue projection -`ConversationSnapshot.queue` is the Host's authoritative transient Queue snapshot; pending steering stays outside this projection. Each row carries its `InboxItemId`, complete editable text when every content block is text, and a flattened preview. `session/queue` replaces the whole projection; reconnect buffering retains only the latest snapshot, and neither durable turn events nor running-status changes guess that an item was claimed. `Session.updateQueue()` sends edit/remove operations without optimistic mutation, so the next Host snapshot is the sole visible commit and a claim race can surface `queue-item-not-found`. +`ConversationSnapshot.queue` is the Host's authoritative transient Queue snapshot; pending steering stays outside this projection. Each row carries its `InboxItemId`, complete editable text when every content block is text, and a flattened preview. `session/queue` replaces the whole projection; reconnect buffering retains only the latest snapshot, and neither durable turn events nor running-status changes guess that an item was claimed. `Session.updateQueue()` sends edit, remove, and strict-steer operations without optimistic mutation, so the next Host snapshot is the sole visible commit; claim and closed-window races surface `queue-item-not-found` and `steer-unavailable`. ## The human transcript diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 49449c51d8..a973c4fbbf 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -22,7 +22,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 ## 待处理队列投影 -`ConversationSnapshot.queue` 是 Host 提供的权威瞬态 Queue 快照;待处理 steering(中途引导)不进入此投影。每行都携带其 `InboxItemId`、所有内容块均为文本时的完整可编辑文本,以及扁平化预览。`session/queue` 会整体替换该投影;重连缓冲只保留最新快照,持久轮次事件和 running 状态变化都不会猜测某个项已被认领。`Session.updateQueue()` 发送编辑/移除操作,不进行乐观更新,因此下一份 Host 快照是唯一可见的提交结果,认领竞态则会返回 `queue-item-not-found`。 +`ConversationSnapshot.queue` 是 Host 提供的权威瞬态 Queue 快照;待处理 steering(中途引导)不进入此投影。每行都携带其 `InboxItemId`、所有内容块均为文本时的完整可编辑文本,以及扁平化预览。`session/queue` 会整体替换该投影;重连缓冲只保留最新快照,持久轮次事件和 running 状态变化都不会猜测某个项已被认领。`Session.updateQueue()` 发送编辑、移除和严格 steering 操作,不进行乐观更新,因此下一份 Host 快照是唯一可见的提交结果;认领与窗口关闭竞态分别会返回 `queue-item-not-found` 和 `steer-unavailable`。 ## 面向人的 transcript(文本记录) diff --git a/packages/client/runtime/src/client/contract/session.ts b/packages/client/runtime/src/client/contract/session.ts index f2c7d4183d..a68fececa3 100644 --- a/packages/client/runtime/src/client/contract/session.ts +++ b/packages/client/runtime/src/client/contract/session.ts @@ -39,9 +39,9 @@ export interface ISession { */ prompt(content: ContentBlock[], mode: 'queue' | 'steer'): Promise> /** - * Apply one mutation to a still-pending queue occurrence. + * Apply one edit, remove, or strict steer action to a still-pending queue occurrence. * @param itemId - agent-owned inbox occurrence identity. - * @param action - edit or remove operation. + * @param action - requested queue operation. * @returns acceptance, or a business/transport error. */ updateQueue(itemId: InboxItemId, action: QueueAction): Promise> diff --git a/packages/client/runtime/tests/queue-store.spec.ts b/packages/client/runtime/tests/queue-store.spec.ts index 192885b66b..b594e9a9df 100644 --- a/packages/client/runtime/tests/queue-store.spec.ts +++ b/packages/client/runtime/tests/queue-store.spec.ts @@ -110,11 +110,20 @@ describe('queue operation transport', () => { await expect(session.updateQueue(iid('q-op'), { kind: 'edit', content: text('next') })) .resolves.toEqual({ ok: true, value: { accepted: true } }) - expect(api.callsOf('session.updateQueue')).toEqual([{ - sessionId: SID, - itemId: 'q-op', - action: { kind: 'edit', content: text('next') }, - }]) + await expect(session.updateQueue(iid('q-op'), { kind: 'steer' })) + .resolves.toEqual({ ok: true, value: { accepted: true } }) + expect(api.callsOf('session.updateQueue')).toEqual([ + { + sessionId: SID, + itemId: 'q-op', + action: { kind: 'edit', content: text('next') }, + }, + { + sessionId: SID, + itemId: 'q-op', + action: { kind: 'steer' }, + }, + ]) expect(session.getSnapshot().queue).toBe(before) }) }) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 316b685585..1a8fc3a22a 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: d92202e6dc2b1003db0cdf97910746a0de133357 -README.zh.md: a75ee75a5fb2b3d8e5278283b6b6da15bf9c02cd +README.md: 2bf74454fab14303f305b9822e1032f67de0d3ae +README.zh.md: 2677ab5cc85c16fffeca688a4648caf2410ba895 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index d92202e6dc..2bf74454fa 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -62,5 +62,5 @@ None; this package neither assembles nor sends a provider request. - **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export. - **The approval panel's "Always allow this type" is deferred** — durable grants need a grant-storage design; only allow-once/reject answer today. - **TodoPanel truncates long item text to one ellipsized line** — the figma strip has no wrap or expand affordance; full text is not readable inline. -- **Queue edit is text-only** — rows containing non-text blocks still show a flattened preview, but their edit control is disabled because the inline editor cannot preserve those blocks. A text row's edit mode replaces delete with save and cancel; Enter saves and Escape cancels. QueueDock exposes no send-now control. -- **Web exposes pending Queue only** — the composer and `conversation.send` never submit `mode:'steer'`. The Host omits pending steering from the Queue snapshot. A consumed `steering/message` still folds into the durable transcript as a plain bubble (no interjection chrome) so external/host steering remains truthful on replay. +- **Queue edit is text-only** — rows containing non-text blocks still show a flattened preview, but their edit control is disabled because the inline editor cannot preserve those blocks. A text row's edit mode replaces delete and strict steer with save and cancel; Enter saves and Escape cancels. +- **Queue strict steer preserves complete messages** — while the Agent is running, the steer action atomically transfers the addressed Queue occurrence into the current next-step window. Mixed-content rows remain eligible because the action forwards the immutable message instead of the text projection. The Host omits pending steering from the Queue snapshot; a consumed `steering/message` still folds into the durable transcript as a plain bubble so replay remains truthful. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index a75ee75a5f..2677ab5cc8 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -62,5 +62,5 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插 - **others 工具行的闪光图标是手绘近似版本**:无法在本地导出设计字形的矢量几何;等到存在精确导出后再将其提升到 ui-primitives。 - **审批面板的「始终允许此类」暂缓**:持久授权需要授权存储设计;今天只能回答允许一次/拒绝。 - **TodoPanel 将过长条目截成单行省略号**:figma 条没有换行或展开入口,完整文本无法在行内读完。 -- **Queue 编辑仅支持文本**:包含非文本块的行仍显示扁平化预览,但由于内联编辑器无法保留这些块,其编辑控件会被禁用。文本行进入编辑模式后,删除会替换为保存和取消;Enter 保存,Escape 取消。QueueDock 不提供立即发送控件。 -- **Web 仅暴露待处理 Queue**:composer 与 `conversation.send` 从不提交 `mode:'steer'`。Host 不会把待处理 steering(中途引导)纳入 Queue 快照。已消费的 `steering/message` 仍会折叠进持久 transcript(文本记录),并以无「插话」徽章的普通气泡呈现,因此从外部/Host 提交的 steering 在回放时仍能如实呈现。 +- **Queue 编辑仅支持文本**:包含非文本块的行仍显示扁平化预览,但由于内联编辑器无法保留这些块,其编辑控件会被禁用。文本行进入编辑模式后,删除和严格 steering(中途引导)操作会被保存和取消取代;Enter 保存,Escape 取消。 +- **Queue 严格 steering 会保留完整消息**:Agent 运行期间,steering 操作会以原子方式把所寻址的 Queue 单次入队项转移到当前 next-step 窗口。包含混合内容的行仍可使用此操作,因为它会转发不可变消息,而非文本投影。Host 不会把待处理 steering 纳入 Queue 快照;已消费的 `steering/message` 仍会折叠进持久 transcript(文本记录),并以普通气泡呈现,因此回放仍然如实。 diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 76560ca9e4..21897a529b 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -89,8 +89,11 @@ export const zh = { 'queue.save': '保存排队消息', 'queue.cancelEdit': '取消编辑', 'queue.remove': '删除排队消息', + 'queue.steer': '插话发送', + 'queue.steer.unavailable': '仅运行中可插话发送', 'queue.editFailed': '编辑失败:这条消息可能已经开始发送。', 'queue.removeFailed': '删除失败:这条消息可能已经开始发送。', + 'queue.steerFailed': '插话失败:当前回复已结束,或这条消息已经开始发送。', 'terminal.signal': '信号 {signal}', 'terminal.exitCode': '退出码 {code}', 'terminal.running': '运行中', @@ -189,8 +192,11 @@ export const en = { 'queue.save': 'Save queued message', 'queue.cancelEdit': 'Cancel editing', 'queue.remove': 'Remove queued message', + 'queue.steer': 'Steer queued message', + 'queue.steer.unavailable': 'Steering is available only while the agent is running', 'queue.editFailed': 'Edit failed: this message may have already started sending.', 'queue.removeFailed': 'Removal failed: this message may have already started sending.', + 'queue.steerFailed': 'Steering failed: the current response ended or this message already started sending.', 'terminal.signal': 'signal {signal}', 'terminal.exitCode': 'exit code {code}', 'terminal.running': 'Running', diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx index e62e5ab389..b374fa36d6 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx @@ -9,7 +9,7 @@ import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' import { IconCheckOutline16, IconChevronDownOutline14, IconChevronUpOutline14, - IconCloseOutline16, IconEditOutline16, IconTrashOutline16, + IconCloseOutline16, IconEditOutline16, IconRightUpOutline16, IconTrashOutline16, } from '@deepseek-ai/dsh-client-ui-primitives' import type { QueueAction, QueueItemId } from '../contract/queue.ts' import { NS } from '../locales.ts' @@ -30,6 +30,7 @@ export type QueueDockProps = PropsRuntime<'conversation.input.dock'> & QueueDock */ export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps) { const queue = useSession(s => s.queue) + const running = useSession(s => s.running) const [editing, setEditing] = useState<{ id: QueueItemId; text: string } | null>(null) const [busy, setBusy] = useState(null) const [collapsed, setCollapsed] = useState(true) @@ -170,6 +171,22 @@ export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps > + )} diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index 9a7d8fd5bb..4ecf41fd10 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -31,9 +31,9 @@ export interface IConversation { */ send(text: string): Promise /** - * Apply one operation to a pending queue occurrence. + * Apply one edit, remove, or strict steer operation to a pending queue occurrence. * @param itemId - agent-owned inbox occurrence identity. - * @param action - edit or remove operation. + * @param action - requested queue operation. * @returns completion; business failures reject. */ updateQueue(itemId: QueueItemId, action: QueueAction): Promise diff --git a/packages/client/ui-conversation/tests/queue-dock.spec.tsx b/packages/client/ui-conversation/tests/queue-dock.spec.tsx index 6c11119c0a..eb5c293501 100644 --- a/packages/client/ui-conversation/tests/queue-dock.spec.tsx +++ b/packages/client/ui-conversation/tests/queue-dock.spec.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom /** * QueueDock rendering and operations: authoritative rows, inline editing, - * collapse state, removal, failure notices, and live retirement. + * collapse state, removal, strict steering, failure notices, and live retirement. */ import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react' @@ -187,10 +187,10 @@ describe('QueueDock', () => { fireEvent.click(getByRole('button', { name: '2 条排队消息' })) expect([...container.querySelectorAll('li')].map(item => item.textContent)) .toEqual(['第一条排队消息', 'image [image]']) - expect(container.querySelectorAll('button')).toHaveLength(5) + expect(container.querySelectorAll('button')).toHaveLength(7) expect(container.querySelectorAll('[aria-label="编辑排队消息"]')).toHaveLength(2) expect(container.querySelectorAll('[aria-label="删除排队消息"]')).toHaveLength(2) - expect(container.querySelectorAll('[aria-label="立即发送排队消息"]')).toHaveLength(0) + expect(container.querySelectorAll('[aria-label="插话发送"]')).toHaveLength(2) expect((container.querySelectorAll('[aria-label="编辑排队消息"]')[0] as HTMLButtonElement).disabled).toBe(false) expect((container.querySelectorAll('[aria-label="编辑排队消息"]')[1] as HTMLButtonElement).disabled).toBe(true) expect(container.querySelectorAll('[aria-label="编辑排队消息"]')[1]?.getAttribute('title')) @@ -273,6 +273,45 @@ describe('QueueDock', () => { }) }) + it('strictly steers complete row content only while the agent is running', async () => { + const running = snapshotWith([row('i-steer', null, 'image [image]')]) + const source = liveSession(running) + const updateQueue = vi.fn(() => Promise.resolve()) + const rendered = render( + , + ) + + const button = rendered.getByLabelText('插话发送') + expect(button).toHaveProperty('disabled', false) + fireEvent.click(button) + await waitFor(() => { + expect(updateQueue).toHaveBeenCalledWith(iid('i-steer'), { kind: 'steer' }) + }) + + act(() => { source.push({ ...running, running: false }) }) + expect(rendered.getByLabelText('插话发送')).toHaveProperty('disabled', true) + expect(rendered.getByLabelText('插话发送').getAttribute('title')).toBe('仅运行中可插话发送') + }) + + it('keeps the row and reports a strict steer race', async () => { + const snap = snapshotWith([row('i-steer-race', 'pending steer')]) + const source = liveSession(snap) + const notify = vi.fn() + const updateQueue = vi.fn(() => Promise.reject(new Error('steer unavailable'))) + const { getByLabelText, getByText } = render( + , + ) + + fireEvent.click(getByLabelText('插话发送')) + await waitFor(() => { + expect(notify).toHaveBeenCalledWith( + 'error', + '插话失败:当前回复已结束,或这条消息已经开始发送。', + ) + }) + expect(getByText('pending steer')).toBeTruthy() + }) + it('keeps the row and surfaces a notice when an operation loses the claim race', async () => { const snap = snapshotWith([row('i-race', 'pending')]) const source = liveSession(snap) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index a2a1bf3c5d..786d4431de 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -2025,11 +2025,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'InboxAction', - declaration: 'export type InboxAction = {\n readonly kind: \'edit\';\n readonly content: ContentBlock[];\n} | {\n readonly kind: \'remove\';\n};', + declaration: 'export type InboxAction = {\n readonly kind: \'edit\';\n readonly content: ContentBlock[];\n} | {\n readonly kind: \'remove\';\n} | {\n readonly kind: \'steer\';\n};', }, { name: 'InboxActionResult', - declaration: 'export type InboxActionResult = \'applied\' | \'not-found\';', + declaration: 'export type InboxActionResult = \'applied\' | \'not-found\' | \'steer-unavailable\';', }, { name: 'InboxItemId', diff --git a/packages/core/agent-loop/README.i18n.yaml b/packages/core/agent-loop/README.i18n.yaml index 6e9e63a22f..691115619b 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: 1662b1076cc116888d048cb6af1be1c7ab8196f6 -README.zh.md: 2fca32a02fdd73961c912c988933e1cd1a1a5817 +README.md: c71b350adfe06a19d4c24cb7e67de895a662bd87 +README.zh.md: d30dfc85e1597a8e193019cc23f4c7c39c991776 diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 1662b1076c..c71b350adf 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -59,7 +59,7 @@ The unified `send()` primitive routes content and source by (`target` × `wakeup `steer()` attaches a one-shot admission receipt to its exact accepted message. After `agent/step` and asynchronous prompt assembly succeed, the loop commits a stable pending batch as `steering/message`, snapshots derived history, and opens `step/start`; only then does each receipt resolve `admitted` with that turn and step. Later arrivals remain pending. Idle steering enters the ordinary FIFO and uses the first request of its eventual turn as the same admission boundary. A turn-concluding tool result, broad cancellation, disposal, or a claimed idle-steering turn that never reaches a request resolves affected receipts `rejected`; `cancel(..., { keepInbox: true })` and non-terminal routing preserve pending delivery. Open-turn `inject()` still commits after all tool results, including accepted context finalized during an interrupted batch, while steering remains provisional until a request admits it. -Every FIFO acceptance mints an `InboxItemId` and publishes `agent/inbox/enqueue` with the complete occurrence. `updateInbox()` owns the synchronous queued-item boundary: edit freezes replacement content without changing message identity or position, while remove publishes discard. Edit publishes `agent/inbox/update`; steering and claimed occurrences return `not-found`. Claim publishes `agent/inbox/dequeue` and irrevocably removes the live address before prompt admission, so a racing update cannot rewrite durable history; `cancel()` without `keepInbox` publishes `agent/inbox/discard`. +Every FIFO acceptance mints an `InboxItemId` and publishes `agent/inbox/enqueue` with the complete occurrence. `updateInbox()` owns the synchronous queued-item boundary: edit freezes replacement content without changing message identity or position, remove publishes discard, and strict steer transfers the immutable message into an open next-step window as a new steering occurrence. A closed window returns `steer-unavailable` without mutation; pending steering and claimed occurrences return `not-found`. Claim publishes `agent/inbox/dequeue` and irrevocably removes the live address before prompt admission, so a racing update cannot rewrite durable history; `cancel()` without `keepInbox` publishes `agent/inbox/discard`. ### Loop lifecycle (`agent.ts`) diff --git a/packages/core/agent-loop/README.zh.md b/packages/core/agent-loop/README.zh.md index 2fca32a02f..d30dfc85e1 100644 --- a/packages/core/agent-loop/README.zh.md +++ b/packages/core/agent-loop/README.zh.md @@ -59,7 +59,7 @@ interface Config { `steer()` 会把一次性准入回执附着到其准确的已接收消息。`agent/step` 和异步提示词组装成功后,循环把稳定的待处理批次提交为 `steering/message`、捕获派生历史并开启 `step/start`;只有此时,每个回执才会解析为 `admitted`,并附带轮次与步骤。之后到达的消息继续待处理。空闲 steering 会进入普通 FIFO,并以其最终轮次的首次请求作为相同准入边界。结束轮次的工具结果、广义取消、dispose(资源释放),或已领取 idle-steering 消息却从未到达请求的轮次,会把受影响回执解析为 `rejected`;`cancel(..., { keepInbox: true })` 和非终止型路由会保留待处理投递。活跃轮次内的 `inject()` 仍会在所有工具结果后提交,包括被中断批次中已最终确认的上下文;steering 则保持待准入,直到请求接纳它。 -每次 FIFO 接受项时都会铸造一个 `InboxItemId`,并通过 `agent/inbox/enqueue` 发布完整的单次入队项。`updateInbox()` 持有同步 queued 项边界:编辑会冻结替换内容,但不改变消息标识或位置;移除会发布 discard。编辑会发布 `agent/inbox/update`;steering 项和已被认领的项会返回 `not-found`。认领操作会发布 `agent/inbox/dequeue`,并在提示词接纳前不可逆地移除实时寻址标识,因此竞态中的更新无法改写持久历史;`cancel()` 在不带 `keepInbox` 时会发布 `agent/inbox/discard`。 +每次 FIFO 接受项时都会铸造一个 `InboxItemId`,并通过 `agent/inbox/enqueue` 发布完整的单次入队项。`updateInbox()` 持有同步 queued 项边界:编辑会冻结替换内容,但不改变消息标识或位置;移除会发布 discard;严格 steering 会把不可变消息作为新的 steering 单次入队项转移到开放的 next-step 窗口。窗口关闭时返回 `steer-unavailable`,且不做任何变更;待处理 steering 和已被认领的项会返回 `not-found`。认领操作会发布 `agent/inbox/dequeue`,并在提示词接纳前不可逆地移除实时寻址标识,因此竞态中的更新无法改写持久历史;`cancel()` 在不带 `keepInbox` 时会发布 `agent/inbox/discard`。 ### 循环生命周期(`agent.ts`) diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 90354f0cc2..88fcd64ead 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -256,6 +256,22 @@ export class ReactLoopAgent implements Agent { emitAgentEvent(this.loopCtx, this, 'agent/inbox/discard', [pending.item]) return 'applied' } + case 'steer': { + if (!this.acceptsNextStep) return 'steer-unavailable' + this.queued.splice(queuedIndex, 1) + const item: InboxItem = Object.freeze({ + id: InboxItemId(randomUUID()), + message: pending.item.message, + placement: 'steering', + }) + this.outbox.push({ message: item.message, steering: true, item }) + // Publish the replacement only after it is owned by the outbox. Its + // enqueue precedes the old occurrence's discard so reentrant + // cancellation can terminally account for both occurrences. + emitAgentEvent(this.loopCtx, this, 'agent/inbox/enqueue', item) + emitAgentEvent(this.loopCtx, this, 'agent/inbox/discard', [pending.item]) + return 'applied' + } default: /* v8 ignore next -- InboxAction is a closed discriminated union. */ return assertNever(action) diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index 31e6d7c2b1..f61af0833c 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -156,6 +156,102 @@ describe('addressable inbox operations', () => { : '')) .toEqual(['keep me']) }) + + it('strictly transfers a queued occurrence into the open turn', async () => { + const adapter = new MockAdapter([textResponse('done')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('queue-to-steer'), { provider: 'mock', model: 'mock' }) + const entered = Promise.withResolvers() + const decision = Promise.withResolvers<{ kind: 'allow' }>() + ctx.on('agent/prompt-submit', async () => { + entered.resolve(undefined) + return decision.promise + }) + + const enqueued: InboxItem[] = [] + const discarded: InboxItem[] = [] + ctx.on('agent/inbox/enqueue', (subject, item) => { + if (subject === agent) enqueued.push(item) + }) + ctx.on('agent/inbox/discard', (subject, items) => { + if (subject === agent) discarded.push(...items) + }) + + const idle = waitForIdle(ctx, agent) + send(agent, 'open the turn') + await entered.promise + send(agent, 'steer this message') + const queued = enqueued.find(item => inboxText(item) === 'steer this message')! + + expect(agent.updateInbox(queued.id, { kind: 'steer' })).toBe('applied') + const steering = enqueued.find(item => item.placement === 'steering')! + expect(steering.id).not.toBe(queued.id) + expect(steering.message).toBe(queued.message) + expect(discarded).toEqual([queued]) + + decision.resolve({ kind: 'allow' }) + await idle + expect(agent.session.events.flatMap(event => + event.type === 'steering/message' ? [event.data.message] : [], + )).toEqual([queued.message]) + expect(agent.updateInbox(queued.id, { kind: 'steer' })).toBe('not-found') + }) + + it('keeps a queued occurrence when the next-step window is closed', () => { + const ctx = new Context() + const session = new Session(SessionId('queue-to-steer-closed')) + const agent = new ReactLoopAgent(ctx, session.id, {}, session) + const enqueued: InboxItem[] = [] + const discarded: InboxItem[] = [] + ctx.on('agent/inbox/enqueue', (_subject, item) => { enqueued.push(item) }) + ctx.on('agent/inbox/discard', (_subject, items) => { discarded.push(...items) }) + + agent.send( + createUserMessage({ content: [{ type: 'text', text: 'stay queued' }], source: { kind: 'user' } }), + { target: 'next-turn', wakeup: false }, + ) + const queued = enqueued[0]! + expect(agent.updateInbox(queued.id, { kind: 'steer' })).toBe('steer-unavailable') + expect(discarded).toEqual([]) + expect(agent.updateInbox(queued.id, { kind: 'remove' })).toBe('applied') + }) + + it('accounts for both occurrences when steering enqueue cancels reentrantly', async () => { + const adapter = new MockAdapter([textResponse('unused')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('queue-to-steer-cancel'), { provider: 'mock', model: 'mock' }) + const entered = Promise.withResolvers() + const decision = Promise.withResolvers<{ kind: 'allow' }>() + ctx.on('agent/prompt-submit', async () => { + entered.resolve(undefined) + return decision.promise + }) + + const enqueued: InboxItem[] = [] + const discarded: InboxItem[] = [] + ctx.on('agent/inbox/enqueue', (subject, item) => { + if (subject !== agent) return + enqueued.push(item) + if (item.placement === 'steering') agent.cancel({ kind: 'user' }) + }) + ctx.on('agent/inbox/discard', (subject, items) => { + if (subject === agent) discarded.push(...items) + }) + + const idle = waitForIdle(ctx, agent) + send(agent, 'open the turn') + await entered.promise + send(agent, 'cancel during conversion') + const queued = enqueued.find(item => inboxText(item) === 'cancel during conversion')! + + expect(agent.updateInbox(queued.id, { kind: 'steer' })).toBe('applied') + const steering = enqueued.find(item => item.placement === 'steering')! + expect(discarded).toEqual([steering, queued]) + + decision.resolve({ kind: 'allow' }) + await idle + expect(agent.session.events.some(event => event.type === 'steering/message')).toBe(false) + }) }) describe('assistant replay provenance', () => { diff --git a/packages/core/agent/README.i18n.yaml b/packages/core/agent/README.i18n.yaml index 78473df4a7..f8431cf022 100644 --- a/packages/core/agent/README.i18n.yaml +++ b/packages/core/agent/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/README.md -README.md: 8a6028352127c4638c0b5e0e3ee85964d1d7d734 -README.zh.md: ffa71ea987ab355ff2f30b6164376199cd5d0170 +README.md: 4c6a6dd95541cfa559e95858fede01d7cd76637f +README.zh.md: 07bf887c557b410005bbe0fa1a988e63a765ad99 diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 8a60283521..4c6a6dd955 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -62,7 +62,7 @@ The handle every plugin programs against: - `agent.send(message, options)` — the one delivery primitive over the (`target` × `wakeup`) matrix. `message` is an already identified, frozen `UserMessage`; callers normally create it with `createUserMessage()` before routing begins. `SendOptions` owns only the `target` and `wakeup` policy. Each accepted FIFO occurrence receives its own `InboxItemId`, even when callers reuse a `MessageId`; `agent/inbox/enqueue`/`update` and the terminal `dequeue` or `discard` carry that complete `InboxItem`. `target: 'next-turn'` queues one independent FIFO item that, if admitted, becomes the sole ordinary prompt in its turn. `target: 'next-step'` with `wakeup: true` submits steering, while `target: 'next-step'` with `wakeup: false` injects durable context without running the model. The [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the turn rationale. - `agent.reserveTurnAdmission()` — synchronously reserve the idle boundary before any queued waking prompt can claim its turn. An accepted prompt, including a same-tick pending wake, has right of way and makes reservation return `undefined`. Later sends keep their ordinary IDs, FIFO placement, and wakeup facts while held; `acceptsNextStep` remains false, `inject()` is not withheld, `whenIdle()` counts the reservation as activity, and the returned release is idempotent. This narrow coordination capability lets standalone durable operations such as manual compaction finish and flush before queued prompts derive from the session. -- `agent.updateInbox(itemId, action)` — synchronously edits or removes one still-pending queued occurrence. Edit keeps its `MessageId`, `InboxItemId`, source, and FIFO position while replacing frozen content; remove emits the occurrence's terminal discard. Steering and claimed occurrences return `not-found`. +- `agent.updateInbox(itemId, action)` — synchronously edits, removes, or strictly steers one still-pending queued occurrence. Edit keeps its `MessageId`, `InboxItemId`, source, and FIFO position while replacing frozen content; remove emits the occurrence's terminal discard. Strict steer requires `acceptsNextStep`, ends the queued occurrence, and accepts the same immutable message as a new steering occurrence with a new `InboxItemId`; a closed window returns `steer-unavailable` without mutation. Pending steering and claimed occurrences return `not-found`. - `agent.followup(input)` — the `next-turn`/wakeup preset of `send()`: queue an ordinary follow-up turn and wake the driver. - `agent.steer(input)` — the `next-step`/wakeup preset: submit one identified message and receive its `SteeringReceipt`. During prompt admission or an open turn, the message stages for the next safe request boundary without dispatching `agent/prompt-submit`; outside that acceptance window, it becomes a woken queued prompt. `receipt.outcome` resolves `admitted` with the turn and step only after the loop logs the message, captures it in immutable request history, and commits `step/start`. A turn-concluding tool result, broad cancellation, disposal, or pre-admission failure resolves it `rejected`; `cancel(..., { keepInbox: true })` and non-terminal routing preserve pending delivery. Reliable callers await the receipt, while best-effort UI steering may ignore it. - `agent.inject(input)` — the `next-step`/no-wakeup preset: append model-facing context without running the model; the next request sees a verbatim user-role message whose provenance is carried by the required `input.source`. During prompt admission or an open turn, injection waits in the outbox for the next safe boundary. Outside that acceptance window, it appends immediately without opening a turn; a context-only admission batch takes this fallback if admission closes without a turn, while context staged beside steering remains pending with it. Persistence reacts to `session/event` independently. Injection emits no `agent/inbox/*` event. diff --git a/packages/core/agent/README.zh.md b/packages/core/agent/README.zh.md index ffa71ea987..07bf887c55 100644 --- a/packages/core/agent/README.zh.md +++ b/packages/core/agent/README.zh.md @@ -62,7 +62,7 @@ Agent *创建* 由实现 `AgentFactory` 的插件(`dsh-agent-loop`)提供, - `agent.send(message, options)`:覆盖(`target` × `wakeup`)矩阵的唯一投递原语。`message` 是已有标识且已冻结的 `UserMessage`;调用方通常会在开始路由前使用 `createUserMessage()` 创建它。`SendOptions` 只持有 `target` 与 `wakeup` 策略。每次获准进入 FIFO 的项都会获得独立的 `InboxItemId`,即使调用方复用了同一个 `MessageId`;`agent/inbox/enqueue`/`update` 及终态 `dequeue` 或 `discard` 都会携带这一完整 `InboxItem`。`target: 'next-turn'` 排队一条独立 FIFO 项,获准后成为其轮次中唯一的普通提示词。`target: 'next-step'` 且 `wakeup: true` 提交 steering(中途引导),而 `target: 'next-step'` 且 `wakeup: false` 注入持久上下文,不运行模型。轮次原理由 [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md)拥有。 - `agent.reserveTurnAdmission()`:在任何已排队唤醒提示词认领其轮次之前,同步预留空闲边界。已获接纳的提示词拥有优先权,包括同一 tick 内仍在等待唤醒的项,此时预留返回 `undefined`。预留期间,之后发送的项保留其普通 ID、FIFO 位置与唤醒信息;`acceptsNextStep` 保持 false,`inject()` 不受阻塞,`whenIdle()` 将该预留计为活动,返回的释放函数可幂等调用。这项范围有限的协调能力使手动压缩(compaction)等独立持久操作能够在排队提示词从会话派生内容前完成并 flush。 -- `agent.updateInbox(itemId, action)`:同步编辑或移除一个仍处于待处理状态的 queued 入队项。编辑会替换已冻结的内容,同时保留其 `MessageId`、`InboxItemId`、来源与 FIFO 位置;移除会发出该项的终态 discard。steering 项和已被认领的项会返回 `not-found`。 +- `agent.updateInbox(itemId, action)`:同步编辑、移除一个仍处于待处理状态的 queued 入队项,或对其执行严格 steering。编辑会替换已冻结的内容,同时保留其 `MessageId`、`InboxItemId`、来源与 FIFO 位置;移除会发出该项的终态 discard。严格 steering 要求 `acceptsNextStep` 为 true;它会结束 queued 单次入队项,并把同一条不可变消息接受为新的 steering 单次入队项,后者使用新的 `InboxItemId`。窗口关闭时返回 `steer-unavailable`,且不做任何变更。待处理 steering 和已被认领的项会返回 `not-found`。 - `agent.followup(input)`:`send()` 的 `next-turn`/wakeup 预设:排队一个普通后续轮次并唤醒驱动器。 - `agent.steer(input)`:`next-step`/wakeup 预设:提交一条已有标识的消息,并取得其 `SteeringReceipt`。提示词接纳期间或轮次打开时,消息会为下一个安全请求边界暂存,且不分发 `agent/prompt-submit`;该接收窗口之外则成为会唤醒驱动器的排队提示词。只有循环记录消息、将其捕获到不可变请求历史并提交 `step/start` 后,`receipt.outcome` 才会解析为 `admitted`,并附带轮次与步骤。结束轮次的工具结果、广义取消、dispose(资源释放)或准入前故障会使其解析为 `rejected`;`cancel(..., { keepInbox: true })` 和非终止型路由会保留待处理投递。需要可靠投递的调用方应等待回执;尽力执行的 UI steering 可以忽略它。 - `agent.inject(input)`:`next-step`/不唤醒预设:追加面向模型的上下文而不运行模型;下一次请求会看到一条逐字的 user role 消息,其来源由必填的 `input.source` 携带。提示词接纳期间或轮次打开时,注入会在 outbox 中等待下一个安全边界。该接收窗口之外,它会立即追加而不开启轮次;如果接纳结束却未开启轮次,仅含上下文的接纳批次会采用这一回退,而与 steering 一同暂存的上下文则会随其继续待处理。持久化独立地响应 `session/event`。注入不发出 `agent/inbox/*` 事件。 diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 1a1010e90e..80d289c9f0 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -54,9 +54,10 @@ export interface InboxItem { export type InboxAction = | { readonly kind: 'edit'; readonly content: ContentBlock[] } | { readonly kind: 'remove' } + | { readonly kind: 'steer' } /** Result of applying an inbox action at the synchronous ownership boundary. */ -export type InboxActionResult = 'applied' | 'not-found' +export type InboxActionResult = 'applied' | 'not-found' | 'steer-unavailable' /** Final admission outcome for one call to {@link Agent.steer}. */ export type SteeringOutcome = @@ -209,10 +210,13 @@ export interface Agent { /** * Mutate one still-pending queued occurrence synchronously. Editing preserves * the message identity and queue position; removal publishes its terminal - * discard. Steering occurrences and driver-claimed items return `not-found`. + * discard. Steer strictly transfers the message into the current next-step + * window, or returns `steer-unavailable` without changing the queued + * occurrence. Steering occurrences and driver-claimed items return + * `not-found`. * @param id - independently addressable queued occurrence. - * @param action - edit or remove operation. - * @returns whether the pending occurrence was found and updated. + * @param action - edit, remove, or strict steer operation. + * @returns the applied outcome or the reason no mutation occurred. */ updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 69093a7692..5f3bec73b0 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md -README.md: 933b5f6167263545b3bef5ca9fb8f8b945e86ef9 -README.zh.md: f1f1106dbd0c50889eca6f6ae52fbb29d1c4c03f +README.md: 87c477362dc159621a38a1ff923bebf07cbd3c6f +README.zh.md: 7316f3a6d93d30e101537c40608037fedfa77321 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 933b5f6167..87c477362d 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -22,7 +22,7 @@ Session model routing is a session-domain contract. `session.models` returns the Generic Agent-bound session, command, and goal operations serve ordinary sessions only. They return `agent-busy` for a session-backed subagent instead of resuming or driving it; explicit-id `session.create` adoption and the attached-only queue controls enforce the same ownership boundary. Subagent conversation reads and continuation use the dedicated `subagent.*` domain, which retains catalog-mode and direct-parent authorization. -Pending queued input is a live control-plane contract, not session history. The gateway mirrors queued `InboxItem` occurrences from `agent/inbox/*` and broadcasts authoritative `session/queue` snapshots on every queued change and reconnect; pending steering stays outside this Web projection. `session.updateQueue` addresses one `InboxItemId`: edit replaces pending content and remove discards it. `session.cancel` aborts only the active turn and preserves pending inbox work; after cancellation reaches quiescence and the closing turn flushes, AgentLoop claims the next waking occurrence in FIFO order. The browser never resends or promotes that occurrence. A driver claim wins races by retiring the address before admission; a later operation returns `queue-item-not-found`. Queue operations query only an attached Agent and never resume a cold session because process-local inbox identities do not survive restart or disposal. The client never infers retirement from turn or status events. +Pending queued input is a live control-plane contract, not session history. The gateway mirrors queued `InboxItem` occurrences from `agent/inbox/*` and broadcasts authoritative `session/queue` snapshots on every queued change and reconnect; pending steering stays outside this Web projection. `session.updateQueue` addresses one `InboxItemId`: edit replaces pending content, remove discards it, and strict steer transfers its complete message into the current next-step window. A closed window returns `steer-unavailable` without changing the row. `session.cancel` aborts only the active turn and preserves pending inbox work; after cancellation reaches quiescence and the closing turn flushes, AgentLoop claims the next waking occurrence in FIFO order. The browser never resends or promotes that occurrence. A driver claim wins races by retiring the address before admission; a later operation returns `queue-item-not-found`. Queue operations query only an attached ordinary-session Agent and never resume a cold session because process-local inbox identities do not survive restart or disposal. The client never infers retirement from turn or status events. Workspace and Session lists are separate reconnect baselines. `workspace.create({ name })` creates a uniquely titled directory under the configured root, while `workspace.create({ path })` adopts an existing canonical directory and permits basename-derived titles to repeat. `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. `workspace.archiveSession` adds one session to the registry-global archive set and answers the full updated set; `workspace.list` carries that set as the reconnect baseline and `host/archived-sessions-changed` pushes the full snapshot after every durable change. Archiving hides the session from grouping surfaces without touching its log or its workspace account; a session neither live nor persisted fails with `session-not-found`. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`. The `session.list` summaries and `host/session-added` frames also carry the optional durable `origin: 'subagent'` classification so navigation can suppress duplicate child rows immediately and after reconnect; that bit is never continuation authority. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index f1f1106dbd..7316f3a6d9 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -22,7 +22,7 @@ 绑定到 Agent 的通用会话、命令与目标操作只服务普通会话。对于由会话支撑的 subagent,它们会返回 `agent-busy`,而不是恢复或驱动它;显式 id 的 `session.create` 接纳与仅针对已附加会话的队列控件也会执行同一所有权边界。subagent 对话读取与继续执行使用专用的 `subagent.*` 领域,该领域保留目录 mode 与直接 parent 授权。 -待处理的 queued 输入属于实时控制平面契约,而非会话历史。网关镜像来自 `agent/inbox/*` 的 queued `InboxItem` 入队项,并在每次 queued 变更和重连时广播权威的 `session/queue` 快照;待处理 steering(中途引导)不进入此 Web 投影。`session.updateQueue` 通过 `InboxItemId` 寻址单个项:编辑会替换待处理内容,移除会将其丢弃。`session.cancel` 仅中止活动轮次,并保留待处理 inbox 工作;取消达到完全停稳且结束中的轮次完成 flush 后,AgentLoop 按 FIFO 顺序认领下一个可唤醒入队项。浏览器绝不重发或提升该入队项。驱动器在接纳前退役寻址标识,因此认领会赢得竞态;之后的操作返回 `queue-item-not-found`。队列操作只查询当前已挂载的 Agent,绝不恢复冷会话,因为进程本地 inbox 标识无法在重启或资源释放后存活。客户端绝不根据轮次或状态事件推断项已退役。 +待处理的 queued 输入属于实时控制平面契约,而非会话历史。网关镜像来自 `agent/inbox/*` 的 queued `InboxItem` 入队项,并在每次 queued 变更和重连时广播权威的 `session/queue` 快照;待处理 steering(中途引导)不进入此 Web 投影。`session.updateQueue` 通过 `InboxItemId` 寻址单个项:编辑会替换待处理内容,移除会将其丢弃,严格 steering 会把其完整消息转移到当前 next-step 窗口。窗口关闭时返回 `steer-unavailable`,且不改变该行。`session.cancel` 仅中止活动轮次,并保留待处理 inbox 工作;取消达到完全停稳且结束中的轮次完成 flush 后,AgentLoop 按 FIFO 顺序认领下一个可唤醒入队项。浏览器绝不重发或提升该入队项。驱动器在接纳前退役寻址标识,因此认领会赢得竞态;之后的操作返回 `queue-item-not-found`。队列操作只查询当前已挂载的普通会话 Agent,绝不恢复冷会话,因为进程本地 inbox 标识无法在重启或资源释放后存活。客户端绝不根据轮次或状态事件推断项已退役。 Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create({ name })` 会在配置根目录下创建显示标题唯一的目录,而 `workspace.create({ path })` 会接纳已有的规范目录,并允许由 basename 派生的标题重复。`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。`workspace.archiveSession` 向注册表级全局归档集合添加一个会话,并应答完整的更新后集合;`workspace.list` 携带该集合作为重连基线,`host/archived-sessions-changed` 在每次持久变更后推送完整快照。归档只把会话从各分组视图中隐藏,不触碰其日志和 workspace 记账;既非实时也未持久化的会话以 `session-not-found` 失败。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。`session.list` 摘要与 `host/session-added` 帧还会携带可选的持久化分类 `origin: 'subagent'`,使导航在实时创建与重连后都能隐藏重复的 child 行;该标记绝不是继续执行的权威依据。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index fd4626e573..7d1c033393 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -1847,13 +1847,28 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro if (agent !== undefined && hasSubagentOwner(agent.session, agent)) { return Promise.resolve(err(request, subagentOwnershipError(sessionId))) } - if (agent === undefined || agent.updateInbox(itemId, action) === 'not-found') { + if (agent === undefined) { return Promise.resolve(err(request, { code: 'queue-item-not-found', message: 'queued item is no longer pending', details: { itemId }, })) } + const result = agent.updateInbox(itemId, action) + if (result === 'not-found') { + return Promise.resolve(err(request, { + code: 'queue-item-not-found', + message: 'queued item is no longer pending', + details: { itemId }, + })) + } + if (result === 'steer-unavailable') { + return Promise.resolve(err(request, { + code: 'steer-unavailable', + message: 'current turn no longer accepts steering', + details: { itemId }, + })) + } return Promise.resolve(ok(request, { accepted: true as const })) }, diff --git a/packages/host/apiproxy/src/api/rpc.schema.ts b/packages/host/apiproxy/src/api/rpc.schema.ts index 40289b91ef..4424b5527c 100644 --- a/packages/host/apiproxy/src/api/rpc.schema.ts +++ b/packages/host/apiproxy/src/api/rpc.schema.ts @@ -48,6 +48,7 @@ export const rpcErrorSchema: z.ZodType = z.discriminatedUnion('code', z.object({ code: z.literal('directory-picker-unavailable'), message: z.string(), details: z.object({ capability: z.string() }) }), z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }), z.object({ code: z.literal('queue-item-not-found'), message: z.string(), details: z.object({ itemId: z.string() }) }), + z.object({ code: z.literal('steer-unavailable'), message: z.string(), details: z.object({ itemId: z.string() }) }), z.object({ code: z.literal('command-error'), message: z.string(), details: z.object({}) }), z.object({ code: z.literal('unknown-command'), message: z.string(), details: z.object({}) }), z.object({ code: z.literal('settings-rejected'), message: z.string(), details: z.object({ ns: z.string() }) }), diff --git a/packages/host/apiproxy/src/api/rpc.ts b/packages/host/apiproxy/src/api/rpc.ts index 09cd1ce3f1..f48a2ca562 100644 --- a/packages/host/apiproxy/src/api/rpc.ts +++ b/packages/host/apiproxy/src/api/rpc.ts @@ -46,6 +46,7 @@ export interface RpcErrorDetailsMap { 'directory-picker-unavailable': { capability: string } 'agent-busy': { reason: string } 'queue-item-not-found': { itemId: InboxItemId } + 'steer-unavailable': { itemId: InboxItemId } /** A known slash command reported a usage/state error; the message is the command's own text. */ 'command-error': {} /** A leading-/ prompt named no registered command; the message names the token. */ diff --git a/packages/host/apiproxy/src/api/sessions.schema.ts b/packages/host/apiproxy/src/api/sessions.schema.ts index 8fcce1e648..1675e396d6 100644 --- a/packages/host/apiproxy/src/api/sessions.schema.ts +++ b/packages/host/apiproxy/src/api/sessions.schema.ts @@ -269,6 +269,7 @@ export const sessionUpdateQueueRequestSchema = z.object({ action: z.discriminatedUnion('kind', [ z.object({ kind: z.literal('edit'), content: z.array(contentBlockSchema) }), z.object({ kind: z.literal('remove') }), + z.object({ kind: z.literal('steer') }), ]), }) as unknown as z.ZodType> diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts index e19ab08ef5..f55cb87001 100644 --- a/packages/host/apiproxy/src/api/sessions.ts +++ b/packages/host/apiproxy/src/api/sessions.ts @@ -129,6 +129,7 @@ export interface SessionModels { export type QueueAction = | { kind: 'edit'; content: ContentBlock[] } | { kind: 'remove' } + | { kind: 'steer' } /** Session list entry (v1 builds no index: list does readdir+stat). */ export interface SessionSummary { @@ -285,7 +286,7 @@ export interface SessionsApi { Promise> /** - * Edits or removes one pending queued occurrence on an ordinary session. + * Edits, removes, or strictly steers one pending queued occurrence on an ordinary session. * Session-backed subagents reject with `agent-busy`. */ updateQueue(request: RpcRequest<{ sessionId: SessionId; itemId: InboxItemId; action: QueueAction }>): diff --git a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts index f2e9e352d7..61e4c6f4d3 100644 --- a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts @@ -291,13 +291,14 @@ function inboxItem(id: string, message: UserMessage, placement: InboxPlacement): } describe('session.updateQueue', () => { - it('routes an addressable action and reports a lost claim race', async () => { + it('routes addressable actions and reports strict steer races', async () => { const ctx = await harness() const agent = stubAgent(ctx) const seen: unknown[] = [] agent.updateInbox = (id, action) => { seen.push({ id, action }) - return id === InboxItemId('present') ? 'applied' : 'not-found' + if (id === InboxItemId('present')) return 'applied' + return id === InboxItemId('closed') ? 'steer-unavailable' : 'not-found' } const api = createApiProxy(ctx, DEFAULTS) @@ -319,9 +320,22 @@ describe('session.updateQueue', () => { }, }) expect(expectErr(missing)).toMatchObject({ code: 'queue-item-not-found' }) + const closed = await api.sessions.updateQueue({ + rpcId: RpcId('q-closed'), + payload: { + sessionId: agent.id, + itemId: InboxItemId('closed'), + action: { kind: 'steer' }, + }, + }) + expect(expectErr(closed)).toMatchObject({ + code: 'steer-unavailable', + details: { itemId: 'closed' }, + }) expect(seen).toEqual([ { id: 'present', action: { kind: 'edit', content: [{ type: 'text', text: 'edited' }] } }, { id: 'claimed', action: { kind: 'remove' } }, + { id: 'closed', action: { kind: 'steer' } }, ]) }) diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 34910acb9f..541ddab1c8 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -77,6 +77,7 @@ describe('rpcErrorSchema', () => { }).code).toBe('model-unavailable') expect(rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: { reason: 'r' } }).code).toBe('agent-busy') expect(rpcErrorSchema.parse({ code: 'queue-item-not-found', message: 'm', details: { itemId: 'i' } }).code).toBe('queue-item-not-found') + expect(rpcErrorSchema.parse({ code: 'steer-unavailable', message: 'm', details: { itemId: 'i' } }).code).toBe('steer-unavailable') expect(rpcErrorSchema.parse({ code: 'command-error', message: 'm', details: {} }).code).toBe('command-error') expect(rpcErrorSchema.parse({ code: 'unknown-command', message: 'm', details: {} }).code).toBe('unknown-command') expect(rpcErrorSchema.parse({ code: 'title-invalid', message: 'm', details: { sessionId: 's' } }).code).toBe('title-invalid') @@ -280,6 +281,9 @@ describe('sessions domain schemas', () => { expect(sessionUpdateQueueRequestSchema.parse({ sessionId: 's1', itemId: 'i1', action: { kind: 'remove' }, }).action.kind).toBe('remove') + expect(sessionUpdateQueueRequestSchema.parse({ + sessionId: 's1', itemId: 'i1', action: { kind: 'steer' }, + }).action.kind).toBe('steer') expect(() => sessionUpdateQueueRequestSchema.parse({ sessionId: 's1', itemId: 'i1', action: { kind: 'promote' }, })).toThrow() From 5b17d3bab5fc09eb7557554a9c9f6b3ebf513d03 Mon Sep 17 00:00:00 2001 From: kingwl Date: Thu, 30 Jul 2026 03:29:12 +0800 Subject: [PATCH 06/19] fix(web): match queue steer icon to design --- .../client/ui-conversation/src/client/queue/QueueDock.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx index b374fa36d6..10c16e6dc5 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx @@ -9,7 +9,7 @@ import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' import { IconCheckOutline16, IconChevronDownOutline14, IconChevronUpOutline14, - IconCloseOutline16, IconEditOutline16, IconRightUpOutline16, IconTrashOutline16, + IconCloseOutline16, IconEditOutline16, IconSendOutline16, IconTrashOutline16, } from '@deepseek-ai/dsh-client-ui-primitives' import type { QueueAction, QueueItemId } from '../contract/queue.ts' import { NS } from '../locales.ts' @@ -185,7 +185,7 @@ export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps ) }} > - + )} From dffe955ed203b85c324fbf3ad77d1996b01e27b0 Mon Sep 17 00:00:00 2001 From: imccyu Date: Sun, 2 Aug 2026 15:36:17 +0800 Subject: [PATCH 07/19] feat(web): surface and configure composer steering --- ...026-07-30-web-queue-steer-action.i18n.yaml | 4 +- .../2026-07-30-web-queue-steer-action.md | 24 +-- .../2026-07-30-web-queue-steer-action.zh.md | 24 +-- apps/web/tests/settings-chrome.e2e.ts | 31 +++- .../queue-actions/editing.expected.md | 2 +- .../queue-actions/preserved.expected.md | 2 + .../settings-chrome/dialog.expected.md | 4 + .../snapshots/steering/mid-steer.expected.md | 1 + apps/web/tests/steering.e2e.ts | 150 +++++++++++++++--- .../client/session-history/history-fold.ts | 3 +- .../src/client/sessions/conversation.ts | 13 +- .../runtime/src/client/sessions/session.ts | 3 + .../src/client/sessions/transcript-adapter.ts | 3 +- .../client/runtime/tests/queue-store.spec.ts | 47 +++++- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 8 +- packages/client/ui-conversation/README.zh.md | 8 +- .../ui-conversation/src/client/apply.ts | 23 ++- .../src/client/chat/ChatView.tsx | 20 ++- .../src/client/chat/MessageItem.tsx | 19 ++- .../client/contract/composer-submission.ts | 10 ++ .../src/client/contract/slots.ts | 3 + .../src/client/input/contract.ts | 22 +-- .../src/client/input/facade.ts | 26 ++- .../ui-conversation/src/client/input/hub.ts | 7 +- .../src/client/input/machine.ts | 31 ++-- .../src/client/input/submission-policy.ts | 72 +++++++++ .../ui-conversation/src/client/locales.ts | 8 + .../src/client/queue/QueueDock.tsx | 5 +- .../ui-conversation/src/client/queue/store.ts | 4 +- .../settings/EnterBehaviorRow.module.css | 56 +++++++ .../src/client/settings/EnterBehaviorRow.tsx | 76 +++++++++ .../src/client/skeleton/InputBar.tsx | 17 +- .../ui-conversation/tests/chat-apply.spec.tsx | 3 + .../ui-conversation/tests/chat-view.spec.tsx | 45 ++++++ .../tests/enter-behavior-row.spec.tsx | 67 ++++++++ .../ui-conversation/tests/input-bar.spec.tsx | 50 ++++-- .../tests/input-machine.spec.ts | 70 +++----- .../tests/input-matrix.spec.tsx | 5 +- .../tests/input-scenarios.spec.tsx | 5 +- .../ui-conversation/tests/queue-dock.spec.tsx | 14 +- .../ui-conversation/tests/skeleton.spec.tsx | 3 +- .../tests/submission-policy.spec.ts | 65 ++++++++ packages/host/apiproxy/src/api-proxy.ts | 45 ++++-- .../host/apiproxy/src/api/events.schema.ts | 1 + packages/host/apiproxy/src/api/events.ts | 13 +- .../apiproxy/tests/api-proxy-commands.spec.ts | 113 +++++++++++-- .../host/apiproxy/tests/rpc-schemas.spec.ts | 2 +- 48 files changed, 1006 insertions(+), 225 deletions(-) create mode 100644 packages/client/ui-conversation/src/client/contract/composer-submission.ts create mode 100644 packages/client/ui-conversation/src/client/input/submission-policy.ts create mode 100644 packages/client/ui-conversation/src/client/settings/EnterBehaviorRow.module.css create mode 100644 packages/client/ui-conversation/src/client/settings/EnterBehaviorRow.tsx create mode 100644 packages/client/ui-conversation/tests/enter-behavior-row.spec.tsx create mode 100644 packages/client/ui-conversation/tests/submission-policy.spec.ts diff --git a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.i18n.yaml index 780858e1d8..1220cc39fa 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.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-30-web-queue-steer-action.md -2026-07-30-web-queue-steer-action.md: 4a8a04c056748d5c607c5ec868de4f2dd8342f2c -2026-07-30-web-queue-steer-action.zh.md: 92225f3a61e24b9eb687c50400aa6fe5e6ff5623 +2026-07-30-web-queue-steer-action.md: b84ee5acf472ba8482f6dfb7f466a905ddfd8f4f +2026-07-30-web-queue-steer-action.zh.md: fd4b4f8f8ab18db8a5372ff1a419fa186e29f399 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.md b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.md index 4a8a04c056..b84ee5acf4 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.md @@ -6,7 +6,7 @@ English | [中文](2026-07-30-web-queue-steer-action.zh.md) ## Problem -The Web composer deliberately queues Enter submissions while an agent runs. QueueDock already gives each pending message an addressable row, and the durable transcript already renders consumed `steering/message` events with an interjection badge, but Web has no action connecting those two surfaces. +The Web composer originally queued every Enter submission while an agent ran. QueueDock already gives each pending message an addressable row, and the durable transcript already renders consumed `steering/message` events as user-style bubbles without message actions, but Web had neither an action connecting those two surfaces nor a direct composer gesture for choosing current-turn steering. Implementing the row action as a client-side delete followed by `session.prompt(mode: 'steer')` would split one user intent across two RPCs. Driver claim could win between them, the steer could fail after deletion, or the existing best-effort `agent.steer()` fallback could silently append a new Queue item after the original occurrence was removed. A send-now action must therefore distinguish current-turn steering from Queue promotion and preserve the original row when steering is no longer possible. @@ -14,12 +14,14 @@ Implementing the row action as a client-side delete followed by `session.prompt( ### Product contract -Each non-editing QueueDock row exposes the upward-arrow action as “插话发送”. The action is enabled only while the session reports a running agent; mixed-content messages remain eligible because steering forwards the complete immutable `UserMessage` rather than the row's text projection. Edit and delete keep their existing behavior, and the composer continues to submit Enter as Queue. +Each non-editing QueueDock row exposes the upward-arrow action as “插话发送”. The action is enabled only while the session reports a running agent; mixed-content messages remain eligible because steering forwards the complete immutable `UserMessage` rather than the row's text projection. Edit and delete keep their existing behavior. -Activating the action requests strict current-turn steering for that exact `InboxItemId`. Success removes the Queue row through the authoritative Host snapshot. When AgentLoop drains it, the existing durable `steering/message` event and transcript badge render the message without a new chat presentation path. +Activating the action requests strict current-turn steering for that exact `InboxItemId`. Success removes the Queue row through the authoritative Host snapshot. When AgentLoop drains it, the existing durable `steering/message` event renders the same user-style bubble without a separate durable presentation path. The running bit is only an interaction hint. AgentLoop's `acceptsNextStep` value is authoritative at the synchronous mutation boundary. If that window has closed, the operation leaves the Queue occurrence unchanged and returns a typed `steer-unavailable` error; if the driver already claimed the occurrence, it returns the existing `queue-item-not-found` error. The UI reports either race without optimistically removing the row. +The composer uses a separate best-effort contract for newly typed input. While the addressed session is idle, Enter and Cmd/Ctrl+Enter both perform an ordinary Queue send. While it is running, a General Settings preference assigns plain Enter to Queue (the default) or Steer, and Cmd/Ctrl+Enter performs the other behavior; Shift+Enter inserts a newline. The browser persists that preference, and it affects only the busy-state gesture pair. If a direct composer Steer misses the current next-step window, AgentLoop automatically admits it as the next waking Queue turn and the Web does not report a failure. + ### Agent and lifecycle boundary `InboxAction` gains a consumer-backed `{ kind: 'steer' }` operation alongside edit and remove. `Agent.updateInbox()` handles it only after locating the queued occurrence and proving `acceptsNextStep`; it never delegates to the best-effort `agent.steer()` alias. @@ -32,17 +34,19 @@ The action does not run `agent/prompt-submit`: choosing steering intentionally c `session.updateQueue` carries the `steer` action and maps the two negative outcomes to typed RPC errors. The conversion is one synchronous Agent operation; the Host never reconstructs it by combining remove and prompt calls. -The Host's transient `session/queue` projection remains Queue-only. It ignores the new pending steering occurrence and removes the old row when its discard arrives. Pending steering does not gain edit, delete, or reconnect presentation in this cut. A later dedicated pending-steering projection may add that observability without widening Queue mutation semantics. +The Host's existing `queuedMirror` remains the sole transient inbox authority. Its `session/queue` snapshot carries every live occurrence with `placement: 'queued' | 'steering'`: QueueDock renders only queued rows, while ChatView renders pending steering at the conversation tail without edit or delete actions. Reconnect replays the same snapshot, so this visibility does not require client optimism or a second registry. -The existing `session.prompt(mode: 'steer')` contract remains best-effort for new input: outside the next-step window it may become a waking follow-up. Only the Queue row action is strict, because failure can safely leave its already-pending message untouched. +When AgentLoop claims pending steering, it emits `agent/inbox/dequeue` immediately before synchronously appending `steering/message`. The Host retires that steering row on the following microtask, allowing the durable session event to enter the linear mux stream first. ChatView matches the shared `MessageId` and suppresses the transient projection as soon as the durable node exists, so one bubble changes authority without a visible gap or duplicate; an append failure still retires the claimed row. + +The existing `session.prompt(mode: 'steer')` contract remains best-effort for new input: outside the next-step window it becomes a waking follow-up. The composer carries an explicit `queue | steer` mode through slash adjudication and reference serialization before calling that contract. A browser-local submission policy owns the persisted busy-Enter preference and resolves plain versus accelerated Enter as complementary gestures; the Settings row and InputBar share that policy without duplicating storage or delivery-window authority. Only the Queue row action is strict, because failure can safely leave its already-pending message untouched. ### Verification AgentLoop contract coverage holds prompt admission open, converts one exact queued occurrence, and proves the replacement steering occurrence keeps the message value, drains as `steering/message`, and never starts its former independent turn. It also pins unavailable-window retention, claimed-address rejection, and re-entrant cancellation lifecycle conservation. -Host schema and proxy tests cover the new action, both typed errors, authoritative Queue snapshots, and the absence of pending steering from reconnect snapshots. QueueDock tests cover running-state enablement, complete-content eligibility, failure retention, and authoritative success retirement. +Host schema and proxy tests cover the new action, both typed errors, placement-aware snapshots and reconnect replay, plus durable-before-retirement ordering. QueueDock tests cover running-state enablement, complete-content eligibility, failure retention, authoritative success retirement, and filtering of steering occurrences. ChatView tests cover the transient bubble and its single-copy handoff to the durable node. -The keyless Web steering scenario queues a message through the real composer while the first response streams, activates the row arrow, then uses `ask_user_question` as a stable pending-steering barrier. After the answer, it proves one badged interjection becomes durable and the next model request obeys it. Queue edit/delete scenarios continue to prove those actions are unchanged. +The keyless Web steering scenario queues a message through the real composer while the first response streams, activates the row arrow, then uses `ask_user_question` as a stable pending-steering barrier. It proves the Host-backed pending bubble appears before admission, hands off to one durable interjection after the answer, and affects the next model request. Assembled composer scenarios prove default-mode Cmd+Enter reaches the same pending and durable path without creating a Queue row, while Steer-mode Cmd+Enter creates a Queue row instead. Settings and submission-policy coverage pin the default, persistence, busy-only scope, and complementary gesture mapping; Queue edit/delete scenarios continue to prove those actions are unchanged. ## Alternatives considered @@ -50,18 +54,18 @@ The keyless Web steering scenario queues a message through the real composer whi **Restore Queue promotion under the upward arrow.** Rejected because moving an item to the front still creates an independent admitted turn. The control promises current-turn steering, not priority within Queue. -**Use the existing best-effort `agent.steer()` behavior.** Rejected for this action because a closed next-step window would silently turn the selected row back into queued work, possibly at a different position and identity. Strict failure preserves the original occurrence and makes the semantic race visible. +**Use the existing best-effort `agent.steer()` behavior for the Queue row.** Rejected for that action because a closed next-step window would silently turn the selected row back into queued work, possibly at a different position and identity. Strict failure preserves the original occurrence and makes the semantic race visible. Newly typed composer input has no existing Queue occurrence to preserve, so it intentionally uses the best-effort behavior. **Change `agent.steer()` to be strict for every caller.** Rejected because TUI and plugin callers use its safe follow-up fallback for newly submitted input. A queued row has recoverable state that those callers do not. **Preserve the same `InboxItemId` while changing placement.** Rejected because `InboxItemId` identifies one FIFO acceptance and `placement` records that acceptance's resolved delivery. Ending one queued occurrence and accepting one steering occurrence keeps lifecycle facts truthful and leaves the conservation invariant unchanged. -**Expose pending steering in `session/queue`.** Deferred because the existing product design provides no pending-steering row state or operations. Authoritative Queue retirement plus the durable consumed bubble is sufficient for the first interaction cut; reconnect visibility can be added through a dedicated projection if product testing shows the gap matters. +**Add a dedicated pending-steering projection and client store.** Rejected because queued and steering occurrences already share one Agent inbox lifecycle and one Host mirror. A second projection would duplicate reconnect state and ordering authority; a placement tag lets each client surface select its rows without widening Queue mutation semantics. **Cancel the active turn and run the selected Queue item.** Rejected because it destroys unrelated in-flight work and starts a new turn rather than steering the current one. ## Consequences -A successful action can be pending but absent from the Web after its Queue row retires and before `steering/message` commits; a refresh during that interval has no pending-steering indication. The running bit can also remain true briefly after the strict next-step window closes, so the button may be enabled for an operation that correctly returns `steer-unavailable`. +`session/queue` describes a placement-aware transient inbox snapshot rather than a Queue-only list, so every consumer must filter by placement. Pending steering survives reconnect and appears immediately, but remains non-durable until `steering/message` commits. The running bit can also remain true briefly after the strict next-step window closes, so the button may be enabled for an operation that correctly returns `steer-unavailable`. The explicit action changes delivery from an independently admitted turn to current-turn steering, so prompt-admission plugins do not process the converted message. Enqueue-before-discard lifecycle publication remains required for re-entrant cancellation safety; focused regression coverage protects that ordering. diff --git a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.zh.md index 92225f3a61..fd4b4f8f8a 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -Web composer 会在 agent 运行期间有意把 Enter 提交作为 Queue 入队。QueueDock 已经为每条待处理消息提供可寻址的行,持久 transcript(文本记录)也已能把消费后的 `steering/message` 事件渲染为带插话徽标的消息,但 Web 没有连接这两个界面的操作。 +Web composer 原本会在 agent 运行期间把所有 Enter 提交作为 Queue 入队。QueueDock 已经为每条待处理消息提供可寻址的行,持久 transcript(文本记录)也已能把消费后的 `steering/message` 事件渲染为不带消息操作的用户样式气泡,但 Web 既没有连接这两个界面的操作,也没有让用户从 composer 直接选择当前轮次 steering 的手势。 如果 Web 先在客户端删除该行,再调用 `session.prompt(mode: 'steer')`,就会把用户的一次意图拆分到两个 RPC 中。驱动器可能在两次调用之间先认领该项,steering 投递也可能在删除后失败;现有尽力而为的 `agent.steer()` 回退还可能在原单次入队项被移除后,静默追加一个新的 Queue 项。因此,立即发送操作必须区分当前轮次 steering 与 Queue 前移,并在 steering 已不可用时保留原行。 @@ -14,12 +14,14 @@ Web composer 会在 agent 运行期间有意把 Enter 提交作为 Queue 入队 ### 产品契约 -每个非编辑态的 QueueDock 行都会提供名为“插话发送”的向上箭头操作。仅当会话报告 agent 正在运行时,该操作才会启用;包含混合内容的消息仍可使用,因为 steering 会转发完整且不可变的 `UserMessage`,而非该行的文本投影。编辑和删除保持现有行为,composer 也继续把 Enter 提交为 Queue。 +每个非编辑态的 QueueDock 行都会提供名为“插话发送”的向上箭头操作。仅当会话报告 agent 正在运行时,该操作才会启用;包含混合内容的消息仍可使用,因为 steering 会转发完整且不可变的 `UserMessage`,而非该行的文本投影。编辑和删除保持现有行为。 -触发该操作会针对对应的 `InboxItemId` 请求严格的当前轮次 steering。操作成功后,权威 Host 快照会移除 Queue 行。AgentLoop 排空该项时,现有持久 `steering/message` 事件与 transcript 插话徽标会渲染这条消息,无需新增聊天展示路径。 +触发该操作会针对对应的 `InboxItemId` 请求严格的当前轮次 steering。操作成功后,权威 Host 快照会移除 Queue 行。AgentLoop 排空该项时,现有持久 `steering/message` 事件会渲染相同的用户样式气泡,无需另建持久展示路径。 running 标志位只用于提示交互状态。在同步变更边界上,AgentLoop 的 `acceptsNextStep` 值才是权威依据。如果该窗口已经关闭,操作会保持 Queue 单次入队项不变,并返回类型化的 `steer-unavailable` 错误;如果驱动器已经认领该项,则返回现有的 `queue-item-not-found` 错误。UI 会报告任一竞态,不会乐观地移除该行。 +Composer 对新输入采用另一套尽力而为契约。所寻址会话空闲时,Enter 和 Cmd/Ctrl+Enter 都执行普通 Queue 发送。会话运行期间,General Settings 偏好会把普通 Enter 分配为 Queue(默认值)或 Steer,Cmd/Ctrl+Enter 则执行另一种行为;Shift+Enter 用于换行。浏览器会持久化该偏好,并且它只影响繁忙态下这对手势。如果 composer 直接发出的 Steer 错过当前 next-step 窗口,AgentLoop 会自动将其接纳为下一条唤醒 Queue 轮次,Web 不显示失败。 + ### Agent 与生命周期边界 `InboxAction` 会在编辑和移除之外,新增由实际消费方支撑的 `{ kind: 'steer' }` 操作。`Agent.updateInbox()` 只有在找到 queued 单次入队项并确认 `acceptsNextStep` 后才会处理该操作,绝不会委托给尽力而为的 `agent.steer()` 别名。 @@ -32,17 +34,19 @@ running 标志位只用于提示交互状态。在同步变更边界上,AgentL `session.updateQueue` 会携带 `steer` 操作,并把两种负面结果映射为类型化 RPC 错误。这项转换是一次同步 Agent 操作;Host 绝不会通过组合移除和提示词调用来重建它。 -Host 的瞬态 `session/queue` 投影仍然只包含 Queue。它会忽略新的待处理 steering 单次入队项,并在收到旧项的 discard 时移除原行。本阶段不会为待处理 steering 增加编辑、删除或重连展示。未来可以用专用的待处理 steering 投影补充这种可观测性,而无需扩大 Queue 变更语义。 +Host 仍以现有 `queuedMirror` 作为唯一的瞬态 inbox 权威。`session/queue` 快照会携带所有存活单次入队项及其 `placement: 'queued' | 'steering'`:QueueDock 只渲染 queued 行,ChatView 则在会话流末尾渲染待处理 steering,且不提供编辑或删除操作。重连会重放同一份快照,因此这项可见性既不依赖客户端乐观展示,也不需要第二个 registry。 -现有 `session.prompt(mode: 'steer')` 对新输入仍采用尽力而为的契约:在 next-step 窗口之外,它可能变为会唤醒 agent 的后续轮次。只有 Queue 行操作采用严格语义,因为失败时可以安全地保留其已经待处理的消息。 +AgentLoop 认领待处理 steering 时,会在同步追加 `steering/message` 之前立即发出 `agent/inbox/dequeue`。Host 会等到下一个微任务才退役该 steering 行,让持久 session 事件先进入线性 mux 流。ChatView 会匹配两边共享的 `MessageId`,并在持久节点出现时立即抑制瞬态投影,因此同一个气泡切换权威时不会产生可见空档或重复;如果追加失败,已认领行仍会退役。 + +现有 `session.prompt(mode: 'steer')` 对新输入仍采用尽力而为的契约:在 next-step 窗口之外,它会变为唤醒 agent 的后续轮次。Composer 会让显式 `queue | steer` 模式经过 slash 裁决与引用序列化,再调用该契约。浏览器本地的提交策略拥有持久化的繁忙态 Enter 偏好,并把普通 Enter 与加速 Enter 解析为互补手势;Settings 行和 InputBar 共享该策略,不重复实现存储或投递窗口权威。只有 Queue 行操作采用严格语义,因为失败时可以安全地保留其已经待处理的消息。 ### 验证 AgentLoop 契约覆盖保持提示词接纳窗口打开,转换一个精确的 queued 单次入队项,并证明替代它的 steering 单次入队项保留消息值、以 `steering/message` 的形式排空,且绝不启动原本的独立轮次。该覆盖还钉住窗口不可用时保留原项、拒绝已被认领的地址,以及可重入取消下的生命周期守恒。 -Host schema 和代理测试覆盖新操作、两种类型化错误、权威 Queue 快照,以及重连快照不包含待处理 steering。QueueDock 测试覆盖按运行状态启用、混合内容消息仍可完整投递、失败时保留原行,以及成功后由权威快照退役。 +Host schema 和代理测试覆盖新操作、两种类型化错误、带 placement 的快照与重连重放,以及先持久化再退役的顺序。QueueDock 测试覆盖按运行状态启用、混合内容消息仍可完整投递、失败时保留原行、成功后由权威快照退役,以及过滤 steering 单次入队项。ChatView 测试覆盖瞬态气泡及其只保留一份的持久节点交接。 -无密钥 Web steering 场景在第一次响应流式输出期间,通过真实 composer 排队一条消息并触发行上的箭头,再用 `ask_user_question` 作为稳定的待处理 steering 屏障。回答问题后,该场景证明一条带徽标的插话成为持久记录,并且下一次模型请求遵循它。Queue 编辑/删除场景继续证明这些操作没有变化。 +无密钥 Web steering 场景在第一次响应流式输出期间,通过真实 composer 排队一条消息并触发行上的箭头,再用 `ask_user_question` 作为稳定的待处理 steering 屏障。该场景证明 Host 支撑的待处理气泡会在准入前出现,在回答后交接为唯一一条持久插话,并影响下一次模型请求。组装后的 composer 场景证明默认模式下的 Cmd+Enter 无需创建 Queue 行,也会进入同一条待处理与持久路径;Steer 模式下的 Cmd+Enter 则会创建 Queue 行。Settings 与提交策略覆盖会固定默认值、持久化、仅限繁忙态的作用域和互补手势映射;Queue 编辑/删除场景继续证明这些操作没有变化。 ## 考虑过的替代方案 @@ -50,18 +54,18 @@ Host schema 和代理测试覆盖新操作、两种类型化错误、权威 Queu **恢复向上箭头对应的 Queue 前移操作。** 不予采纳,因为把某个项移到队首仍然会创建一个独立接纳的轮次。该控件承诺的是当前轮次 steering,而不是 Queue 内的优先级。 -**使用现有尽力而为的 `agent.steer()` 行为。** 不予采纳,因为关闭的 next-step 窗口会静默地把选中行重新变成 queued 工作,而且位置和标识可能不同。严格失败会保留原单次入队项,并让这项语义竞态明确可见。 +**为 Queue 行使用现有尽力而为的 `agent.steer()` 行为。** 不予采纳,因为关闭的 next-step 窗口会静默地把选中行重新变成 queued 工作,而且位置和标识可能不同。严格失败会保留原单次入队项,并让这项语义竞态明确可见。新输入的 composer 消息没有需要保留的现有 Queue 单次入队项,因此有意采用尽力而为行为。 **让每个调用方使用的 `agent.steer()` 都采用严格语义。** 不予采纳,因为 TUI 和插件调用方会针对新提交的输入使用其安全的后续轮次回退。queued 行具有这些调用方不具备的可恢复状态。 **改变投递方式时保留同一个 `InboxItemId`。** 不予采纳,因为 `InboxItemId` 标识一次 FIFO 接受,而 `placement` 记录该次接受解析出的投递方式。结束一个 queued 单次入队项并接受一个 steering 单次入队项,能够使生命周期事实保持如实,并让守恒不变量保持不变。 -**在 `session/queue` 中暴露待处理 steering。** 暂缓,因为现有产品设计没有为待处理 steering 提供行状态或操作。权威的 Queue 退役加上持久的已消费气泡,足以支撑首个交互阶段;如果产品测试表明这一缺口影响显著,可以通过专用投影增加重连可见性。 +**增加专用的待处理 steering 投影和客户端 store。** 不予采纳,因为 queued 与 steering 单次入队项已经共享同一套 Agent inbox 生命周期和 Host mirror。第二份投影会重复保存重连状态与顺序权威;placement 标签能让各客户端界面选取自己的行,而不扩大 Queue 变更语义。 **取消活动轮次并运行选中的 Queue 项。** 不予采纳,因为这会破坏无关的进行中工作,并且会启动新轮次,而不是 steering 当前轮次。 ## 后果 -操作成功后,从 Queue 行退役到 `steering/message` 提交之间,对应消息可能仍处于待处理状态,却不会出现在 Web 中;如果在此期间刷新,界面不会显示待处理 steering。严格 next-step 窗口关闭后,running 标志位仍可能短暂保持为 true,因此按钮可能会为一个最终正确返回 `steer-unavailable` 的操作保持启用。 +`session/queue` 表示带 placement 的瞬态 inbox 快照,而不只是 Queue 列表,因此每个消费方都必须按 placement 过滤。待处理 steering 会在界面中立即出现并能在重连后恢复,但在 `steering/message` 提交前仍不持久。严格 next-step 窗口关闭后,running 标志位仍可能短暂保持为 true,因此按钮可能会为一个最终正确返回 `steer-unavailable` 的操作保持启用。 这项显式操作会把投递方式从经独立接纳的轮次改为当前轮次 steering,因此提示词接纳插件不会处理转换后的消息。为保证可重入取消安全,生命周期事件仍必须先发布 enqueue 再发布 discard;有针对性的回归覆盖会保护这一顺序。 diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index 7bdfcc1d59..19e1b33538 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -2,8 +2,9 @@ // section switching, both close paths), the Appearance preference row (the // real theme gesture — click 深色 and the whole cascade runs: ThemeService preference -> localStorage dsh.theme // -> theme/change -> ui-layout's presenter -> body attribute -> alias token) -// and the Language row (settings-scoped localization + persisted dsh.locale), -// plus Permission as the persisted default for subsequently created sessions. +// the Language row (settings-scoped localization + persisted dsh.locale), +// the busy-state Enter preference, plus Permission as the persisted default +// for subsequently created sessions. // Zero model calls: everything is pure client + persistence state on a blank // frame, so there is no fixture and a stray stream would fail loud on the // open llm seam. @@ -182,6 +183,32 @@ describe('web e2e: settings modal and General preferences', () => { expect(tripwire.pageErrors).toEqual([]) }, 90_000) + it('persists the busy-state Enter behavior across reload and restores Queue', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-enter-behavior')) + await page.getByRole('button', { name: '设置', exact: true }).click() + const dialog = page.getByRole('dialog', { name: '设置' }) + await dialog.waitFor({ timeout: 10_000 }) + await dialog.getByRole('button', { name: '排队发送' }).click() + await page.getByRole('menuitem', { name: '插话发送' }).click() + await dialog.getByRole('button', { name: '插话发送' }).waitFor({ timeout: 10_000 }) + expect(await page.evaluate(() => localStorage.getItem('dsh.conversation.busyEnter'))).toBe('steer') + await page.keyboard.press('Escape') + + const warningStart = tripwire.warnings.length + await page.reload({ waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + acknowledgeReloadConnectionLoss(tripwire, warningStart) + await page.getByRole('button', { name: '设置', exact: true }).click() + const reloaded = page.getByRole('dialog', { name: '设置' }) + await reloaded.getByRole('button', { name: '插话发送' }).waitFor({ timeout: 10_000 }) + await reloaded.getByRole('button', { name: '插话发送' }).click() + await page.getByRole('menuitem', { name: '排队发送' }).click() + await reloaded.getByRole('button', { name: '排队发送' }).waitFor({ timeout: 10_000 }) + expect(await page.evaluate(() => localStorage.getItem('dsh.conversation.busyEnter'))).toBe('queue') + await page.keyboard.press('Escape') + expect(tripwire.pageErrors).toEqual([]) + }, 90_000) + it('switches the settings surface language and persists dsh.locale', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-language')) await page.getByRole('button', { name: '设置', exact: true }).click() diff --git a/apps/web/tests/snapshots/queue-actions/editing.expected.md b/apps/web/tests/snapshots/queue-actions/editing.expected.md index 3f6ebab8a2..bfff0cd9f0 100644 --- a/apps/web/tests/snapshots/queue-actions/editing.expected.md +++ b/apps/web/tests/snapshots/queue-actions/editing.expected.md @@ -23,7 +23,7 @@ - img - button "Remove queued message": - img - - button "插话发送": + - button "Steer queued message": - img - listitem: - textbox "Edit queued message": Edited queue item diff --git a/apps/web/tests/snapshots/queue-actions/preserved.expected.md b/apps/web/tests/snapshots/queue-actions/preserved.expected.md index 0a43cee68b..fe6f5b6087 100644 --- a/apps/web/tests/snapshots/queue-actions/preserved.expected.md +++ b/apps/web/tests/snapshots/queue-actions/preserved.expected.md @@ -33,6 +33,8 @@ - img - button "Remove queued message": - img + - button "Steer queued message": + - img - textbox "Message the agent" - button "Commands": - img diff --git a/apps/web/tests/snapshots/settings-chrome/dialog.expected.md b/apps/web/tests/snapshots/settings-chrome/dialog.expected.md index 118e4ff3e1..cb693b62a8 100644 --- a/apps/web/tests/snapshots/settings-chrome/dialog.expected.md +++ b/apps/web/tests/snapshots/settings-chrome/dialog.expected.md @@ -28,3 +28,7 @@ - button "跟随系统" [pressed]: - img - text: 跟随系统 + - text: 繁忙时 Enter 键行为 仅在智能体运行时生效;Cmd/Ctrl+Enter 使用另一行为 + - button "排队发送": + - text: 排队发送 + - img diff --git a/apps/web/tests/snapshots/steering/mid-steer.expected.md b/apps/web/tests/snapshots/steering/mid-steer.expected.md index 27b40ef442..9f077b21a5 100644 --- a/apps/web/tests/snapshots/steering/mid-steer.expected.md +++ b/apps/web/tests/snapshots/steering/mid-steer.expected.md @@ -23,6 +23,7 @@ - img - text: Ask question waiting - status: Deep diving... +- text: "Interjection: include the word BANANA in your final reply." - region "Ready to continue?": - text: Checkpoint - heading "Ready to continue?" [level=2] diff --git a/apps/web/tests/steering.e2e.ts b/apps/web/tests/steering.e2e.ts index bfa8aae162..794a92b74b 100644 --- a/apps/web/tests/steering.e2e.ts +++ b/apps/web/tests/steering.e2e.ts @@ -1,7 +1,7 @@ -// Web e2e scenario: queue a message while the first response streams, strictly -// transfer that exact occurrence to steering through QueueDock, then prove it -// is logged, rendered, and obeyed. The following question tool supplies a -// deterministic pending-steering snapshot before the step can drain. +// Web e2e scenarios for both steering entry points: QueueDock strictly +// transfers one queued occurrence, while the complementary composer gestures +// choose Queue or Steer. The question tool supplies a deterministic pending- +// steering snapshot before the step can drain. import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { join } from 'node:path' @@ -18,13 +18,10 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/steering', import.meta.url)) const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') -// Two goldens for the two distinct states this interaction produces: the -// mid-turn moment (steer ACCEPTED but deliberately invisible — the loop -// drains steering at the step boundary, so no steering text exists while -// the question still blocks the step) and the settled transcript (plain -// bubble in place, final reply obeying it). The pair pins the timing -// semantics visually: if the client ever starts rendering pending steers -// eagerly, the mid-steer golden flips first. +// Two goldens pin the transient Host projection and its durable handoff: the +// mid-turn state renders accepted steering from session/queue while the +// question blocks admission, then the settled state renders the same message +// from steering/message beside the reply that obeys it. const MID_EXPECTED = join(SNAPSHOT_DIR, 'mid-steer.expected.md') const SETTLED_EXPECTED = join(SNAPSHOT_DIR, 'settled.expected.md') const MODE = webSnapshotMode() @@ -90,18 +87,17 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => { const steerButton = queuedRow.getByRole('button', { name: 'Steer queued message' }) await expect.poll(() => steerButton.isEnabled(), { timeout: 10_000 }).toBe(true) await steerButton.click({ timeout: 10_000 }) - await expect.poll(() => page.getByText(STEER, { exact: true }).count(), { timeout: 10_000 }).toBe(0) + const pendingSteering = page.locator('[data-pending-steering]').filter({ hasText: STEER }) + await pendingSteering.waitFor({ timeout: 10_000 }) - // The blocked composer is the mid-turn barrier: the tool cannot finish - // this step, so the accepted steering remains pending and invisible. + // The blocked composer keeps steering pending long enough to observe the + // Host-authoritative mirror before the loop admits it durably. const composer = page.locator('[data-question-key]') await composer.waitFor({ timeout: MODE === 'record' ? 120_000 : 30_000 }) if (MODE !== 'record') { - // Mid-turn golden: the converted steer is pending in the loop but the - // loop drains steering only at the step boundary, so no Queue row or - // steering/message bubble renders while the question still blocks. - expect(await page.getByText(STEER, { exact: true }).count()).toBe(0) + expect(await page.getByText(STEER, { exact: true }).count()).toBe(1) + expect(await pendingSteering.count()).toBe(1) expect(await page.getByRole('button', { name: 'Edit queued message' }).count()).toBe(0) const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(MID_EXPECTED, snapshot, MODE) @@ -137,6 +133,7 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => { // Visible: the plain steering bubble plus the reply that obeys it // (steer text + final reply each contain the marker word). await expect.poll(() => page.getByText(STEER, { exact: true }).count(), { timeout: 15_000 }).toBe(1) + expect(await pendingSteering.count()).toBe(0) await expect.poll(() => page.getByText('BANANA', { exact: false }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2) expect(await page.locator('[data-question-key]').count()).toBe(0) // Settled golden: steer text between the question round trip and the @@ -151,3 +148,120 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => { await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'mid-steer.expected.md', 'settled.expected.md']) }) }) + +describe('web e2e: composer shortcut steers directly', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + const sessionEvents: SessionEvent[] = [] + + beforeAll(async () => { + scaffold = await launchWebScaffold({ replayFixture: FIXTURE, paceMs: 100 }) + scaffold.ctx.on('session/event', (_session, event) => { sessionEvents.push(event) }) + 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 }) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it.skipIf(MODE === 'record')('uses Cmd+Enter without creating a Queue row', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-steering')) + expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT]) + const input = page.locator('textarea').first() + await input.waitFor({ timeout: 10_000 }) + const settled = scaffold.whenTurnSettled(30_000) + await input.fill(PROMPT) + await input.press('Enter') + await page.getByRole('button', { name: 'Stop generating' }).waitFor({ timeout: 10_000 }) + + await input.fill(STEER) + await input.press('Meta+Enter') + await expect.poll(() => input.inputValue(), { timeout: 5_000 }).toBe('') + expect(await page.locator('[data-queue-dock]').count()).toBe(0) + + const composer = page.locator('[data-question-key]') + await composer.waitFor({ timeout: 30_000 }) + const pendingSteering = page.locator('[data-pending-steering]').filter({ hasText: STEER }) + await pendingSteering.waitFor({ timeout: 10_000 }) + await composer.getByRole('radio', { name: 'Yes' }).click() + await composer.getByRole('radio', { name: 'Yes' }).press('Enter') + await settled + + const steerEvents = sessionEvents.filter(event => event.type === 'steering/message') + expect(steerEvents).toHaveLength(1) + expect((steerEvents[0] as SessionEvent & { data: { turn: number } }).data.turn).toBe(1) + await expect.poll(() => page.getByText(STEER, { exact: true }).count(), { timeout: 15_000 }).toBe(1) + expect(await pendingSteering.count()).toBe(0) + await expect.poll(() => page.getByText('BANANA', { exact: false }).count(), { timeout: 10_000 }) + .toBeGreaterThanOrEqual(2) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 90_000) +}) + +describe('web e2e: composer shortcut follows the swapped busy behavior', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + const sessionEvents: SessionEvent[] = [] + + beforeAll(async () => { + scaffold = await launchWebScaffold({ replayFixture: FIXTURE, paceMs: 100 }) + scaffold.ctx.on('session/event', (_session, event) => { sessionEvents.push(event) }) + 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 }) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it.skipIf(MODE === 'record')('queues Cmd+Enter when plain Enter is configured to Steer', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-swapped-shortcut')) + await page.getByRole('button', { name: 'Settings', exact: true }).click() + const dialog = page.getByRole('dialog', { name: 'Settings' }) + await dialog.getByRole('button', { name: 'Queue' }).click() + await page.getByRole('menuitem', { name: 'Steer' }).click() + await dialog.getByRole('button', { name: 'Steer' }).waitFor({ timeout: 10_000 }) + await page.keyboard.press('Escape') + + const input = page.locator('textarea').first() + const settled = scaffold.whenTurnSettled(30_000) + await input.fill(PROMPT) + await input.press('Enter') + await page.getByRole('button', { name: 'Stop generating' }).waitFor({ timeout: 10_000 }) + + const queuedText = 'Queued by the complementary Cmd+Enter shortcut.' + await input.fill(queuedText) + await input.press('Meta+Enter') + const queuedRow = page.locator('[data-queue-dock]').getByRole('listitem').filter({ hasText: queuedText }) + await queuedRow.getByText(queuedText, { exact: true }).waitFor({ timeout: 10_000 }) + expect(await page.locator('[data-pending-steering]').filter({ hasText: queuedText }).count()).toBe(0) + expect(sessionEvents.filter(event => event.type === 'steering/message')).toHaveLength(0) + + // Remove the asserted Queue row, then finish the recorded question turn + // so replay teardown still proves that every fixture call was consumed. + await queuedRow.getByRole('button', { name: 'Remove queued message' }).click() + const composer = page.locator('[data-question-key]') + await composer.waitFor({ timeout: 30_000 }) + await composer.getByRole('radio', { name: 'Yes' }).click() + await composer.getByRole('radio', { name: 'Yes' }).press('Enter') + await settled + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 90_000) +}) diff --git a/packages/client/runtime/src/client/session-history/history-fold.ts b/packages/client/runtime/src/client/session-history/history-fold.ts index e7f9922425..c4bc6ed9b5 100644 --- a/packages/client/runtime/src/client/session-history/history-fold.ts +++ b/packages/client/runtime/src/client/session-history/history-fold.ts @@ -146,7 +146,8 @@ function materializeNode( } case 'steering/message': return { - kind: 'steering', seq: event.seq, time: event.time, turn: event.data.turn, + kind: 'steering', messageId: event.data.message.id, + seq: event.seq, time: event.time, turn: event.data.turn, content: event.data.message.content, source: event.data.message.source, } case 'tool/result': { diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index a4c260a068..31872356c8 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -4,6 +4,7 @@ // string here (narrow to real brands when convenient). import type { CommandId } from '@deepseek-ai/dsh-commands/brand' +import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types' import type { TodoItem } from '@deepseek-ai/dsh-session/types' @@ -104,6 +105,8 @@ export interface AssistantMessageNode { /** A steering message injected mid-turn. */ export interface SteeringMessageNode { kind: 'steering' + /** Stable identity shared with its pre-admission inbox occurrence. */ + messageId: MessageId seq: number /** Unix epoch ms from the source session event. */ time: number @@ -271,9 +274,15 @@ export interface RunningToolCall { } -/** One independently addressable row from the transient queue snapshot. */ +/** One transient inbox occurrence from the authoritative `session/queue` snapshot. */ export interface QueuedMessage { readonly id: InboxItemId + /** Stable message identity used for transient-to-durable steering handoff. */ + readonly messageId: MessageId + /** Agent-resolved placement; only queued rows accept queue mutations. */ + readonly placement: 'queued' | 'steering' + /** Complete content used to render pending steering before it becomes durable. */ + readonly content: readonly ContentBlock[] readonly preview: string /** Complete editable text; null when the message contains non-text blocks. */ readonly text: string | null @@ -332,7 +341,7 @@ export interface ConversationSnapshot { */ codeDispatches: ReadonlyMap pending: readonly PendingInteraction[] - /** Authoritative transient inbox snapshot, replaced after every host-side change. */ + /** Authoritative transient inbox snapshot, including queued and steering placements. */ queue: readonly QueuedMessage[] running: boolean /** diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 2bcb2ac5fc..490693a6a4 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -446,6 +446,9 @@ export class Session implements SessionFace { case 'session/queue': { this.queued = frame.items.map(item => ({ id: item.id, + messageId: item.message.id, + placement: item.placement, + content: item.message.content, preview: queuePreviewOf(item.message.content), text: queueTextOf(item.message.content), })) diff --git a/packages/client/runtime/src/client/sessions/transcript-adapter.ts b/packages/client/runtime/src/client/sessions/transcript-adapter.ts index 6fae44ede4..05ac3cb067 100644 --- a/packages/client/runtime/src/client/sessions/transcript-adapter.ts +++ b/packages/client/runtime/src/client/sessions/transcript-adapter.ts @@ -74,7 +74,8 @@ function materializeNode( } case 'steering/message': return { - kind: 'steering', seq: event.seq, time: event.time, turn: event.data.turn, + kind: 'steering', messageId: event.data.message.id, + seq: event.seq, time: event.time, turn: event.data.turn, content: event.data.message.content, source: event.data.message.source, } case 'tool/result': { diff --git a/packages/client/runtime/tests/queue-store.spec.ts b/packages/client/runtime/tests/queue-store.spec.ts index b594e9a9df..cc2f3d0e53 100644 --- a/packages/client/runtime/tests/queue-store.spec.ts +++ b/packages/client/runtime/tests/queue-store.spec.ts @@ -22,6 +22,7 @@ interface QueueFixture { id: string body: string content?: ContentBlock[] + placement?: 'queued' | 'steering' } /** Build one authoritative queue snapshot. */ @@ -31,6 +32,7 @@ function queueFrame(items: QueueFixture[]): MuxFrame { sessionId: SID, items: items.map(item => ({ id: iid(item.id), + placement: item.placement ?? 'queued', message: createUserMessage({ content: item.content ?? text(item.body), source: { kind: 'user', rpcId: rid(`rpc-${item.id}`) } as never, @@ -49,8 +51,14 @@ describe('queue snapshot intake', () => { session.handleMuxEnvelope(rid('env-1'), queueFrame([ { id: 'q-1', body: '第一条 排队\n消息' }, ])) - expect(session.getSnapshot().queue).toEqual([ - { id: 'q-1', preview: '第一条 排队 消息', text: '第一条 排队\n消息' }, + const queue = session.getSnapshot().queue + expect(typeof queue[0]?.messageId).toBe('string') + expect(queue).toMatchObject([ + { + id: 'q-1', placement: 'queued', + content: [{ type: 'text', text: '第一条 排队\n消息' }], + preview: '第一条 排队 消息', text: '第一条 排队\n消息', + }, ]) }) @@ -61,8 +69,14 @@ describe('queue snapshot intake', () => { body: '', content: [{ type: 'text', text: 'hi' }, { type: 'image', data: 'x' } as never], }])) - expect(session.getSnapshot().queue).toEqual([ - { id: 'q-image', preview: 'hi [image]', text: null }, + const queue = session.getSnapshot().queue + expect(typeof queue[0]?.messageId).toBe('string') + expect(queue).toMatchObject([ + { + id: 'q-image', placement: 'queued', + content: [{ type: 'text', text: 'hi' }, { type: 'image', data: 'x' }], + preview: 'hi [image]', text: null, + }, ]) }) @@ -85,8 +99,14 @@ describe('queue snapshot intake', () => { session.handleMuxEnvelope(rid('env-5'), queueFrame([ { id: 'q-2', body: 'two edited' }, ])) - expect(session.getSnapshot().queue).toEqual([ - { id: 'q-2', preview: 'two edited', text: 'two edited' }, + const queue = session.getSnapshot().queue + expect(typeof queue[0]?.messageId).toBe('string') + expect(queue).toMatchObject([ + { + id: 'q-2', placement: 'queued', + content: [{ type: 'text', text: 'two edited' }], + preview: 'two edited', text: 'two edited', + }, ]) session.handleMuxEnvelope(rid('env-6'), queueFrame([])) expect(session.getSnapshot().queue).toEqual([]) @@ -99,6 +119,21 @@ describe('queue snapshot intake', () => { session.handleAgentError('unrelated') expect(session.getSnapshot().queue).toBe(before) }) + + it('retains steering placement and complete content in the same authoritative snapshot', () => { + const session = makeSession() + session.handleMuxEnvelope(rid('env-steering'), queueFrame([ + { id: 'q-next', body: 'later' }, + { id: 's-now', body: 'interrupt now', placement: 'steering' }, + ])) + + expect(session.getSnapshot().queue.map(item => ({ + id: item.id, placement: item.placement, content: item.content, + }))).toEqual([ + { id: 'q-next', placement: 'queued', content: text('later') }, + { id: 's-now', placement: 'steering', content: text('interrupt now') }, + ]) + }) }) describe('queue operation transport', () => { diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 1a8fc3a22a..f3f3c43124 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: 2bf74454fab14303f305b9822e1032f67de0d3ae -README.zh.md: 2677ab5cc85c16fffeca688a4648caf2410ba895 +README.md: c80ca786fb289055fcb3ad2b5a7aab64127ca485 +README.zh.md: 601139a00d2d40fb34e0cdcb8554984f7db9a724 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 2bf74454fa..c80ca786fb 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -34,7 +34,11 @@ Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.to The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`/ 已完成 · ` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: 0` — before Goal and Queue — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and starts collapsed as a header of title plus `"/ tasks · in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the standing list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included. -`QueueDock` is the terminal input-dock entry at `order: 20`. It hides while empty, renders one pending row directly, and defaults two or more rows to a collapsed `" 条排队消息"` header whose button expands or collapses the complete list. The header exposes `aria-expanded` and `aria-controls`; the expanded list scrolls within a 180px height bound. An active edit or mutation keeps its rows visible, and emptying the queue restores the collapsed default for the next queue. Each visible row remains a single-line preview with its exact-occurrence edit and delete actions. +`QueueDock` is the terminal input-dock entry at `order: 20`. It hides while empty, renders one pending row directly, and defaults two or more rows to a collapsed `" 条排队消息"` header whose button expands or collapses the complete list. The header exposes `aria-expanded` and `aria-controls`; the expanded list scrolls within a 180px height bound. An active edit or mutation keeps its rows visible, and emptying the queue restores the collapsed default for the next queue. Each visible row remains a single-line preview with its exact-occurrence edit, delete, and strict-steer actions; an unavailable steering window leaves the Queue occurrence in place and reports the failure. + +The Host's placement-aware `session/queue` snapshot also carries pending steering. QueueDock filters it out, while ChatView projects it as a user-style bubble at the conversation tail. The Host delays steering retirement until the durable `steering/message` has entered the mux stream, and ChatView deduplicates the two projections by their shared `MessageId`; the bubble therefore hands off without a gap or duplicate, while reconnect restores pending state from the same authority. + +Keyboard message submission resolves delivery from the addressed session's running state. While idle, Enter and Cmd/Ctrl+Enter both perform an ordinary Queue send. While running, the browser-persisted General Settings preference assigns plain Enter to `Queue` (the default) or `Steer`, and Cmd/Ctrl+Enter performs the other behavior; Shift+Enter remains a newline. The preference affects only the busy-state gesture pair, and the send button and non-keyboard submit actions remain Queue. Composer Steer uses the existing best-effort `session.prompt(mode: 'steer')` contract: if the current next-step window closes before acceptance, AgentLoop admits the message as the next waking Queue turn without surfacing a failure or losing the draft transaction. Per-session UI state for selection and the active view lives in the declared chat store (`stores.ts` `createChatStore`); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies `useSession`/`sessionId`, global `useSessions`/`useWorkspaces`, and the input machine's `useInput`/`inputActions`; store faces and inject factories supply the remaining state and callbacks. @@ -63,4 +67,4 @@ None; this package neither assembles nor sends a provider request. - **The approval panel's "Always allow this type" is deferred** — durable grants need a grant-storage design; only allow-once/reject answer today. - **TodoPanel truncates long item text to one ellipsized line** — the figma strip has no wrap or expand affordance; full text is not readable inline. - **Queue edit is text-only** — rows containing non-text blocks still show a flattened preview, but their edit control is disabled because the inline editor cannot preserve those blocks. A text row's edit mode replaces delete and strict steer with save and cancel; Enter saves and Escape cancels. -- **Queue strict steer preserves complete messages** — while the Agent is running, the steer action atomically transfers the addressed Queue occurrence into the current next-step window. Mixed-content rows remain eligible because the action forwards the immutable message instead of the text projection. The Host omits pending steering from the Queue snapshot; a consumed `steering/message` still folds into the durable transcript as a plain bubble so replay remains truthful. +- **Queue strict steer preserves complete messages** — while the Agent is running, the steer action atomically transfers the addressed Queue occurrence into the current next-step window. Mixed-content rows remain eligible because the action forwards the immutable message instead of the text projection. The placement-aware Host snapshot renders pending steering at the conversation tail until the consumed `steering/message` folds into the durable transcript, so immediate display, reconnect, and replay share one linear authority. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 2677ab5cc8..601139a00d 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -34,7 +34,11 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: 0` 占用 `'conversation.input.dock'` 列表 slot(位于 Goal 与 Queue 之前),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加 `"<已完成>/<总数> tasks · in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。 -`QueueDock` 是 `order: 20` 的末端 input-dock 条目。队列为空时隐藏;只有一个待处理项时直接渲染该行;存在两个或更多待处理项时,默认收起为 `" 条排队消息"` 表头,其按钮可展开或收起完整列表。表头暴露 `aria-expanded` 和 `aria-controls`;展开后的列表以 180px 为高度上限,并可滚动。存在进行中的编辑或变更时,列表行会保持可见;队列清空后,下一次出现队列时会恢复默认收起状态。每条可见行仍是单行预览,并提供针对精确单次入队项的编辑和删除操作。 +`QueueDock` 是 `order: 20` 的末端 input-dock 条目。队列为空时隐藏;只有一个待处理项时直接渲染该行;存在两个或更多待处理项时,默认收起为 `" 条排队消息"` 表头,其按钮可展开或收起完整列表。表头暴露 `aria-expanded` 和 `aria-controls`;展开后的列表以 180px 为高度上限,并可滚动。存在进行中的编辑或变更时,列表行会保持可见;队列清空后,下一次出现队列时会恢复默认收起状态。每条可见行仍是单行预览,并提供针对精确单次入队项的编辑、删除和严格 steering(中途引导)操作;steering 窗口不可用时,Queue 单次入队项会留在原处并显示失败。 + +Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。QueueDock 会将其过滤掉,ChatView 则把它投影为会话流末尾的用户样式气泡。Host 会等持久 `steering/message` 进入 mux 流之后再退役 steering,ChatView 则按两份投影共享的 `MessageId` 去重;气泡交接时因而不会产生空档或重复,重连也能从同一权威恢复待处理状态。 + +键盘消息提交会根据所寻址会话的运行状态解析投递方式。空闲时,Enter 和 Cmd/Ctrl+Enter 都执行普通 Queue 发送。运行期间,浏览器持久化的 General Settings 偏好会把普通 Enter 分配为 `Queue`(默认值)或 `Steer`,Cmd/Ctrl+Enter 则执行另一种行为;Shift+Enter 仍然换行。该偏好只影响繁忙态下这对手势,发送按钮与非键盘提交操作仍使用 Queue。Composer Steer 复用现有尽力而为的 `session.prompt(mode: 'steer')` 契约:如果当前 next-step 窗口在接纳前关闭,AgentLoop 会把消息接纳为下一条唤醒 Queue 轮次,不显示失败,也不会丢失草稿事务。 逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store(`stores.ts` `createChatStore`)中;InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession`/`sessionId`、全局 `useSessions`/`useWorkspaces`,以及输入状态机的 `useInput`/`inputActions`;store 表层与 inject factory 提供其余状态和回调。 @@ -63,4 +67,4 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插 - **审批面板的「始终允许此类」暂缓**:持久授权需要授权存储设计;今天只能回答允许一次/拒绝。 - **TodoPanel 将过长条目截成单行省略号**:figma 条没有换行或展开入口,完整文本无法在行内读完。 - **Queue 编辑仅支持文本**:包含非文本块的行仍显示扁平化预览,但由于内联编辑器无法保留这些块,其编辑控件会被禁用。文本行进入编辑模式后,删除和严格 steering(中途引导)操作会被保存和取消取代;Enter 保存,Escape 取消。 -- **Queue 严格 steering 会保留完整消息**:Agent 运行期间,steering 操作会以原子方式把所寻址的 Queue 单次入队项转移到当前 next-step 窗口。包含混合内容的行仍可使用此操作,因为它会转发不可变消息,而非文本投影。Host 不会把待处理 steering 纳入 Queue 快照;已消费的 `steering/message` 仍会折叠进持久 transcript(文本记录),并以普通气泡呈现,因此回放仍然如实。 +- **Queue 严格 steering 会保留完整消息**:Agent 运行期间,steering 操作会以原子方式把所寻址的 Queue 单次入队项转移到当前 next-step 窗口。包含混合内容的行仍可使用此操作,因为它会转发不可变消息,而非文本投影。带 placement 的 Host 快照会在会话流末尾渲染待处理 steering,直到已消费的 `steering/message` 折叠进持久 transcript(文本记录),因此立即展示、重连和回放共享同一个线性权威。 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 625728d4cf..bb34164a3f 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -1,6 +1,6 @@ /** Registers the conversation components, shared store, and service callbacks. */ import type { Context } from 'cordis' -import { resolveSlotLabel, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots' +import { deferRegistration, resolveSlotLabel, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots' import type { ISessions, SessionId } from '@deepseek-ai/dsh-client-runtime/client' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). @@ -16,7 +16,10 @@ import { createChatStore } from './stores.ts' import { ConversationService } from './service.ts' import type { IConversation } from './service.ts' import { InputHub } from './input/hub.ts' +import { ComposerSubmissionPolicy } from './input/submission-policy.ts' import { InputBar } from './skeleton/InputBar.tsx' +import { EnterBehaviorRow } from './settings/EnterBehaviorRow.tsx' +import type { EnterBehaviorRowInjected } from './settings/EnterBehaviorRow.tsx' import { ChatView } from './chat/ChatView.tsx' import { StatsLine } from './chat/StatsLine.tsx' import { bashToolviewSample } from './toolviews/bash-sample.tsx' @@ -93,6 +96,22 @@ export function apply(ctx: Context): void { // Apply-time construction keeps store identity bound to this fiber. const chatStore = createChatStore() + const submissionPolicy = new ComposerSubmissionPolicy() + + ctx.effect(() => { + const row = deferRegistration(ctx.slots, 'settings.general.item', EnterBehaviorRow, () => + ctx.slots.register({ + name: 'settings.general.item', + id: 'composer-enter', + order: 20, + locale: NS, + inject: (): EnterBehaviorRowInjected => ({ + hooks: { busyEnter: submissionPolicy.busyEnter }, + setBusyEnter: (behavior) => { submissionPolicy.setBusyEnter(behavior) }, + }), + }, EnterBehaviorRow)) + return () => { row.dispose() } + }, 'ui-conversation: Enter behavior settings row') // Chat scroll offsets by session, surviving view switches (the chat view // unmounts under the tab ring). Deliberately not persisted: a fresh page @@ -203,6 +222,7 @@ export function apply(ctx: Context): void { if (sessionId === undefined) { return { keyboard: undefined, + resolveSubmitMode: (running, gesture) => submissionPolicy.resolve(running, gesture), toggleCommandMenu: undefined, stop: undefined, command: undefined, @@ -213,6 +233,7 @@ export function apply(ctx: Context): void { const slash = inputHub.slash(sessionId) return { keyboard: shell, + resolveSubmitMode: (running, gesture) => submissionPolicy.resolve(running, gesture), toggleCommandMenu: slash === undefined ? undefined : (selection) => { diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index 4a8e715bcf..4f237c2d0c 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -34,7 +34,7 @@ import { assistantActionsSeqs, deriveChatFlow, type ChatFlowItem } from './chat- import { AssistantMarkdown } from './AssistantMarkdown.tsx' import { GenericCommandCard } from './GenericCommandCard.tsx' import { GenericToolCard } from './GenericToolCard.tsx' -import { MessageItem } from './MessageItem.tsx' +import { MessageItem, PendingSteeringBubble } from './MessageItem.tsx' import css from './ChatView.module.css' const FOLLOW_THRESHOLD = 24 @@ -236,6 +236,7 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, inspectCall, chatScroll, forkAt, t, }: ChatViewSlotProps) { const nodes = useSession(s => s.nodes) + const inbox = useSession(s => s.queue) // Workspace root off the session list row: path summaries display relative to it. const cwd = useSessions(s => s.byId[sessionId]?.cwd) const running = useSession(s => s.running) @@ -248,6 +249,10 @@ export function ChatView({ const selectedCallId = useStore(s => s.selection?.callId) const items = useMemo(() => deriveChatFlow(nodes), [nodes]) + const pendingSteering = useMemo(() => { + const durable = new Set(nodes.flatMap(node => node.kind === 'steering' ? [node.messageId] : [])) + return inbox.filter(item => item.placement === 'steering' && !durable.has(item.messageId)) + }, [inbox, nodes]) const activeRetry = useMemo(() => activeRetrySeq(nodes, running), [nodes, running]) // Only the last content assistant of each turn owns IconActions; mid-turn // text (before tools) omits `time` so AssistantMarkdown stays chrome-free. @@ -261,6 +266,7 @@ export function ChatView({ const firstSeqRef = useRef(null) const openedRef = useRef(false) const lastKeyRef = useRef(null) + const lastSteeringIdRef = useRef(null) /** Flow tip signature — follow-scroll only when this moves, never on a * scroll-driven at-bottom chrome re-render (that was snapping inertial * scrolls the rest of the way to the floor). */ @@ -269,7 +275,8 @@ export function ChatView({ const firstSeq = nodes[0]?.seq ?? null const lastItem = items[items.length - 1] const lastKey = lastItem?.key ?? null - const followSig = `${openState}:${firstSeq}:${lastKey}:${nodes.length}:${running ? 1 : 0}:${runningCalls.length}` + const lastSteeringId = pendingSteering[pendingSteering.length - 1]?.id ?? null + const followSig = `${openState}:${firstSeq}:${lastKey}:${nodes.length}:${running ? 1 : 0}:${runningCalls.length}:${lastSteeringId ?? ''}` const toBottom = (el: HTMLElement): void => { el.scrollTop = el.scrollHeight @@ -298,6 +305,7 @@ export function ChatView({ } firstSeqRef.current = firstSeq lastKeyRef.current = lastKey + lastSteeringIdRef.current = lastSteeringId followSigRef.current = followSig return } @@ -308,6 +316,7 @@ export function ChatView({ firstSeqRef.current = firstSeq /* v8 ignore next -- ?? arm: a prepend adds nodes, so the flow list here is never empty. */ lastKeyRef.current = lastKey + lastSteeringIdRef.current = lastSteeringId followSigRef.current = followSig return } @@ -316,12 +325,14 @@ export function ChatView({ // (send lives in the composer, so arrival is detected here, not armed there). const appendedUser = lastKey !== lastKeyRef.current && lastItem !== undefined && lastItem.kind === 'node' && lastItem.node.kind === 'user' + const appendedSteering = lastSteeringId !== null && lastSteeringId !== lastSteeringIdRef.current const tipMoved = followSigRef.current !== followSig lastKeyRef.current = lastKey + lastSteeringIdRef.current = lastSteeringId followSigRef.current = followSig // Follow new flow content while pinned; do NOT re-pin on every render // merely because atBottomRef is true (scroll threshold → setState → snap). - if (appendedUser || (tipMoved && atBottomRef.current)) toBottom(el) + if (appendedUser || appendedSteering || (tipMoved && atBottomRef.current)) toBottom(el) }) const onScrollRef = useRef(() => {}) @@ -467,6 +478,9 @@ export function ChatView({ {/* Turn-level loading signal: rides the whole running turn (first-token wait, tool execution, streaming) so it never flickers per step. */} {running && } + {pendingSteering.map(item => ( + + ))} {!atBottom && (
diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index b54604060a..5783d0211d 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -169,17 +169,19 @@ function projectUserText(text: string): ReactNode { /** Right-aligned bubble shared by user and steering rows (steering has no actions). */ function UserStyleBubble({ - content, actions, t, + content, actions, pending = false, t, }: { content: readonly unknown[] /** Optional IconActions (or similar) below the bubble; receives the joined text. */ actions?: (text: string) => ReactNode + /** Whether this is the Host-authoritative pre-admission steering projection. */ + pending?: boolean t: ChatViewSlotProps['t'] }): ReactNode { const { text, rest } = contentText(content) const truncated = (total: number): string => t('json.truncated', { total }) return ( -
+
{projectUserText(text)} {rest.map((block, i) => )} @@ -189,6 +191,19 @@ function UserStyleBubble({ ) } +/** + * Render one Host-authoritative pending steering item with the same visual + * language as its eventual durable transcript node. + * @param props - Pending message content and conversation translator. + * @returns the pending steering bubble. + */ +export function PendingSteeringBubble({ content, t }: { + content: readonly unknown[] + t: ChatViewSlotProps['t'] +}): ReactNode { + return +} + export const MessageItem = memo(function MessageItem({ node, retryActive = false, onFork, t, }: MessageItemProps) { diff --git a/packages/client/ui-conversation/src/client/contract/composer-submission.ts b/packages/client/ui-conversation/src/client/contract/composer-submission.ts new file mode 100644 index 0000000000..c5bcdc7826 --- /dev/null +++ b/packages/client/ui-conversation/src/client/contract/composer-submission.ts @@ -0,0 +1,10 @@ +/** Composer submission vocabulary shared by the input and settings domains. */ + +/** Delivery mode requested for one ordinary composer message. */ +export type InputSubmitMode = 'queue' | 'steer' + +/** Configurable meaning of plain Enter while the addressed agent is busy. */ +export type BusyEnterBehavior = InputSubmitMode + +/** Keyboard gesture whose delivery mode the submission policy resolves. */ +export type ComposerSubmitGesture = 'enter' | 'accelerated' diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index a6c9d16e61..16f789f70c 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -7,6 +7,7 @@ import type { CommandNode, ConversationSnapshot, ObservableSnapshot, PendingInte 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' +import type { ComposerSubmitGesture, InputSubmitMode } from './composer-submission.ts' import type { CallId, SelectionTarget, ViewTab } from './views.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { @@ -285,6 +286,8 @@ export interface ComposerBarOwnerProps { export interface ComposerBarInjected { /** The InputBar-exclusive keyboard/DOM command face (decision 20 private plane); absent with the session. */ keyboard: ComposerKeyboard | undefined + /** Resolve one keyboard submission gesture against the current running state and persisted preference. */ + resolveSubmitMode: (running: boolean, gesture: ComposerSubmitGesture) => InputSubmitMode /** Toggle the shared slash menu with only its command source; absent without ui-slash or a session. */ toggleCommandMenu: ((selection: EditSelection) => void) | undefined /** Cancel the in-flight turn; absent with the session. */ diff --git a/packages/client/ui-conversation/src/client/input/contract.ts b/packages/client/ui-conversation/src/client/input/contract.ts index f503381bbb..6af78bdb72 100644 --- a/packages/client/ui-conversation/src/client/input/contract.ts +++ b/packages/client/ui-conversation/src/client/input/contract.ts @@ -11,6 +11,7 @@ import type { ReferenceInsert, SubmitOutcome, TokenSpan, } from '@deepseek-ai/dsh-client-ui-slash/client' import type { QueueRow } from '../contract/queue.ts' +import type { InputSubmitMode } from '../contract/composer-submission.ts' /** * The scoped-event application verbs: the hub's bail listeners call these, @@ -28,8 +29,11 @@ export interface InputTarget { export interface SessionInput extends InputTarget { /** Single write path for draft text (all mutation rides machine events). */ setDraft(text: string): void - /** THE complexity sink: enter adjudication, submit transaction, and the default sink live inside. */ - submit(): void + /** + * THE complexity sink: enter adjudication, submit transaction, and the default sink live inside. + * @param mode - delivery intent retained through asynchronous adjudication and serialization. + */ + submit(mode?: InputSubmitMode): void /** * Surface a notice outside the machine's own effect stream: detached * command results and business notifications render through here. @@ -82,8 +86,8 @@ export interface ComposerKeyboard { readonly snapshot: InputState /** Draft write with the DOM-observed edit shape (narrows occurrence math). */ setDraft(text: string, editRange?: EditRange): void - /** Newline at the selection as a machine transaction (Ctrl+Enter path). */ - newline(selection: EditSelection): void + /** Submit with an explicit delivery mode resolved by the keyboard policy. */ + submit(mode: InputSubmitMode): void undo(): void redo(): void /** Paste over the selection (sync components ride the same transaction). */ @@ -191,7 +195,7 @@ export interface InputState { readonly occurrences: readonly Occurrence[] /** Live paste-match attempt (absent when no paste is matchable). */ readonly paste?: PasteAttemptState - /** Read-only queue projection (session/queued frames + connect snapshot). */ + /** Read-only transient inbox projection (`session/queue`, including pending steering). */ readonly queue: readonly QueuedMessage[] } @@ -206,6 +210,8 @@ export interface SubmitAttempt { readonly signal: AbortSignal /** Draft at enter time; rollback restores it only while the live draft still equals it. */ readonly draftSnapshot: string + /** Default-message delivery intent retained while slash adjudication is pending. */ + readonly mode: InputSubmitMode } /** @@ -217,8 +223,6 @@ export interface SubmitAttempt { export type InputEvent = /** Full next draft from the textarea; editRange narrows the occurrence math (absent → diff scan). */ | { readonly type: 'draft-changed'; readonly draft: string; readonly editRange?: EditRange } - /** Insert '\n' replacing the selection (F1: the execCommand newline path moved into the machine). */ - | { readonly type: 'newline'; readonly selection: EditSelection } | { readonly type: 'begin-command'; readonly claim: CommandClaim; readonly span: TokenSpan } /** Place one U+FFFC at the span and mint the occurrence (scoped insert-reference event payload). */ | { readonly type: 'insert-ref'; readonly reference: ReferenceInsert; readonly span: TokenSpan } @@ -239,7 +243,7 @@ export type InputEvent = | { readonly type: 'paste-upgrade'; readonly attemptId: number; readonly span: TokenSpan; readonly reference: ReferenceInsert } /** Shell-observed attempt killers the machine cannot see itself (caret/selection ops, Slash interaction updates). */ | { readonly type: 'invalidate-paste' } - | { readonly type: 'enter' } + | { readonly type: 'enter'; readonly mode: InputSubmitMode } | { readonly type: 'adjudicated'; readonly attempt: SubmitAttempt; readonly outcome: PickOutcome } | { readonly type: 'adjudication-failed'; readonly attempt: SubmitAttempt; readonly message: string } | { readonly type: 'submit-settled'; readonly attempt: SubmitAttempt; readonly ok: boolean; readonly outcome?: SubmitOutcome; readonly message?: string } @@ -258,5 +262,5 @@ export type InputEvent = export type InputEffect = | { readonly type: 'adjudicate'; readonly attempt: SubmitAttempt; readonly draft: string } | { readonly type: 'begin-submit'; readonly attempt: SubmitAttempt; readonly claim: CommandClaim; readonly args: string } - | { readonly type: 'default-sink'; readonly draft: string } + | { readonly type: 'default-sink'; readonly draft: string; readonly mode: InputSubmitMode } | { readonly type: 'notice'; readonly level: 'info' | 'error'; readonly text: string } diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts index 79a223a43e..79516d04fa 100644 --- a/packages/client/ui-conversation/src/client/input/facade.ts +++ b/packages/client/ui-conversation/src/client/input/facade.ts @@ -16,6 +16,7 @@ import type { EditRange, EditSelection, InputActions, InputEffect, InputNotice, InputState, PasteComponent, QueuedMessage, SessionInput, SubmitAttempt, } from './contract.ts' +import type { InputSubmitMode } from '../contract/composer-submission.ts' import { InputMachine } from './machine.ts' /** Popup face the shell needs (dismissal only; typed structurally to avoid a value import). */ @@ -39,7 +40,7 @@ export interface SessionInputDeps { /** Queue read face; overlaid onto InputState.queue (absent = empty). */ queue?: ObservableSnapshot | undefined /** The plain-message sink (send choreography / materialize fork — the hub owns it). */ - defaultSink(text: string): void + defaultSink(text: string, mode: InputSubmitMode): void } /** Guard tier from the machine phase. */ @@ -68,7 +69,7 @@ export class SessionInputShell implements SessionInput { /** The public provide-channel action face (one stable identity per session — decision 20). */ readonly actions: InputActions = { setDraft: (text) => { this.setDraft(text) }, - submit: () => { this.submit() }, + submit: () => { this.submit('queue') }, } // Real wall clock: the typing-run merge window must actually expire in @@ -106,15 +107,6 @@ export class SessionInputShell implements SessionInput { this.run(this.core.dispatch({ type: 'send-committed' })) } - /** - * Insert a newline at the selection as one machine transaction (the - * execCommand path is gone — a second undo history would fork). - * @param selection - current DOM selection in draft coordinates. - */ - newline(selection: EditSelection): void { - this.run(this.core.dispatch({ type: 'newline', selection })) - } - /** Undo the latest transaction (InputBar intercepts the platform chord). */ undo(): void { this.run(this.core.dispatch({ type: 'undo' })) @@ -152,8 +144,8 @@ export class SessionInputShell implements SessionInput { * (adjudicating/submitting) force-closes the transient layers: the popup * dismisses and the menu tracks frozen. */ - submit(): void { - this.run(this.core.dispatch({ type: 'enter' })) + submit(mode: InputSubmitMode = 'queue'): void { + this.run(this.core.dispatch({ type: 'enter', mode })) const phase = this.snapshot.phase if (phase === 'adjudicating' || phase === 'submitting') { this.deps.popup?.()?.dismiss() @@ -338,7 +330,7 @@ export class SessionInputShell implements SessionInput { return } case 'default-sink': { - this.sinkSerialized(fx.draft) + this.sinkSerialized(fx.draft, fx.mode) return } default: @@ -353,10 +345,10 @@ export class SessionInputShell implements SessionInput { * send — notice + draft and chips retained, never a silent downgrade to * the clipboard text. Chip-free drafts skip the async detour. */ - private sinkSerialized(draft: string): void { + private sinkSerialized(draft: string, mode: InputSubmitMode): void { const occurrences = this.core.state.occurrences if (occurrences.length === 0) { - this.deps.defaultSink(draft.trim()) + this.deps.defaultSink(draft.trim(), mode) return } const slash = this.deps.slash?.() @@ -376,7 +368,7 @@ export class SessionInputShell implements SessionInput { cursor = part.offset + 1 } out += draft.slice(cursor) - this.deps.defaultSink(out.trim()) + this.deps.defaultSink(out.trim(), mode) }, (error: unknown) => { controller.abort() diff --git a/packages/client/ui-conversation/src/client/input/hub.ts b/packages/client/ui-conversation/src/client/input/hub.ts index 1fe2f95e7f..560999aa21 100644 --- a/packages/client/ui-conversation/src/client/input/hub.ts +++ b/packages/client/ui-conversation/src/client/input/hub.ts @@ -12,6 +12,7 @@ import type { ClientContext, ISessions, SessionBinding, SessionFace, SessionId } import type { SlashController } from '@deepseek-ai/dsh-client-ui-slash/client' import { queueReadFaceOf } from '../queue/store.ts' import type { ComposerKeyboard, InputService, SessionInput } from './contract.ts' +import type { InputSubmitMode } from '../contract/composer-submission.ts' import type { PopupDismissFace } from './facade.ts' import { SessionInputShell } from './facade.ts' @@ -56,7 +57,7 @@ export class InputHub implements InputService { slash: () => this.controller(actx), popup: () => this.popup(actx), queue: queueReadFaceOf(session), - defaultSink: (text) => { this.sink(session, text) }, + defaultSink: (text, mode) => { this.sink(session, text, mode) }, }) this.shells.set(id, shell) // The one teardown axis: listeners, shell, and map entries all ride the @@ -123,12 +124,12 @@ export class InputHub implements InputService { * exactly one path; a failed first prompt is an ordinary prompt failure * (error strip via promptError, draft restored only while untouched). */ - private sink(session: SessionFace, text: string): void { + private sink(session: SessionFace, text: string, mode: InputSubmitMode): void { if (text === '') return const shell = this.shells.get(session.sessionId) // Commit, not an editable clear: undo must not resurrect sent content. shell?.commitSend() - void session.prompt([{ type: 'text', text }], 'queue').then( + void session.prompt([{ type: 'text', text }], mode).then( (result) => { if (!result.ok && shell?.snapshot.draft === '') shell.setDraft(text) }, diff --git a/packages/client/ui-conversation/src/client/input/machine.ts b/packages/client/ui-conversation/src/client/input/machine.ts index f32d9e11d7..ec62eb63b4 100644 --- a/packages/client/ui-conversation/src/client/input/machine.ts +++ b/packages/client/ui-conversation/src/client/input/machine.ts @@ -14,6 +14,7 @@ * paste-upgrade all answer their bail events this way). */ import type { CommandClaim, ReferenceInsert, TokenSpan } from '@deepseek-ai/dsh-client-ui-slash/client' +import type { InputSubmitMode } from '../contract/composer-submission.ts' import type { ConsumeTokenGuard, EditRange, EditSelection, InputEffect, InputEvent, InputMachineOptions, InputState, Occurrence, PasteAttemptState, PasteComponent, SubmitAttempt, @@ -149,7 +150,6 @@ export class InputMachine { dispatch(ev: InputEvent): readonly InputEffect[] { switch (ev.type) { case 'draft-changed': return this.onDraftChanged(ev.draft, ev.editRange) - case 'newline': return this.onNewline(ev.selection) case 'begin-command': return this.onBeginCommand(ev.claim, ev.span) case 'insert-ref': return this.onInsertRef(ev.reference, ev.span) case 'consume-token': return this.onConsumeToken(ev.guard) @@ -162,7 +162,7 @@ export class InputMachine { this.paste = undefined return [] } - case 'enter': return this.onEnter() + case 'enter': return this.onEnter(ev.mode) case 'adjudicated': return this.onAdjudicated(ev.attempt, ev.outcome) case 'adjudication-failed': return this.onAdjudicationFailed(ev.attempt, ev.message) case 'submit-settled': return this.onSubmitSettled(ev) @@ -254,19 +254,6 @@ export class InputMachine { return [] } - /** F1: caret newline as an ordinary machine transaction (execCommand path removed). */ - private onNewline(selection: EditSelection): InputEffect[] { - const { start, end } = selection - if (start < 0 || start > end || end > this.draft.length) return [] - this.pushTxn(selection) - this.typingRun = undefined - this.reconcile({ start, end, insertedLength: 1 }) - this.adopt(this.draft.slice(0, start) + '\n' + this.draft.slice(end)) - this.watchClaim() - this.paste = undefined - return [] - } - /** Span CAS: revision equality (content identity follows) plus bounds sanity. */ private casOk(span: TokenSpan): boolean { return span.draftRev === this.draftRev @@ -461,18 +448,18 @@ export class InputMachine { // ---- submit plane ---- /** Mint the next SubmitAttempt and take the in-flight slot. */ - private beginAttempt(): SubmitAttempt { + private beginAttempt(mode: InputSubmitMode): SubmitAttempt { const controller = new AbortController() this.seq += 1 - const attempt: SubmitAttempt = { seq: this.seq, signal: controller.signal, draftSnapshot: this.draft } + const attempt: SubmitAttempt = { seq: this.seq, signal: controller.signal, draftSnapshot: this.draft, mode } this.inflight = { attempt, controller } return attempt } - private onEnter(): InputEffect[] { + private onEnter(mode: InputSubmitMode): InputEffect[] { if (this.phase === 'adjudicating' || this.phase === 'submitting') return [] if (this.phase === 'claimed' && this.claim !== undefined) { - const attempt = this.beginAttempt() + const attempt = this.beginAttempt(mode) this.phase = 'submitting' this.paste = undefined return [{ type: 'begin-submit', attempt, claim: this.claim, args: argsAfter(this.draft, this.claim.token) }] @@ -481,11 +468,11 @@ export class InputMachine { if (trimmed === '') return [] this.paste = undefined if (trimmed.startsWith('/')) { - const attempt = this.beginAttempt() + const attempt = this.beginAttempt(mode) this.phase = 'adjudicating' return [{ type: 'adjudicate', attempt, draft: this.draft }] } - return [{ type: 'default-sink', draft: this.draft }] + return [{ type: 'default-sink', draft: this.draft, mode }] } private onAdjudicated(attempt: SubmitAttempt, outcome: Extract['outcome']): InputEffect[] { @@ -506,7 +493,7 @@ export class InputMachine { this.inflight = undefined this.phase = 'plain' return outcome === undefined - ? [{ type: 'default-sink', draft: attempt.draftSnapshot }] + ? [{ type: 'default-sink', draft: attempt.draftSnapshot, mode: attempt.mode }] : [] } diff --git a/packages/client/ui-conversation/src/client/input/submission-policy.ts b/packages/client/ui-conversation/src/client/input/submission-policy.ts new file mode 100644 index 0000000000..99ce299c52 --- /dev/null +++ b/packages/client/ui-conversation/src/client/input/submission-policy.ts @@ -0,0 +1,72 @@ +/** + * Browser-local Composer submission policy. It owns the persisted busy-Enter + * preference and resolves keyboard gestures into queue/steer delivery modes; + * Host and Agent keep the actual delivery-window authority. + */ +import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { + BusyEnterBehavior, ComposerSubmitGesture, InputSubmitMode, +} from '../contract/composer-submission.ts' + +/** localStorage key holding the busy-Enter preference. */ +export const BUSY_ENTER_STORAGE_KEY = 'dsh.conversation.busyEnter' + +/** Default preserves Enter-as-Queue for running conversations. */ +export const DEFAULT_BUSY_ENTER_BEHAVIOR: BusyEnterBehavior = 'queue' + +/** + * Persisted policy used by both the composer inject face and its Settings row. + * Direct `steer` is intentionally best-effort: AgentLoop turns a closed-window + * submission into the next waking Queue item. + */ +export class ComposerSubmissionPolicy { + /** Reactive preference source for the Settings row. */ + readonly busyEnter: SnapshotStore = createSnapshotStore(restoreBusyEnter()) + + /** + * Resolve one keyboard gesture without changing state. + * @param running - whether the addressed agent currently reports busy. + * @param gesture - plain Enter or the Cmd/Ctrl-accelerated chord. + * @returns Queue outside busy state; otherwise the preferred mode or its opposite. + */ + resolve(running: boolean, gesture: ComposerSubmitGesture): InputSubmitMode { + if (!running) return 'queue' + const preferred = this.busyEnter.getSnapshot() + if (gesture === 'enter') return preferred + return preferred === 'queue' ? 'steer' : 'queue' + } + + /** + * Change and persist the plain-Enter behavior used during busy state. + * @param behavior - Queue or Steer. + */ + setBusyEnter(behavior: BusyEnterBehavior): void { + if (this.busyEnter.getSnapshot() === behavior) return + this.busyEnter.set(behavior) + persistBusyEnter(behavior) + } +} + +/** Restore a valid preference; unavailable or corrupt storage uses Queue. */ +function restoreBusyEnter(): BusyEnterBehavior { + if (typeof localStorage === 'undefined') return DEFAULT_BUSY_ENTER_BEHAVIOR + let stored: string | null + try { + stored = localStorage.getItem(BUSY_ENTER_STORAGE_KEY) + } catch { + // Storage access can fail in privacy modes; the default remains usable. + return DEFAULT_BUSY_ENTER_BEHAVIOR + } + if (stored === 'queue' || stored === 'steer') return stored + return DEFAULT_BUSY_ENTER_BEHAVIOR +} + +/** Persist a preference when browser storage is available. */ +function persistBusyEnter(behavior: BusyEnterBehavior): void { + if (typeof localStorage === 'undefined') return + try { + localStorage.setItem(BUSY_ENTER_STORAGE_KEY, behavior) + } catch { + // A storage failure makes the preference session-only; input stays usable. + } +} diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 21897a529b..1ecb33b854 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -23,6 +23,10 @@ export const zh = { 'input.stop': '停止生成', 'input.send': '发送消息', 'input.accessMode': '访问模式,当前:{name}', + 'settings.enter.title': '繁忙时 Enter 键行为', + 'settings.enter.description': '仅在智能体运行时生效;Cmd/Ctrl+Enter 使用另一行为', + 'settings.enter.queue': '排队发送', + 'settings.enter.steer': '插话发送', 'access.confirm.title': '确认启用 Full access?', 'access.confirm.description': '启用 Full access 后,agent 将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。', 'access.confirm.acknowledge': '我已了解风险,并愿意继续', @@ -126,6 +130,10 @@ export const en = { 'input.stop': 'Stop generating', 'input.send': 'Send message', 'input.accessMode': 'Access mode, current: {name}', + 'settings.enter.title': 'Enter behavior while busy', + 'settings.enter.description': 'Busy only; Cmd/Ctrl+Enter uses the other behavior', + 'settings.enter.queue': 'Queue', + 'settings.enter.steer': 'Steer', 'access.confirm.title': 'Enable Full access?', 'access.confirm.description': 'Full access reduces confirmation steps and lets the agent perform more actions directly, including sensitive operations, file changes, or external commands. Only use it when you trust the current task.', 'access.confirm.acknowledge': 'I understand the risks and want to continue', diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx index 10c16e6dc5..5085a6e579 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx @@ -4,7 +4,7 @@ // The 'conversation.input.dock' SlotMap declaration lives in // ../contract/slots.ts beside the other input-region slots. import type { Context } from 'cordis' -import { useEffect, useId, useState } from 'react' +import { useEffect, useId, useMemo, useState } from 'react' import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' import { @@ -29,7 +29,8 @@ export type QueueDockProps = PropsRuntime<'conversation.input.dock'> & QueueDock * collapsible count header; an empty queue renders nothing. */ export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps) { - const queue = useSession(s => s.queue) + const inbox = useSession(s => s.queue) + const queue = useMemo(() => inbox.filter(row => row.placement === 'queued'), [inbox]) const running = useSession(s => s.running) const [editing, setEditing] = useState<{ id: QueueItemId; text: string } | null>(null) const [busy, setBusy] = useState(null) diff --git a/packages/client/ui-conversation/src/client/queue/store.ts b/packages/client/ui-conversation/src/client/queue/store.ts index 536523465b..35364383c6 100644 --- a/packages/client/ui-conversation/src/client/queue/store.ts +++ b/packages/client/ui-conversation/src/client/queue/store.ts @@ -1,7 +1,7 @@ /** * Queue read face for the InputState.queue projection (frozen contract in * ../input/contract.ts): a uSES-compatible observable over one session's - * queue rows. The Session snapshot already keeps the queue array + * transient inbox rows. The Session snapshot already keeps the queue array * reference-stable across unrelated snapshot swaps, so this is a pure * projection — no second store, no copy. */ @@ -9,7 +9,7 @@ import type { ObservableSnapshot, SessionFace } from '@deepseek-ai/dsh-client-ru import type { QueuedMessage } from '../input/contract.ts' /** - * Project a session's queue rows as a bare observable (subscribe/getSnapshot). + * Project a session's transient inbox rows as a bare observable (subscribe/getSnapshot). * The wiring layer (T5) overlays this onto InputState.queue; the runtime * QueuedMessage and the input-contract QueuedMessage are structurally * identical. diff --git a/packages/client/ui-conversation/src/client/settings/EnterBehaviorRow.module.css b/packages/client/ui-conversation/src/client/settings/EnterBehaviorRow.module.css new file mode 100644 index 0000000000..9d8b3d7ceb --- /dev/null +++ b/packages/client/ui-conversation/src/client/settings/EnterBehaviorRow.module.css @@ -0,0 +1,56 @@ +/* Composer Enter preference row: title/description plus selector pill. */ + +.row { + display: flex; + align-items: center; + gap: 8px; + padding: 16px 0; + border-bottom: 1px solid var(--dsw-alias-border-l2); +} + +.rowText { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; + padding-right: 48px; +} + +.title { + font-size: 14px; + font-weight: 400; + line-height: 22px; + color: var(--dsw-alias-label-primary); +} + +.desc { + font-size: 12px; + font-weight: 400; + line-height: 18px; + color: var(--dsw-alias-label-tertiary); +} + +.selector { + display: inline-flex; + align-items: center; + gap: 12px; + height: 36px; + padding: 0 14px; + border: none; + border-radius: 18px; + background: var(--dsw-alias-bg-module-platform); + font: inherit; + font-size: 14px; + line-height: 22px; + color: var(--dsw-alias-label-primary); + cursor: pointer; +} + +.selector:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +.chevron { + flex: none; +} diff --git a/packages/client/ui-conversation/src/client/settings/EnterBehaviorRow.tsx b/packages/client/ui-conversation/src/client/settings/EnterBehaviorRow.tsx new file mode 100644 index 0000000000..2b5552c3cc --- /dev/null +++ b/packages/client/ui-conversation/src/client/settings/EnterBehaviorRow.tsx @@ -0,0 +1,76 @@ +/** General Settings row for the Composer's busy-state Enter preference. */ +import { useState } from 'react' +import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import { IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives' +import type { BusyEnterBehavior } from '../contract/composer-submission.ts' +import type { ConversationKey } from '../locales.ts' +import css from './EnterBehaviorRow.module.css' + +/** Registration-side preference face. */ +export interface EnterBehaviorRowInjected { + hooks: { + /** Persisted busy-state preference bound as useBusyEnter. */ + busyEnter: SnapshotStore + } + /** Change the busy-state plain-Enter behavior. */ + setBusyEnter: (behavior: BusyEnterBehavior) => void +} + +/** Full Settings-row props. */ +export type EnterBehaviorRowProps = + PropsRuntime<'settings.general.item'> + & PropsLocale<'conversation'> + & InjectFace + +const OPTIONS: readonly { + id: BusyEnterBehavior + label: ConversationKey +}[] = [ + { id: 'queue', label: 'settings.enter.queue' }, + { id: 'steer', label: 'settings.enter.steer' }, +] + +/** + * Render the busy-state Enter behavior selector. + * @param props - composed Settings slot props. + * @returns the preference row. + */ +export function EnterBehaviorRow({ useBusyEnter, setBusyEnter, t }: EnterBehaviorRowProps) { + const behavior = useBusyEnter(value => value) + const [open, setOpen] = useState(false) + const selectedLabel = behavior === 'queue' ? 'settings.enter.queue' : 'settings.enter.steer' + + return ( +
+
+
{t('settings.enter.title')}
+
{t('settings.enter.description')}
+
+ { setOpen(false) }} + items={OPTIONS.map(option => ({ id: option.id, label: t(option.label) }))} + selectedId={behavior} + onSelect={(id) => { + setOpen(false) + setBusyEnter(id as BusyEnterBehavior) + }} + align="end" + portal + anchor={( + + )} + /> +
+ ) +} diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 43a0f3e1ec..7b8aa87414 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -34,7 +34,7 @@ export interface InputBarError { export type InputBarProps = ComposerBarProps export function InputBar({ - useSession, useInput, inputActions, keyboard, toggleCommandMenu, stop, command, t, + useSession, useInput, inputActions, keyboard, resolveSubmitMode, toggleCommandMenu, stop, command, t, renderSlot, useNotices, useLexicon, useMenuLauncher, useProjection, sessionId, variant, disabled: inert = false, placeholder, accessory, overlay, leftItems, rightItems, footer, }: InputBarProps) { @@ -178,23 +178,10 @@ export function InputBar({ e.preventDefault() return } - if (e.ctrlKey || e.metaKey) { - // Newline as a machine transaction (the machine owns undo history; an - // execCommand write would fork a second, browser-owned history). - e.preventDefault() - if (!machineBusy && !locked) { - const el = e.currentTarget - const sel = selectionOf(el) - keyboard.newline(sel) - const caret = sel.start + 1 - requestAnimationFrame(() => { el.setSelectionRange(caret, caret) }) - } - return - } e.preventDefault() if (e.repeat) return // held-down Enter must not machine-gun sends if (locked || machineBusy) return - inputActions.submit() + keyboard.submit(resolveSubmitMode(running, e.ctrlKey || e.metaKey ? 'accelerated' : 'enter')) } const onChange = (e: ChangeEvent): void => { diff --git a/packages/client/ui-conversation/tests/chat-apply.spec.tsx b/packages/client/ui-conversation/tests/chat-apply.spec.tsx index d3fabd9bee..20e7e54f86 100644 --- a/packages/client/ui-conversation/tests/chat-apply.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-apply.spec.tsx @@ -37,6 +37,7 @@ async function bench() { await runtime.root.declare({ 'conversation': { kind: 'single', scope: 'session-maybe' }, 'details': { kind: 'single', scope: 'session' }, + 'settings.general.item': { kind: 'list', scope: 'root' }, }, (_p: { renderSlot?: unknown }) => null) const feature = await runtime.mount({ inject: [...inject], apply }) @@ -85,6 +86,7 @@ describe('apply wiring', () => { // The hero workspace picker hole rides the conversation entry's children // declaration (the empty-state occupant is gone). expect(b.slots.spec('conversation.hero.workspace')).toEqual({ kind: 'single', scope: 'root' }) + expect(b.slots.entries('settings.general.item').map(entry => entry.options.id)).toEqual(['composer-enter']) await b.runtime.dispose() }) @@ -112,6 +114,7 @@ describe('apply wiring', () => { expect(b.slots.entries('conversation.chat.toolview')).toHaveLength(0) expect(b.slots.spec('conversation.chat.toolview')).toBeUndefined() expect(b.slots.entries('details')).toHaveLength(0) + expect(b.slots.entries('settings.general.item')).toHaveLength(0) expect(b.runtime.ctx.get('conversation')).toBeUndefined() await b.runtime.dispose() }) diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 04f412fb25..507b301922 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -251,6 +251,51 @@ describe('ChatView', () => { expect(view.getByText('run a')).toBeTruthy() }) + it('renders Host-pending steering at the flow tail and hands off to the durable node', () => { + const pending = { + id: 'steer-occurrence' as never, + messageId: 'steer-message' as never, + placement: 'steering' as const, + content: [{ type: 'text' as const, text: 'interrupt now' }], + preview: 'interrupt now', + text: 'interrupt now', + } + const queued = { + id: 'queued-occurrence' as never, + messageId: 'queued-message' as never, + placement: 'queued' as const, + content: [{ type: 'text' as const, text: 'later' }], + preview: 'later', + text: 'later', + } + const h = makeHarness({ nodes: [assistant(1, 'working')], queue: [queued, pending], running: true }) + const view = render() + + expect(view.getByText('interrupt now').closest('[data-pending-steering]')).not.toBeNull() + expect(view.queryByText('later')).toBeNull() + expect(view.getByRole('status').compareDocumentPosition(view.getByText('interrupt now')) + & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0) + + act(() => { + h.set({ + queue: [queued, pending], + nodes: [ + assistant(1, 'working'), + { + kind: 'steering', messageId: pending.messageId, + seq: 2, time: 2_000, turn: 1, + content: [{ type: 'text', text: 'interrupt now' }], source: null, + }, + ], + }) + }) + expect(view.getAllByText('interrupt now')).toHaveLength(1) + expect(view.container.querySelector('[data-pending-steering]')).toBeNull() + + act(() => { h.set({ queue: [queued] }) }) + expect(view.getAllByText('interrupt now')).toHaveLength(1) + }) + it('animates only the latest unresolved model retry', () => { const retryNode = retry(2) const nextRetry = { ...retry(3), turn: 2, retry: 2 } diff --git a/packages/client/ui-conversation/tests/enter-behavior-row.spec.tsx b/packages/client/ui-conversation/tests/enter-behavior-row.spec.tsx new file mode 100644 index 0000000000..e8d44c9bf3 --- /dev/null +++ b/packages/client/ui-conversation/tests/enter-behavior-row.spec.tsx @@ -0,0 +1,67 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' +import { createSnapshotStore, type SessionListState, type WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { EnterBehaviorRow } from '../src/client/settings/EnterBehaviorRow.tsx' +import type { EnterBehaviorRowProps } from '../src/client/settings/EnterBehaviorRow.tsx' +import { ComposerSubmissionPolicy } from '../src/client/input/submission-policy.ts' +import { en } from '../src/client/locales.ts' + +afterEach(() => { + cleanup() + localStorage.clear() +}) + +function emptySessions() { + return bindSnapshotSelector(createSnapshotStore({ + ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined, + })) +} + +function emptyWorkspaces() { + return bindSnapshotSelector(createSnapshotStore({ + items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, + baselinesReady: true, recentWorkspaceId: undefined, + })) +} + +function mount() { + const policy = new ComposerSubmissionPolicy() + const setBusyEnter = vi.fn((behavior: 'queue' | 'steer') => { policy.setBusyEnter(behavior) }) + const props: EnterBehaviorRowProps = { + useSessions: emptySessions(), + useWorkspaces: emptyWorkspaces(), + useBusyEnter: bindSnapshotSelector(policy.busyEnter), + setBusyEnter, + t: makeTranslate(en), + } + render() + return { policy, setBusyEnter } +} + +describe('EnterBehaviorRow', () => { + it('explains the busy-only scope and shows Queue by default', () => { + mount() + expect(screen.getByText('Enter behavior while busy')).toBeDefined() + expect(screen.getByText('Busy only; Cmd/Ctrl+Enter uses the other behavior')).toBeDefined() + expect(screen.getByRole('button', { name: /Queue/ }).getAttribute('aria-expanded')).toBe('false') + }) + + it('selects Steer, follows later preference changes, and closes outside', () => { + const b = mount() + const trigger = screen.getByRole('button', { name: /Queue/ }) + fireEvent.click(trigger) + fireEvent.click(screen.getByRole('menuitem', { name: 'Steer' })) + expect(b.setBusyEnter).toHaveBeenCalledWith('steer') + expect(screen.getByRole('button', { name: /Steer/ })).toBeDefined() + + act(() => { b.policy.setBusyEnter('queue') }) + const queueTrigger = screen.getByRole('button', { name: /Queue/ }) + fireEvent.click(queueTrigger) + expect(screen.getByRole('menuitem', { name: 'Steer' })).toBeDefined() + fireEvent.pointerDown(document.body) + expect(screen.queryByRole('menuitem', { name: 'Steer' })).toBeNull() + }) +}) diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index 2072fd2c90..bfe34316fa 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom // InputBar behavior over the machine wiring: Enter-send semantics (IME guard, -// shift newline, ctrl/meta insert, repeat suppression), queue-cut-1 running +// Shift newline, busy Enter policy, Ctrl/Meta steering, repeat suppression), running // semantics (input stays free; primary turns stop), the machine pending lock, // decoration backdrop, error/notice strips, and the focus-keeping mousedown. @@ -53,6 +53,7 @@ interface BenchOptions { leftItems?: React.ReactNode rightItems?: React.ReactNode commandMenuOpen?: boolean + busyEnter?: 'queue' | 'steer' toggleCommandMenu?: (selection: { start: number; end: number }) => void } @@ -107,6 +108,11 @@ function bench(over?: BenchOptions) { useInput: bindSnapshotSelector(shell.state), inputActions: shell.actions, keyboard: shell, + resolveSubmitMode: (running, gesture) => { + if (!running) return 'queue' + const preferred = over?.busyEnter ?? 'queue' + return gesture === 'enter' ? preferred : preferred === 'queue' ? 'steer' : 'queue' + }, toggleCommandMenu: over?.toggleCommandMenu ?? vi.fn(), useNotices: bindSnapshotSelector(shell.notices), useLexicon: bindSnapshotSelector(shell.lexicon), @@ -137,7 +143,7 @@ describe('Enter semantics', () => { it('plain Enter submits queue mode through the machine; repeat and empty are suppressed', () => { const { textarea, sink } = bench({ draft: 'hello' }) fireEvent.keyDown(textarea, { key: 'Enter' }) - expect(sink).toHaveBeenCalledWith('hello') + expect(sink).toHaveBeenCalledWith('hello', 'queue') fireEvent.keyDown(textarea, { key: 'Enter', repeat: true }) expect(sink).toHaveBeenCalledTimes(1) const empty = bench({ draft: ' ' }) @@ -159,12 +165,18 @@ describe('Enter semantics', () => { expect(sink).not.toHaveBeenCalled() // and not preventDefault'd: native newline }) - it('Ctrl/Meta+Enter inserts a newline through the machine (no browser execCommand)', () => { - const { textarea, shell, sink } = bench({ draft: 'hello' }) - textarea.setSelectionRange(5, 5) - fireEvent.keyDown(textarea, { key: 'Enter', ctrlKey: true }) - expect(shell.snapshot.draft).toBe('hello\n') - expect(sink).not.toHaveBeenCalled() + it('Ctrl/Meta+Enter sends normally while idle and steers while running', () => { + const idle = bench({ draft: 'hello' }) + fireEvent.keyDown(idle.textarea, { key: 'Enter', metaKey: true }) + expect(idle.sink).toHaveBeenCalledWith('hello', 'queue') + + const busyCtrl = bench({ running: true, draft: 'steer with ctrl' }) + fireEvent.keyDown(busyCtrl.textarea, { key: 'Enter', ctrlKey: true }) + expect(busyCtrl.sink).toHaveBeenCalledWith('steer with ctrl', 'steer') + + const busyMeta = bench({ running: true, draft: 'steer with cmd' }) + fireEvent.keyDown(busyMeta.textarea, { key: 'Enter', metaKey: true }) + expect(busyMeta.sink).toHaveBeenCalledWith('steer with cmd', 'steer') }) it('platform undo/redo chords route to the machine, never the browser stack', () => { @@ -205,12 +217,28 @@ describe('running and lock semantics (queue cut 1)', () => { expect(textarea.disabled).toBe(false) // running no longer locks fireEvent.change(textarea, { target: { value: '排队消息2' } }) fireEvent.keyDown(textarea, { key: 'Enter' }) - expect(sink).toHaveBeenCalledWith('排队消息2') + expect(sink).toHaveBeenCalledWith('排队消息2', 'queue') expect(button.getAttribute('aria-label')).toBe('停止生成') fireEvent.click(button) expect(stop).toHaveBeenCalledTimes(1) }) + it('running plain Enter follows the busy-state Steer preference', () => { + const { textarea, sink } = bench({ running: true, busyEnter: 'steer', draft: '直接插话' }) + fireEvent.keyDown(textarea, { key: 'Enter' }) + expect(sink).toHaveBeenCalledWith('直接插话', 'steer') + }) + + it('running Cmd/Ctrl+Enter uses the opposite of the busy-state Enter preference', () => { + const meta = bench({ running: true, busyEnter: 'steer', draft: '排到下一轮' }) + fireEvent.keyDown(meta.textarea, { key: 'Enter', metaKey: true }) + expect(meta.sink).toHaveBeenCalledWith('排到下一轮', 'queue') + + const ctrl = bench({ running: true, busyEnter: 'steer', draft: 'also queue' }) + fireEvent.keyDown(ctrl.textarea, { key: 'Enter', ctrlKey: true }) + expect(ctrl.sink).toHaveBeenCalledWith('also queue', 'queue') + }) + it('running subagent primary admits a follow-up instead of exposing Stop', () => { const { button, sink, stop } = bench({ running: true, @@ -226,7 +254,7 @@ describe('running and lock semantics (queue cut 1)', () => { }) expect(button.getAttribute('aria-label')).toBe('发送消息') fireEvent.click(button) - expect(sink).toHaveBeenCalledWith('后续消息') + expect(sink).toHaveBeenCalledWith('后续消息', 'queue') expect(stop).not.toHaveBeenCalled() const empty = bench({ @@ -253,7 +281,7 @@ describe('running and lock semantics (queue cut 1)', () => { it('idle primary sends and disables on empty draft', () => { const { button, sink } = bench({ draft: 'go' }) fireEvent.click(button) - expect(sink).toHaveBeenCalledWith('go') + expect(sink).toHaveBeenCalledWith('go', 'queue') const empty = bench() expect(empty.button.disabled).toBe(true) }) diff --git a/packages/client/ui-conversation/tests/input-machine.spec.ts b/packages/client/ui-conversation/tests/input-machine.spec.ts index 510cb28076..89737d7106 100644 --- a/packages/client/ui-conversation/tests/input-machine.spec.ts +++ b/packages/client/ui-conversation/tests/input-machine.spec.ts @@ -40,9 +40,9 @@ function effectAt( } /** Drive plain → adjudicating and hand back the minted attempt. */ -function enterAdjudicating(m: InputMachine, draft: string): SubmitAttempt { +function enterAdjudicating(m: InputMachine, draft: string, mode: 'queue' | 'steer' = 'queue'): SubmitAttempt { m.dispatch({ type: 'draft-changed', draft }) - const fx = m.dispatch({ type: 'enter' }) + const fx = m.dispatch({ type: 'enter', mode }) return effectAt(fx, 0, 'adjudicate').attempt } @@ -52,35 +52,42 @@ function enterSubmitting(m: InputMachine, name: string, args: string): { attempt m.dispatch({ type: 'draft-changed', draft: `/${name.slice(0, 2)}` }) m.dispatch({ type: 'begin-command', claim, span: spanOf(m, 0, m.state.draft.length) }) m.dispatch({ type: 'draft-changed', draft: claim.token + args }) - const fx = m.dispatch({ type: 'enter' }) + const fx = m.dispatch({ type: 'enter', mode: 'queue' }) return { attempt: effectAt(fx, 0, 'begin-submit').attempt, claim } } function staleAttempt(): SubmitAttempt { - return { seq: 9999, signal: new AbortController().signal, draftSnapshot: '' } + return { seq: 9999, signal: new AbortController().signal, draftSnapshot: '', mode: 'queue' } } describe('input-machine: plain × enter', () => { it('empty and whitespace-only drafts produce nothing', () => { const m = new InputMachine() - expect(m.dispatch({ type: 'enter' })).toEqual([]) + expect(m.dispatch({ type: 'enter', mode: 'queue' })).toEqual([]) m.dispatch({ type: 'draft-changed', draft: ' \n ' }) - expect(m.dispatch({ type: 'enter' })).toEqual([]) + expect(m.dispatch({ type: 'enter', mode: 'queue' })).toEqual([]) expect(m.state.phase).toBe('plain') }) it('non-command text falls to the default sink', () => { const m = new InputMachine() m.dispatch({ type: 'draft-changed', draft: 'hello world' }) - expect(m.dispatch({ type: 'enter' })) - .toEqual([{ type: 'default-sink', draft: 'hello world' }]) + expect(m.dispatch({ type: 'enter', mode: 'queue' })) + .toEqual([{ type: 'default-sink', draft: 'hello world', mode: 'queue' }]) expect(m.state.phase).toBe('plain') }) + it('retains an explicit steer mode on the default sink effect', () => { + const m = new InputMachine() + m.dispatch({ type: 'draft-changed', draft: 'steer now' }) + expect(m.dispatch({ type: 'enter', mode: 'steer' })) + .toEqual([{ type: 'default-sink', draft: 'steer now', mode: 'steer' }]) + }) + it('leading "/" enters adjudicating with a minted attempt carrying the draft snapshot', () => { const m = new InputMachine() m.dispatch({ type: 'draft-changed', draft: '/goal x' }) - const fx = m.dispatch({ type: 'enter' }) + const fx = m.dispatch({ type: 'enter', mode: 'queue' }) const eff = effectAt(fx, 0, 'adjudicate') expect(eff.draft).toBe('/goal x') expect(eff.attempt.draftSnapshot).toBe('/goal x') @@ -91,14 +98,14 @@ describe('input-machine: plain × enter', () => { it('leading is judged after trim including newlines', () => { const m = new InputMachine() m.dispatch({ type: 'draft-changed', draft: '\n\n/goal x' }) - expect(m.dispatch({ type: 'enter' })[0]?.type).toBe('adjudicate') + expect(m.dispatch({ type: 'enter', mode: 'queue' })[0]?.type).toBe('adjudicate') }) it('a non-whitespace prefix before "/" is not leading — default sink', () => { const m = new InputMachine() m.dispatch({ type: 'draft-changed', draft: '第一行\n/goal x' }) - expect(m.dispatch({ type: 'enter' })) - .toEqual([{ type: 'default-sink', draft: '第一行\n/goal x' }]) + expect(m.dispatch({ type: 'enter', mode: 'queue' })) + .toEqual([{ type: 'default-sink', draft: '第一行\n/goal x', mode: 'queue' }]) }) }) @@ -126,9 +133,9 @@ describe('input-machine: adjudication outcomes', () => { it('undefined outcome falls back to the default sink', () => { const m = new InputMachine() - const attempt = enterAdjudicating(m, '/unknown thing') + const attempt = enterAdjudicating(m, '/unknown thing', 'steer') expect(m.dispatch({ type: 'adjudicated', attempt, outcome: undefined })) - .toEqual([{ type: 'default-sink', draft: '/unknown thing' }]) + .toEqual([{ type: 'default-sink', draft: '/unknown thing', mode: 'steer' }]) expect(m.state.phase).toBe('plain') }) @@ -152,7 +159,7 @@ describe('input-machine: adjudication outcomes', () => { it('enter is a no-op while adjudicating (pending lock)', () => { const m = new InputMachine() enterAdjudicating(m, '/goal x') - expect(m.dispatch({ type: 'enter' })).toEqual([]) + expect(m.dispatch({ type: 'enter', mode: 'queue' })).toEqual([]) expect(m.state.phase).toBe('adjudicating') }) @@ -344,31 +351,6 @@ describe('input-machine: occurrence reconciliation on draft edits', () => { }) }) -describe('input-machine: newline transaction (F1)', () => { - it('inserts \\n at the caret and shifts trailing occurrences', () => { - const m = new InputMachine() - m.dispatch({ type: 'draft-changed', draft: 'ab @wor' }) - m.dispatch({ type: 'insert-ref', reference: refOf('w'), span: spanOf(m, 3, 7) }) - m.dispatch({ type: 'newline', selection: { start: 2, end: 2 } }) - expect(m.state.draft).toBe(`ab\n ${P} `) - expect(m.state.occurrences[0]?.offset).toBe(4) - m.dispatch({ type: 'undo' }) - expect(m.state.draft).toBe(`ab ${P} `) - }) - - it('replaces a selection, breaks the claim prefix when leading, and rejects out-of-bounds', () => { - const m = new InputMachine() - m.dispatch({ type: 'draft-changed', draft: '/go' }) - m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 0, 3) }) - expect(m.dispatch({ type: 'newline', selection: { start: 0, end: 99 } })).toEqual([]) - expect(m.state.phase).toBe('claimed') - m.dispatch({ type: 'newline', selection: { start: 0, end: 0 } }) - expect(m.state.draft).toBe('\n/goal ') - expect(m.state.phase).toBe('plain') - expect(m.state.claim).toBeUndefined() - }) -}) - describe('input-machine: consume-token guards', () => { it('span guard: CAS pass deletes the token — success observable as a draftRev advance', () => { const m = new InputMachine() @@ -587,7 +569,7 @@ describe('input-machine: paste plane', () => { const b = new InputMachine() b.dispatch({ type: 'paste-begin', text: 'plain text', selection: { start: 0, end: 0 } }) - b.dispatch({ type: 'enter' }) + b.dispatch({ type: 'enter', mode: 'queue' }) expect(b.state.paste).toBeUndefined() }) @@ -755,7 +737,7 @@ describe('input-machine: submitting transaction', () => { it('enter and begin-command are locked; draft-changed is recorded without leaving submitting', () => { const m = new InputMachine() enterSubmitting(m, 'goal', 'x') - expect(m.dispatch({ type: 'enter' })).toEqual([]) + expect(m.dispatch({ type: 'enter', mode: 'queue' })).toEqual([]) expect(m.dispatch({ type: 'draft-changed', draft: '/goal y' })).toEqual([]) expect(m.state).toMatchObject({ phase: 'submitting', draft: '/goal y' }) }) @@ -768,7 +750,7 @@ describe('input-machine: submitting transaction', () => { m.dispatch({ type: 'draft-changed', draft: '/go', editRange: { start: 0, end: 1, insertedLength: 0 } }) m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 0, 3) }) m.dispatch({ type: 'draft-changed', draft: '/goal go' }) - const attempt = effectAt(m.dispatch({ type: 'enter' }), 0, 'begin-submit').attempt + const attempt = effectAt(m.dispatch({ type: 'enter', mode: 'queue' }), 0, 'begin-submit').attempt const fx = m.dispatch({ type: 'submit-settled', attempt, ok: true, outcome: { kind: 'success', text: 'goal set' } }) expect(fx).toEqual([{ type: 'notice', level: 'info', text: 'goal set' }]) expect(m.state).toMatchObject({ phase: 'plain', draft: '', occurrences: [] }) @@ -809,7 +791,7 @@ describe('input-machine: submitting transaction', () => { const m = new InputMachine() const { attempt: first } = enterSubmitting(m, 'goal', 'x') m.dispatch({ type: 'submit-settled', attempt: first, ok: false, message: 'retry' }) - const second = effectAt(m.dispatch({ type: 'enter' }), 0, 'begin-submit').attempt + const second = effectAt(m.dispatch({ type: 'enter', mode: 'queue' }), 0, 'begin-submit').attempt expect(second.seq).not.toBe(first.seq) expect(m.dispatch({ type: 'submit-settled', attempt: first, ok: true })).toEqual([]) expect(m.state.phase).toBe('submitting') diff --git a/packages/client/ui-conversation/tests/input-matrix.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.spec.tsx index 45ce41c3b8..af8dbb8e66 100644 --- a/packages/client/ui-conversation/tests/input-matrix.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.spec.tsx @@ -47,6 +47,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled useInput: bindSnapshotSelector(shell.state), inputActions: shell.actions, keyboard: shell, + resolveSubmitMode: () => 'queue', toggleCommandMenu: vi.fn(), useNotices: bindSnapshotSelector(shell.notices), useLexicon: bindSnapshotSelector(shell.lexicon), @@ -88,7 +89,7 @@ describe('matrix row: plain', () => { fireEvent.change(textarea, { target: { value: '普通消息' } }) expect(shell.snapshot.claim).toBeUndefined() fireEvent.keyDown(textarea, { key: 'Enter' }) - expect(sink).toHaveBeenCalledWith('普通消息') + expect(sink).toHaveBeenCalledWith('普通消息', 'queue') expect(shell.snapshot.phase).toBe('plain') }) }) @@ -187,7 +188,7 @@ describe('matrix row: locked (session disabled)', () => { expect((textarea).disabled).toBe(false) fireEvent.change(textarea, { target: { value: '排队' } }) fireEvent.keyDown(textarea, { key: 'Enter' }) - expect(sink).toHaveBeenCalledWith('排队') + expect(sink).toHaveBeenCalledWith('排队', 'queue') }) }) diff --git a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx index a4eb2e668f..f3ef3410ff 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx @@ -133,6 +133,7 @@ async function scopedBench(register?: (slash: SlashService) => void) { useInput: bindSnapshotSelector(shell.state), inputActions: shell.actions, keyboard: shell, + resolveSubmitMode: () => 'queue', toggleCommandMenu: (selection) => { const snapshot = shell.snapshot controller.toggleSource('command', { @@ -235,7 +236,7 @@ describe('scenario D: execute-kind /compact', () => { act(() => { b2.shell.setDraft('/compact 现在') }) fireEvent.keyDown(b2.textarea, { key: 'Enter' }) // execute with trailing → matchEnter answers undefined → default sink. - await vi.waitFor(() => { expect(b2.sink).toHaveBeenCalledWith('/compact 现在') }) + await vi.waitFor(() => { expect(b2.sink).toHaveBeenCalledWith('/compact 现在', 'queue') }) expect(b2.executed).toHaveLength(0) }) }) @@ -289,7 +290,7 @@ describe('scenario I: unknown /xyz + enter', () => { const b = await bench() act(() => { b.shell.setDraft('/xyz 干点啥') }) fireEvent.keyDown(b.textarea, { key: 'Enter' }) - await vi.waitFor(() => { expect(b.sink).toHaveBeenCalledWith('/xyz 干点啥') }) + await vi.waitFor(() => { expect(b.sink).toHaveBeenCalledWith('/xyz 干点啥', 'queue') }) expect(b.shell.snapshot.phase).toBe('plain') expect(b.execute).not.toHaveBeenCalled() }) diff --git a/packages/client/ui-conversation/tests/queue-dock.spec.tsx b/packages/client/ui-conversation/tests/queue-dock.spec.tsx index eb5c293501..4195488a53 100644 --- a/packages/client/ui-conversation/tests/queue-dock.spec.tsx +++ b/packages/client/ui-conversation/tests/queue-dock.spec.tsx @@ -23,7 +23,11 @@ const SID = 's1' as SessionId const iid = (id: string): QueueItemId => id as QueueItemId function row(id: string, text: string | null, preview = text ?? '[image]'): QueuedMessage { - return { id: iid(id), preview, text } + return { + id: iid(id), messageId: `message-${id}` as never, placement: 'queued', + content: text === null ? [{ type: 'image', data: 'x' } as never] : [{ type: 'text', text }], + preview, text, + } } function snapshotWith(queue: QueuedMessage[]): ConversationSnapshot { @@ -85,6 +89,14 @@ describe('QueueDock', () => { expect(container.innerHTML).toBe('') }) + it('leaves pending steering to the conversation flow', () => { + const steering = { ...row('s-1', 'interrupt'), placement: 'steering' as const } + const snap = snapshotWith([steering]) + const source = liveSession(snap) + const { container } = render() + expect(container.innerHTML).toBe('') + }) + it('renders one row directly and defaults multiple rows to a collapsible count header', () => { const single = snapshotWith([row('i-1', 'one')]) const source = liveSession(single) diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index eff655d229..1642425930 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -164,6 +164,7 @@ function mount( useInput={useInput} inputActions={inputActions} keyboard={wiring} + resolveSubmitMode={() => 'queue'} toggleCommandMenu={vi.fn()} useNotices={bindSnapshotSelector(wiring.notices)} useLexicon={bindSnapshotSelector(wiring.lexicon)} @@ -220,7 +221,7 @@ describe('ConversationRoot resident composer', () => { fireEvent.change(box, { target: { value: 'ordinary revised' } }) expect(b.chat.store.getSnapshot().draft).toBe('ordinary revised') fireEvent.keyDown(box, { key: 'Enter' }) - expect(b.sink).toHaveBeenCalledWith('ordinary revised') + expect(b.sink).toHaveBeenCalledWith('ordinary revised', 'queue') expect((b.view.getByRole('button', { name: 'Child' }) as HTMLButtonElement).disabled).toBe(true) expect(b.view.queryByText('Root')).toBeNull() }) diff --git a/packages/client/ui-conversation/tests/submission-policy.spec.ts b/packages/client/ui-conversation/tests/submission-policy.spec.ts new file mode 100644 index 0000000000..485840fc2e --- /dev/null +++ b/packages/client/ui-conversation/tests/submission-policy.spec.ts @@ -0,0 +1,65 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + BUSY_ENTER_STORAGE_KEY, ComposerSubmissionPolicy, DEFAULT_BUSY_ENTER_BEHAVIOR, +} from '../src/client/input/submission-policy.ts' + +afterEach(() => { + vi.unstubAllGlobals() + localStorage.clear() +}) + +describe('ComposerSubmissionPolicy', () => { + it('defaults to Queue and only applies the preference while running', () => { + const policy = new ComposerSubmissionPolicy() + expect(policy.busyEnter.getSnapshot()).toBe(DEFAULT_BUSY_ENTER_BEHAVIOR) + expect(policy.resolve(false, 'enter')).toBe('queue') + expect(policy.resolve(false, 'accelerated')).toBe('queue') + expect(policy.resolve(true, 'enter')).toBe('queue') + expect(policy.resolve(true, 'accelerated')).toBe('steer') + + const changed = vi.fn() + policy.busyEnter.subscribe(changed) + policy.setBusyEnter('steer') + expect(changed).toHaveBeenCalledTimes(1) + expect(policy.resolve(true, 'enter')).toBe('steer') + expect(policy.resolve(true, 'accelerated')).toBe('queue') + expect(policy.resolve(false, 'enter')).toBe('queue') + expect(policy.resolve(false, 'accelerated')).toBe('queue') + expect(localStorage.getItem(BUSY_ENTER_STORAGE_KEY)).toBe('steer') + }) + + it('restores a valid preference and leaves an identical write untouched', () => { + localStorage.setItem(BUSY_ENTER_STORAGE_KEY, 'steer') + const write = vi.spyOn(Storage.prototype, 'setItem') + const policy = new ComposerSubmissionPolicy() + expect(policy.busyEnter.getSnapshot()).toBe('steer') + policy.setBusyEnter('steer') + expect(write).not.toHaveBeenCalled() + write.mockRestore() + }) + + it('uses Queue for invalid, unavailable, or unreadable storage', () => { + localStorage.setItem(BUSY_ENTER_STORAGE_KEY, 'invalid') + expect(new ComposerSubmissionPolicy().busyEnter.getSnapshot()).toBe('queue') + + vi.stubGlobal('localStorage', undefined) + expect(new ComposerSubmissionPolicy().busyEnter.getSnapshot()).toBe('queue') + + vi.stubGlobal('localStorage', { + getItem: () => { throw new Error('blocked') }, + setItem: vi.fn(), + }) + expect(new ComposerSubmissionPolicy().busyEnter.getSnapshot()).toBe('queue') + }) + + it('keeps the in-memory preference when persistence throws', () => { + vi.stubGlobal('localStorage', { + getItem: () => null, + setItem: () => { throw new Error('quota') }, + }) + const policy = new ComposerSubmissionPolicy() + policy.setBusyEnter('steer') + expect(policy.busyEnter.getSnapshot()).toBe('steer') + }) +}) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 7d1c033393..e9808518fe 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -818,29 +818,28 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro sessionId, items: items.map(item => ({ id: item.id, + placement: item.placement, message: item.message, })), }) } ctx.effect(() => { - const retire = (agent: Agent, item: InboxItem): boolean => { - const entries = queuedMirror.get(agent.id) - if (entries === undefined) { - rememberUnseen(agent.id, item.id, { kind: 'terminal' }) - return false - } - const index = entries.findIndex(entry => entry.id === item.id) - if (index === -1) { - rememberUnseen(agent.id, item.id, { kind: 'terminal' }) - return false - } + const retireKnown = (sessionId: SessionId, itemId: InboxItemId): boolean => { + const entries = queuedMirror.get(sessionId) + if (entries === undefined) return false + const index = entries.findIndex(entry => entry.id === itemId) + if (index === -1) return false entries.splice(index, 1) - if (entries.length === 0) queuedMirror.delete(agent.id) + if (entries.length === 0) queuedMirror.delete(sessionId) return true } + const retire = (agent: Agent, item: InboxItem): boolean => { + if (retireKnown(agent.id, item.id)) return true + rememberUnseen(agent.id, item.id, { kind: 'terminal' }) + return false + } const disposers = [ ctx.on('agent/inbox/enqueue', (agent: Agent, item: InboxItem) => { - if (item.placement !== 'queued') return const unseen = takeUnseen(agent.id, item.id) if (unseen?.kind === 'terminal') return let entries = queuedMirror.get(agent.id) @@ -866,7 +865,24 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro publishQueue(agent.id) }), ctx.on('agent/inbox/dequeue', (agent: Agent, item: InboxItem) => { - if (retire(agent, item)) publishQueue(agent.id) + if (item.placement === 'steering') { + // AgentLoop appends the durable steering/message synchronously after + // this claim. Retain and retire the mirror row in the following + // microtask so any re-entrant snapshot and the Host's linear mux + // stream keep it visible until the durable event exists. + const present = queuedMirror.get(agent.id)?.some(entry => entry.id === item.id) === true + if (!present) { + retire(agent, item) + return + } + queueMicrotask(() => { + if (retireKnown(agent.id, item.id)) publishQueue(agent.id) + }) + } else if (retire(agent, item)) { + // Queued claims have no durable same-message handoff to order. + // Publish retirement synchronously as before. + publishQueue(agent.id) + } }), ctx.on('agent/inbox/discard', (agent: Agent, items: InboxItem[]) => { let changed = false @@ -2509,6 +2525,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro sessionId, items: items.map(item => ({ id: item.id, + placement: item.placement, message: item.message, })), })) diff --git a/packages/host/apiproxy/src/api/events.schema.ts b/packages/host/apiproxy/src/api/events.schema.ts index 0fd97b346e..3d5e94c2c1 100644 --- a/packages/host/apiproxy/src/api/events.schema.ts +++ b/packages/host/apiproxy/src/api/events.schema.ts @@ -54,6 +54,7 @@ export const muxFrameSchema = z.discriminatedUnion('type', [ sessionId: sessionIdSchema, items: z.array(z.object({ id: inboxItemIdSchema, + placement: z.union([z.literal('queued'), z.literal('steering')]), message: messageSchema, })), }), diff --git a/packages/host/apiproxy/src/api/events.ts b/packages/host/apiproxy/src/api/events.ts index 43bebaa595..f4460fadc2 100644 --- a/packages/host/apiproxy/src/api/events.ts +++ b/packages/host/apiproxy/src/api/events.ts @@ -32,10 +32,12 @@ export type ToolEventView = | { for: 'call'; view: ToolCallView } | { for: 'result'; view: ToolResultView } -/** One pending queued occurrence in an authoritative queue snapshot. */ +/** One pending inbox occurrence in the authoritative `session/queue` snapshot. */ export interface QueuedInboxItem { - /** Agent-owned occurrence identity used by queue mutations. */ + /** Agent-owned occurrence identity; queue mutations address only `queued` items. */ id: InboxItemId + /** Agent-resolved FIFO placement; clients render queued and steering items on different surfaces. */ + placement: 'queued' | 'steering' /** Complete pending message; it is not durable until the Agent claims it. */ message: Message } @@ -71,11 +73,12 @@ export type MuxFrame = | { type: 'question/requested'; sessionId: SessionId; questions: AskUserQuestionItem[] } | { type: 'question/resolved'; sessionId: SessionId; questionRpcId: RpcId; outcome: 'answered' | 'cancelled' } /** - * Complete transient queue state after every enqueue, mutation, claim, or + * Complete transient inbox state after every enqueue, mutation, claim, or * discard. Pending work is not model-visible and therefore has no durable * session event; the whole snapshot makes edit, deletion, cancel, and - * reconnect converge through one authoritative signal. Pending steering is - * outside this Web queue projection. + * reconnect converge through one authoritative signal. `session/queue` + * covers both resolved placements: queued items render + * in QueueDock, while pending steering renders at the conversation tail. */ | { type: 'session/queue'; sessionId: SessionId; items: QueuedInboxItem[] } /** diff --git a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts index 61e4c6f4d3..473d452327 100644 --- a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts @@ -379,7 +379,7 @@ describe('session/queue frames', () => { const liveFrames = (await collected).filter(frame => frame.type === 'session/queue') expect(liveFrames.map(frame => frame.items)).toEqual([ - [{ id: edited.id, message: edited.message }], + [{ id: edited.id, placement: edited.placement, message: edited.message }], ]) const replay = new AbortController() const replayFrames = await collect( @@ -393,8 +393,8 @@ describe('session/queue frames', () => { const agent = stubAgent(ctx) const live = new AbortController() const liveStream = api.events.mux({ rpcId: RpcId('t-mux-live'), payload: {} }, live.signal) - // subscribed baseline + one queued snapshot; pending steering stays off this wire. - const liveCollected = collect(liveStream, 2, live) + // subscribed baseline + one snapshot per accepted inbox occurrence. + const liveCollected = collect(liveStream, 3, live) const queued = inboxItem('i-1', inboxMessage('m-1', 'queued prompt'), 'queued') const steering = inboxItem('i-2', inboxMessage('m-2', 'steering prompt'), 'steering') @@ -406,7 +406,15 @@ describe('session/queue frames', () => { { type: 'session/queue', sessionId: agent.id, - items: [{ id: queued.id, message: queued.message }], + items: [{ id: queued.id, placement: 'queued', message: queued.message }], + }, + { + type: 'session/queue', + sessionId: agent.id, + items: [ + { id: queued.id, placement: 'queued', message: queued.message }, + { id: steering.id, placement: 'steering', message: steering.message }, + ], }, ]) @@ -414,7 +422,88 @@ describe('session/queue frames', () => { const replay = new AbortController() const replayFrames = await collect( api.events.mux({ rpcId: RpcId('t-mux-replay'), payload: {} }, replay.signal), 2, replay) - expect(replayFrames.filter(f => f.type === 'session/queue')).toEqual([liveFrames[0]]) + expect(replayFrames.filter(f => f.type === 'session/queue')).toEqual([liveFrames[1]]) + }) + + it('publishes the durable steering event before retiring its transient row', async () => { + const ctx = await harness() + const api = createApiProxy(ctx, DEFAULTS) + const agent = stubAgent(ctx) + const abort = new AbortController() + const collected = collect( + api.events.mux({ rpcId: RpcId('t-steering-order'), payload: {} }, abort.signal), 5, abort) + const steering = inboxItem('i-steering', inboxMessage('m-steering', 'interrupt now'), 'steering') + + agent.session.append('turn/start', { + turn: 1, + trigger: { kind: 'message', source: { kind: 'user' } }, + }) + ctx.emit('agent/inbox/enqueue', agent, steering) + ctx.emit('agent/inbox/dequeue', agent, steering) + agent.session.append('steering/message', { + turn: 1, + message: steering.message, + }, { surfaceOp: 'append' }) + + const frames = await collected + expect(frames.map(frame => frame.type)).toEqual([ + 'session/subscribed', + 'session/event', + 'session/queue', + 'session/event', + 'session/queue', + ]) + expect(frames[2]).toMatchObject({ + type: 'session/queue', + items: [{ id: steering.id, placement: 'steering' }], + }) + expect(frames[3]).toMatchObject({ + type: 'session/event', + event: { type: 'steering/message', data: { message: { id: steering.message.id } } }, + }) + expect(frames[4]).toMatchObject({ type: 'session/queue', items: [] }) + }) + + it('retains claimed steering in re-entrant snapshots until its durable event', async () => { + const ctx = await harness() + const api = createApiProxy(ctx, DEFAULTS) + const agent = stubAgent(ctx) + const steering = inboxItem('i-steering', inboxMessage('m-steering', 'interrupt now'), 'steering') + const queued = inboxItem('i-reentrant', inboxMessage('m-reentrant', 'later'), 'queued') + ctx.on('agent/inbox/dequeue', (subject, item) => { + if (subject === agent && item.id === steering.id) ctx.emit('agent/inbox/enqueue', agent, queued) + }) + const abort = new AbortController() + const collected = collect( + api.events.mux({ rpcId: RpcId('t-steering-reentrant-order'), payload: {} }, abort.signal), 6, abort) + + agent.session.append('turn/start', { + turn: 1, + trigger: { kind: 'message', source: { kind: 'user' } }, + }) + ctx.emit('agent/inbox/enqueue', agent, steering) + ctx.emit('agent/inbox/dequeue', agent, steering) + agent.session.append('steering/message', { + turn: 1, + message: steering.message, + }, { surfaceOp: 'append' }) + + const frames = await collected + expect(frames[3]).toMatchObject({ + type: 'session/queue', + items: [ + { id: steering.id, placement: 'steering' }, + { id: queued.id, placement: 'queued' }, + ], + }) + expect(frames[4]).toMatchObject({ + type: 'session/event', + event: { type: 'steering/message', data: { message: { id: steering.message.id } } }, + }) + expect(frames[5]).toMatchObject({ + type: 'session/queue', + items: [{ id: queued.id, placement: 'queued' }], + }) }) it('publishes edits in place in the authoritative order', async () => { @@ -434,10 +523,16 @@ describe('session/queue frames', () => { const frames = (await collected).filter(frame => frame.type === 'session/queue') expect(frames.map(frame => frame.items)).toEqual([ - [{ id: first.id, message: first.message }], - [{ id: first.id, message: first.message }, { id: second.id, message: second.message }], - [{ id: first.id, message: first.message }, { id: edited.id, message: edited.message }], - [{ id: first.id, message: first.message }], + [{ id: first.id, placement: first.placement, message: first.message }], + [ + { id: first.id, placement: first.placement, message: first.message }, + { id: second.id, placement: second.placement, message: second.message }, + ], + [ + { id: first.id, placement: first.placement, message: first.message }, + { id: edited.id, placement: edited.placement, message: edited.message }, + ], + [{ id: first.id, placement: first.placement, message: first.message }], ]) }) diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 541ddab1c8..5c721b33d1 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -481,7 +481,7 @@ describe('events frame schemas', () => { { type: 'question/requested', sessionId: 's', questions: [{ id: 'q', question: 'Q?', options: [{ label: 'L' }], multiSelect: true }] }, { type: 'question/resolved', sessionId: 's', questionRpcId: 'r', outcome: 'answered' }, { type: 'session/queue', sessionId: 's', items: [ - { id: 'i1', message: { id: 'm1', role: 'user', content: [{ type: 'text', text: 'queued prompt' }], source: { kind: 'user', rpcId: 'r9' } } }, + { id: 'i1', placement: 'steering', message: { id: 'm1', role: 'user', content: [{ type: 'text', text: 'queued prompt' }], source: { kind: 'user', rpcId: 'r9' } } }, ] }, { type: 'session/projection', sessionId: 's', key: 'todos', value: [{ content: 'x', status: 'pending' }], seq: 7 }, { type: 'stream/error', error: { code: 'internal', message: 'm', details: {} } }, From 3a9f78c55eba0d27971492c84f085649637d2365 Mon Sep 17 00:00:00 2001 From: imccyu Date: Sun, 2 Aug 2026 15:50:05 +0800 Subject: [PATCH 08/19] feat(web): restore actions after steering consumption --- ...-29-addressable-queue-operations.i18n.yaml | 4 +-- ...2026-07-29-addressable-queue-operations.md | 2 +- ...6-07-29-addressable-queue-operations.zh.md | 2 +- ...026-07-30-web-queue-steer-action.i18n.yaml | 4 +-- .../2026-07-30-web-queue-steer-action.md | 10 +++---- .../2026-07-30-web-queue-steer-action.zh.md | 10 +++---- .../snapshots/steering/mid-steer.expected.md | 2 ++ .../snapshots/steering/settled.expected.md | 6 +++- .../client/ui-conversation/README.i18n.yaml | 4 +-- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../src/client/chat/MessageIconActions.tsx | 26 +++++++++-------- .../src/client/chat/MessageItem.tsx | 28 ++++++++++++++----- .../tests/chat-branch-tails.spec.tsx | 16 +++++++++-- .../ui-conversation/tests/chat-view.spec.tsx | 15 ++++++++++ 15 files changed, 91 insertions(+), 42 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.i18n.yaml index 217bcd32db..75c93214c7 100644 --- a/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.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-29-addressable-queue-operations.md -2026-07-29-addressable-queue-operations.md: e5fbacfa7de244daa1c5f8e504ee98a4a7730393 -2026-07-29-addressable-queue-operations.zh.md: a532d18d1b29fcebc7e9d8222da9eb5a162b8ec6 +2026-07-29-addressable-queue-operations.md: 04bcaa26be2f4f70c4003f6cc2fce4798499d205 +2026-07-29-addressable-queue-operations.zh.md: a16bf8667294de8d08da54ddb0d317bf9938e4eb diff --git a/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.md b/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.md index e5fbacfa7d..04bcaa26be 100644 --- a/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.md +++ b/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.md @@ -18,7 +18,7 @@ The Web queue rendered pending messages but could not edit or delete one row. `M **Queue addresses require a live ordinary-session Agent.** `session.updateQueue` queries only the mounted Agent registry and never resumes a cold session: an `InboxItemId` is process-local and cannot name work after restart or disposal. A session-backed subagent returns `agent-busy` before inbox access and retains its continuation owner; for ordinary sessions, a missing Agent and a driver-claimed occurrence both return `queue-item-not-found`. -**Web actions address Queue only.** The Host excludes pending steering from `session/queue`; steering retains its existing durable transcript path after consumption. QueueDock hides while empty, renders one pending occurrence directly, and defaults two or more occurrences to a collapsed `" 条排队消息"` header that expands or collapses the complete list. The header exposes `aria-expanded` and `aria-controls`; the expanded list scrolls within a 180px height bound. An active edit or mutation keeps its rows visible, and emptying the queue restores the collapsed default for the next queue. Visible rows expose edit, delete, and a running-only strict-steer action. The UI derives queue row and mutation types from the runtime `SessionFace` contract rather than importing the connection plugin, so plugin cooperation continues through services and snapshots. Edit is available only when all content blocks are text; the editor cannot silently drop non-text blocks. An editing row exposes only save and cancel, with Enter and Escape as their keyboard equivalents. Delete removes the exact occurrence, while strict steer preserves every content block and retires the row only through the authoritative snapshot. The Web stop action preserves pending Queue work; AgentLoop claims the next waking occurrence only after the interrupted turn reaches quiescence, and its dequeue event retires that row without a browser resend. The [Web Queue steer action](2026-07-30-web-queue-steer-action.md) owns the strict transfer contract. +**Web actions address Queue only.** The placement-aware `session/queue` snapshot carries both queued and pending-steering occurrences; QueueDock selects only queued items, while ChatView projects steering and retains the existing durable transcript path after consumption. QueueDock hides while empty, renders one pending occurrence directly, and defaults two or more occurrences to a collapsed `" 条排队消息"` header that expands or collapses the complete list. The header exposes `aria-expanded` and `aria-controls`; the expanded list scrolls within a 180px height bound. An active edit or mutation keeps its rows visible, and emptying the queue restores the collapsed default for the next queue. Visible rows expose edit, delete, and a running-only strict-steer action. The UI derives queue row and mutation types from the runtime `SessionFace` contract rather than importing the connection plugin, so plugin cooperation continues through services and snapshots. Edit is available only when all content blocks are text; the editor cannot silently drop non-text blocks. An editing row exposes only save and cancel, with Enter and Escape as their keyboard equivalents. Delete removes the exact occurrence, while strict steer preserves every content block and retires the row only through the authoritative snapshot. The Web stop action preserves pending Queue work; AgentLoop claims the next waking occurrence only after the interrupted turn reaches quiescence, and its dequeue event retires that row without a browser resend. The [Web Queue steer action](2026-07-30-web-queue-steer-action.md) owns the strict transfer and pending-projection contract. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.zh.md b/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.zh.md index a532d18d1b..a16bf86672 100644 --- a/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.zh.md +++ b/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.zh.md @@ -18,7 +18,7 @@ Web 队列能够渲染待处理消息,但无法编辑或删除其中某一行 **Queue 寻址要求普通会话的 Agent 存活。** `session.updateQueue` 只查询已挂载的 Agent 注册表,绝不恢复冷会话:`InboxItemId` 属于进程本地标识,无法在重启或资源释放后继续指向工作。由会话支撑的 subagent 会在访问 inbox 前返回 `agent-busy`,并保留其继续执行 owner;对于普通会话,Agent 缺失和单次入队项已被驱动器认领这两种情况都返回 `queue-item-not-found`。 -**Web 操作只面向 Queue。** Host 从 `session/queue` 中排除待处理 steering;steering 消费后仍沿用既有的持久 transcript(文本记录)路径。QueueDock 在队列为空时隐藏,只有一个待处理项时直接渲染该行,存在两个或更多待处理项时则默认收起为可展开或收起完整列表的 `" 条排队消息"` 表头。表头暴露 `aria-expanded` 和 `aria-controls`;展开后的列表以 180px 为高度上限,并可滚动。存在进行中的编辑或变更时,列表行会保持可见;队列清空后,下一次出现队列时会恢复默认收起状态。可见行暴露编辑、删除以及仅在运行期间可用的严格 steering 操作。UI 从运行时 `SessionFace` 契约派生队列行与变更类型,而不是导入连接插件,因此插件仍通过服务和快照协作。仅当所有内容块都是文本时才提供编辑功能;编辑器不能静默丢弃非文本块。编辑中的行只展示保存和取消操作,对应的键盘操作分别是 Enter 和 Escape。删除会移除对应的精确入队项;严格 steering 会保留每个内容块,并且只通过权威快照退役该行。Web 停止操作会保留待处理 Queue 工作;只有在被中断轮次达到完全停稳后,AgentLoop 才认领下一个可唤醒入队项,其出队事件会退役该行,无需浏览器重发。[Web Queue steering 操作](2026-07-30-web-queue-steer-action.md)负责定义严格转移契约。 +**Web 操作只面向 Queue。** 带 placement 的 `session/queue` 会同时携带 queued 和待处理 steering;QueueDock 只选择 queued 项,ChatView 则投影 steering,并在消费后沿用既有的持久 transcript(文本记录)路径。QueueDock 在队列为空时隐藏,只有一个待处理项时直接渲染该行,存在两个或更多待处理项时则默认收起为可展开或收起完整列表的 `" 条排队消息"` 表头。表头暴露 `aria-expanded` 和 `aria-controls`;展开后的列表以 180px 为高度上限,并可滚动。存在进行中的编辑或变更时,列表行会保持可见;队列清空后,下一次出现队列时会恢复默认收起状态。可见行暴露编辑、删除以及仅在运行期间可用的严格 steering 操作。UI 从运行时 `SessionFace` 契约派生队列行与变更类型,而不是导入连接插件,因此插件仍通过服务和快照协作。仅当所有内容块都是文本时才提供编辑功能;编辑器不能静默丢弃非文本块。编辑中的行只展示保存和取消操作,对应的键盘操作分别是 Enter 和 Escape。删除会移除对应的精确入队项;严格 steering 会保留每个内容块,并且只通过权威快照退役该行。Web 停止操作会保留待处理 Queue 工作;只有在被中断轮次达到完全停稳后,AgentLoop 才认领下一个可唤醒入队项,其出队事件会退役该行,无需浏览器重发。[Web Queue steering 操作](2026-07-30-web-queue-steer-action.md)负责定义严格转移与待处理投影契约。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.i18n.yaml index 1220cc39fa..555e2fb434 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.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-30-web-queue-steer-action.md -2026-07-30-web-queue-steer-action.md: b84ee5acf472ba8482f6dfb7f466a905ddfd8f4f -2026-07-30-web-queue-steer-action.zh.md: fd4b4f8f8ab18db8a5372ff1a419fa186e29f399 +2026-07-30-web-queue-steer-action.md: 78d8c4d50daa4d162ff17c1efc03c32face8019a +2026-07-30-web-queue-steer-action.zh.md: 6b8af563ce527b8122b9f5e783a6ef700dcdd401 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.md b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.md index b84ee5acf4..78d8c4d50d 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.md @@ -6,7 +6,7 @@ English | [中文](2026-07-30-web-queue-steer-action.zh.md) ## Problem -The Web composer originally queued every Enter submission while an agent ran. QueueDock already gives each pending message an addressable row, and the durable transcript already renders consumed `steering/message` events as user-style bubbles without message actions, but Web had neither an action connecting those two surfaces nor a direct composer gesture for choosing current-turn steering. +The Web composer originally queued every Enter submission while an agent ran. QueueDock already gives each pending message an addressable row, and the durable transcript already renders consumed `steering/message` events as user-style bubbles, but Web had neither an action connecting those two surfaces nor a direct composer gesture for choosing current-turn steering. Implementing the row action as a client-side delete followed by `session.prompt(mode: 'steer')` would split one user intent across two RPCs. Driver claim could win between them, the steer could fail after deletion, or the existing best-effort `agent.steer()` fallback could silently append a new Queue item after the original occurrence was removed. A send-now action must therefore distinguish current-turn steering from Queue promotion and preserve the original row when steering is no longer possible. @@ -16,7 +16,7 @@ Implementing the row action as a client-side delete followed by `session.prompt( Each non-editing QueueDock row exposes the upward-arrow action as “插话发送”. The action is enabled only while the session reports a running agent; mixed-content messages remain eligible because steering forwards the complete immutable `UserMessage` rather than the row's text projection. Edit and delete keep their existing behavior. -Activating the action requests strict current-turn steering for that exact `InboxItemId`. Success removes the Queue row through the authoritative Host snapshot. When AgentLoop drains it, the existing durable `steering/message` event renders the same user-style bubble without a separate durable presentation path. +Activating the action requests strict current-turn steering for that exact `InboxItemId`. Success removes the Queue row through the authoritative Host snapshot and immediately projects the same pending steering after the `Deep diving...` running-status row; that bubble offers Copy but no Fork because the message has no durable event sequence yet. Once AgentLoop drains it, the existing durable `steering/message` event takes over the same user-style bubble and restores its clock, Copy, and Fork without a separate durable presentation path. The running bit is only an interaction hint. AgentLoop's `acceptsNextStep` value is authoritative at the synchronous mutation boundary. If that window has closed, the operation leaves the Queue occurrence unchanged and returns a typed `steer-unavailable` error; if the driver already claimed the occurrence, it returns the existing `queue-item-not-found` error. The UI reports either race without optimistically removing the row. @@ -34,9 +34,9 @@ The action does not run `agent/prompt-submit`: choosing steering intentionally c `session.updateQueue` carries the `steer` action and maps the two negative outcomes to typed RPC errors. The conversion is one synchronous Agent operation; the Host never reconstructs it by combining remove and prompt calls. -The Host's existing `queuedMirror` remains the sole transient inbox authority. Its `session/queue` snapshot carries every live occurrence with `placement: 'queued' | 'steering'`: QueueDock renders only queued rows, while ChatView renders pending steering at the conversation tail without edit or delete actions. Reconnect replays the same snapshot, so this visibility does not require client optimism or a second registry. +The Host's existing `queuedMirror` remains the sole transient inbox authority. Its `session/queue` snapshot carries every live occurrence with `placement: 'queued' | 'steering'`: QueueDock renders only queued rows, while ChatView renders pending steering at the conversation tail after the `Deep diving...` running-status row, with Copy but without Fork, edit, or delete actions. Reconnect replays the same snapshot, so this visibility does not require client optimism or a second registry. -When AgentLoop claims pending steering, it emits `agent/inbox/dequeue` immediately before synchronously appending `steering/message`. The Host retires that steering row on the following microtask, allowing the durable session event to enter the linear mux stream first. ChatView matches the shared `MessageId` and suppresses the transient projection as soon as the durable node exists, so one bubble changes authority without a visible gap or duplicate; an append failure still retires the claimed row. +When AgentLoop claims pending steering, it emits `agent/inbox/dequeue` immediately before synchronously appending `steering/message`. The Host retires that steering row on the following microtask, allowing the durable session event to enter the linear mux stream first. ChatView matches the shared `MessageId` and suppresses the transient projection as soon as the durable node exists, so one bubble changes authority without a visible gap or duplicate; the durable projection restores the clock, Copy, and Fork against its logged event time and sequence, while an append failure still retires the claimed row. The existing `session.prompt(mode: 'steer')` contract remains best-effort for new input: outside the next-step window it becomes a waking follow-up. The composer carries an explicit `queue | steer` mode through slash adjudication and reference serialization before calling that contract. A browser-local submission policy owns the persisted busy-Enter preference and resolves plain versus accelerated Enter as complementary gestures; the Settings row and InputBar share that policy without duplicating storage or delivery-window authority. Only the Queue row action is strict, because failure can safely leave its already-pending message untouched. @@ -44,7 +44,7 @@ The existing `session.prompt(mode: 'steer')` contract remains best-effort for ne AgentLoop contract coverage holds prompt admission open, converts one exact queued occurrence, and proves the replacement steering occurrence keeps the message value, drains as `steering/message`, and never starts its former independent turn. It also pins unavailable-window retention, claimed-address rejection, and re-entrant cancellation lifecycle conservation. -Host schema and proxy tests cover the new action, both typed errors, placement-aware snapshots and reconnect replay, plus durable-before-retirement ordering. QueueDock tests cover running-state enablement, complete-content eligibility, failure retention, authoritative success retirement, and filtering of steering occurrences. ChatView tests cover the transient bubble and its single-copy handoff to the durable node. +Host schema and proxy tests cover the new action, both typed errors, placement-aware snapshots and reconnect replay, plus durable-before-retirement ordering. QueueDock tests cover running-state enablement, complete-content eligibility, failure retention, authoritative success retirement, and filtering of steering occurrences. ChatView tests and Web ARIA snapshots cover pending steering after the running-status row with Copy alone, plus its single-copy handoff to a durable node with clock, Copy, and Fork. The keyless Web steering scenario queues a message through the real composer while the first response streams, activates the row arrow, then uses `ask_user_question` as a stable pending-steering barrier. It proves the Host-backed pending bubble appears before admission, hands off to one durable interjection after the answer, and affects the next model request. Assembled composer scenarios prove default-mode Cmd+Enter reaches the same pending and durable path without creating a Queue row, while Steer-mode Cmd+Enter creates a Queue row instead. Settings and submission-policy coverage pin the default, persistence, busy-only scope, and complementary gesture mapping; Queue edit/delete scenarios continue to prove those actions are unchanged. diff --git a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.zh.md index fd4b4f8f8a..6b8af563ce 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -Web composer 原本会在 agent 运行期间把所有 Enter 提交作为 Queue 入队。QueueDock 已经为每条待处理消息提供可寻址的行,持久 transcript(文本记录)也已能把消费后的 `steering/message` 事件渲染为不带消息操作的用户样式气泡,但 Web 既没有连接这两个界面的操作,也没有让用户从 composer 直接选择当前轮次 steering 的手势。 +Web composer 原本会在 agent 运行期间把所有 Enter 提交作为 Queue 入队。QueueDock 已经为每条待处理消息提供可寻址的行,持久 transcript(文本记录)也已能把消费后的 `steering/message` 事件渲染为用户样式气泡,但 Web 既没有连接这两个界面的操作,也没有让用户从 composer 直接选择当前轮次 steering 的手势。 如果 Web 先在客户端删除该行,再调用 `session.prompt(mode: 'steer')`,就会把用户的一次意图拆分到两个 RPC 中。驱动器可能在两次调用之间先认领该项,steering 投递也可能在删除后失败;现有尽力而为的 `agent.steer()` 回退还可能在原单次入队项被移除后,静默追加一个新的 Queue 项。因此,立即发送操作必须区分当前轮次 steering 与 Queue 前移,并在 steering 已不可用时保留原行。 @@ -16,7 +16,7 @@ Web composer 原本会在 agent 运行期间把所有 Enter 提交作为 Queue 每个非编辑态的 QueueDock 行都会提供名为“插话发送”的向上箭头操作。仅当会话报告 agent 正在运行时,该操作才会启用;包含混合内容的消息仍可使用,因为 steering 会转发完整且不可变的 `UserMessage`,而非该行的文本投影。编辑和删除保持现有行为。 -触发该操作会针对对应的 `InboxItemId` 请求严格的当前轮次 steering。操作成功后,权威 Host 快照会移除 Queue 行。AgentLoop 排空该项时,现有持久 `steering/message` 事件会渲染相同的用户样式气泡,无需另建持久展示路径。 +触发该操作会针对对应的 `InboxItemId` 请求严格的当前轮次 steering。操作成功后,权威 Host 快照会移除 Queue 行,并在 `Deep diving...` 运行状态行之后立即投影同一条待处理 steering;该气泡提供复制,但消息尚无持久事件序号,因此不提供 fork。AgentLoop 排空该项后,现有持久 `steering/message` 事件会接管同一个用户样式气泡,并恢复时钟、复制和 fork,无需另建持久展示路径。 running 标志位只用于提示交互状态。在同步变更边界上,AgentLoop 的 `acceptsNextStep` 值才是权威依据。如果该窗口已经关闭,操作会保持 Queue 单次入队项不变,并返回类型化的 `steer-unavailable` 错误;如果驱动器已经认领该项,则返回现有的 `queue-item-not-found` 错误。UI 会报告任一竞态,不会乐观地移除该行。 @@ -34,9 +34,9 @@ Composer 对新输入采用另一套尽力而为契约。所寻址会话空闲 `session.updateQueue` 会携带 `steer` 操作,并把两种负面结果映射为类型化 RPC 错误。这项转换是一次同步 Agent 操作;Host 绝不会通过组合移除和提示词调用来重建它。 -Host 仍以现有 `queuedMirror` 作为唯一的瞬态 inbox 权威。`session/queue` 快照会携带所有存活单次入队项及其 `placement: 'queued' | 'steering'`:QueueDock 只渲染 queued 行,ChatView 则在会话流末尾渲染待处理 steering,且不提供编辑或删除操作。重连会重放同一份快照,因此这项可见性既不依赖客户端乐观展示,也不需要第二个 registry。 +Host 仍以现有 `queuedMirror` 作为唯一的瞬态 inbox 权威。`session/queue` 快照会携带所有存活单次入队项及其 `placement: 'queued' | 'steering'`:QueueDock 只渲染 queued 行,ChatView 则在会话流末尾、`Deep diving...` 运行状态行之后渲染待处理 steering,提供复制操作,但不提供 fork、编辑或删除操作。重连会重放同一份快照,因此这项可见性既不依赖客户端乐观展示,也不需要第二个 registry。 -AgentLoop 认领待处理 steering 时,会在同步追加 `steering/message` 之前立即发出 `agent/inbox/dequeue`。Host 会等到下一个微任务才退役该 steering 行,让持久 session 事件先进入线性 mux 流。ChatView 会匹配两边共享的 `MessageId`,并在持久节点出现时立即抑制瞬态投影,因此同一个气泡切换权威时不会产生可见空档或重复;如果追加失败,已认领行仍会退役。 +AgentLoop 认领待处理 steering 时,会在同步追加 `steering/message` 之前立即发出 `agent/inbox/dequeue`。Host 会等到下一个微任务才退役该 steering 行,让持久 session 事件先进入线性 mux 流。ChatView 会匹配两边共享的 `MessageId`,并在持久节点出现时立即抑制瞬态投影,因此同一个气泡切换权威时不会产生可见空档或重复;持久投影会根据已记录的事件时间与序号恢复时钟、复制与 fork 操作,而追加失败时已认领行仍会退役。 现有 `session.prompt(mode: 'steer')` 对新输入仍采用尽力而为的契约:在 next-step 窗口之外,它会变为唤醒 agent 的后续轮次。Composer 会让显式 `queue | steer` 模式经过 slash 裁决与引用序列化,再调用该契约。浏览器本地的提交策略拥有持久化的繁忙态 Enter 偏好,并把普通 Enter 与加速 Enter 解析为互补手势;Settings 行和 InputBar 共享该策略,不重复实现存储或投递窗口权威。只有 Queue 行操作采用严格语义,因为失败时可以安全地保留其已经待处理的消息。 @@ -44,7 +44,7 @@ AgentLoop 认领待处理 steering 时,会在同步追加 `steering/message` AgentLoop 契约覆盖保持提示词接纳窗口打开,转换一个精确的 queued 单次入队项,并证明替代它的 steering 单次入队项保留消息值、以 `steering/message` 的形式排空,且绝不启动原本的独立轮次。该覆盖还钉住窗口不可用时保留原项、拒绝已被认领的地址,以及可重入取消下的生命周期守恒。 -Host schema 和代理测试覆盖新操作、两种类型化错误、带 placement 的快照与重连重放,以及先持久化再退役的顺序。QueueDock 测试覆盖按运行状态启用、混合内容消息仍可完整投递、失败时保留原行、成功后由权威快照退役,以及过滤 steering 单次入队项。ChatView 测试覆盖瞬态气泡及其只保留一份的持久节点交接。 +Host schema 和代理测试覆盖新操作、两种类型化错误、带 placement 的快照与重连重放,以及先持久化再退役的顺序。QueueDock 测试覆盖按运行状态启用、混合内容消息仍可完整投递、失败时保留原行、成功后由权威快照退役,以及过滤 steering 单次入队项。ChatView 测试与 Web ARIA 快照覆盖待处理阶段位于运行状态行之后且仅有复制,并覆盖它向带时钟、复制和 fork 的持久节点进行单份交接。 无密钥 Web steering 场景在第一次响应流式输出期间,通过真实 composer 排队一条消息并触发行上的箭头,再用 `ask_user_question` 作为稳定的待处理 steering 屏障。该场景证明 Host 支撑的待处理气泡会在准入前出现,在回答后交接为唯一一条持久插话,并影响下一次模型请求。组装后的 composer 场景证明默认模式下的 Cmd+Enter 无需创建 Queue 行,也会进入同一条待处理与持久路径;Steer 模式下的 Cmd+Enter 则会创建 Queue 行。Settings 与提交策略覆盖会固定默认值、持久化、仅限繁忙态的作用域和互补手势映射;Queue 编辑/删除场景继续证明这些操作没有变化。 diff --git a/apps/web/tests/snapshots/steering/mid-steer.expected.md b/apps/web/tests/snapshots/steering/mid-steer.expected.md index 9f077b21a5..61f48ffbab 100644 --- a/apps/web/tests/snapshots/steering/mid-steer.expected.md +++ b/apps/web/tests/snapshots/steering/mid-steer.expected.md @@ -24,6 +24,8 @@ - text: Ask question waiting - status: Deep diving... - text: "Interjection: include the word BANANA in your final reply." +- button "Copy": + - img - region "Ready to continue?": - text: Checkpoint - heading "Ready to continue?" [level=2] diff --git a/apps/web/tests/snapshots/steering/settled.expected.md b/apps/web/tests/snapshots/steering/settled.expected.md index 72e28598e5..be28e218d3 100644 --- a/apps/web/tests/snapshots/steering/settled.expected.md +++ b/apps/web/tests/snapshots/steering/settled.expected.md @@ -21,7 +21,11 @@ - img - img - text: Ask question 1/1 answered -- text: "Interjection: include the word BANANA in your final reply." +- text: "Interjection: include the word BANANA in your final reply. {{clock}}" +- button "Copy": + - img +- button "Branch into a new conversation": + - img - button "Think The user selected \"Yes\" and wants me to include the word \"BANANA\" in my final reply. Let me acknowledge their answer.": - img - img diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index f3f3c43124..fcf4c0b84e 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: c80ca786fb289055fcb3ad2b5a7aab64127ca485 -README.zh.md: 601139a00d2d40fb34e0cdcb8554984f7db9a724 +README.md: 61056fc36e2e7f5f85726e158b9fd27e06c21941 +README.zh.md: 3a1a74a4106321415ee343908c999a2cf41615a0 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index c80ca786fb..61056fc36e 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -36,7 +36,7 @@ The todo surfaces are two registrations over that shape, both plain registrant p `QueueDock` is the terminal input-dock entry at `order: 20`. It hides while empty, renders one pending row directly, and defaults two or more rows to a collapsed `" 条排队消息"` header whose button expands or collapses the complete list. The header exposes `aria-expanded` and `aria-controls`; the expanded list scrolls within a 180px height bound. An active edit or mutation keeps its rows visible, and emptying the queue restores the collapsed default for the next queue. Each visible row remains a single-line preview with its exact-occurrence edit, delete, and strict-steer actions; an unavailable steering window leaves the Queue occurrence in place and reports the failure. -The Host's placement-aware `session/queue` snapshot also carries pending steering. QueueDock filters it out, while ChatView projects it as a user-style bubble at the conversation tail. The Host delays steering retirement until the durable `steering/message` has entered the mux stream, and ChatView deduplicates the two projections by their shared `MessageId`; the bubble therefore hands off without a gap or duplicate, while reconnect restores pending state from the same authority. +The Host's placement-aware `session/queue` snapshot also carries pending steering. QueueDock filters it out, while ChatView projects it as a user-style bubble with Copy at the conversation tail; Fork stays absent because the message has not entered a durable turn. The Host delays steering retirement until the durable `steering/message` has entered the mux stream, and ChatView deduplicates the two projections by their shared `MessageId`; the bubble therefore hands off without a gap or duplicate, restores Copy and Fork from the durable node, and survives reconnect from the same authority. Keyboard message submission resolves delivery from the addressed session's running state. While idle, Enter and Cmd/Ctrl+Enter both perform an ordinary Queue send. While running, the browser-persisted General Settings preference assigns plain Enter to `Queue` (the default) or `Steer`, and Cmd/Ctrl+Enter performs the other behavior; Shift+Enter remains a newline. The preference affects only the busy-state gesture pair, and the send button and non-keyboard submit actions remain Queue. Composer Steer uses the existing best-effort `session.prompt(mode: 'steer')` contract: if the current next-step window closes before acceptance, AgentLoop admits the message as the next waking Queue turn without surfacing a failure or losing the draft transaction. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 601139a00d..3a1a74a410 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -36,7 +36,7 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插 `QueueDock` 是 `order: 20` 的末端 input-dock 条目。队列为空时隐藏;只有一个待处理项时直接渲染该行;存在两个或更多待处理项时,默认收起为 `" 条排队消息"` 表头,其按钮可展开或收起完整列表。表头暴露 `aria-expanded` 和 `aria-controls`;展开后的列表以 180px 为高度上限,并可滚动。存在进行中的编辑或变更时,列表行会保持可见;队列清空后,下一次出现队列时会恢复默认收起状态。每条可见行仍是单行预览,并提供针对精确单次入队项的编辑、删除和严格 steering(中途引导)操作;steering 窗口不可用时,Queue 单次入队项会留在原处并显示失败。 -Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。QueueDock 会将其过滤掉,ChatView 则把它投影为会话流末尾的用户样式气泡。Host 会等持久 `steering/message` 进入 mux 流之后再退役 steering,ChatView 则按两份投影共享的 `MessageId` 去重;气泡交接时因而不会产生空档或重复,重连也能从同一权威恢复待处理状态。 +Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。QueueDock 会将其过滤掉,ChatView 则把它投影为会话流末尾带复制操作的用户样式气泡;消息尚未进入持久轮次,因此不显示 fork。Host 会等持久 `steering/message` 进入 mux 流之后再退役 steering,ChatView 则按两份投影共享的 `MessageId` 去重;气泡交接时因而不会产生空档或重复,会从持久节点恢复复制与 fork 操作,并能在重连后从同一权威恢复。 键盘消息提交会根据所寻址会话的运行状态解析投递方式。空闲时,Enter 和 Cmd/Ctrl+Enter 都执行普通 Queue 发送。运行期间,浏览器持久化的 General Settings 偏好会把普通 Enter 分配为 `Queue`(默认值)或 `Steer`,Cmd/Ctrl+Enter 则执行另一种行为;Shift+Enter 仍然换行。该偏好只影响繁忙态下这对手势,发送按钮与非键盘提交操作仍使用 Queue。Composer Steer 复用现有尽力而为的 `session.prompt(mode: 'steer')` 契约:如果当前 next-step 窗口在接纳前关闭,AgentLoop 会把消息接纳为下一条唤醒 Queue 轮次,不显示失败,也不会丢失草稿事务。 diff --git a/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx b/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx index cd6b0a36ce..91ba066544 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx @@ -1,5 +1,5 @@ -// Shared IconActions chrome for user and assistant messages: copy live, -// branch wired through onBranch, date-aware clock. +// Shared IconActions chrome for user, steering, and assistant messages: copy +// live, optional branch wiring, and an optional date-aware clock. import { useCallback } from 'react' import { @@ -13,12 +13,14 @@ import css from './MessageIconActions.module.css' export interface MessageIconActionsProps { /** Plain text the copy action writes. */ text: string - /** Unix epoch ms for the clock label. */ - time: number + /** Unix epoch ms for the clock label; omitted for transient messages. */ + time?: number | undefined /** Clock before icons (user) or after (assistant). */ clock: 'start' | 'end' /** Fork the session at this message. */ onBranch?: (() => void) | undefined + /** Whether to render the branch action; defaults to true. */ + showBranch?: boolean | undefined /** Parent layout class composed onto the actions row. */ className?: string | undefined /** The owning view's locale seat, passed down as a plain prop. */ @@ -31,13 +33,13 @@ export interface MessageIconActionsProps { * @returns The actions row element. */ export function MessageIconActions({ - text, time, clock, onBranch, className, t, + text, time, clock, onBranch, showBranch = true, className, t, }: MessageIconActionsProps) { const day = useCalendarDay() const onCopy = useCallback(() => { void writeClipboard(text) }, [text]) - const clockEl = ( + const clockEl = time === undefined ? null : ( {formatMessageClock(time, t, day)} @@ -50,11 +52,13 @@ export function MessageIconActions({ - - - + {showBranch && ( + + + + )} {clock === 'end' ? clockEl : null}
) diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index 5783d0211d..6e542bf468 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -1,6 +1,6 @@ -// MessageItem: simple chat nodes — user bubble (right-aligned, with -// clock + copy / branch IconActions), steering (same bubble, no actions), -// context injection, compaction marker, retry disclosure, and +// MessageItem: simple chat nodes — user and consumed-steering bubbles +// (right-aligned, with clock + copy / branch IconActions), pending steering +// (copy only), context injection, compaction marker, retry disclosure, and // unknown-surface JSON rows. import { memo, useEffect, useMemo, useState } from 'react' @@ -167,7 +167,7 @@ function projectUserText(text: string): ReactNode { return <>{parts} } -/** Right-aligned bubble shared by user and steering rows (steering has no actions). */ +/** Right-aligned bubble shared by user and steering rows. */ function UserStyleBubble({ content, actions, pending = false, t, }: { @@ -201,7 +201,22 @@ export function PendingSteeringBubble({ content, t }: { content: readonly unknown[] t: ChatViewSlotProps['t'] }): ReactNode { - return + return ( + ( + + )} + /> + ) } export const MessageItem = memo(function MessageItem({ @@ -210,6 +225,7 @@ export const MessageItem = memo(function MessageItem({ const truncated = (total: number): string => t('json.truncated', { total }) switch (node.kind) { case 'user': + case 'steering': return ( ) - case 'steering': - return case 'context': return ( diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx index a829f6f04a..4074996063 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -102,18 +102,28 @@ describe('MessageItem arms', () => { fireEvent.click(screen.getByRole('button', { name: '复制' })) }) - it('steering bubbles render text and non-text rest blocks, without user actions or a badge', () => { + it('consumed steering renders copy and branch actions without a badge', () => { + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }) + const fork = vi.fn() const view = render( , ) expect(view.queryByText('插话')).toBeNull() expect(view.getByText('steer!')).toBeTruthy() expect(view.getByText(/附加内容块/)).toBeTruthy() - expect(view.queryByRole('button', { name: '复制' })).toBeNull() + fireEvent.click(view.getByRole('button', { name: '复制' })) + expect(writeText).toHaveBeenCalledWith('steer!') + fireEvent.click(view.getByRole('button', { name: '在新对话中分支' })) + expect(fork).toHaveBeenCalledWith(2) }) it('context uses the Tool calls disclosure chrome and keeps its JSON collapsed by default', () => { diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 507b301922..b0e55736a4 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -252,6 +252,11 @@ describe('ChatView', () => { }) it('renders Host-pending steering at the flow tail and hands off to the durable node', () => { + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }) const pending = { id: 'steer-occurrence' as never, messageId: 'steer-message' as never, @@ -273,6 +278,11 @@ describe('ChatView', () => { expect(view.getByText('interrupt now').closest('[data-pending-steering]')).not.toBeNull() expect(view.queryByText('later')).toBeNull() + const pendingBubble = view.getByText('interrupt now').closest('[data-pending-steering]') + expect(pendingBubble).not.toBeNull() + fireEvent.click(within(pendingBubble as HTMLElement).getByRole('button', { name: '复制' })) + expect(writeText).toHaveBeenCalledWith('interrupt now') + expect(within(pendingBubble as HTMLElement).queryByRole('button', { name: '在新对话中分支' })).toBeNull() expect(view.getByRole('status').compareDocumentPosition(view.getByText('interrupt now')) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0) @@ -291,6 +301,11 @@ describe('ChatView', () => { }) expect(view.getAllByText('interrupt now')).toHaveLength(1) expect(view.container.querySelector('[data-pending-steering]')).toBeNull() + expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(2) + const branchButtons = view.getAllByRole('button', { name: '在新对话中分支' }) + expect(branchButtons).toHaveLength(2) + fireEvent.click(branchButtons[1]!) + expect(h.forkAt).toHaveBeenCalledWith(2) act(() => { h.set({ queue: [queued] }) }) expect(view.getAllByText('interrupt now')).toHaveLength(1) From 53531b9af2a4cb84ab814aa7551fa1539c72f415 Mon Sep 17 00:00:00 2001 From: imccyu Date: Sun, 2 Aug 2026 16:24:23 +0800 Subject: [PATCH 09/19] test(snapshot): refresh Cordis steering API fixture --- .../tests/snapshots/cordis-inspect-jsdoc/session.jsonl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index 65d3fbc960..2bbe6dd86b 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -13,7 +13,7 @@ {"type":"assistant/chunk","seq":11,"time":1785487622703,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":12,"time":1785487622703,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"7c207b09-7f6e-4e53-a5d2-77e0d2bbb474"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[7,8,9,10,11],"surfaceOp":"append"} {"type":"tool/call","seq":13,"time":1785487622703,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} -{"type":"tool/result","seq":14,"time":1785487622726,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n reserveTurnAdmission(): (() => void) | undefined;\n updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n followup(message: UserMessage): void;\n steer(message: UserMessage): SteeringReceipt;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n adapterDefaults?: LlmCallConfigAdapterDefaults;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export type InboxAction = {\n readonly kind: 'edit';\n readonly content: ContentBlock[];\n } | {\n readonly kind: 'remove';\n };\n export type InboxActionResult = 'applied' | 'not-found';\n export type InboxItemId = Branded<'InboxItemId'>;\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmCallConfigAdapterDefaults {\n reasoningEffort?: true;\n maxTokens?: true;\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReadFileLine {\n number: number;\n text: string;\n }\n export interface ReadResultView {\n card: 'read';\n title?: string;\n path: string;\n offset: number;\n lines: ReadFileLine[];\n totalLines: number;\n lang?: string;\n content?: ContentBlock[];\n }\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export interface RequestContext {\n provider: string;\n model: string;\n contextWindow?: number;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SearchFileMatches {\n path: string;\n matches: SearchLineMatch[];\n }\n export interface SearchLineMatch {\n lineNumber: number;\n line: string;\n }\n export interface SearchMatchesResultView {\n card: 'search';\n shape: 'matches';\n title?: string;\n files: SearchFileMatches[];\n truncated: boolean;\n total: number;\n }\n export interface SearchPathsResultView {\n card: 'search';\n shape: 'paths';\n title?: string;\n paths: string[];\n truncated: boolean;\n total: number;\n }\n export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\n export interface SendOptions {\n target: SendTarget;\n wakeup: boolean;\n }\n export type SendTarget = 'next-turn' | 'next-step';\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': {\n turn: number;\n message: UserMessage;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n 'request/context': RequestContext;\n 'session/end-seed': Record;\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: 'subagent';\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type SteeringOutcome = {\n readonly status: 'admitted';\n readonly turn: number;\n readonly step: number;\n } | {\n readonly status: 'rejected';\n };\n export interface SteeringReceipt {\n readonly outcome: Promise;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n retry: {\n kind: 'retry';\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }\n export interface WebFetchResultView {\n card: 'web';\n kind: 'fetch';\n title?: string;\n url: string;\n statusCode: number;\n truncated: boolean;\n }\n export type WebResultView = WebSearchResultView | WebFetchResultView;\n export interface WebSearchResultView {\n card: 'web';\n kind: 'search';\n title?: string;\n sources: WebSource[];\n answer?: string;\n truncated: boolean;\n }\n export interface WebSource {\n url: string;\n title?: string;\n snippet?: string;\n publishedAt?: string;\n }"}],"isError":false}],"role":"user","id":"a4ec9786-5e3f-45b2-a6de-efdf953287f9"}},"sourceEventSeqs":[13],"surfaceOp":"append"} +{"type":"tool/result","seq":14,"time":1785487622726,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n reserveTurnAdmission(): (() => void) | undefined;\n updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n followup(message: UserMessage): void;\n steer(message: UserMessage): SteeringReceipt;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n adapterDefaults?: LlmCallConfigAdapterDefaults;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export type InboxAction = {\n readonly kind: 'edit';\n readonly content: ContentBlock[];\n } | {\n readonly kind: 'remove';\n } | {\n readonly kind: 'steer';\n };\n export type InboxActionResult = 'applied' | 'not-found' | 'steer-unavailable';\n export type InboxItemId = Branded<'InboxItemId'>;\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmCallConfigAdapterDefaults {\n reasoningEffort?: true;\n maxTokens?: true;\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReadFileLine {\n number: number;\n text: string;\n }\n export interface ReadResultView {\n card: 'read';\n title?: string;\n path: string;\n offset: number;\n lines: ReadFileLine[];\n totalLines: number;\n lang?: string;\n content?: ContentBlock[];\n }\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export interface RequestContext {\n provider: string;\n model: string;\n contextWindow?: number;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SearchFileMatches {\n path: string;\n matches: SearchLineMatch[];\n }\n export interface SearchLineMatch {\n lineNumber: number;\n line: string;\n }\n export interface SearchMatchesResultView {\n card: 'search';\n shape: 'matches';\n title?: string;\n files: SearchFileMatches[];\n truncated: boolean;\n total: number;\n }\n export interface SearchPathsResultView {\n card: 'search';\n shape: 'paths';\n title?: string;\n paths: string[];\n truncated: boolean;\n total: number;\n }\n export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\n export interface SendOptions {\n target: SendTarget;\n wakeup: boolean;\n }\n export type SendTarget = 'next-turn' | 'next-step';\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': {\n turn: number;\n message: UserMessage;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n 'request/context': RequestContext;\n 'session/end-seed': Record;\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: 'subagent';\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type SteeringOutcome = {\n readonly status: 'admitted';\n readonly turn: number;\n readonly step: number;\n } | {\n readonly status: 'rejected';\n };\n export interface SteeringReceipt {\n readonly outcome: Promise;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n retry: {\n kind: 'retry';\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }\n export interface WebFetchResultView {\n card: 'web';\n kind: 'fetch';\n title?: string;\n url: string;\n statusCode: number;\n truncated: boolean;\n }\n export type WebResultView = WebSearchResultView | WebFetchResultView;\n export interface WebSearchResultView {\n card: 'web';\n kind: 'search';\n title?: string;\n sources: WebSource[];\n answer?: string;\n truncated: boolean;\n }\n export interface WebSource {\n url: string;\n title?: string;\n snippet?: string;\n publishedAt?: string;\n }"}],"isError":false}],"role":"user","id":"a4ec9786-5e3f-45b2-a6de-efdf953287f9"}},"sourceEventSeqs":[13],"surfaceOp":"append"} {"type":"step/end","seq":15,"time":1785487622726,"data":{"turn":1,"step":1}} {"type":"step/start","seq":16,"time":1785487622735,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":17,"time":1784449176734,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} From a31b3eb7b803fba22f15c4084e48fafdd04dca27 Mon Sep 17 00:00:00 2001 From: imccyu Date: Sun, 2 Aug 2026 16:42:11 +0800 Subject: [PATCH 10/19] test(host): cover queue mirror event expiry --- .../apiproxy/tests/api-proxy-commands.spec.ts | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts index 473d452327..4cf975dc99 100644 --- a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts @@ -387,6 +387,33 @@ describe('session/queue frames', () => { expect(replayFrames.filter(frame => frame.type === 'session/queue')).toEqual(liveFrames) }) + it('expires unmatched mutations after the synchronous re-entry window', async () => { + const ctx = await harness() + const agent = stubAgent(ctx) + const api = createApiProxy(ctx, DEFAULTS) + const original = inboxItem('i-stale-edit', inboxMessage('m-stale-edit', 'original'), 'queued') + const staleEdit = inboxItem('i-stale-edit', inboxMessage('m-stale-edit', 'stale edit'), 'queued') + const staleTerminal = inboxItem('i-stale-terminal', inboxMessage('m-stale-terminal', 'keep me'), 'queued') + + ctx.emit('agent/inbox/update', agent, staleEdit) + ctx.emit('agent/inbox/discard', agent, [staleTerminal]) + await Promise.resolve() + ctx.emit('agent/inbox/enqueue', agent, original) + ctx.emit('agent/inbox/enqueue', agent, staleTerminal) + + const replay = new AbortController() + const frames = await collect( + api.events.mux({ rpcId: RpcId('t-mux-expired-unseen'), payload: {} }, replay.signal), 2, replay) + expect(frames.filter(frame => frame.type === 'session/queue')).toEqual([{ + type: 'session/queue', + sessionId: agent.id, + items: [ + { id: original.id, placement: original.placement, message: original.message }, + { id: staleTerminal.id, placement: staleTerminal.placement, message: staleTerminal.message }, + ], + }]) + }) + it('publishes complete live snapshots and replays the latest snapshot on reconnect', async () => { const ctx = await harness() const api = createApiProxy(ctx, DEFAULTS) From d59ab5596eb9278c5ce7f123b0a8f024d89f4757 Mon Sep 17 00:00:00 2001 From: imccyu Date: Sun, 2 Aug 2026 16:42:20 +0800 Subject: [PATCH 11/19] docs(host): align queue mirror contract --- packages/host/apiproxy/README.i18n.yaml | 4 ++-- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- packages/host/apiproxy/src/api-proxy.ts | 9 +++++---- 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 5f3bec73b0..4f0e10cb87 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md -README.md: 87c477362dc159621a38a1ff923bebf07cbd3c6f -README.zh.md: 7316f3a6d93d30e101537c40608037fedfa77321 +README.md: d4af267ef46f327bdabf31429b291ffd04308203 +README.zh.md: f148cfa0c023b54d62016c5211205d391b634544 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 87c477362d..d4af267ef4 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -22,7 +22,7 @@ Session model routing is a session-domain contract. `session.models` returns the Generic Agent-bound session, command, and goal operations serve ordinary sessions only. They return `agent-busy` for a session-backed subagent instead of resuming or driving it; explicit-id `session.create` adoption and the attached-only queue controls enforce the same ownership boundary. Subagent conversation reads and continuation use the dedicated `subagent.*` domain, which retains catalog-mode and direct-parent authorization. -Pending queued input is a live control-plane contract, not session history. The gateway mirrors queued `InboxItem` occurrences from `agent/inbox/*` and broadcasts authoritative `session/queue` snapshots on every queued change and reconnect; pending steering stays outside this Web projection. `session.updateQueue` addresses one `InboxItemId`: edit replaces pending content, remove discards it, and strict steer transfers its complete message into the current next-step window. A closed window returns `steer-unavailable` without changing the row. `session.cancel` aborts only the active turn and preserves pending inbox work; after cancellation reaches quiescence and the closing turn flushes, AgentLoop claims the next waking occurrence in FIFO order. The browser never resends or promotes that occurrence. A driver claim wins races by retiring the address before admission; a later operation returns `queue-item-not-found`. Queue operations query only an attached ordinary-session Agent and never resume a cold session because process-local inbox identities do not survive restart or disposal. The client never infers retirement from turn or status events. +Pending inbox input is a live control-plane contract, not session history. The gateway mirrors `InboxItem` occurrences from `agent/inbox/*` with their `queued` or `steering` placement and broadcasts authoritative `session/queue` snapshots on every change and reconnect. A steering occurrence remains in this projection until the corresponding durable `steering/message` has been published, preserving the Host's linear event order during the handoff. `session.updateQueue` addresses one `InboxItemId`: edit replaces pending content, remove discards it, and strict steer transfers its complete message into the current next-step window. A closed window returns `steer-unavailable` without changing the row. `session.cancel` aborts only the active turn and preserves pending inbox work; after cancellation reaches quiescence and the closing turn flushes, AgentLoop claims the next waking occurrence in FIFO order. The browser never resends or promotes that occurrence. A driver claim wins races by retiring the address before admission; a later operation returns `queue-item-not-found`. Queue operations query only an attached ordinary-session Agent and never resume a cold session because process-local inbox identities do not survive restart or disposal. The client never infers retirement from turn or status events. Workspace and Session lists are separate reconnect baselines. `workspace.create({ name })` creates a uniquely titled directory under the configured root, while `workspace.create({ path })` adopts an existing canonical directory and permits basename-derived titles to repeat. `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. `workspace.archiveSession` adds one session to the registry-global archive set and answers the full updated set; `workspace.list` carries that set as the reconnect baseline and `host/archived-sessions-changed` pushes the full snapshot after every durable change. Archiving hides the session from grouping surfaces without touching its log or its workspace account; a session neither live nor persisted fails with `session-not-found`. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`. The `session.list` summaries and `host/session-added` frames also carry the optional durable `origin: 'subagent'` classification so navigation can suppress duplicate child rows immediately and after reconnect; that bit is never continuation authority. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 7316f3a6d9..f148cfa0c0 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -22,7 +22,7 @@ 绑定到 Agent 的通用会话、命令与目标操作只服务普通会话。对于由会话支撑的 subagent,它们会返回 `agent-busy`,而不是恢复或驱动它;显式 id 的 `session.create` 接纳与仅针对已附加会话的队列控件也会执行同一所有权边界。subagent 对话读取与继续执行使用专用的 `subagent.*` 领域,该领域保留目录 mode 与直接 parent 授权。 -待处理的 queued 输入属于实时控制平面契约,而非会话历史。网关镜像来自 `agent/inbox/*` 的 queued `InboxItem` 入队项,并在每次 queued 变更和重连时广播权威的 `session/queue` 快照;待处理 steering(中途引导)不进入此 Web 投影。`session.updateQueue` 通过 `InboxItemId` 寻址单个项:编辑会替换待处理内容,移除会将其丢弃,严格 steering 会把其完整消息转移到当前 next-step 窗口。窗口关闭时返回 `steer-unavailable`,且不改变该行。`session.cancel` 仅中止活动轮次,并保留待处理 inbox 工作;取消达到完全停稳且结束中的轮次完成 flush 后,AgentLoop 按 FIFO 顺序认领下一个可唤醒入队项。浏览器绝不重发或提升该入队项。驱动器在接纳前退役寻址标识,因此认领会赢得竞态;之后的操作返回 `queue-item-not-found`。队列操作只查询当前已挂载的普通会话 Agent,绝不恢复冷会话,因为进程本地 inbox 标识无法在重启或资源释放后存活。客户端绝不根据轮次或状态事件推断项已退役。 +待处理的 inbox 输入属于实时控制平面契约,而非会话历史。网关镜像来自 `agent/inbox/*` 的 `InboxItem` 入队项及其 `queued` 或 `steering` placement,并在每次变更和重连时广播权威的 `session/queue` 快照。steering 入队项会一直保留在该投影中,直到对应的持久 `steering/message` 已发布,从而在交接期间保持 Host 的线性事件顺序。`session.updateQueue` 通过 `InboxItemId` 寻址单个项:编辑会替换待处理内容,移除会将其丢弃,严格 steering 会把其完整消息转移到当前 next-step 窗口。窗口关闭时返回 `steer-unavailable`,且不改变该行。`session.cancel` 仅中止活动轮次,并保留待处理 inbox 工作;取消达到完全停稳且结束中的轮次完成 flush 后,AgentLoop 按 FIFO 顺序认领下一个可唤醒入队项。浏览器绝不重发或提升该入队项。驱动器在接纳前退役寻址标识,因此认领会赢得竞态;之后的操作返回 `queue-item-not-found`。队列操作只查询当前已挂载的普通会话 Agent,绝不恢复冷会话,因为进程本地 inbox 标识无法在重启或资源释放后存活。客户端绝不根据轮次或状态事件推断项已退役。 Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create({ name })` 会在配置根目录下创建显示标题唯一的目录,而 `workspace.create({ path })` 会接纳已有的规范目录,并允许由 basename 派生的标题重复。`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。`workspace.archiveSession` 向注册表级全局归档集合添加一个会话,并应答完整的更新后集合;`workspace.list` 携带该集合作为重连基线,`host/archived-sessions-changed` 在每次持久变更后推送完整快照。归档只把会话从各分组视图中隐藏,不触碰其日志和 workspace 记账;既非实时也未持久化的会话以 `session-not-found` 失败。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。`session.list` 摘要与 `host/session-added` 帧还会携带可选的持久化分类 `origin: 'subagent'`,使导航在实时创建与重连后都能隐藏重复的 child 行;该标记绝不是继续执行的权威依据。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index e9808518fe..124f3a5fd0 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -776,10 +776,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }) /** - * Per-session queued-occurrence mirror serving the mux-open queue snapshot - * (the same refresh-recovery baseline as pending questions). Each terminal - * queue event retires one matching occurrence, so repeated sends of the same - * identified message remain visible until every occurrence is claimed. + * Per-session pending-occurrence mirror serving live and mux-open + * `session/queue` snapshots. It carries both queued and steering placements. + * Each terminal inbox event retires one matching occurrence, so repeated + * sends of the same identified message remain visible until every occurrence + * is claimed. */ const queuedMirror = new Map() type UnseenQueueEvent = From cf918fa05dc755f4166bba03de8a46866a846d4b Mon Sep 17 00:00:00 2001 From: imccyu Date: Sun, 2 Aug 2026 16:43:16 +0800 Subject: [PATCH 12/19] test(web): explain steering replay pace --- apps/web/tests/steering.e2e.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/tests/steering.e2e.ts b/apps/web/tests/steering.e2e.ts index 794a92b74b..1f9672d74a 100644 --- a/apps/web/tests/steering.e2e.ts +++ b/apps/web/tests/steering.e2e.ts @@ -48,7 +48,8 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => { const sessionEvents: SessionEvent[] = [] beforeAll(async () => { - // The slower replay keeps the Queue action available until the recorded question barrier arrives. + // The 100 ms replay pace is test-only headroom that keeps the Queue action + // available until the recorded question barrier arrives, not a product timing contract. scaffold = await launchWebScaffold(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 100 }) scaffold.ctx.on('session/event', (_session, event) => { sessionEvents.push(event) }) browser = await chromium.launch() From 7d08e43720f9a2f0e4123271ecbffe6d72721d96 Mon Sep 17 00:00:00 2001 From: imccyu Date: Sun, 2 Aug 2026 17:50:00 +0800 Subject: [PATCH 13/19] fix(web): address steering review feedback --- ...026-07-30-web-queue-steer-action.i18n.yaml | 4 +- .../2026-07-30-web-queue-steer-action.md | 20 +++++----- .../2026-07-30-web-queue-steer-action.zh.md | 20 +++++----- apps/web/tests/steering.e2e.ts | 17 +++++--- packages/client/runtime/README.i18n.yaml | 4 +- packages/client/runtime/README.md | 2 +- packages/client/runtime/README.zh.md | 2 +- .../runtime/src/client/sessions/session.ts | 11 +++++ .../client/runtime/tests/queue-store.spec.ts | 40 ++++++++++++++++++- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 6 +-- packages/client/ui-conversation/README.zh.md | 6 +-- .../ui-conversation/src/client/apply.ts | 6 ++- .../src/client/chat/ChatView.tsx | 8 ++-- .../src/client/contract/slots.ts | 6 ++- .../src/client/input/submission-policy.ts | 11 +++-- .../ui-conversation/src/client/locales.ts | 4 +- .../src/client/queue/QueueDock.tsx | 11 ++--- .../ui-conversation/src/client/service.ts | 6 ++- .../src/client/skeleton/InputBar.tsx | 6 ++- .../ui-conversation/tests/chat-view.spec.tsx | 27 +++++++++++-- .../ui-conversation/tests/input-bar.spec.tsx | 22 +++++++++- .../ui-conversation/tests/queue-dock.spec.tsx | 29 ++++++++++++-- .../tests/service-orchestration.spec.ts | 23 +++++++++++ .../tests/submission-policy.spec.ts | 18 +++++---- packages/core/agent-loop/src/agent.ts | 7 +++- .../tests/contract-regressions.spec.ts | 6 ++- 27 files changed, 248 insertions(+), 78 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.i18n.yaml index 555e2fb434..70b35c2ff0 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.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-30-web-queue-steer-action.md -2026-07-30-web-queue-steer-action.md: 78d8c4d50daa4d162ff17c1efc03c32face8019a -2026-07-30-web-queue-steer-action.zh.md: 6b8af563ce527b8122b9f5e783a6ef700dcdd401 +2026-07-30-web-queue-steer-action.md: b5bc3547e0f72ba8211f4338fb4c1f5223a3d0cc +2026-07-30-web-queue-steer-action.zh.md: 6edde8e2cc2bee0a52a0b05df8f62f77b3d9ccfa diff --git a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.md b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.md index 78d8c4d50d..b5bc3547e0 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.md @@ -14,19 +14,19 @@ Implementing the row action as a client-side delete followed by `session.prompt( ### Product contract -Each non-editing QueueDock row exposes the upward-arrow action as “插话发送”. The action is enabled only while the session reports a running agent; mixed-content messages remain eligible because steering forwards the complete immutable `UserMessage` rather than the row's text projection. Edit and delete keep their existing behavior. +Each non-editing ordinary-session QueueDock row exposes the upward-arrow action as “插话发送”. The action is enabled only while the session reports a running agent; mixed-content messages remain eligible because steering forwards the complete immutable `UserMessage` rather than the row's text projection. An addressed subagent keeps its Queue projection read-only because its continuation transport does not expose queue mutation. Activating the action requests strict current-turn steering for that exact `InboxItemId`. Success removes the Queue row through the authoritative Host snapshot and immediately projects the same pending steering after the `Deep diving...` running-status row; that bubble offers Copy but no Fork because the message has no durable event sequence yet. Once AgentLoop drains it, the existing durable `steering/message` event takes over the same user-style bubble and restores its clock, Copy, and Fork without a separate durable presentation path. -The running bit is only an interaction hint. AgentLoop's `acceptsNextStep` value is authoritative at the synchronous mutation boundary. If that window has closed, the operation leaves the Queue occurrence unchanged and returns a typed `steer-unavailable` error; if the driver already claimed the occurrence, it returns the existing `queue-item-not-found` error. The UI reports either race without optimistically removing the row. +The running bit is only an interaction hint. AgentLoop's `acceptsNextStep` value is authoritative at the synchronous mutation boundary. If that window has closed, the operation leaves the Queue occurrence unchanged and returns a typed `steer-unavailable` error, after which the original waking occurrence proceeds through Queue. If the driver already claimed the occurrence, it returns the existing `queue-item-not-found` error and independent-turn delivery is already underway. The UI treats both races as converged Queue delivery without a failure notice; transport and unknown errors still surface. -The composer uses a separate best-effort contract for newly typed input. While the addressed session is idle, Enter and Cmd/Ctrl+Enter both perform an ordinary Queue send. While it is running, a General Settings preference assigns plain Enter to Queue (the default) or Steer, and Cmd/Ctrl+Enter performs the other behavior; Shift+Enter inserts a newline. The browser persists that preference, and it affects only the busy-state gesture pair. If a direct composer Steer misses the current next-step window, AgentLoop automatically admits it as the next waking Queue turn and the Web does not report a failure. +The composer uses a separate best-effort contract for newly typed input. While the addressed session is idle, Enter and Cmd/Ctrl+Enter both perform an ordinary Queue send. While a primary session is running, a General Settings preference assigns plain Enter to Queue (the default) or Steer, and Cmd/Ctrl+Enter performs the other behavior; Shift+Enter inserts a newline. An addressed subagent keeps both gestures on its Queue-only continuation transport. The browser persists the preference, and it affects only the steer-capable busy-state gesture pair. If a direct composer Steer misses the current next-step window, AgentLoop automatically admits it as the next waking Queue turn and the Web does not report a failure. ### Agent and lifecycle boundary `InboxAction` gains a consumer-backed `{ kind: 'steer' }` operation alongside edit and remove. `Agent.updateInbox()` handles it only after locating the queued occurrence and proving `acceptsNextStep`; it never delegates to the best-effort `agent.steer()` alias. -An applied action ends the queued occurrence and accepts the same immutable `UserMessage` as a new steering occurrence. The steering occurrence receives a new `InboxItemId` and truthful `placement: 'steering'`, while the message retains its `MessageId`, content, and source. AgentLoop installs the new outbox entry before publishing lifecycle events, then emits its enqueue before the old occurrence's discard so re-entrant cancellation cannot observe or retire an unannounced item. The existing inbox conservation invariant therefore continues to require one enqueue and one terminal dequeue or discard for each occurrence. +An applied action ends the queued occurrence and accepts the same immutable `UserMessage` as a new steering occurrence. The steering occurrence receives a new `InboxItemId` and truthful `placement: 'steering'`, while the message retains its `MessageId`, content, source, and any pending `SteeringReceipt` delivery controller. AgentLoop installs the new outbox entry before publishing lifecycle events, then emits its enqueue before the old occurrence's discard so re-entrant cancellation cannot observe or retire an unannounced item. The existing inbox conservation invariant therefore continues to require one enqueue and one terminal dequeue or discard for each occurrence. The action does not run `agent/prompt-submit`: choosing steering intentionally changes delivery from an independently admitted turn to current-turn next-step input. It neither cancels current work nor reorders the remaining Queue. @@ -36,15 +36,15 @@ The action does not run `agent/prompt-submit`: choosing steering intentionally c The Host's existing `queuedMirror` remains the sole transient inbox authority. Its `session/queue` snapshot carries every live occurrence with `placement: 'queued' | 'steering'`: QueueDock renders only queued rows, while ChatView renders pending steering at the conversation tail after the `Deep diving...` running-status row, with Copy but without Fork, edit, or delete actions. Reconnect replays the same snapshot, so this visibility does not require client optimism or a second registry. -When AgentLoop claims pending steering, it emits `agent/inbox/dequeue` immediately before synchronously appending `steering/message`. The Host retires that steering row on the following microtask, allowing the durable session event to enter the linear mux stream first. ChatView matches the shared `MessageId` and suppresses the transient projection as soon as the durable node exists, so one bubble changes authority without a visible gap or duplicate; the durable projection restores the clock, Copy, and Fork against its logged event time and sequence, while an append failure still retires the claimed row. +When AgentLoop claims pending steering, it emits `agent/inbox/dequeue` immediately before synchronously appending `steering/message`. The Host retires that steering row on the following microtask, allowing the durable session event to enter the linear mux stream first. On the accepted live event, the client Session retires the first matching current steering occurrence before publishing its snapshot; history replay does not consume a later occurrence that reused the same `MessageId`. ChatView therefore renders one authority at a time without scanning durable history, and the durable projection restores the clock, Copy, and Fork against its logged event time and sequence. An append failure still retires the claimed row. -The existing `session.prompt(mode: 'steer')` contract remains best-effort for new input: outside the next-step window it becomes a waking follow-up. The composer carries an explicit `queue | steer` mode through slash adjudication and reference serialization before calling that contract. A browser-local submission policy owns the persisted busy-Enter preference and resolves plain versus accelerated Enter as complementary gestures; the Settings row and InputBar share that policy without duplicating storage or delivery-window authority. Only the Queue row action is strict, because failure can safely leave its already-pending message untouched. +The existing `session.prompt(mode: 'steer')` contract remains best-effort for new primary-session input: outside the next-step window it becomes a waking follow-up. The composer carries an explicit `queue | steer` mode through slash adjudication and reference serialization before calling that contract. A browser-local submission policy owns the persisted busy-Enter preference and resolves plain versus accelerated Enter as complementary gestures only for steer-capable sessions; the Settings row and InputBar share that policy without duplicating storage or delivery-window authority. Only the Queue row action is strict, because either negative result converges through the original Queue occurrence. ### Verification -AgentLoop contract coverage holds prompt admission open, converts one exact queued occurrence, and proves the replacement steering occurrence keeps the message value, drains as `steering/message`, and never starts its former independent turn. It also pins unavailable-window retention, claimed-address rejection, and re-entrant cancellation lifecycle conservation. +AgentLoop contract coverage holds prompt admission open, converts one exact queued occurrence, and proves the replacement steering occurrence keeps the message value and delivery receipt, drains as `steering/message`, and never starts its former independent turn. It also pins unavailable-window retention, claimed-address rejection, and re-entrant cancellation lifecycle conservation. -Host schema and proxy tests cover the new action, both typed errors, placement-aware snapshots and reconnect replay, plus durable-before-retirement ordering. QueueDock tests cover running-state enablement, complete-content eligibility, failure retention, authoritative success retirement, and filtering of steering occurrences. ChatView tests and Web ARIA snapshots cover pending steering after the running-status row with Copy alone, plus its single-copy handoff to a durable node with clock, Copy, and Fork. +Host schema and proxy tests cover the new action, both typed errors, placement-aware snapshots and reconnect replay, plus durable-before-retirement ordering. Client tests cover silent convergence of both semantic races, genuine error reporting, read-only subagent rows and Queue-only subagent gestures. Runtime and ChatView tests cover occurrence-aware pending-to-durable handoff, including repeated `MessageId` values, while Web ARIA snapshots cover pending steering after the running-status row with Copy alone and the durable node with clock, Copy, and Fork. The keyless Web steering scenario queues a message through the real composer while the first response streams, activates the row arrow, then uses `ask_user_question` as a stable pending-steering barrier. It proves the Host-backed pending bubble appears before admission, hands off to one durable interjection after the answer, and affects the next model request. Assembled composer scenarios prove default-mode Cmd+Enter reaches the same pending and durable path without creating a Queue row, while Steer-mode Cmd+Enter creates a Queue row instead. Settings and submission-policy coverage pin the default, persistence, busy-only scope, and complementary gesture mapping; Queue edit/delete scenarios continue to prove those actions are unchanged. @@ -54,7 +54,7 @@ The keyless Web steering scenario queues a message through the real composer whi **Restore Queue promotion under the upward arrow.** Rejected because moving an item to the front still creates an independent admitted turn. The control promises current-turn steering, not priority within Queue. -**Use the existing best-effort `agent.steer()` behavior for the Queue row.** Rejected for that action because a closed next-step window would silently turn the selected row back into queued work, possibly at a different position and identity. Strict failure preserves the original occurrence and makes the semantic race visible. Newly typed composer input has no existing Queue occurrence to preserve, so it intentionally uses the best-effort behavior. +**Use the existing best-effort `agent.steer()` behavior for the Queue row.** Rejected for that action because a closed next-step window would create a new queued occurrence, possibly at a different position and identity. Strict refusal preserves the original occurrence so the UI can treat it as the same accepted Queue delivery. Newly typed composer input has no existing Queue occurrence to preserve, so it intentionally uses the best-effort behavior. **Change `agent.steer()` to be strict for every caller.** Rejected because TUI and plugin callers use its safe follow-up fallback for newly submitted input. A queued row has recoverable state that those callers do not. @@ -66,6 +66,6 @@ The keyless Web steering scenario queues a message through the real composer whi ## Consequences -`session/queue` describes a placement-aware transient inbox snapshot rather than a Queue-only list, so every consumer must filter by placement. Pending steering survives reconnect and appears immediately, but remains non-durable until `steering/message` commits. The running bit can also remain true briefly after the strict next-step window closes, so the button may be enabled for an operation that correctly returns `steer-unavailable`. +`session/queue` describes a placement-aware transient inbox snapshot rather than a Queue-only list, so every consumer must filter by placement. Pending steering survives reconnect and appears immediately, but remains non-durable until `steering/message` commits. The running bit can also remain true briefly after the strict next-step window closes, so an enabled action may internally return `steer-unavailable` while the product continues through Queue without reporting failure. The explicit action changes delivery from an independently admitted turn to current-turn steering, so prompt-admission plugins do not process the converted message. Enqueue-before-discard lifecycle publication remains required for re-entrant cancellation safety; focused regression coverage protects that ordering. diff --git a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.zh.md index 6b8af563ce..6edde8e2cc 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-queue-steer-action.zh.md @@ -14,19 +14,19 @@ Web composer 原本会在 agent 运行期间把所有 Enter 提交作为 Queue ### 产品契约 -每个非编辑态的 QueueDock 行都会提供名为“插话发送”的向上箭头操作。仅当会话报告 agent 正在运行时,该操作才会启用;包含混合内容的消息仍可使用,因为 steering 会转发完整且不可变的 `UserMessage`,而非该行的文本投影。编辑和删除保持现有行为。 +普通会话中每个非编辑态的 QueueDock 行都会提供名为“插话发送”的向上箭头操作。仅当会话报告 agent 正在运行时,该操作才会启用;包含混合内容的消息仍可使用,因为 steering 会转发完整且不可变的 `UserMessage`,而非该行的文本投影。已寻址 subagent 的 Queue 投影保持只读,因为其继续执行传输不提供 Queue 变更。 触发该操作会针对对应的 `InboxItemId` 请求严格的当前轮次 steering。操作成功后,权威 Host 快照会移除 Queue 行,并在 `Deep diving...` 运行状态行之后立即投影同一条待处理 steering;该气泡提供复制,但消息尚无持久事件序号,因此不提供 fork。AgentLoop 排空该项后,现有持久 `steering/message` 事件会接管同一个用户样式气泡,并恢复时钟、复制和 fork,无需另建持久展示路径。 -running 标志位只用于提示交互状态。在同步变更边界上,AgentLoop 的 `acceptsNextStep` 值才是权威依据。如果该窗口已经关闭,操作会保持 Queue 单次入队项不变,并返回类型化的 `steer-unavailable` 错误;如果驱动器已经认领该项,则返回现有的 `queue-item-not-found` 错误。UI 会报告任一竞态,不会乐观地移除该行。 +running 标志位只用于提示交互状态。在同步变更边界上,AgentLoop 的 `acceptsNextStep` 值才是权威依据。如果该窗口已经关闭,操作会保持 Queue 单次入队项不变并返回类型化的 `steer-unavailable` 错误,随后原唤醒单次入队项会经 Queue 继续执行。如果驱动器已经认领该项,则返回现有的 `queue-item-not-found` 错误,且独立轮次投递已经开始。UI 会把两种竞态都视为已收敛的 Queue 投递,不显示失败通知;传输和未知错误仍会显示。 -Composer 对新输入采用另一套尽力而为契约。所寻址会话空闲时,Enter 和 Cmd/Ctrl+Enter 都执行普通 Queue 发送。会话运行期间,General Settings 偏好会把普通 Enter 分配为 Queue(默认值)或 Steer,Cmd/Ctrl+Enter 则执行另一种行为;Shift+Enter 用于换行。浏览器会持久化该偏好,并且它只影响繁忙态下这对手势。如果 composer 直接发出的 Steer 错过当前 next-step 窗口,AgentLoop 会自动将其接纳为下一条唤醒 Queue 轮次,Web 不显示失败。 +Composer 对新输入采用另一套尽力而为契约。所寻址会话空闲时,Enter 和 Cmd/Ctrl+Enter 都执行普通 Queue 发送。主会话运行期间,General Settings 偏好会把普通 Enter 分配为 Queue(默认值)或 Steer,Cmd/Ctrl+Enter 则执行另一种行为;Shift+Enter 用于换行。已寻址 subagent 会让这两个手势都使用其仅支持 Queue 的继续执行传输。浏览器会持久化该偏好,并且它只影响支持 steering 的繁忙态手势对。如果 composer 直接发出的 Steer 错过当前 next-step 窗口,AgentLoop 会自动将其接纳为下一条唤醒 Queue 轮次,Web 不显示失败。 ### Agent 与生命周期边界 `InboxAction` 会在编辑和移除之外,新增由实际消费方支撑的 `{ kind: 'steer' }` 操作。`Agent.updateInbox()` 只有在找到 queued 单次入队项并确认 `acceptsNextStep` 后才会处理该操作,绝不会委托给尽力而为的 `agent.steer()` 别名。 -操作成功应用后,系统会结束 queued 单次入队项,并把同一个不可变 `UserMessage` 接受为新的 steering 单次入队项。steering 单次入队项会获得新的 `InboxItemId` 和如实反映投递方式的 `placement: 'steering'`,消息则保留其 `MessageId`、内容和来源。AgentLoop 会先安装新的 outbox 项,再发布生命周期事件;随后先发出新单次入队项的 enqueue,再发出旧单次入队项的 discard,确保可重入取消无法观察或退役一个尚未公布的项。因此,现有 inbox 守恒不变量仍然要求每个单次入队项恰好对应一个 enqueue,以及一个终态 dequeue 或 discard。 +操作成功应用后,系统会结束 queued 单次入队项,并把同一个不可变 `UserMessage` 接受为新的 steering 单次入队项。steering 单次入队项会获得新的 `InboxItemId` 和如实反映投递方式的 `placement: 'steering'`,消息则保留其 `MessageId`、内容、来源和任何待处理 `SteeringReceipt` 投递控制器。AgentLoop 会先安装新的 outbox 项,再发布生命周期事件;随后先发出新单次入队项的 enqueue,再发出旧单次入队项的 discard,确保可重入取消无法观察或退役一个尚未公布的项。因此,现有 inbox 守恒不变量仍然要求每个单次入队项恰好对应一个 enqueue,以及一个终态 dequeue 或 discard。 该操作不会运行 `agent/prompt-submit`:选择 steering 会有意把投递方式从经独立接纳的轮次改为当前轮次的 next-step 输入。它既不会取消当前工作,也不会重新排序 Queue 中的剩余项。 @@ -36,15 +36,15 @@ Composer 对新输入采用另一套尽力而为契约。所寻址会话空闲 Host 仍以现有 `queuedMirror` 作为唯一的瞬态 inbox 权威。`session/queue` 快照会携带所有存活单次入队项及其 `placement: 'queued' | 'steering'`:QueueDock 只渲染 queued 行,ChatView 则在会话流末尾、`Deep diving...` 运行状态行之后渲染待处理 steering,提供复制操作,但不提供 fork、编辑或删除操作。重连会重放同一份快照,因此这项可见性既不依赖客户端乐观展示,也不需要第二个 registry。 -AgentLoop 认领待处理 steering 时,会在同步追加 `steering/message` 之前立即发出 `agent/inbox/dequeue`。Host 会等到下一个微任务才退役该 steering 行,让持久 session 事件先进入线性 mux 流。ChatView 会匹配两边共享的 `MessageId`,并在持久节点出现时立即抑制瞬态投影,因此同一个气泡切换权威时不会产生可见空档或重复;持久投影会根据已记录的事件时间与序号恢复时钟、复制与 fork 操作,而追加失败时已认领行仍会退役。 +AgentLoop 认领待处理 steering 时,会在同步追加 `steering/message` 之前立即发出 `agent/inbox/dequeue`。Host 会等到下一个微任务才退役该 steering 行,让持久 session 事件先进入线性 mux 流。客户端 Session 接纳该实时事件时,会在发布快照前退役第一个匹配的当前 steering 单次入队项;历史回放不会消费后来复用同一 `MessageId` 的单次入队项。因此,ChatView 无需扫描持久历史就能每次只渲染一份权威,持久投影则会根据已记录的事件时间与序号恢复时钟、复制与 fork 操作。追加失败时,已认领行仍会退役。 -现有 `session.prompt(mode: 'steer')` 对新输入仍采用尽力而为的契约:在 next-step 窗口之外,它会变为唤醒 agent 的后续轮次。Composer 会让显式 `queue | steer` 模式经过 slash 裁决与引用序列化,再调用该契约。浏览器本地的提交策略拥有持久化的繁忙态 Enter 偏好,并把普通 Enter 与加速 Enter 解析为互补手势;Settings 行和 InputBar 共享该策略,不重复实现存储或投递窗口权威。只有 Queue 行操作采用严格语义,因为失败时可以安全地保留其已经待处理的消息。 +现有 `session.prompt(mode: 'steer')` 对主会话新输入仍采用尽力而为的契约:在 next-step 窗口之外,它会变为唤醒 agent 的后续轮次。Composer 会让显式 `queue | steer` 模式经过 slash 裁决与引用序列化,再调用该契约。浏览器本地的提交策略拥有持久化的繁忙态 Enter 偏好,并且只为支持 steering 的会话把普通 Enter 与加速 Enter 解析为互补手势;Settings 行和 InputBar 共享该策略,不重复实现存储或投递窗口权威。只有 Queue 行操作采用严格语义,因为任一种负面结果都会经原 Queue 单次入队项收敛。 ### 验证 -AgentLoop 契约覆盖保持提示词接纳窗口打开,转换一个精确的 queued 单次入队项,并证明替代它的 steering 单次入队项保留消息值、以 `steering/message` 的形式排空,且绝不启动原本的独立轮次。该覆盖还钉住窗口不可用时保留原项、拒绝已被认领的地址,以及可重入取消下的生命周期守恒。 +AgentLoop 契约覆盖保持提示词接纳窗口打开,转换一个精确的 queued 单次入队项,并证明替代它的 steering 单次入队项保留消息值和投递回执、以 `steering/message` 的形式排空,且绝不启动原本的独立轮次。该覆盖还钉住窗口不可用时保留原项、拒绝已被认领的地址,以及可重入取消下的生命周期守恒。 -Host schema 和代理测试覆盖新操作、两种类型化错误、带 placement 的快照与重连重放,以及先持久化再退役的顺序。QueueDock 测试覆盖按运行状态启用、混合内容消息仍可完整投递、失败时保留原行、成功后由权威快照退役,以及过滤 steering 单次入队项。ChatView 测试与 Web ARIA 快照覆盖待处理阶段位于运行状态行之后且仅有复制,并覆盖它向带时钟、复制和 fork 的持久节点进行单份交接。 +Host schema 和代理测试覆盖新操作、两种类型化错误、带 placement 的快照与重连重放,以及先持久化再退役的顺序。客户端测试覆盖两种语义竞态的静默收敛、真实错误报告、只读 subagent 行和仅支持 Queue 的 subagent 手势。运行时与 ChatView 测试覆盖按单次入队项完成的待处理到持久交接,包括重复的 `MessageId` 值;Web ARIA 快照则覆盖位于运行状态行之后且仅有复制的待处理 steering,以及带时钟、复制和 fork 的持久节点。 无密钥 Web steering 场景在第一次响应流式输出期间,通过真实 composer 排队一条消息并触发行上的箭头,再用 `ask_user_question` 作为稳定的待处理 steering 屏障。该场景证明 Host 支撑的待处理气泡会在准入前出现,在回答后交接为唯一一条持久插话,并影响下一次模型请求。组装后的 composer 场景证明默认模式下的 Cmd+Enter 无需创建 Queue 行,也会进入同一条待处理与持久路径;Steer 模式下的 Cmd+Enter 则会创建 Queue 行。Settings 与提交策略覆盖会固定默认值、持久化、仅限繁忙态的作用域和互补手势映射;Queue 编辑/删除场景继续证明这些操作没有变化。 @@ -54,7 +54,7 @@ Host schema 和代理测试覆盖新操作、两种类型化错误、带 placeme **恢复向上箭头对应的 Queue 前移操作。** 不予采纳,因为把某个项移到队首仍然会创建一个独立接纳的轮次。该控件承诺的是当前轮次 steering,而不是 Queue 内的优先级。 -**为 Queue 行使用现有尽力而为的 `agent.steer()` 行为。** 不予采纳,因为关闭的 next-step 窗口会静默地把选中行重新变成 queued 工作,而且位置和标识可能不同。严格失败会保留原单次入队项,并让这项语义竞态明确可见。新输入的 composer 消息没有需要保留的现有 Queue 单次入队项,因此有意采用尽力而为行为。 +**为 Queue 行使用现有尽力而为的 `agent.steer()` 行为。** 不予采纳,因为关闭的 next-step 窗口会创建新的 queued 单次入队项,而且位置和标识可能不同。严格拒绝会保留原单次入队项,让 UI 能将其视为同一次已接纳的 Queue 投递。新输入的 composer 消息没有需要保留的现有 Queue 单次入队项,因此有意采用尽力而为行为。 **让每个调用方使用的 `agent.steer()` 都采用严格语义。** 不予采纳,因为 TUI 和插件调用方会针对新提交的输入使用其安全的后续轮次回退。queued 行具有这些调用方不具备的可恢复状态。 @@ -66,6 +66,6 @@ Host schema 和代理测试覆盖新操作、两种类型化错误、带 placeme ## 后果 -`session/queue` 表示带 placement 的瞬态 inbox 快照,而不只是 Queue 列表,因此每个消费方都必须按 placement 过滤。待处理 steering 会在界面中立即出现并能在重连后恢复,但在 `steering/message` 提交前仍不持久。严格 next-step 窗口关闭后,running 标志位仍可能短暂保持为 true,因此按钮可能会为一个最终正确返回 `steer-unavailable` 的操作保持启用。 +`session/queue` 表示带 placement 的瞬态 inbox 快照,而不只是 Queue 列表,因此每个消费方都必须按 placement 过滤。待处理 steering 会在界面中立即出现并能在重连后恢复,但在 `steering/message` 提交前仍不持久。严格 next-step 窗口关闭后,running 标志位仍可能短暂保持为 true,因此已启用的操作可能会在内部返回 `steer-unavailable`,而产品仍经 Queue 继续执行且不显示失败。 这项显式操作会把投递方式从经独立接纳的轮次改为当前轮次 steering,因此提示词接纳插件不会处理转换后的消息。为保证可重入取消安全,生命周期事件仍必须先发布 enqueue 再发布 discard;有针对性的回归覆盖会保护这一顺序。 diff --git a/apps/web/tests/steering.e2e.ts b/apps/web/tests/steering.e2e.ts index 1f9672d74a..1ea43f99db 100644 --- a/apps/web/tests/steering.e2e.ts +++ b/apps/web/tests/steering.e2e.ts @@ -25,6 +25,11 @@ const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') const MID_EXPECTED = join(SNAPSHOT_DIR, 'mid-steer.expected.md') const SETTLED_EXPECTED = join(SNAPSHOT_DIR, 'settled.expected.md') const MODE = webSnapshotMode() +// The question composer replaces the textarea, so fill → Queue row → Steer +// must finish inside the first replay chunk window. At 15 ms that window is +// shorter than Playwright's round trips; 100 ms supplies test-only headroom, +// while larger values lengthen all three replay scenarios linearly. +const REPLAY_PACE_MS = 100 const PROMPT = 'Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop.' const STEER = 'Interjection: include the word BANANA in your final reply.' @@ -48,9 +53,9 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => { const sessionEvents: SessionEvent[] = [] beforeAll(async () => { - // The 100 ms replay pace is test-only headroom that keeps the Queue action - // available until the recorded question barrier arrives, not a product timing contract. - scaffold = await launchWebScaffold(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 100 }) + scaffold = await launchWebScaffold(MODE === 'record' + ? {} + : { replayFixture: FIXTURE, paceMs: REPLAY_PACE_MS }) scaffold.ctx.on('session/event', (_session, event) => { sessionEvents.push(event) }) browser = await chromium.launch() page = await newEnglishPage(browser) @@ -89,6 +94,8 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => { await expect.poll(() => steerButton.isEnabled(), { timeout: 10_000 }).toBe(true) await steerButton.click({ timeout: 10_000 }) const pendingSteering = page.locator('[data-pending-steering]').filter({ hasText: STEER }) + // A timeout while the Queue row remains means strict steer lost to a + // closing window (`steer-unavailable`); inspect replay pacing first. await pendingSteering.waitFor({ timeout: 10_000 }) // The blocked composer keeps steering pending long enough to observe the @@ -158,7 +165,7 @@ describe('web e2e: composer shortcut steers directly', () => { const sessionEvents: SessionEvent[] = [] beforeAll(async () => { - scaffold = await launchWebScaffold({ replayFixture: FIXTURE, paceMs: 100 }) + scaffold = await launchWebScaffold({ replayFixture: FIXTURE, paceMs: REPLAY_PACE_MS }) scaffold.ctx.on('session/event', (_session, event) => { sessionEvents.push(event) }) browser = await chromium.launch() page = await newEnglishPage(browser) @@ -216,7 +223,7 @@ describe('web e2e: composer shortcut follows the swapped busy behavior', () => { const sessionEvents: SessionEvent[] = [] beforeAll(async () => { - scaffold = await launchWebScaffold({ replayFixture: FIXTURE, paceMs: 100 }) + scaffold = await launchWebScaffold({ replayFixture: FIXTURE, paceMs: REPLAY_PACE_MS }) scaffold.ctx.on('session/event', (_session, event) => { sessionEvents.push(event) }) browser = await chromium.launch() page = await newEnglishPage(browser) diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index 0bbb962352..6f31ca3beb 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/runtime/README.md -README.md: a98e97d796ca4e4be07d8d8d25ebc0a24d066a8b -README.zh.md: a973c4fbbf16633fed11d137ae548604516d8a51 +README.md: eca7db1f9b2d5c7e28fa86a363ca4408703b99df +README.zh.md: 6a2e8c6085d06a9f04c1270e5976452b995a7e77 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index a98e97d796..eca7db1f9b 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -22,7 +22,7 @@ SlotsService gives the renderer separate bare observables for `useSessions` and ## Pending queue projection -`ConversationSnapshot.queue` is the Host's authoritative transient Queue snapshot; pending steering stays outside this projection. Each row carries its `InboxItemId`, complete editable text when every content block is text, and a flattened preview. `session/queue` replaces the whole projection; reconnect buffering retains only the latest snapshot, and neither durable turn events nor running-status changes guess that an item was claimed. `Session.updateQueue()` sends edit, remove, and strict-steer operations without optimistic mutation, so the next Host snapshot is the sole visible commit; claim and closed-window races surface `queue-item-not-found` and `steer-unavailable`. +`ConversationSnapshot.queue` is the Host's authoritative transient inbox snapshot and carries both queued and pending-steering occurrences with their resolved placement. Each row carries its `InboxItemId`, stable `MessageId`, complete editable text when every content block is text, and a flattened preview. `session/queue` replaces the whole projection, while an accepted live `steering/message` event retires only the first matching current steering occurrence so the durable node can take over before the following Host snapshot; history replay never consumes a later occurrence that reused the same `MessageId`. Reconnect buffering retains only the latest snapshot, and neither ordinary durable turn events nor running-status changes guess that an item was claimed. `Session.updateQueue()` sends edit, remove, and strict-steer operations without optimistic mutation; claim and closed-window races surface `queue-item-not-found` and `steer-unavailable`. ## The human transcript diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index a973c4fbbf..6a2e8c6085 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -22,7 +22,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 ## 待处理队列投影 -`ConversationSnapshot.queue` 是 Host 提供的权威瞬态 Queue 快照;待处理 steering(中途引导)不进入此投影。每行都携带其 `InboxItemId`、所有内容块均为文本时的完整可编辑文本,以及扁平化预览。`session/queue` 会整体替换该投影;重连缓冲只保留最新快照,持久轮次事件和 running 状态变化都不会猜测某个项已被认领。`Session.updateQueue()` 发送编辑、移除和严格 steering 操作,不进行乐观更新,因此下一份 Host 快照是唯一可见的提交结果;认领与窗口关闭竞态分别会返回 `queue-item-not-found` 和 `steer-unavailable`。 +`ConversationSnapshot.queue` 是 Host 提供的权威瞬态 inbox 快照,携带 queued 与待处理 steering(中途引导)单次入队项及其已解析 placement。每行都携带其 `InboxItemId`、稳定的 `MessageId`、所有内容块均为文本时的完整可编辑文本,以及扁平化预览。`session/queue` 会整体替换该投影;已接纳的实时 `steering/message` 事件则只退役第一个匹配的当前 steering 单次入队项,让持久节点能在下一份 Host 快照之前接管,而历史回放绝不会消费后来复用同一 `MessageId` 的单次入队项。重连缓冲只保留最新快照,普通持久轮次事件和 running 状态变化都不会猜测某个项已被认领。`Session.updateQueue()` 发送编辑、移除和严格 steering 操作,不进行乐观更新;认领与窗口关闭竞态分别会返回 `queue-item-not-found` 和 `steer-unavailable`。 ## 面向人的 transcript(文本记录) diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 490693a6a4..58d3028fba 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -650,9 +650,20 @@ export class Session implements SessionFace { this.events.push(event) this.views.push(view) this.transcript.append(event, view) + this.handoffPendingSteering(event) this.applyEventSideEffects(event, view) } + /** Retire the first matching live steering occurrence when its durable event takes over. */ + private handoffPendingSteering(event: SessionEvent): void { + if (event.type !== 'steering/message') return + const index = this.queued.findIndex(item => + item.placement === 'steering' && item.messageId === event.data.message.id) + if (index === -1) return + this.queued = this.queued.filter((_item, candidate) => candidate !== index) + this.queueRev++ + } + /** Land a live session/event (open/repair in flight -> buffer; overlapping seq -> drop; * a seq gap -> buffer + tail-page repull instead of appending a hole (audit S3: a gap is an * expected reconnect-window artifact, repaired by refetch). The window stays one contiguous diff --git a/packages/client/runtime/tests/queue-store.spec.ts b/packages/client/runtime/tests/queue-store.spec.ts index cc2f3d0e53..82d422aadc 100644 --- a/packages/client/runtime/tests/queue-store.spec.ts +++ b/packages/client/runtime/tests/queue-store.spec.ts @@ -5,7 +5,8 @@ */ import { describe, expect, it } from 'vitest' import { createUserMessage } from '@deepseek-ai/dsh-llm' -import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' +import type { ContentBlock, UserMessage } from '@deepseek-ai/dsh-llm/types' +import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type { InboxItemId, MuxFrame, RpcId, SessionId, } from '@deepseek-ai/dsh-client-connection/client' @@ -23,6 +24,7 @@ interface QueueFixture { body: string content?: ContentBlock[] placement?: 'queued' | 'steering' + message?: UserMessage } /** Build one authoritative queue snapshot. */ @@ -33,7 +35,7 @@ function queueFrame(items: QueueFixture[]): MuxFrame { items: items.map(item => ({ id: iid(item.id), placement: item.placement ?? 'queued', - message: createUserMessage({ + message: item.message ?? createUserMessage({ content: item.content ?? text(item.body), source: { kind: 'user', rpcId: rid(`rpc-${item.id}`) } as never, }), @@ -134,6 +136,40 @@ describe('queue snapshot intake', () => { { id: 's-now', placement: 'steering', content: text('interrupt now') }, ]) }) + + it('hands off exactly one current occurrence when live steering becomes durable', async () => { + const session = makeSession() + await session.open() + const message = createUserMessage({ + content: text('same message'), + source: { kind: 'user' }, + }) + session.handleMuxEnvelope(rid('env-same-id'), queueFrame([ + { id: 's-first', body: '', placement: 'steering', message }, + { id: 's-second', body: '', placement: 'steering', message }, + ])) + const durable = { + seq: 0, + time: 1_700_000_000_000, + type: 'steering/message', + surfaceOp: 'append', + data: { turn: 1, message }, + } as SessionEvent + + session.handleMuxEnvelope(rid('env-durable'), { + type: 'session/event', sessionId: SID, event: durable, + }) + expect(session.getSnapshot().queue.map(item => item.id)).toEqual(['s-second']) + expect(session.getSnapshot().nodes.filter(node => node.kind === 'steering')).toHaveLength(1) + + session.handleMuxEnvelope(rid('env-reused-id'), queueFrame([ + { id: 's-later', body: '', placement: 'steering', message }, + ])) + session.handleMuxEnvelope(rid('env-replayed-durable'), { + type: 'session/event', sessionId: SID, event: durable, + }) + expect(session.getSnapshot().queue.map(item => item.id)).toEqual(['s-later']) + }) }) describe('queue operation transport', () => { diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index fcf4c0b84e..a78b80035b 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: 61056fc36e2e7f5f85726e158b9fd27e06c21941 -README.zh.md: 3a1a74a4106321415ee343908c999a2cf41615a0 +README.md: ea48725b02ad0c440984af4aadfec17d0e63791d +README.zh.md: 654901caca68762e313dda456c1e0df23faf734a diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 61056fc36e..ea48725b02 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -34,11 +34,11 @@ Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.to The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`/ 已完成 · ` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: 0` — before Goal and Queue — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and starts collapsed as a header of title plus `"/ tasks · in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the standing list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included. -`QueueDock` is the terminal input-dock entry at `order: 20`. It hides while empty, renders one pending row directly, and defaults two or more rows to a collapsed `" 条排队消息"` header whose button expands or collapses the complete list. The header exposes `aria-expanded` and `aria-controls`; the expanded list scrolls within a 180px height bound. An active edit or mutation keeps its rows visible, and emptying the queue restores the collapsed default for the next queue. Each visible row remains a single-line preview with its exact-occurrence edit, delete, and strict-steer actions; an unavailable steering window leaves the Queue occurrence in place and reports the failure. +`QueueDock` is the terminal input-dock entry at `order: 20`. It hides while empty, renders one pending row directly, and defaults two or more rows to a collapsed `" 条排队消息"` header whose button expands or collapses the complete list. The header exposes `aria-expanded` and `aria-controls`; the expanded list scrolls within a 180px height bound. An active edit or mutation keeps its rows visible, and emptying the queue restores the collapsed default for the next queue. Each visible ordinary-session row remains a single-line preview with its exact-occurrence edit, delete, and strict-steer actions; addressed subagents retain the rows as a read-only projection because their continuation transport does not expose queue mutation. If strict steer loses to a closed window, the original occurrence remains queued for normal delivery; if the driver already claimed it, normal delivery is already underway. Neither converged race displays a failure, while transport and unknown failures do. -The Host's placement-aware `session/queue` snapshot also carries pending steering. QueueDock filters it out, while ChatView projects it as a user-style bubble with Copy at the conversation tail; Fork stays absent because the message has not entered a durable turn. The Host delays steering retirement until the durable `steering/message` has entered the mux stream, and ChatView deduplicates the two projections by their shared `MessageId`; the bubble therefore hands off without a gap or duplicate, restores Copy and Fork from the durable node, and survives reconnect from the same authority. +The Host's placement-aware `session/queue` snapshot also carries pending steering. QueueDock filters it out, while ChatView projects it as a user-style bubble with Copy at the conversation tail; Fork stays absent because the message has not entered a durable turn. The Host delays steering retirement until the durable `steering/message` has entered the mux stream. On that accepted live event, the client runtime retires the first matching current steering occurrence before publishing the snapshot; historical events cannot hide later occurrences that reuse the same `MessageId`. The bubble therefore hands off without a gap or duplicate, restores Copy and Fork from the durable node, and survives reconnect from the same authority. -Keyboard message submission resolves delivery from the addressed session's running state. While idle, Enter and Cmd/Ctrl+Enter both perform an ordinary Queue send. While running, the browser-persisted General Settings preference assigns plain Enter to `Queue` (the default) or `Steer`, and Cmd/Ctrl+Enter performs the other behavior; Shift+Enter remains a newline. The preference affects only the busy-state gesture pair, and the send button and non-keyboard submit actions remain Queue. Composer Steer uses the existing best-effort `session.prompt(mode: 'steer')` contract: if the current next-step window closes before acceptance, AgentLoop admits the message as the next waking Queue turn without surfacing a failure or losing the draft transaction. +Keyboard message submission resolves delivery from the addressed session's running state and steering capability. While idle, Enter and Cmd/Ctrl+Enter both perform an ordinary Queue send. While a primary session is running, the browser-persisted General Settings preference assigns plain Enter to `Queue` (the default) or `Steer`, and Cmd/Ctrl+Enter performs the other behavior; Shift+Enter remains a newline. Addressed subagents keep both gestures on their Queue-only continuation transport even while running. The preference affects only the steer-capable busy-state gesture pair, and the send button and non-keyboard submit actions remain Queue. Composer Steer uses the existing best-effort `session.prompt(mode: 'steer')` contract: if the current next-step window closes before acceptance, AgentLoop admits the message as the next waking Queue turn without surfacing a failure or losing the draft transaction. Per-session UI state for selection and the active view lives in the declared chat store (`stores.ts` `createChatStore`); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies `useSession`/`sessionId`, global `useSessions`/`useWorkspaces`, and the input machine's `useInput`/`inputActions`; store faces and inject factories supply the remaining state and callbacks. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 3a1a74a410..654901caca 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -34,11 +34,11 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: 0` 占用 `'conversation.input.dock'` 列表 slot(位于 Goal 与 Queue 之前),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加 `"<已完成>/<总数> tasks · in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。 -`QueueDock` 是 `order: 20` 的末端 input-dock 条目。队列为空时隐藏;只有一个待处理项时直接渲染该行;存在两个或更多待处理项时,默认收起为 `" 条排队消息"` 表头,其按钮可展开或收起完整列表。表头暴露 `aria-expanded` 和 `aria-controls`;展开后的列表以 180px 为高度上限,并可滚动。存在进行中的编辑或变更时,列表行会保持可见;队列清空后,下一次出现队列时会恢复默认收起状态。每条可见行仍是单行预览,并提供针对精确单次入队项的编辑、删除和严格 steering(中途引导)操作;steering 窗口不可用时,Queue 单次入队项会留在原处并显示失败。 +`QueueDock` 是 `order: 20` 的末端 input-dock 条目。队列为空时隐藏;只有一个待处理项时直接渲染该行;存在两个或更多待处理项时,默认收起为 `" 条排队消息"` 表头,其按钮可展开或收起完整列表。表头暴露 `aria-expanded` 和 `aria-controls`;展开后的列表以 180px 为高度上限,并可滚动。存在进行中的编辑或变更时,列表行会保持可见;队列清空后,下一次出现队列时会恢复默认收起状态。普通会话中的每条可见行仍是单行预览,并提供针对精确单次入队项的编辑、删除和严格 steering(中途引导)操作;已寻址 subagent 则保留只读行,因为其继续执行传输不提供 Queue 变更。如果严格 steering 输给已关闭的窗口,原单次入队项会留在 Queue 中正常投递;如果驱动器已经认领该项,正常投递就已开始。这两种已收敛的竞态都不显示失败,传输和未知错误仍会显示。 -Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。QueueDock 会将其过滤掉,ChatView 则把它投影为会话流末尾带复制操作的用户样式气泡;消息尚未进入持久轮次,因此不显示 fork。Host 会等持久 `steering/message` 进入 mux 流之后再退役 steering,ChatView 则按两份投影共享的 `MessageId` 去重;气泡交接时因而不会产生空档或重复,会从持久节点恢复复制与 fork 操作,并能在重连后从同一权威恢复。 +Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。QueueDock 会将其过滤掉,ChatView 则把它投影为会话流末尾带复制操作的用户样式气泡;消息尚未进入持久轮次,因此不显示 fork。Host 会等持久 `steering/message` 进入 mux 流之后再退役 steering。客户端运行时接纳该实时事件时,会在发布快照前退役第一个匹配的当前 steering 单次入队项;历史事件无法隐藏后来复用同一 `MessageId` 的单次入队项。气泡交接时因而不会产生空档或重复,会从持久节点恢复复制与 fork 操作,并能在重连后从同一权威恢复。 -键盘消息提交会根据所寻址会话的运行状态解析投递方式。空闲时,Enter 和 Cmd/Ctrl+Enter 都执行普通 Queue 发送。运行期间,浏览器持久化的 General Settings 偏好会把普通 Enter 分配为 `Queue`(默认值)或 `Steer`,Cmd/Ctrl+Enter 则执行另一种行为;Shift+Enter 仍然换行。该偏好只影响繁忙态下这对手势,发送按钮与非键盘提交操作仍使用 Queue。Composer Steer 复用现有尽力而为的 `session.prompt(mode: 'steer')` 契约:如果当前 next-step 窗口在接纳前关闭,AgentLoop 会把消息接纳为下一条唤醒 Queue 轮次,不显示失败,也不会丢失草稿事务。 +键盘消息提交会根据所寻址会话的运行状态和 steering 能力解析投递方式。空闲时,Enter 和 Cmd/Ctrl+Enter 都执行普通 Queue 发送。主会话运行期间,浏览器持久化的 General Settings 偏好会把普通 Enter 分配为 `Queue`(默认值)或 `Steer`,Cmd/Ctrl+Enter 则执行另一种行为;Shift+Enter 仍然换行。已寻址 subagent 即使正在运行,也会让这两个手势都使用其仅支持 Queue 的继续执行传输。该偏好只影响支持 steering 的繁忙态手势对,发送按钮与非键盘提交操作仍使用 Queue。Composer Steer 复用现有尽力而为的 `session.prompt(mode: 'steer')` 契约:如果当前 next-step 窗口在接纳前关闭,AgentLoop 会把消息接纳为下一条唤醒 Queue 轮次,不显示失败,也不会丢失草稿事务。 逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store(`stores.ts` `createChatStore`)中;InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession`/`sessionId`、全局 `useSessions`/`useWorkspaces`,以及输入状态机的 `useInput`/`inputActions`;store 表层与 inject factory 提供其余状态和回调。 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index bb34164a3f..ec1f9f7ab9 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -222,7 +222,8 @@ export function apply(ctx: Context): void { if (sessionId === undefined) { return { keyboard: undefined, - resolveSubmitMode: (running, gesture) => submissionPolicy.resolve(running, gesture), + resolveSubmitMode: (running, gesture, steeringAvailable) => + submissionPolicy.resolve(running, gesture, steeringAvailable), toggleCommandMenu: undefined, stop: undefined, command: undefined, @@ -233,7 +234,8 @@ export function apply(ctx: Context): void { const slash = inputHub.slash(sessionId) return { keyboard: shell, - resolveSubmitMode: (running, gesture) => submissionPolicy.resolve(running, gesture), + resolveSubmitMode: (running, gesture, steeringAvailable) => + submissionPolicy.resolve(running, gesture, steeringAvailable), toggleCommandMenu: slash === undefined ? undefined : (selection) => { diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index 4f237c2d0c..989f56bba2 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -249,10 +249,10 @@ export function ChatView({ const selectedCallId = useStore(s => s.selection?.callId) const items = useMemo(() => deriveChatFlow(nodes), [nodes]) - const pendingSteering = useMemo(() => { - const durable = new Set(nodes.flatMap(node => node.kind === 'steering' ? [node.messageId] : [])) - return inbox.filter(item => item.placement === 'steering' && !durable.has(item.messageId)) - }, [inbox, nodes]) + const pendingSteering = useMemo( + () => inbox.filter(item => item.placement === 'steering'), + [inbox], + ) const activeRetry = useMemo(() => activeRetrySeq(nodes, running), [nodes, running]) // Only the last content assistant of each turn owns IconActions; mid-turn // text (before tools) omits `time` so AssistantMarkdown stays chrome-free. diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 16f789f70c..52dc8dd2d9 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -287,7 +287,11 @@ export interface ComposerBarInjected { /** The InputBar-exclusive keyboard/DOM command face (decision 20 private plane); absent with the session. */ keyboard: ComposerKeyboard | undefined /** Resolve one keyboard submission gesture against the current running state and persisted preference. */ - resolveSubmitMode: (running: boolean, gesture: ComposerSubmitGesture) => InputSubmitMode + resolveSubmitMode: ( + running: boolean, + gesture: ComposerSubmitGesture, + steeringAvailable: boolean, + ) => InputSubmitMode /** Toggle the shared slash menu with only its command source; absent without ui-slash or a session. */ toggleCommandMenu: ((selection: EditSelection) => void) | undefined /** Cancel the in-flight turn; absent with the session. */ diff --git a/packages/client/ui-conversation/src/client/input/submission-policy.ts b/packages/client/ui-conversation/src/client/input/submission-policy.ts index 99ce299c52..6ef87e42c8 100644 --- a/packages/client/ui-conversation/src/client/input/submission-policy.ts +++ b/packages/client/ui-conversation/src/client/input/submission-policy.ts @@ -27,10 +27,15 @@ export class ComposerSubmissionPolicy { * Resolve one keyboard gesture without changing state. * @param running - whether the addressed agent currently reports busy. * @param gesture - plain Enter or the Cmd/Ctrl-accelerated chord. - * @returns Queue outside busy state; otherwise the preferred mode or its opposite. + * @param steeringAvailable - whether this session transport supports steering. + * @returns Queue outside steer-capable busy state; otherwise the preferred mode or its opposite. */ - resolve(running: boolean, gesture: ComposerSubmitGesture): InputSubmitMode { - if (!running) return 'queue' + resolve( + running: boolean, + gesture: ComposerSubmitGesture, + steeringAvailable: boolean, + ): InputSubmitMode { + if (!running || !steeringAvailable) return 'queue' const preferred = this.busyEnter.getSnapshot() if (gesture === 'enter') return preferred return preferred === 'queue' ? 'steer' : 'queue' diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 1ecb33b854..87252fbc77 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -97,7 +97,7 @@ export const zh = { 'queue.steer.unavailable': '仅运行中可插话发送', 'queue.editFailed': '编辑失败:这条消息可能已经开始发送。', 'queue.removeFailed': '删除失败:这条消息可能已经开始发送。', - 'queue.steerFailed': '插话失败:当前回复已结束,或这条消息已经开始发送。', + 'queue.steerFailed': '插话发送失败,请重试。', 'terminal.signal': '信号 {signal}', 'terminal.exitCode': '退出码 {code}', 'terminal.running': '运行中', @@ -204,7 +204,7 @@ export const en = { 'queue.steer.unavailable': 'Steering is available only while the agent is running', 'queue.editFailed': 'Edit failed: this message may have already started sending.', 'queue.removeFailed': 'Removal failed: this message may have already started sending.', - 'queue.steerFailed': 'Steering failed: the current response ended or this message already started sending.', + 'queue.steerFailed': 'Steering failed. Try again.', 'terminal.signal': 'signal {signal}', 'terminal.exitCode': 'exit code {code}', 'terminal.running': 'Running', diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx index 5085a6e579..67d6153519 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx @@ -32,6 +32,7 @@ export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps const inbox = useSession(s => s.queue) const queue = useMemo(() => inbox.filter(row => row.placement === 'queued'), [inbox]) const running = useSession(s => s.running) + const queueMutable = useSession(s => s.subagent === null) const [editing, setEditing] = useState<{ id: QueueItemId; text: string } | null>(null) const [busy, setBusy] = useState(null) const [collapsed, setCollapsed] = useState(true) @@ -39,12 +40,12 @@ export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps useEffect(() => { if (queue.length === 0 && !collapsed) setCollapsed(true) - if (editing !== null && !queue.some(row => row.id === editing.id)) setEditing(null) - }, [collapsed, editing, queue]) + if (editing !== null && (!queueMutable || !queue.some(row => row.id === editing.id))) setEditing(null) + }, [collapsed, editing, queue, queueMutable]) if (queue.length === 0) return null - const interactionActive = editing !== null || busy !== null + const interactionActive = queueMutable && (editing !== null || busy !== null) const expanded = !collapsed || interactionActive const listVisible = queue.length === 1 || expanded @@ -116,7 +117,7 @@ export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps /> ) : {row.preview}} -
+ {queueMutable &&
{editing?.id === row.id ? ( <> @@ -190,7 +191,7 @@ export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps )} -
+
} ))} diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index 4ecf41fd10..decca00e01 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -34,7 +34,7 @@ export interface IConversation { * Apply one edit, remove, or strict steer operation to a pending queue occurrence. * @param itemId - agent-owned inbox occurrence identity. * @param action - requested queue operation. - * @returns completion; business failures reject. + * @returns completion; converged strict-steer races resolve, while other failures reject. */ updateQueue(itemId: QueueItemId, action: QueueAction): Promise /** @@ -82,6 +82,10 @@ export class ConversationService extends Service implements IConversation { const session = this.scopedSession('updateQueue') const result = await session.updateQueue(itemId, action) if (!result.ok) { + if ( + action.kind === 'steer' + && (result.error.code === 'steer-unavailable' || result.error.code === 'queue-item-not-found') + ) return throw new Error(`conversation.updateQueue failed: ${result.error.code}: ${result.error.message}`) } } diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 7b8aa87414..c2a718b84a 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -181,7 +181,11 @@ export function InputBar({ e.preventDefault() if (e.repeat) return // held-down Enter must not machine-gun sends if (locked || machineBusy) return - keyboard.submit(resolveSubmitMode(running, e.ctrlKey || e.metaKey ? 'accelerated' : 'enter')) + keyboard.submit(resolveSubmitMode( + running, + e.ctrlKey || e.metaKey ? 'accelerated' : 'enter', + subagent === null, + )) } const onChange = (e: ChangeEvent): void => { diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index b0e55736a4..04630cf1f6 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -288,7 +288,7 @@ describe('ChatView', () => { act(() => { h.set({ - queue: [queued, pending], + queue: [queued], nodes: [ assistant(1, 'working'), { @@ -306,9 +306,30 @@ describe('ChatView', () => { expect(branchButtons).toHaveLength(2) fireEvent.click(branchButtons[1]!) expect(h.forkAt).toHaveBeenCalledWith(2) + }) - act(() => { h.set({ queue: [queued] }) }) - expect(view.getAllByText('interrupt now')).toHaveLength(1) + it('keeps a later pending occurrence visible when it reuses a durable MessageId', () => { + const pending = { + id: 'steer-occurrence-later' as never, + messageId: 'shared-steer-message' as never, + placement: 'steering' as const, + content: [{ type: 'text' as const, text: 'same steering' }], + preview: 'same steering', + text: 'same steering', + } + const h = makeHarness({ + queue: [pending], + nodes: [{ + kind: 'steering', messageId: pending.messageId, + seq: 2, time: 2_000, turn: 1, + content: pending.content, source: null, + }], + running: true, + }) + const view = render() + + expect(view.getAllByText('same steering')).toHaveLength(2) + expect(view.container.querySelectorAll('[data-pending-steering]')).toHaveLength(1) }) it('animates only the latest unresolved model retry', () => { diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index bfe34316fa..180b41b73b 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -108,8 +108,8 @@ function bench(over?: BenchOptions) { useInput: bindSnapshotSelector(shell.state), inputActions: shell.actions, keyboard: shell, - resolveSubmitMode: (running, gesture) => { - if (!running) return 'queue' + resolveSubmitMode: (running, gesture, steeringAvailable) => { + if (!running || !steeringAvailable) return 'queue' const preferred = over?.busyEnter ?? 'queue' return gesture === 'enter' ? preferred : preferred === 'queue' ? 'steer' : 'queue' }, @@ -271,6 +271,24 @@ describe('running and lock semantics (queue cut 1)', () => { expect(empty.button.disabled).toBe(true) }) + it('keeps both running subagent Enter gestures on Queue transport', () => { + const subagent = { + address: { + parentSessionId: 'parent' as SessionId, + childSessionId: SID, + mode: 'continuable' as const, + }, + parentAvailable: true, + } + const plain = bench({ running: true, busyEnter: 'steer', draft: 'plain', subagent }) + fireEvent.keyDown(plain.textarea, { key: 'Enter' }) + expect(plain.sink).toHaveBeenCalledWith('plain', 'queue') + + const accelerated = bench({ running: true, draft: 'accelerated', subagent }) + fireEvent.keyDown(accelerated.textarea, { key: 'Enter', metaKey: true }) + expect(accelerated.sink).toHaveBeenCalledWith('accelerated', 'queue') + }) + it('disabled (session removed) locks the textarea and chrome', () => { const { textarea, view } = bench({ disabled: true }) expect(textarea.disabled).toBe(true) diff --git a/packages/client/ui-conversation/tests/queue-dock.spec.tsx b/packages/client/ui-conversation/tests/queue-dock.spec.tsx index 4195488a53..371766db5c 100644 --- a/packages/client/ui-conversation/tests/queue-dock.spec.tsx +++ b/packages/client/ui-conversation/tests/queue-dock.spec.tsx @@ -305,11 +305,34 @@ describe('QueueDock', () => { expect(rendered.getByLabelText('插话发送').getAttribute('title')).toBe('仅运行中可插话发送') }) - it('keeps the row and reports a strict steer race', async () => { + it('renders a session-backed subagent Queue without unsupported actions', () => { + const snap = { + ...snapshotWith([row('i-subagent', 'pending child follow-up')]), + subagent: { + address: { + parentSessionId: 'parent' as SessionId, + childSessionId: SID, + mode: 'continuable' as const, + }, + parentAvailable: true, + }, + } + const source = liveSession(snap) + const view = render( + , + ) + + expect(view.getByText('pending child follow-up')).toBeTruthy() + expect(view.queryByLabelText('编辑排队消息')).toBeNull() + expect(view.queryByLabelText('删除排队消息')).toBeNull() + expect(view.queryByLabelText('插话发送')).toBeNull() + }) + + it('keeps the row and reports a genuine steer failure', async () => { const snap = snapshotWith([row('i-steer-race', 'pending steer')]) const source = liveSession(snap) const notify = vi.fn() - const updateQueue = vi.fn(() => Promise.reject(new Error('steer unavailable'))) + const updateQueue = vi.fn(() => Promise.reject(new Error('transport failed'))) const { getByLabelText, getByText } = render( , ) @@ -318,7 +341,7 @@ describe('QueueDock', () => { await waitFor(() => { expect(notify).toHaveBeenCalledWith( 'error', - '插话失败:当前回复已结束,或这条消息已经开始发送。', + '插话发送失败,请重试。', ) }) expect(getByText('pending steer')).toBeTruthy() diff --git a/packages/client/ui-conversation/tests/service-orchestration.spec.ts b/packages/client/ui-conversation/tests/service-orchestration.spec.ts index 41a21b86e3..ebd51f8408 100644 --- a/packages/client/ui-conversation/tests/service-orchestration.spec.ts +++ b/packages/client/ui-conversation/tests/service-orchestration.spec.ts @@ -50,6 +50,29 @@ describe('ConversationService', () => { await expect(b.scoped.send('x')).rejects.toThrow('conversation.send failed: agent-busy: busy') b.cancel.mockResolvedValueOnce({ ok: false, error: { code: 'internal', message: 'nope', details: {} } } as never) await expect(b.scoped.cancel()).rejects.toThrow('conversation.cancel failed: internal: nope') + b.updateQueue.mockResolvedValueOnce({ + ok: false, error: { code: 'internal', message: 'broken', details: {} }, + } as never) + await expect(b.scoped.updateQueue('item-1' as never, { kind: 'steer' })) + .rejects.toThrow('conversation.updateQueue failed: internal: broken') + await b.runtime.dispose() + }) + + it('treats strict-steer races as converged Queue delivery', async () => { + const b = await bench() + b.updateQueue.mockResolvedValueOnce({ + ok: false, error: { code: 'steer-unavailable', message: 'closed', details: {} }, + } as never) + await expect(b.scoped.updateQueue('item-1' as never, { kind: 'steer' })).resolves.toBeUndefined() + b.updateQueue.mockResolvedValueOnce({ + ok: false, error: { code: 'queue-item-not-found', message: 'claimed', details: {} }, + } as never) + await expect(b.scoped.updateQueue('item-2' as never, { kind: 'steer' })).resolves.toBeUndefined() + b.updateQueue.mockResolvedValueOnce({ + ok: false, error: { code: 'queue-item-not-found', message: 'claimed', details: {} }, + } as never) + await expect(b.scoped.updateQueue('item-3' as never, { kind: 'remove' })) + .rejects.toThrow('conversation.updateQueue failed: queue-item-not-found: claimed') await b.runtime.dispose() }) diff --git a/packages/client/ui-conversation/tests/submission-policy.spec.ts b/packages/client/ui-conversation/tests/submission-policy.spec.ts index 485840fc2e..5b892982ab 100644 --- a/packages/client/ui-conversation/tests/submission-policy.spec.ts +++ b/packages/client/ui-conversation/tests/submission-policy.spec.ts @@ -13,19 +13,21 @@ describe('ComposerSubmissionPolicy', () => { it('defaults to Queue and only applies the preference while running', () => { const policy = new ComposerSubmissionPolicy() expect(policy.busyEnter.getSnapshot()).toBe(DEFAULT_BUSY_ENTER_BEHAVIOR) - expect(policy.resolve(false, 'enter')).toBe('queue') - expect(policy.resolve(false, 'accelerated')).toBe('queue') - expect(policy.resolve(true, 'enter')).toBe('queue') - expect(policy.resolve(true, 'accelerated')).toBe('steer') + expect(policy.resolve(false, 'enter', true)).toBe('queue') + expect(policy.resolve(false, 'accelerated', true)).toBe('queue') + expect(policy.resolve(true, 'enter', true)).toBe('queue') + expect(policy.resolve(true, 'accelerated', true)).toBe('steer') + expect(policy.resolve(true, 'enter', false)).toBe('queue') + expect(policy.resolve(true, 'accelerated', false)).toBe('queue') const changed = vi.fn() policy.busyEnter.subscribe(changed) policy.setBusyEnter('steer') expect(changed).toHaveBeenCalledTimes(1) - expect(policy.resolve(true, 'enter')).toBe('steer') - expect(policy.resolve(true, 'accelerated')).toBe('queue') - expect(policy.resolve(false, 'enter')).toBe('queue') - expect(policy.resolve(false, 'accelerated')).toBe('queue') + expect(policy.resolve(true, 'enter', true)).toBe('steer') + expect(policy.resolve(true, 'accelerated', true)).toBe('queue') + expect(policy.resolve(false, 'enter', true)).toBe('queue') + expect(policy.resolve(false, 'accelerated', true)).toBe('queue') expect(localStorage.getItem(BUSY_ENTER_STORAGE_KEY)).toBe('steer') }) diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 88fcd64ead..7954d31cf3 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -264,7 +264,12 @@ export class ReactLoopAgent implements Agent { message: pending.item.message, placement: 'steering', }) - this.outbox.push({ message: item.message, steering: true, item }) + this.outbox.push({ + message: item.message, + steering: true, + item, + ...pending.delivery === undefined ? {} : { delivery: pending.delivery }, + }) // Publish the replacement only after it is owned by the outbox. Its // enqueue precedes the old occurrence's discard so reentrant // cancellation can terminally account for both occurrences. diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index f61af0833c..672b1aa597 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -179,8 +179,11 @@ describe('addressable inbox operations', () => { const idle = waitForIdle(ctx, agent) send(agent, 'open the turn') + const receipt = agent.steer(createUserMessage({ + content: [{ type: 'text', text: 'steer this message' }], + source: { kind: 'user' }, + })) await entered.promise - send(agent, 'steer this message') const queued = enqueued.find(item => inboxText(item) === 'steer this message')! expect(agent.updateInbox(queued.id, { kind: 'steer' })).toBe('applied') @@ -194,6 +197,7 @@ describe('addressable inbox operations', () => { expect(agent.session.events.flatMap(event => event.type === 'steering/message' ? [event.data.message] : [], )).toEqual([queued.message]) + expect(await receipt.outcome).toEqual({ status: 'admitted', turn: 1, step: 1 }) expect(agent.updateInbox(queued.id, { kind: 'steer' })).toBe('not-found') }) From 975b57b54aec648baf77219fea8d03c1b231478e Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Sun, 2 Aug 2026 05:29:25 -0700 Subject: [PATCH 14/19] feat(web): follow live reasoning tail while collapsed --- ...6-08-02-web-thinking-tail-scroll.i18n.yaml | 6 ++++ .../2026-08-02-web-thinking-tail-scroll.md | 31 ++++++++++++++++ .../2026-08-02-web-thinking-tail-scroll.zh.md | 31 ++++++++++++++++ apps/web/tests/lifecycle-chrome.e2e.ts | 19 ++++++++-- .../client/ui-conversation/README.i18n.yaml | 4 +-- packages/client/ui-conversation/README.md | 2 ++ packages/client/ui-conversation/README.zh.md | 2 ++ .../src/client/chat/AssistantMarkdown.tsx | 9 ++++- .../src/client/chat/ToolRow.module.css | 5 +++ .../src/client/chat/ToolRow.tsx | 23 +++++++++--- .../tests/chat-tool-row.spec.tsx | 36 +++++++++++++++++++ 11 files changed, 158 insertions(+), 10 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md create mode 100644 .agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.zh.md diff --git a/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.i18n.yaml b/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.i18n.yaml new file mode 100644 index 0000000000..f4f0ef3891 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.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-02-web-thinking-tail-scroll.md +2026-08-02-web-thinking-tail-scroll.md: c45840731153627b4ce460ee140257ba33d2c007 +2026-08-02-web-thinking-tail-scroll.zh.md: b8d0444d62294e123bec1d26cb4c07538bbf966f diff --git a/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md b/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md new file mode 100644 index 0000000000..c458407311 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md @@ -0,0 +1,31 @@ +# Agent Note: Web thinking tail scroll — collapsed reasoning follows live output + +Status: implemented + +English | [中文](2026-08-02-web-thinking-tail-scroll.zh.md) + +## Problem + +The Web Think row rendered the first reasoning line as its collapsed summary for both settled and streaming blocks. Once that first line existed, every later reasoning delta changed hidden body text only. A fast model therefore looked stationary while it was thinking, and the user had to expand the full chain of thought to verify that output was still moving. The product backlog already called for “thinking: scrolling chain-of-thought updates, expandable”; the current row satisfied only the second half. + +## Decision + +Only a collapsed Think row whose reasoning block is the active streaming tail follows live output. Its summary is the latest non-blank line instead of the settled first line, and the existing single-line summary element becomes a programmatic horizontal scrollport pinned to `scrollWidth - clientWidth` after each text update. Direct `scrollLeft` assignment deliberately follows real deltas without inventing an independent marquee speed: fast tokens move fast, a paused model stops, and short text stays still because the scroll range is zero. + +The behavior is owned by the existing presentation components. `AssistantMarkdown` chooses the latest line only while the Think row is running; `ToolRow` already owns collapsed/open state and therefore owns whether its summary should follow the inline end. No session, wire, durable event, or model-visible contract changes. Expanding removes the collapsed summary and renders the complete reasoning body in ordinary page flow. When the row settles, it restores the stable first line and resets the summary to the left edge. Other tool summaries and settled Think rows retain their existing ellipsis behavior. + +## Alternatives considered + +**Animate a CSS marquee independent of streaming.** Rejected: it would keep moving through provider stalls and make a slow model look fast, which breaks the throughput signal the interaction exists to expose. + +**Always show a fixed suffix of the complete reasoning string.** Rejected: character slicing can cut a word or grapheme, discards the current line’s beginning before overflow actually requires it, and jumps rather than moving with each delta. + +**Auto-scroll the expanded reasoning body or the conversation page.** Rejected: expanded content is a reading surface. Forcing it to follow would fight a user who scrolls back; the follower belongs only to the collapsed one-line summary. + +## Consequences + +The collapsed row now communicates provider cadence through content motion as well as the existing sweep, while the settled transcript remains byte-for-byte stable. The scroll update runs only on React renders the streaming accumulator already causes; it adds no timer, animation loop, subscription, durable state, or transport traffic. A long current reasoning line retains its full DOM text and programmatically clips the already-overflowing prefix, so expansion still reveals the complete block and assistive technology reads the same current summary text. + +## Testing + +`packages/client/ui-conversation/tests/chat-tool-row.spec.tsx` pins the latest-line selection, the calculated right-edge scroll position, and the settlement reset to the first line and `scrollLeft = 0`. The keyless assembled Chromium scenario in `apps/web/tests/lifecycle-chrome.e2e.ts` replays real recorded reasoning chunks at observable pacing, narrows the viewport until the summary overflows, and asserts that the live collapsed Think row reaches its actual browser scroll extent. Its settled replay golden remains unchanged, proving the historical summary contract stays stable. diff --git a/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.zh.md b/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.zh.md new file mode 100644 index 0000000000..b8d0444d62 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.zh.md @@ -0,0 +1,31 @@ +# Agent Note:Web 思考尾部滚动 —— 折叠态 reasoning 跟随实时输出 + +Status: implemented + +[English](2026-08-02-web-thinking-tail-scroll.md) | 中文 + +## 问题 + +Web Think 行在结算与流式 block 中都把 reasoning 首行渲染成折叠摘要。首行一旦出现,之后每个 reasoning delta 只会改变隐藏的正文。于是快速模型在思考时看起来静止,用户必须展开完整思维链才能确认输出仍在推进。产品事项表已经要求“thinking:滚动展示思维链更新、可展开”;当前行只满足了后半项。 + +## 决策 + +只有 reasoning block 是当前流式尾部、且仍处于折叠态的 Think 行会跟随实时输出。其摘要使用最新的非空行,而不是结算后的首行;已有单行摘要元素成为程序化横向滚动区,每次文本更新后钉到 `scrollWidth - clientWidth`。这里刻意直接赋值 `scrollLeft`,通过真实 delta 推进而不虚构独立的跑马灯速度:token 快则移动快,模型停顿则停止,短文本因滚动范围为零而保持静止。 + +该行为由已有呈现组件拥有。`AssistantMarkdown` 只在 Think 行运行时选择最新行;`ToolRow` 已经拥有折叠/展开状态,因此由它决定摘要是否追随行内末端。不改变 session、wire、持久事件或模型可见契约。展开会移除折叠摘要,并让完整 reasoning 正文进入普通页面流。该行结算后恢复稳定首行,同时把摘要重置到左端。其他工具摘要与已结算 Think 行保留已有省略号行为。 + +## 曾考虑的替代方案 + +**播放与流式输出无关的 CSS 跑马灯。** 否决:它会在 provider 停顿时继续移动,让慢模型显得很快,破坏该交互本应暴露的吞吐信号。 + +**始终显示完整 reasoning 字符串的固定后缀。** 否决:按字符切片可能截断单词或字素,在内容真正溢出前就丢掉当前行的开头,而且只会跳变,无法随每个 delta 移动。 + +**自动滚动展开的 reasoning 正文或会话页面。** 否决:展开内容是阅读界面,强制跟随会与向上回看的用户争夺滚动;跟随器只属于折叠的单行摘要。 + +## 后果 + +折叠行现在会同时通过内容移动和已有扫光传达 provider 节奏,而结算后的 transcript 保持逐字节稳定。滚动更新只发生在流式累加器本就会触发的 React 渲染中;不会增加计时器、动画循环、订阅、持久状态或传输流量。较长的当前 reasoning 行仍会把完整文本留在 DOM 中,只以编程方式裁掉已经溢出的前缀,因此展开仍能显示完整 block,辅助技术读到的也仍是同一份当前摘要文本。 + +## 测试 + +`packages/client/ui-conversation/tests/chat-tool-row.spec.tsx` 固定最新行选择、算出的右端滚动位置,以及结算后恢复首行和 `scrollLeft = 0`。`apps/web/tests/lifecycle-chrome.e2e.ts` 中的 keyless 完整 Chromium 场景以可观察节奏回放真实录制的 reasoning chunks,把视口收窄到摘要溢出,并断言实时折叠 Think 行到达真实浏览器的滚动边界。其结算态 replay golden 保持不变,证明历史摘要契约仍然稳定。 diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts index e285b0f0ac..65098063ad 100644 --- a/apps/web/tests/lifecycle-chrome.e2e.ts +++ b/apps/web/tests/lifecycle-chrome.e2e.ts @@ -158,8 +158,23 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () } const settled = scaffold.whenTurnSettled() await input.fill(PROMPT) - await input.press('Enter') - const sessionId = await settled + const observeTurn = async () => { + if (MODE !== 'record') await page.setViewportSize({ width: 640, height: 1000 }) + try { + await input.press('Enter') + if (MODE !== 'record') { + const liveTail = page.locator('[data-variant="think"][data-state="running"] [data-follow-end]') + await expect.poll(async () => await liveTail.evaluate(element => ( + element.scrollWidth > element.clientWidth + && element.scrollLeft >= element.scrollWidth - element.clientWidth - 1 + )), { timeout: 10_000 }).toBe(true) + } + return await settled + } finally { + if (MODE !== 'record') await page.setViewportSize({ width: 1680, height: 1000 }) + } + } + const sessionId = await observeTurn() if (MODE === 'record') { await recordFixture(scaffold, sessionId, FIXTURE) } diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index a78b80035b..d131e31202 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: ea48725b02ad0c440984af4aadfec17d0e63791d -README.zh.md: 654901caca68762e313dda456c1e0df23faf734a +README.md: e610b990dd89204fd7e22e8b86f807d10b8ba439 +README.zh.md: 268e05a806db1468ba689608c178646af132fe25 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index ea48725b02..e610b990dd 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -16,6 +16,8 @@ The session header declares and renders the session-scoped `'conversation.sessio Logged non-user messages render as a default-collapsed `上下文注入` disclosure. It shares the Tool calls header geometry and interaction with `ToolRow` through the package-internal `DisclosureRow`, while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap, shows inline JSON for both `content` and `source`, and synthesizes no tool state, summary, or keyed toolview dispatch ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md)). +A Think row stays collapsed by default and exposes live reasoning throughput without expanding the chain of thought: while its reasoning block is the streaming tail, the summary switches from the settled first line to the latest non-blank line and its one-line scrollport follows each delta to the inline end. Expanding the row removes the moving summary and leaves the full reasoning in ordinary page flow, so page reading never fights an internal follower; settlement restores the stable first-line summary at the left edge ([decision](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md)). + Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and a path summary; that path is a hover-underline link that opens the file with the host OS default application (`host.openPath`, relative paths resolve against the session cwd). Tool rows are not whole-row click targets and do not open the details panel. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged). Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering. A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed `BashRow` carries the card resident below its summary row; since tool rows are no longer details-panel click targets, the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which is what keeps a summary surface bounded — the panel stays the single-call reading surface. Inline output is licensed per render intent — the terminal and web cards, each with its own bound; a generic tool's content remains panel-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)). diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 654901caca..268e05a806 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -14,6 +14,8 @@ 已记录的非用户消息渲染为默认折叠的 `上下文注入` 展开项。它通过包内部的 `DisclosureRow` 与 `ToolRow` 共享 Tool calls 标题栏的几何与交互,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,并以内联 JSON 展示 `content` 和 `source`,且不会合成工具状态、摘要或键控 toolview 分发([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md))。 +Think 行默认保持折叠,并在不展开思维链的情况下暴露实时推理吞吐:当 reasoning block 是流式尾部时,摘要从结算后的首行切换到最新的非空行,其单行滚动区会随每个 delta 追到行内末端。展开该行会移除移动摘要,让完整 reasoning 进入普通页面流,因此页面阅读不会与内部跟随器争夺滚动;结算后恢复左对齐的稳定首行摘要([决策](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md))。 + 通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和路径摘要;该路径是悬停下划线链接,点击后通过宿主操作系统的默认应用打开文件(`host.openPath`,相对路径相对会话 cwd 解析)。工具行不再是整行点击目标,也不会打开 details 面板。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行)。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect`、`Mount temporary Plugin` 和 `Unmount temporary Plugin`;mount 行保留 code 变体的可展开源码渲染。 声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView`/`resultView` 对推导的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null,落回通用路径。因此两个渲染点也都显示卡片的运行状态点,它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。多行命令的每一行各占一个提示行,状态点只在第一行为整次调用标记一次——退出状态属于整次调用,因此每行一枚就会声称一个 bash 并不报告的逐行结果。键控的 `BashRow` 把卡片常驻在摘要行下方;由于工具行已不再是详情面板的点击目标,卡片的复制与展开控件就是该行唯一的交互。渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`(8),面板为 16,正是这一点让摘要面保持有界——面板仍是单次调用的阅读面。内联输出按渲染意图开放——终端卡片与 web 卡片,各有自己的上限;通用工具的内容仍然只在面板中呈现([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。 diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx index 387a7fd82a..f5cbadef8a 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx @@ -39,6 +39,13 @@ function firstLine(text: string): string { return nl === -1 ? text : text.slice(0, nl) } +/** Latest non-blank reasoning line while the block is still streaming. */ +function latestLine(text: string): string { + const visible = text.trimEnd() + const nl = visible.lastIndexOf('\n') + return nl === -1 ? visible : visible.slice(nl + 1) +} + /** Joined text blocks for the copy action (reasoning / tool heads stay out). */ function copyText(blocks: readonly AssistantBlock[]): string { const parts: string[] = [] @@ -61,7 +68,7 @@ function ThinkRow({ text, running, t }: { text: string; running: boolean; t: Ass variant="think" icon={} title="Think" - summary={firstLine(text)} + summary={running ? latestLine(text) : firstLine(text)} body={text} state={running ? 'running' : 'ok'} /> diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css index 81e41b066f..14bfe0e76c 100644 --- a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css +++ b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css @@ -84,6 +84,11 @@ color: var(--dsw-alias-label-tertiary); } +/* Live reasoning follows its one-line summary to the inline end. */ +.summary[data-follow-end] { + text-overflow: clip; +} + /* File-tool path: same geometry as .summary; hover underline + pointer. */ .fileLink { flex: 1 1 auto; diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx index 46cdbda191..4ec07aa6fc 100644 --- a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx +++ b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx @@ -19,7 +19,7 @@ // independent); an error row's collapsed summary is the failure's first line in // the error color. -import { useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react' +import { useLayoutEffect, useRef, useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react' import clsx from 'clsx' import { CodeBlock, DiffBlock, ReadBlock, SearchBlock, StateDot, TerminalBlock, WebBlock, @@ -152,6 +152,7 @@ export function ToolRow({ inspect, }: ToolRowProps) { const [expanded, setExpanded] = useState(false) + const summaryRef = useRef(null) const terminalBody = terminal ?? null const diffBody = diff ?? null const readBody = read ?? null @@ -173,6 +174,15 @@ export function ToolRow({ const summaryText = failureLine ?? summary // The failure line is error prose, not the path: no open-file affordance. const fileLink = filePath !== undefined && onOpenFile !== undefined && failureLine === null + const isThink = variant === 'think' + const followSummaryEnd = isThink && state === 'running' && !open + useLayoutEffect(() => { + const summaryElement = summaryRef.current + if (summaryElement === null) return + summaryElement.scrollLeft = followSummaryEnd + ? summaryElement.scrollWidth - summaryElement.clientWidth + : 0 + }, [followSummaryEnd, summaryText]) const toggleExpand = () => { setExpanded(v => !v) } @@ -188,9 +198,8 @@ export function ToolRow({ if (event.key === 'Enter' || event.key === ' ') event.stopPropagation() } // Think reasoning is prose, not an input payload: expanded, it renders as - // plain indented text (no IN/OUT card) and the inline summary — the body's - // own first line — yields to avoid repeating itself. - const isThink = variant === 'think' + // plain indented text (no IN/OUT card) and the inline summary yields to avoid + // repeating the body. // The code variant's program renders through CodeBlock (shiki), so only its // output joins the IN/OUT card; every other variant's input does too. const cardBody = variant === 'code' ? null : body @@ -227,7 +236,11 @@ export function ToolRow({ {summaryText} ) : ( - + {summaryText} )} diff --git a/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx b/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx index e0d01656ba..ff9a2a3f07 100644 --- a/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx @@ -320,6 +320,42 @@ describe('ToolRow', () => { }) describe('ThinkRow', () => { + it('follows the latest streaming line, scrolls to its end, then restores the settled first line', () => { + const view = render( + , + ) + const summary = view.getByText('Newest reasoning tokens') + Object.defineProperties(summary, { + scrollWidth: { configurable: true, value: 300 }, + clientWidth: { configurable: true, value: 100 }, + }) + + view.rerender( + , + ) + expect(summary.scrollLeft).toBe(200) + expect(summary.getAttribute('data-follow-end')).toBe('true') + + view.rerender( + , + ) + expect(view.getByText('Inspect the session')).toBeTruthy() + expect(summary.scrollLeft).toBe(0) + expect(summary.hasAttribute('data-follow-end')).toBe(false) + }) + it('expands from either Think or the reasoning summary', () => { const view = render( Date: Sun, 2 Aug 2026 05:58:31 -0700 Subject: [PATCH 15/19] feat(llm-replay): resolve {{fromRequest:...}} placeholders against the live request A scripted sidecar cannot know values minted at run time, so terminal goal updates (which must echo the random goal id) were previously un-scriptable. Placeholders in scripted entries now resolve against the request corpus at stream time: last match wins, capture group 1 or the whole match substitutes, and unmatched/invalid/unterminated patterns fail loud. --- packages/support/llm-replay/README.i18n.yaml | 4 +- packages/support/llm-replay/README.md | 4 +- packages/support/llm-replay/README.zh.md | 4 +- packages/support/llm-replay/src/index.ts | 86 ++++++++++++++++++- .../llm-replay/tests/llm-replay.spec.ts | 71 ++++++++++++++- 5 files changed, 163 insertions(+), 6 deletions(-) diff --git a/packages/support/llm-replay/README.i18n.yaml b/packages/support/llm-replay/README.i18n.yaml index e4f6711ea7..b6a31ebb99 100644 --- a/packages/support/llm-replay/README.i18n.yaml +++ b/packages/support/llm-replay/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/support/llm-replay/README.md -README.md: 0deb6e76b29d40483b754ac01c98ee0e01bfcbe8 -README.zh.md: 7720e2d1bc6eb7bc5c89d5c1708767a54a7b0080 +README.md: ea52525ee85aae58006c852afe93291ea70807d5 +README.zh.md: e8c0ec225df29fc6f5493776d75bc7f9e3e078de diff --git a/packages/support/llm-replay/README.md b/packages/support/llm-replay/README.md index 0deb6e76b2..ea52525ee8 100644 --- a/packages/support/llm-replay/README.md +++ b/packages/support/llm-replay/README.md @@ -12,6 +12,8 @@ The fixture IS the persisted session log (`/session.jsonl`). Its `assi Two failure modes are not reconstructable from `assistant/chunk` alone — a pure throw before any chunk (e.g. an HTTP 401, where the log holds only a `turn/end {error}` and no chunks) and a cancel/hang (timing, not chunk content). A scenario that needs those supplies an optional sidecar (`/replay.override.json`) that either replaces the derived script (a bare `ReplayEntry[]`) or augments it (`{ patches: [{ at, entry }] }`: keep every JSONL-derived call and swap the named 0-based call indexes; `at` equal to the derived length appends the retry attempt after an injected transient throw). Patch indexes must be unique. The override document, each patch and entry, and every chunk discriminant are validated when the file loads. A `hang` entry may name `readyFile`; replay writes that empty marker after its prefix chunks reach the loop and before it waits for cancellation, so an external driver can cancel deterministically without observing a presentation update. +A scripted string may embed `{{fromRequest:}}` to fill a value no static sidecar can know — for example a randomly minted goal id the model must echo back into `update_goal`. At stream time every placeholder resolves against the live request: the corpus is every string leaf of the request messages joined by newlines, the pattern's LAST corpus match wins, and its first capture group (or the whole match without one) substitutes in place. A pattern that matches nothing, an invalid pattern, and an unterminated placeholder each fail loud; the first `}}` ends the placeholder, so patterns cannot contain `}}`. + ## Nested agents: per-session keying A scenario where a parent agent delegates to in-process subagents records more than one log: the parent (`session.jsonl`) plus one per child (`session.1.jsonl`, …). Each agent runs as its own `Session` on the same context, so replay must serve each one its own script. @@ -55,7 +57,7 @@ Replay keys every call by its calling session id (`GenerateOptions.sessionId`, s - `installLlmReplay(ctx, config)` — install the configured replay adapter or catch-all `llm/stream` listener; returns a `ReplayHandle` (`dispose()` for HMR safety plus `assertConsumed()`, the teardown check that every recorded script bound to a live session and every bound cursor drained — turning a scenario that silently drove fewer model calls than recorded into a crisp diagnostic). Use this in tests to drive replay without the Loader or env vars. - `loadSessionScripts(config)` — resolve the ordered `SessionScript[]` (primary + children) for a scenario, ready to bind to live sessions in first-call order. - `loadReplayScript(config)` — resolve the `ReplayEntry[]` for the primary session only (validated sidecar replacement/patches if present, else derived from the JSONL; fail-loud if the fixture is missing). -- `deriveReplayScript(events)` / `parseSessionLog(text)` / `parseSessionHeader(text)` — the pure helpers that turn a recorded session log into a script and read its header `id`/`createdAt`. A derived group must end in a `finish` chunk; a group without one is the fingerprint of a thrown `stream()` and must instead be expressed via an override sidecar. +- `deriveReplayScript(events)` / `parseSessionLog(text)` / `parseSessionHeader(text)` / `resolveScriptedEntry(entry, messages)` — the pure helpers that turn a recorded session log into a script, read its header `id`/`createdAt`, and resolve `{{fromRequest:...}}` placeholders against one live request. A derived group must end in a `finish` chunk; a group without one is the fingerprint of a thrown `stream()` and must instead be expressed via an override sidecar. - Types `ReplayEntry` / `ReplayOverrideDoc` / `ReplayOverridePatch` / `SessionScript` / `ReplayConfig` / `ReplayProviderConfig` / `ReplayModelConfig` / `ReplayHandle` / `Config`. ## Plugin export shape diff --git a/packages/support/llm-replay/README.zh.md b/packages/support/llm-replay/README.zh.md index 7720e2d1bc..e8c0ec225d 100644 --- a/packages/support/llm-replay/README.zh.md +++ b/packages/support/llm-replay/README.zh.md @@ -12,6 +12,8 @@ fixture 就是持久化的会话日志(`/session.jsonl`)。其 `as 有两种失败模式无法仅根据 `assistant/chunk` 重建:在产生任何分片前直接抛出异常(例如 HTTP 401,此时日志只有 `turn/end {error}` 而没有分片),以及取消或挂起(差异在时序,而非分片内容)。需要这些行为的场景可提供伴随文件(`/replay.override.json`):它可以替换派生脚本(裸 `ReplayEntry[]`),也可以增补派生脚本(`{ patches: [{ at, entry }] }`:保留所有从 JSONL 派生的调用,只替换指定的从 0 开始计数的调用索引;当 `at` 等于派生长度时,则在注入瞬态异常后的重试位置追加一次调用)。补丁索引不得重复。文件加载时会校验覆写文档、每个补丁和条目,以及每个分片的判别标签。`hang` 条目可以指定 `readyFile`;当前缀分片到达循环后、开始等待取消前,回放会写入这个空标记,使外部驱动程序无需观察展示层更新即可确定性地取消。 +脚本字符串可以内嵌 `{{fromRequest:}}`,用来填入静态伴随文件不可能预知的值——例如模型必须原样回填到 `update_goal` 的随机生成 goal id。回放时每个占位符针对实时请求解析:语料是请求消息的所有字符串叶子按换行拼接的结果,取该模式在语料中的最后一次匹配,用其第一个捕获组(无捕获组时用整个匹配)原位替换。模式匹配不到内容、模式非法、占位符未闭合都会明确报错;第一个 `}}` 即结束占位符,因此模式本身不能包含 `}}`。 + ## 嵌套 agent:每会话键控 父 agent 委托给进程内 subagent(子 agent)的场景会记录多个日志:父会话使用 `session.jsonl`,每个子会话各使用一个日志(`session.1.jsonl` 等)。每个 agent 都在同一上下文中作为独立的 `Session` 运行,因此回放必须为每个 agent 提供各自的脚本。 @@ -55,7 +57,7 @@ fixture 就是持久化的会话日志(`/session.jsonl`)。其 `as - `installLlmReplay(ctx, config)`:安装已配置回放适配器或 catch-all `llm/stream` 监听器;返回 `ReplayHandle`(包含用于保证 HMR(热模块替换)安全的 `dispose()`,以及清理阶段执行的 `assertConsumed()` 检查;后者确保每个已记录脚本都绑定到实时会话,且每个已绑定游标都已耗尽,从而将场景静默驱动的模型调用少于记录数转换为明确诊断)。在测试中使用它,可以不通过 Loader 或 env var 驱动回放。 - `loadSessionScripts(config)`:解析场景的有序的 `SessionScript[]`(主会话 + 子会话),准备按首次调用顺序绑定到实时会话。 - `loadReplayScript(config)`:只解析主会话的 `ReplayEntry[]`(如果伴随文件存在,则使用经校验的替换或补丁;否则从 JSONL 派生;fixture 缺失时明确报错)。 -- `deriveReplayScript(events)` / `parseSessionLog(text)` / `parseSessionHeader(text)`:将已记录会话日志转换为脚本并读取其 header `id`/`createdAt` 的纯辅助工具。派生分组必须以 `finish` 分片结束;没有该分片的分组是 `stream()` 抛出异常的指纹,必须改用 override sidecar 表达。 +- `deriveReplayScript(events)` / `parseSessionLog(text)` / `parseSessionHeader(text)` / `resolveScriptedEntry(entry, messages)`:将已记录会话日志转换为脚本、读取其 header `id`/`createdAt`、并针对单次实时请求解析 `{{fromRequest:...}}` 占位符的纯辅助工具。派生分组必须以 `finish` 分片结束;没有该分片的分组是 `stream()` 抛出异常的指纹,必须改用 override sidecar 表达。 - 类型 `ReplayEntry` / `ReplayOverrideDoc` / `ReplayOverridePatch` / `SessionScript` / `ReplayConfig` / `ReplayProviderConfig` / `ReplayModelConfig` / `ReplayHandle` / `Config`。 ## 插件导出形态 diff --git a/packages/support/llm-replay/src/index.ts b/packages/support/llm-replay/src/index.ts index bfb9859415..70dae88446 100644 --- a/packages/support/llm-replay/src/index.ts +++ b/packages/support/llm-replay/src/index.ts @@ -241,6 +241,90 @@ const REPLAY_CHUNK_TYPES = new Set([ 'finish', ]) +const FROM_REQUEST_OPEN = '{{fromRequest:' +const FROM_REQUEST_CLOSE = '}}' + +/** Collect every string leaf of one JSON-shaped value, in traversal order. */ +function collectStrings(value: unknown, out: string[]): void { + if (typeof value === 'string') { + out.push(value) + return + } + if (Array.isArray(value)) { + for (const item of value) collectStrings(item, out) + return + } + if (value !== null && typeof value === 'object') { + for (const item of Object.values(value)) collectStrings(item, out) + } +} + +/** Resolve one placeholder pattern against the request corpus; the LAST match wins. */ +function resolveFromRequest(pattern: string, corpus: string): string { + let regex: RegExp + try { + regex = new RegExp(pattern, 'g') + } catch (error) { + // RegExp construction only throws SyntaxError; String() carries its message. + throw new Error(`llm-replay: fromRequest has an invalid pattern ${JSON.stringify(pattern)}: ${String(error)}`) + } + let last: RegExpExecArray | undefined + for (const match of corpus.matchAll(regex)) last = match + if (last === undefined) { + throw new Error(`llm-replay: fromRequest pattern ${JSON.stringify(pattern)} matched nothing in the request`) + } + return last[1] ?? last[0] +} + +/** Replace every `{{fromRequest:}}` occurrence in one scripted string. */ +function substituteString(text: string, corpus: string): string { + let result = '' + let cursor = 0 + while (true) { + const open = text.indexOf(FROM_REQUEST_OPEN, cursor) + if (open === -1) return result + text.slice(cursor) + const close = text.indexOf(FROM_REQUEST_CLOSE, open + FROM_REQUEST_OPEN.length) + if (close === -1) { + throw new Error(`llm-replay: fromRequest placeholder is unterminated in ${JSON.stringify(text)}`) + } + const pattern = text.slice(open + FROM_REQUEST_OPEN.length, close) + result += text.slice(cursor, open) + resolveFromRequest(pattern, corpus) + cursor = close + FROM_REQUEST_CLOSE.length + } +} + +/** Deep-copy one JSON-shaped value with scripted placeholders resolved. */ +function substituteValue(value: unknown, corpus: string): unknown { + if (typeof value === 'string') { + return value.includes(FROM_REQUEST_OPEN) ? substituteString(value, corpus) : value + } + if (Array.isArray(value)) return value.map(item => substituteValue(item, corpus)) + if (value !== null && typeof value === 'object') { + return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, substituteValue(item, corpus)])) + } + return value +} + +/** + * Resolve every `{{fromRequest:}}` placeholder in one scripted entry + * against the live request. The corpus is every string leaf of the request + * messages joined by newlines; the pattern's LAST corpus match wins and its + * first capture group (or, without one, the whole match) substitutes in place. + * Scenario sidecars use this to script arguments no static file can know, + * such as a randomly minted goal id the model must echo back. A pattern that + * matches nothing, an invalid pattern, and an unterminated placeholder each + * fail loud. Patterns cannot contain `}}` — the first `}}` ends the placeholder. + * @param entry - the scripted entry about to replay. + * @param messages - the live request messages searched by the placeholders. + * @returns the entry itself when no placeholder appears, else a resolved deep copy. + */ +export function resolveScriptedEntry(entry: ReplayEntry, messages: GenerateOptions['messages']): ReplayEntry { + if (!JSON.stringify(entry).includes(FROM_REQUEST_OPEN)) return entry + const leaves: string[] = [] + collectStrings(messages, leaves) + return substituteValue(entry, leaves.join('\n')) as ReplayEntry +} + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } @@ -583,7 +667,7 @@ export function installLlmReplay(ctx: Context, config: ReplayConfig): ReplayHand + `but its script has only ${boundState.entries.length}; re-record the scenario`, ) } - yield* replayEntry(entry, options.signal, paceMs) + yield* replayEntry(resolveScriptedEntry(entry, options.messages), options.signal, paceMs) })() } const providers = config.providers ?? [] diff --git a/packages/support/llm-replay/tests/llm-replay.spec.ts b/packages/support/llm-replay/tests/llm-replay.spec.ts index d693c0571b..345aa22679 100644 --- a/packages/support/llm-replay/tests/llm-replay.spec.ts +++ b/packages/support/llm-replay/tests/llm-replay.spec.ts @@ -4,7 +4,7 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' import type { SessionEvent } from '@deepseek-ai/dsh-session' -import LlmService, { GenerateOptions, LlmAdapter, StreamChunk } from '@deepseek-ai/dsh-llm' +import LlmService, { CallId, createUserMessage, GenerateOptions, LlmAdapter, StreamChunk } from '@deepseek-ai/dsh-llm' import { type ReplayEntry, type SessionScript, @@ -17,6 +17,7 @@ import { name, parseSessionHeader, parseSessionLog, + resolveScriptedEntry, } from '../src/index.ts' /** @@ -310,6 +311,74 @@ describe('installLlmReplay (through the real LlmService)', () => { expect(await drain(ctx.llm.stream({ provider: 'm', model: 'm', messages: [] }))).toEqual(TEXT_CHUNKS) }) + describe('{{fromRequest:...}} substitution', () => { + const requestMessages = [createUserMessage({ + content: [{ type: 'text' as const, text: 'stale {"goal":{"id":"goal-old"}} then {"goal":{"id":"goal-42ab"}}' }], + source: { kind: 'user' as const }, + })] + + function scriptedCall(argumentsDelta: string): StreamChunk[] { + return [ + { type: 'block-start', index: 0, blockType: 'tool-call' }, + { type: 'tool-call-delta', index: 0, id: CallId('c1'), name: 'update_goal', argumentsDelta }, + { type: 'block-end', index: 0, block: { type: 'tool-call', id: CallId('c1'), name: 'update_goal', arguments: argumentsDelta } }, + { type: 'finish', reason: { kind: 'tool-calls' } }, + ] + } + + async function streamScripted(argumentsDelta: string): Promise { + writeLog(TEXT_CHUNKS) + const overrideFile = join(dir, 'replay.override.json') + writeFileSync(overrideFile, JSON.stringify([{ kind: 'chunks', chunks: scriptedCall(argumentsDelta) }]), 'utf8') + const ctx = new Context() + await ctx.plugin(LlmService) + installLlmReplay(ctx, { file, overrideFile }) + return drain(ctx.llm.stream({ provider: 'm', model: 'm', messages: requestMessages })) + } + + it('resolves the capture group from the LAST request match in every scripted string field', async () => { + const streamed = await streamScripted('{"goal_id":"{{fromRequest:"id":"(goal-[^"]+)"}}","revision":1}') + const delta = streamed.find(chunk => chunk.type === 'tool-call-delta') + expect(delta).toMatchObject({ argumentsDelta: '{"goal_id":"goal-42ab","revision":1}' }) + const end = streamed.find(chunk => chunk.type === 'block-end') + expect(end).toMatchObject({ block: { arguments: '{"goal_id":"goal-42ab","revision":1}' } }) + }) + + it('substitutes the whole match when the pattern has no capture group', async () => { + const streamed = await streamScripted('{"goal_id":"{{fromRequest:goal-[0-9a-z]+}}"}') + const delta = streamed.find(chunk => chunk.type === 'tool-call-delta') + expect(delta).toMatchObject({ argumentsDelta: '{"goal_id":"goal-42ab"}' }) + }) + + it('fails loud when a placeholder matches nothing in the request', async () => { + await expect(streamScripted('{"goal_id":"{{fromRequest:task-[0-9]+}}"}')) + .rejects.toThrow(/fromRequest.*matched nothing/) + }) + + it('fails loud on an invalid placeholder pattern', async () => { + await expect(streamScripted('{"goal_id":"{{fromRequest:(goal-}}"}')) + .rejects.toThrow(/fromRequest.*invalid pattern/) + }) + + it('fails loud on an unterminated placeholder', () => { + const entry: ReplayEntry = { kind: 'chunks', chunks: scriptedCall('{"goal_id":"{{fromRequest:goal-1"}') } + expect(() => resolveScriptedEntry(entry, requestMessages)).toThrow(/fromRequest placeholder is unterminated/) + }) + + it('returns the exact same entry when no placeholder appears', () => { + const entry: ReplayEntry = { kind: 'chunks', chunks: TEXT_CHUNKS } + expect(resolveScriptedEntry(entry, requestMessages)).toBe(entry) + }) + + it('skips non-string request leaves when building the corpus', () => { + const messages = requestMessages.map(message => ({ ...message, seq: 7 })) as unknown as GenerateOptions['messages'] + const entry: ReplayEntry = { kind: 'chunks', chunks: scriptedCall('{"goal_id":"{{fromRequest:goal-42[a-z]+}}"}') } + const resolved = resolveScriptedEntry(entry, messages) + if (resolved.kind !== 'chunks') throw new Error('expected chunks entry') + expect(resolved.chunks[1]).toMatchObject({ argumentsDelta: '{"goal_id":"goal-42ab"}' }) + }) + }) + it('registers a replay-only provider catalog when configured', async () => { writeLog(TEXT_CHUNKS) const ctx = new Context() From 666ef95f81058bb0dc6b99bcc359d97be267ee4c Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Sun, 2 Aug 2026 05:58:45 -0700 Subject: [PATCH 16/19] fix(tool-goal): let the model deliver a wrap-up message after a goal-round complete/blocked A goal round reporting complete or blocked used to conclude the turn at the tool result, so the model never spoke after the call and sessions ended on a bare update_goal card. The terminal update now defers one plugin-sourced / instruction onto its result asking for a grounded closing message without further tool calls; the turn then ends through the ordinary no-tool-calls stop. Direct-human mutations stay uninstructed. Wording chosen by A/B sampling on deepseek-v4-pro; one extra request per goal lifecycle. New keyless ACP snapshot goal-wrapup drives the shipped app through create -> round one -> autonomous complete and pins the injection, the same-turn closing message, and the completed turn end. --- ...-08-02-goal-round-wrapup-message.i18n.yaml | 6 ++ .../2026-08-02-goal-round-wrapup-message.md | 31 ++++++++++ ...2026-08-02-goal-round-wrapup-message.zh.md | 31 ++++++++++ ...26-07-19-model-facing-goal-tools.i18n.yaml | 6 +- .../2026-07-19-model-facing-goal-tools.md | 2 +- .../2026-07-19-model-facing-goal-tools.zh.md | 2 +- docs/config-catalog.md | 4 +- .../goal-snapshots/goal-wrapup/input.json | 13 ++++ .../goal-wrapup/replay.override.json | 42 +++++++++++++ .../goal-wrapup/session.expected.jsonl | 50 ++++++++++++++++ .../goal-snapshots/goal-wrapup/session.jsonl | 1 + .../goal-wrapup/stdout.expected.jsonl | 5 ++ examples/acp-agent/tests/goal.snapshot.ts | 59 +++++++++++++++++++ packages/goal/tool-goal/README.i18n.yaml | 4 +- packages/goal/tool-goal/README.md | 6 +- packages/goal/tool-goal/README.zh.md | 6 +- packages/goal/tool-goal/src/index.ts | 12 +++- packages/goal/tool-goal/src/wrapup.ts | 40 +++++++++++++ .../goal/tool-goal/tests/tool-goal.spec.ts | 33 ++++++++++- 19 files changed, 334 insertions(+), 19 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-02-goal-round-wrapup-message.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-02-goal-round-wrapup-message.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-02-goal-round-wrapup-message.zh.md create mode 100644 examples/acp-agent/tests/goal-snapshots/goal-wrapup/input.json create mode 100644 examples/acp-agent/tests/goal-snapshots/goal-wrapup/replay.override.json create mode 100644 examples/acp-agent/tests/goal-snapshots/goal-wrapup/session.expected.jsonl create mode 100644 examples/acp-agent/tests/goal-snapshots/goal-wrapup/session.jsonl create mode 100644 examples/acp-agent/tests/goal-snapshots/goal-wrapup/stdout.expected.jsonl create mode 100644 packages/goal/tool-goal/src/wrapup.ts diff --git a/.agents/notes/implemented/bug-fix/2026-08-02-goal-round-wrapup-message.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-02-goal-round-wrapup-message.i18n.yaml new file mode 100644 index 0000000000..c8784f5991 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-02-goal-round-wrapup-message.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-02-goal-round-wrapup-message.md +2026-08-02-goal-round-wrapup-message.md: c6bc3d5912b0789efde55880c2be892e98e34a5b +2026-08-02-goal-round-wrapup-message.zh.md: 0a504b4dcc61feb932775b3d9ffd8424f3b0597d diff --git a/.agents/notes/implemented/bug-fix/2026-08-02-goal-round-wrapup-message.md b/.agents/notes/implemented/bug-fix/2026-08-02-goal-round-wrapup-message.md new file mode 100644 index 0000000000..c6bc3d5912 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-02-goal-round-wrapup-message.md @@ -0,0 +1,31 @@ +# Agent Note: Goal-round wrap-up message + +Status: implemented + +English | [中文](2026-08-02-goal-round-wrapup-message.zh.md) + +## Problem + +An autonomous goal round that reported `update_goal` `complete` or `blocked` concluded the physical turn at the tool result, so the model never spoke after the call. Sessions ended on a bare `update_goal` card, and internal testers read that as the agent stopping mid-sentence: the model's pre-call text routinely announces a report ("goal achieved, marking complete:") that never arrives, because the standard tool-use expectation is one more assistant message after a tool result and neither the goal-round prompt nor the tool description said the call was terminal. The hard stop came from the [goal-tool decision](../feature/2026-07-19-model-facing-goal-tools.md), whose turn-stop clause this note supersedes. + +## Decision + +A goal-round `complete` or `blocked` success no longer calls `concludeTurn()`. Instead the tool defers one wrap-up context onto its own result: a `{ kind: 'plugin', plugin: 'tool-goal' }`-sourced user message carrying a ``/`` instruction to write a grounded closing message to the user and call no more tools. The turn then ends through the agent loop's ordinary no-tool-calls stop, so no new loop primitive exists and steering semantics are untouched. Direct-human mutations remain uninstructed exactly as before. The cost is one additional model request per goal lifecycle, not per round. + +The instruction wording was selected by A/B sampling on `deepseek-v4-pro` with a reconstructed goal-round transcript: a structured instruction (outcome, verification, artifacts, next steps) consistently beat a minimal "summarize" one on completeness; adding a session-grounding clause shifted unsupported detail from asserted fact to hedged suggestion; and the no-instruction control produced high-variance closings, including confidently fabricated file-level detail. + +Scripting the keyless proof required one snapshot-harness addition: `dsh-llm-replay` resolves `{{fromRequest:}}` placeholders in scripted entries against the live request, because a static sidecar cannot know the randomly minted goal id the model must echo into `update_goal`. + +## Verification + +`tool-goal` package tests pin the injected context (source, tag, objective, no-more-tools clause) and the absent `concludesTurn` for both terminal actions, plus the uninstructed direct-human pause and complete paths, at 100% file coverage. `llm-replay` unit tests pin the placeholder contract: last-match-wins capture, whole-match fallback, and loud failures for unmatched, invalid, and unterminated patterns. The new keyless ACP snapshot `goal-wrapup` drives the shipped application through create → round one → autonomous complete and asserts the plugin-sourced wrap-up injection, the same-turn closing assistant message, and the `completed` turn end in both the durable session log and the ACP stdout stream. + +## Alternatives considered + +- **Surface the completion text on the `update_goal` UI card** — rejected: `complete` carries no free text today, and adding a `summary` argument would route a user-facing report through tool arguments while still cutting off the model's natural post-result message. +- **Keep `concludeTurn()` and add a "one more text-only step" loop primitive** — rejected: new `agent-loop` machinery for behavior the ordinary stop already provides once nothing concludes the turn. +- **Instruct inside the tool result content** — rejected: the goal tools' canonical output is compact JSON consumed programmatically; a prose instruction block inside it would mix the model-facing contract with the tool's replayable value. + +## Consequences + +Every autonomous goal ends with a user-facing closing message instead of a bare tool card, at the cost of one model request per goal lifecycle. `concludeTurn()` keeps its loop semantics but loses its only first-party caller outside subagent structured output. Snapshot scenarios can now script values that only exist at run time via `{{fromRequest:...}}`, which unblocks keyless coverage of any echo-an-id tool flow, goal or otherwise. diff --git a/.agents/notes/implemented/bug-fix/2026-08-02-goal-round-wrapup-message.zh.md b/.agents/notes/implemented/bug-fix/2026-08-02-goal-round-wrapup-message.zh.md new file mode 100644 index 0000000000..0a504b4dcc --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-02-goal-round-wrapup-message.zh.md @@ -0,0 +1,31 @@ +# Agent Note:Goal Round 收尾消息 + +Status: implemented + +[English](2026-08-02-goal-round-wrapup-message.md) | 中文 + +## 问题 + +自主 Goal Round 报告 `update_goal` `complete` 或 `blocked` 时,物理轮次在工具结果处直接终结,模型在调用之后再无发言机会。会话终止在一张裸的 `update_goal` 卡片上,内测同学的观感是 agent 话说到一半戛然而止:模型调用前的文本通常预告了一份汇报(“目标达成,标记完成:”)却永远没有下文,因为标准 tool-use 预期是工具结果之后还有一条 assistant 消息,而 Goal Round 提示词与工具描述都没有说明这次调用是终点。硬停止来自 [goal 工具决策](../feature/2026-07-19-model-facing-goal-tools.md),本 note 取代其中的轮次停止条款。 + +## 决策 + +Goal Round 的 `complete` 或 `blocked` 成功不再调用 `concludeTurn()`。工具改为在自己的结果上附带一条收尾上下文:以 `{ kind: 'plugin', plugin: 'tool-goal' }` 为 source 的 user 消息,携带 ``/`` 指令,要求模型向用户写出有依据的收尾消息且不再调用工具。之后轮次经由 agent loop 常规的无工具调用停止路径结束,因此不存在新的 loop 原语,steering 语义不受影响。人类直接变更保持原样、不注入指令。代价是每个 goal 生命周期一次额外模型请求,而非每轮一次。 + +指令措辞通过在 `deepseek-v4-pro` 上用重构的 Goal Round 转录做 A/B 采样选定:结构化指令(结果、验证、产物、后续)在完整度上稳定优于极简“总结一下”;补充“以会话内证据为准”的 grounding 条款让无依据细节从断言事实退为带保留的建议;而无指令对照组的收尾方差很大,包括言之凿凿的文件级细节编造。 + +为让 keyless 证明可脚本化,快照设施补了一项能力:`dsh-llm-replay` 会针对实时请求解析脚本条目中的 `{{fromRequest:}}` 占位符,因为静态伴随文件不可能预知模型必须回填进 `update_goal` 的随机生成 goal id。 + +## 验证 + +`tool-goal` 包测试钉住两个终态 action 注入的上下文(source、标签、objective、禁止再调工具条款)与不存在的 `concludesTurn`,以及人类直接 pause 与 complete 的不注入路径,文件覆盖率 100%。`llm-replay` 单元测试钉住占位符契约:最后一次匹配取胜的捕获、无捕获组时整体匹配回退,以及未匹配、非法、未闭合模式的明确报错。新增 keyless ACP 快照 `goal-wrapup` 驱动成品应用走完 create → 第一轮 → 自主 complete,并在持久会话日志与 ACP stdout 流中同时断言 plugin 来源的收尾注入、同轮内的收尾 assistant 消息与 `completed` 轮次结束。 + +## 曾考虑的替代方案 + +- **在 `update_goal` 的 UI 卡片上展示完成文本** — 拒绝:`complete` 如今不携带任何自由文本;新增 `summary` 参数会让面向用户的汇报走工具参数通道,而且依然砍掉了模型在结果之后的自然发言。 +- **保留 `concludeTurn()` 并新增“再多一步纯文本”的 loop 原语** — 拒绝:为常规停止路径已经能提供的行为(只要没有结果终结轮次)增加新的 `agent-loop` 机制。 +- **把指令写进工具结果内容** — 拒绝:goal 工具的规范输出是被程序化消费的紧凑 JSON;在其中混入散文指令会把模型侧契约和工具的可回放值搅在一起。 + +## Consequences + +每个自主 goal 都以一条面向用户的收尾消息结束,而非一张裸工具卡片,代价是每个 goal 生命周期一次模型请求。`concludeTurn()` 保留其 loop 语义,但在 subagent 结构化输出之外失去了唯一的一方调用者。快照场景现在可以通过 `{{fromRequest:...}}` 脚本化只在运行时才存在的值,为任何“回显 id”类工具流程(不限于 goal)解锁 keyless 覆盖。 diff --git a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml index 6cf16c04ad..1f787065ab 100644 --- a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.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 -2026-07-19-model-facing-goal-tools.md: bc4305af80bb13ceeff1888d489dcd8a00132f94 -2026-07-19-model-facing-goal-tools.zh.md: b07f62aa526902c4b2e9c081777a76ca53783d31 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md +2026-07-19-model-facing-goal-tools.md: 18235c484194f5daf10556ebfc13bdc2d672be2e +2026-07-19-model-facing-goal-tools.zh.md: cc23a76e5faac2c203052d834ca0a87ca5dbed2a diff --git a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md index bc4305af80..18235c4841 100644 --- a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md +++ b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.md @@ -22,7 +22,7 @@ The prompt tells the model that it may infer goal intent from a direct human req All three tools use exclusive execution so a model-ordered batch observes prior mutations and their new revisions. Results are compact JSON. UI presentation is a pure function of arguments and uses generic read or mutation cards; mutation cards select meaningful action values before the goal id, so accepted fillers cannot blank their input. Activation is reported only as live observation and is never written into replay state. -An autonomous goal round that successfully reports completion or blocking marks its tool result as concluding the physical turn, preventing an unnecessary follow-up request. Direct-human mutations do not conclude the turn: the assistant can acknowledge the change, and concurrent human steering remains available to ordinary stopping checks. +An autonomous goal round that successfully reports completion or blocking defers one wrap-up instruction onto its tool result so the model still addresses the user before the turn ends through the ordinary no-tool-calls stop; the original conclude-at-result stop is superseded by the [goal-round wrap-up decision](../bug-fix/2026-08-02-goal-round-wrapup-message.md). Direct-human mutations receive no instruction: the assistant can acknowledge the change, and concurrent human steering remains available to ordinary stopping checks. ### Execution authority diff --git a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md index b07f62aa52..cc23a76e5f 100644 --- a/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md +++ b/.agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.zh.md @@ -22,7 +22,7 @@ Status: implemented 三个工具都采用独占执行,使模型排序的批次可以观察此前变更及其新修订号。结果为紧凑 JSON。UI 展示是参数的纯函数,使用通用读取或变更卡片;变更卡片选择输入时,先取有实际意义的操作值,再取目标 id,因此允许的占位值不会使卡片输入留空。激活态仅作为实时观察返回,绝不会写入回放状态。 -自主目标回合成功报告完成或阻塞后,其工具结果会被标记为结束该物理轮次,避免再发起一次不必要的模型请求。直接人类发起的变更不会结束轮次:agent 可以确认该变更,并且并发的人类 steering(中途引导)仍可参与普通的停止检查。 +自主目标回合成功报告完成或阻塞后,其工具结果会附带一条收尾指令,模型仍会在轮次经由常规无工具调用停止路径结束前向用户发言;原先在结果处终结轮次的做法已被[Goal Round 收尾决策](../bug-fix/2026-08-02-goal-round-wrapup-message.md)取代。直接人类发起的变更不会收到指令:agent 可以确认该变更,并且并发的人类 steering(中途引导)仍可参与普通的停止检查。 ### 执行权限 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 2a40c7d800..c9fa141dd6 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -765,7 +765,7 @@ export interface ReplayModelConfig { Depends on: [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) -Source: [`packages/support/llm-replay/src/index.ts:617`](../packages/support/llm-replay/src/index.ts) +Source: [`packages/support/llm-replay/src/index.ts:701`](../packages/support/llm-replay/src/index.ts) ## `@deepseek-ai/dsh-llm-retry` @@ -1772,7 +1772,7 @@ export interface Config { } ``` -Source: [`packages/goal/tool-goal/src/index.ts:25`](../packages/goal/tool-goal/src/index.ts) +Source: [`packages/goal/tool-goal/src/index.ts:26`](../packages/goal/tool-goal/src/index.ts) ## `@deepseek-ai/dsh-tool-lsp` diff --git a/examples/acp-agent/tests/goal-snapshots/goal-wrapup/input.json b/examples/acp-agent/tests/goal-snapshots/goal-wrapup/input.json new file mode 100644 index 0000000000..8b6865a6ed --- /dev/null +++ b/examples/acp-agent/tests/goal-snapshots/goal-wrapup/input.json @@ -0,0 +1,13 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { + "op": "promptAndWaitForAgentMessage", + "text": "Create a durable goal for the wrap-up snapshot, then report readiness.", + "waitForText": "GOAL READY" + }, + { "op": "waitForTurnStart", "minimumTurn": 2 }, + { "op": "waitForTurnEnd" } + ] +} diff --git a/examples/acp-agent/tests/goal-snapshots/goal-wrapup/replay.override.json b/examples/acp-agent/tests/goal-snapshots/goal-wrapup/replay.override.json new file mode 100644 index 0000000000..d36b73f313 --- /dev/null +++ b/examples/acp-agent/tests/goal-snapshots/goal-wrapup/replay.override.json @@ -0,0 +1,42 @@ +[ + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "tool-call" }, + { "type": "tool-call-delta", "index": 0, "id": "call_goal_create", "name": "create_goal", "argumentsDelta": "{\"objective\":\"Finish the ACP goal wrap-up snapshot proof\",\"max_goal_rounds\":2}" }, + { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_goal_create", "name": "create_goal", "arguments": "{\"objective\":\"Finish the ACP goal wrap-up snapshot proof\",\"max_goal_rounds\":2}" } }, + { "type": "usage", "usage": { "inputTokens": 20, "outputTokens": 8 } }, + { "type": "finish", "reason": { "kind": "tool-calls" } } + ] + }, + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "text" }, + { "type": "text-delta", "index": 0, "text": "GOAL READY" }, + { "type": "block-end", "index": 0, "block": { "type": "text", "text": "GOAL READY" } }, + { "type": "usage", "usage": { "inputTokens": 28, "outputTokens": 2 } }, + { "type": "finish", "reason": { "kind": "stop" } } + ] + }, + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "tool-call" }, + { "type": "tool-call-delta", "index": 0, "id": "call_goal_complete", "name": "update_goal", "argumentsDelta": "{\"goal_id\":\"{{fromRequest:goal-[0-9a-f-]+}}\",\"revision\":1,\"action\":\"complete\"}" }, + { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_goal_complete", "name": "update_goal", "arguments": "{\"goal_id\":\"{{fromRequest:goal-[0-9a-f-]+}}\",\"revision\":1,\"action\":\"complete\"}" } }, + { "type": "usage", "usage": { "inputTokens": 40, "outputTokens": 9 } }, + { "type": "finish", "reason": { "kind": "tool-calls" } } + ] + }, + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "text" }, + { "type": "text-delta", "index": 0, "text": "GOAL WRAP-UP: the snapshot objective is achieved and this closing message reaches the user." }, + { "type": "block-end", "index": 0, "block": { "type": "text", "text": "GOAL WRAP-UP: the snapshot objective is achieved and this closing message reaches the user." } }, + { "type": "usage", "usage": { "inputTokens": 52, "outputTokens": 14 } }, + { "type": "finish", "reason": { "kind": "stop" } } + ] + } +] diff --git a/examples/acp-agent/tests/goal-snapshots/goal-wrapup/session.expected.jsonl b/examples/acp-agent/tests/goal-snapshots/goal-wrapup/session.expected.jsonl new file mode 100644 index 0000000000..bf708d211b --- /dev/null +++ b/examples/acp-agent/tests/goal-snapshots/goal-wrapup/session.expected.jsonl @@ -0,0 +1,50 @@ +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Create a durable goal for the wrap-up snapshot, then report readiness."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":0,"data":{"title":"Create a durable goal for","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"user/message","seq":3,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"step/start","seq":4,"time":0,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":5,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":6,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_goal_create","name":"create_goal","argumentsDelta":"{\"objective\":\"Finish the ACP goal wrap-up snapshot proof\",\"max_goal_rounds\":2}"}}} +{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the ACP goal wrap-up snapshot proof\",\"max_goal_rounds\":2}"}}}} +{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":20,"outputTokens":8}}}} +{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":12,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the ACP goal wrap-up snapshot proof\",\"max_goal_rounds\":2}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":20,"outputTokens":8}},"sourceEventSeqs":[7,8,9,10,11],"surfaceOp":"append"} +{"type":"tool/call","seq":13,"time":0,"data":{"turn":1,"step":1,"callId":"call_goal_create","name":"create_goal","arguments":"{\"objective\":\"Finish the ACP goal wrap-up snapshot proof\",\"max_goal_rounds\":2}"}} +{"type":"tool/result","seq":14,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_goal_create"},"content":[{"type":"tool-result","toolCallId":"call_goal_create","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the ACP goal wrap-up snapshot proof\",\"phase\":\"active\",\"roundsStarted\":0,\"maxGoalRounds\":2},\"activation\":\"armed\"}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[13],"surfaceOp":"append"} +{"type":"user/message","seq":15,"time":0,"data":{"content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":1,\"objective\":\"Finish the ACP goal wrap-up snapshot proof\",\"phase\":\"active\",\"maxGoalRounds\":2},\"roundsStarted\":0,\"createdAt\":0,\"updatedAt\":0}"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":0,"change":{"kind":"goal/change","version":1,"operation":"create","goal":{"id":"goal-{{sessionId}}","revision":1,"objective":"Finish the ACP goal wrap-up snapshot proof","phase":"active","maxGoalRounds":2},"roundsStarted":0,"createdAt":0,"updatedAt":0}},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"step/end","seq":16,"time":0,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":17,"time":0,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"GOAL READY"}}} +{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"GOAL READY"}}}} +{"type":"assistant/chunk","seq":21,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":28,"outputTokens":2}}}} +{"type":"assistant/chunk","seq":22,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":23,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"GOAL READY"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":28,"outputTokens":2}},"sourceEventSeqs":[18,19,20,21,22],"surfaceOp":"append"} +{"type":"step/end","seq":24,"time":0,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":25,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"turn/start","seq":26,"time":0,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":1}}}} +{"type":"user/message","seq":27,"time":0,"data":{"content":[{"type":"text","text":"\nObjective: \"Finish the ACP goal wrap-up snapshot proof\"\nRound: 1/2\n\nContinue working toward the objective in this same session. Treat the current workspace, tool results, and durable session state as authoritative; inspect them instead of assuming earlier narration is still current. Make concrete progress and verify the result. Before claiming completion, gather evidence that the whole objective is achieved, read the current goal, and mark it complete. If work remains, leave the goal active for the next round. Follow the configured goal-tool policy before reporting a blocker.\n"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":1,"round":1},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"step/start","seq":28,"time":0,"data":{"turn":2,"step":1}} +{"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":30,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_goal_complete","name":"update_goal","argumentsDelta":"{\"goal_id\":\"goal-{{sessionId}}\",\"revision\":1,\"action\":\"complete\"}"}}} +{"type":"assistant/chunk","seq":31,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_goal_complete","name":"update_goal","arguments":"{\"goal_id\":\"goal-{{sessionId}}\",\"revision\":1,\"action\":\"complete\"}"}}}} +{"type":"assistant/chunk","seq":32,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":40,"outputTokens":9}}}} +{"type":"assistant/chunk","seq":33,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":34,"time":0,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_goal_complete","name":"update_goal","arguments":"{\"goal_id\":\"goal-{{sessionId}}\",\"revision\":1,\"action\":\"complete\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":40,"outputTokens":9}},"sourceEventSeqs":[29,30,31,32,33],"surfaceOp":"append"} +{"type":"tool/call","seq":35,"time":0,"data":{"turn":2,"step":1,"callId":"call_goal_complete","name":"update_goal","arguments":"{\"goal_id\":\"goal-{{sessionId}}\",\"revision\":1,\"action\":\"complete\"}"}} +{"type":"tool/result","seq":36,"time":0,"data":{"turn":2,"step":1,"message":{"source":{"kind":"tool","callId":"call_goal_complete"},"content":[{"type":"tool-result","toolCallId":"call_goal_complete","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":2,\"objective\":\"Finish the ACP goal wrap-up snapshot proof\",\"phase\":\"complete\",\"roundsStarted\":1,\"maxGoalRounds\":2},\"activation\":\"disarmed\"}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[35],"surfaceOp":"append"} +{"type":"user/message","seq":37,"time":0,"data":{"content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":2,\"objective\":\"Finish the ACP goal wrap-up snapshot proof\",\"phase\":\"complete\",\"maxGoalRounds\":2},\"roundsStarted\":1,\"createdAt\":0,\"updatedAt\":0}"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":2,"round":0,"change":{"kind":"goal/change","version":1,"operation":"complete","goal":{"id":"goal-{{sessionId}}","revision":2,"objective":"Finish the ACP goal wrap-up snapshot proof","phase":"complete","maxGoalRounds":2},"roundsStarted":1,"createdAt":0,"updatedAt":0}},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","seq":38,"time":0,"data":{"content":[{"type":"text","text":"\nObjective: \"Finish the ACP goal wrap-up snapshot proof\"\nThe goal is marked complete and this autonomous run is ending. Write the closing message to the user now: state the outcome, summarize what was done and how it was verified, and point to the concrete results (files, commits, or other artifacts). Report only what earlier rounds and tool results in this session actually establish; when a detail is not in the session, say so instead of inventing it. Note anything the user should review or do next. Address the user directly. Do not call any more tools.\n"}],"source":{"kind":"plugin","plugin":"tool-goal"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"step/end","seq":39,"time":0,"data":{"turn":2,"step":1}} +{"type":"step/start","seq":40,"time":0,"data":{"turn":2,"step":2}} +{"type":"assistant/chunk","seq":41,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":42,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"text-delta","index":0,"text":"GOAL WRAP-UP: the snapshot objective is achieved and this closing message reaches the user."}}} +{"type":"assistant/chunk","seq":43,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"GOAL WRAP-UP: the snapshot objective is achieved and this closing message reaches the user."}}}} +{"type":"assistant/chunk","seq":44,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":52,"outputTokens":14}}}} +{"type":"assistant/chunk","seq":45,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":46,"time":0,"data":{"turn":2,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"GOAL WRAP-UP: the snapshot objective is achieved and this closing message reaches the user."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":52,"outputTokens":14}},"sourceEventSeqs":[41,42,43,44,45],"surfaceOp":"append"} +{"type":"step/end","seq":47,"time":0,"data":{"turn":2,"step":2}} +{"type":"turn/end","seq":48,"time":0,"data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/goal-snapshots/goal-wrapup/session.jsonl b/examples/acp-agent/tests/goal-snapshots/goal-wrapup/session.jsonl new file mode 100644 index 0000000000..94002ff85d --- /dev/null +++ b/examples/acp-agent/tests/goal-snapshots/goal-wrapup/session.jsonl @@ -0,0 +1 @@ +{"type":"session","version":0,"id":"goal-wrapup-placeholder","createdAt":0,"cwd":"{{cwd}}"} diff --git a/examples/acp-agent/tests/goal-snapshots/goal-wrapup/stdout.expected.jsonl b/examples/acp-agent/tests/goal-snapshots/goal-wrapup/stdout.expected.jsonl new file mode 100644 index 0000000000..e5c0dbb921 --- /dev/null +++ b/examples/acp-agent/tests/goal-snapshots/goal-wrapup/stdout.expected.jsonl @@ -0,0 +1,5 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"GOAL READY"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"GOAL WRAP-UP: the snapshot objective is achieved and this closing message reaches the user."}}}} diff --git a/examples/acp-agent/tests/goal.snapshot.ts b/examples/acp-agent/tests/goal.snapshot.ts index b6a44cb66c..a7757eacf7 100644 --- a/examples/acp-agent/tests/goal.snapshot.ts +++ b/examples/acp-agent/tests/goal.snapshot.ts @@ -21,6 +21,7 @@ const fixtureFile = join(scenarioDir, 'session.jsonl') const overrideFile = join(scenarioDir, 'replay.override.json') const stdoutExpected = join(scenarioDir, 'stdout.expected.jsonl') const sessionExpected = join(scenarioDir, 'session.expected.jsonl') +const wrapupDir = join(dirname(fileURLToPath(import.meta.url)), 'goal-snapshots/goal-wrapup') const refreshing = process.env.DSH_SNAPSHOT === 'refresh' const agent: AgentUnderTest = { @@ -112,4 +113,62 @@ describe('same-session goal snapshot through the ACP automation driver', () => { expect(stdout).toBe(await readFile(stdoutExpected, 'utf8')) expect(session).toBe(await readFile(sessionExpected, 'utf8')) }) + + it('injects the wrap-up instruction after an autonomous completion and delivers a closing message', async () => { + const input = JSON.parse(await readFile(join(wrapupDir, 'input.json'), 'utf8')) as InputScript + const result = await runScenario(input, { + agent, + mode: 'replay', + fixtureFile: join(wrapupDir, 'session.jsonl'), + overrideFile: join(wrapupDir, 'replay.override.json'), + configPath: agent.configPath, + }) + + expect(result.stderr).toBe('') + expect(result.sessionLogs).toHaveLength(1) + const log = result.sessionLogs[0] + if (log === undefined) throw new Error('goal wrap-up snapshot did not persist its session') + const records = parseJsonl(log.content) + const events = records.slice(1) as unknown as SessionEvent[] + const calls = events.filter(event => event.type === 'tool/call').map(event => event.data.name) + expect(calls).toEqual(['create_goal', 'update_goal']) + expect(foldGoal(events)).toMatchObject({ + goal: { + objective: 'Finish the ACP goal wrap-up snapshot proof', + phase: 'complete', + revision: 2, + }, + roundsStarted: 1, + }) + // The wrap-up instruction is one plugin-sourced context injected after the + // terminal tool result, and the model still answers inside the same turn. + const wrapups = events.filter(event => event.type === 'user/message' + && event.data.source.kind === 'plugin' && event.data.source.plugin === 'tool-goal') + expect(wrapups).toHaveLength(1) + const wrapupText = wrapups.map(event => event.type === 'user/message' ? event.data.content : [])[0] + expect(JSON.stringify(wrapupText)).toContain('') + const closing = events.filter(event => event.type === 'assistant/message') + .flatMap(event => event.data.message.content) + .filter(block => block.type === 'text' && block.text.startsWith('GOAL WRAP-UP')) + expect(closing).toHaveLength(1) + const roundTurnEnds = events.filter(event => event.type === 'turn/end' && event.data.turn === 2) + expect(roundTurnEnds).toEqual([expect.objectContaining({ data: { turn: 2, reason: { kind: 'completed' } } })]) + + const context: NormalizeContext = { + sessionIds: [result.sessionId, log.id].filter((id): id is string => id !== undefined), + cwd: result.cwd, + } + const stdout = normalizeStdout(result.rawStdout, context) + const session = normalizeGoalLog(log.content, context) + const wrapupStdoutExpected = join(wrapupDir, 'stdout.expected.jsonl') + const wrapupSessionExpected = join(wrapupDir, 'session.expected.jsonl') + if (refreshing) { + await Promise.all([ + writeFile(wrapupStdoutExpected, stdout), + writeFile(wrapupSessionExpected, session), + ]) + } + expect(stdout).toBe(await readFile(wrapupStdoutExpected, 'utf8')) + expect(session).toBe(await readFile(wrapupSessionExpected, 'utf8')) + }) }) diff --git a/packages/goal/tool-goal/README.i18n.yaml b/packages/goal/tool-goal/README.i18n.yaml index 3b9dd9f964..354456002b 100644 --- a/packages/goal/tool-goal/README.i18n.yaml +++ b/packages/goal/tool-goal/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/goal/tool-goal/README.md -README.md: aaed61dd517aeb2f94efa22c34c64d1068155d46 -README.zh.md: 5365b64ef65fb3d3f00e19357327479ebd8285a8 +README.md: 2fa80c2e5fa3d675a48fc18506635fd811ac8f80 +README.zh.md: c6c39e3cc739fb39a4a36080db5246e7c7349147 diff --git a/packages/goal/tool-goal/README.md b/packages/goal/tool-goal/README.md index aaed61dd51..2fa80c2e5f 100644 --- a/packages/goal/tool-goal/README.md +++ b/packages/goal/tool-goal/README.md @@ -14,7 +14,7 @@ All calls are exclusive, so a model-ordered batch observes earlier mutations and All three canonical values match the compact JSON already rendered to Native callers: `{ goal: null }` or `{ goal: { id, revision, objective, phase, roundsStarted, maxGoalRounds, blockedReason? }, activation }`. Programmatic consumers therefore receive the same domain structure without parsing the rendered JSON. -An autonomous goal round that successfully reports `complete` or `blocked` marks that tool execution with `concludeTurn()` so the physical turn stops after the step. Direct-human mutations never contribute this stop: the assistant may acknowledge the change and concurrent human steering remains available to the loop. +An autonomous goal round that successfully reports `complete` or `blocked` defers one wrap-up context onto that tool result: an injected instruction telling the model to write a final closing message to the user and call no more tools, after which the turn ends through the ordinary no-tool-calls stop. Direct-human mutations receive no instruction: the assistant may acknowledge the change and concurrent human steering remains available to the loop. ## Authority @@ -61,11 +61,11 @@ Prefix-stable while the plugin scope, configured threshold, and guidance text ar #### What the model sees -The generated [`get_goal`, `create_goal`, and `update_goal` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-goal). Successful results are compact JSON. Mutation results are followed by the goal domain's raw `` snapshot after the tool batch. `activation` in a result is a live observation and never becomes replay authority. +The generated [`get_goal`, `create_goal`, and `update_goal` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-goal). Successful results are compact JSON. Mutation results are followed by the goal domain's raw `` snapshot after the tool batch. `activation` in a result is a live observation and never becomes replay authority. A goal-round `complete` or `blocked` result additionally injects one ``/`` wrap-up instruction that asks for a grounded closing message to the user without further tool calls. #### Token effect -Fixed schema cost plus one compact result per call. Mutations also retain the domain snapshot until compaction. +Fixed schema cost plus one compact result per call. Mutations also retain the domain snapshot until compaction. A goal-round terminal update adds the injected wrap-up instruction and one further model request for the closing message — once per goal lifecycle, not per round. #### KV Cache effect diff --git a/packages/goal/tool-goal/README.zh.md b/packages/goal/tool-goal/README.zh.md index 5365b64ef6..c6c39e3cc7 100644 --- a/packages/goal/tool-goal/README.zh.md +++ b/packages/goal/tool-goal/README.zh.md @@ -14,7 +14,7 @@ 3 个规范值都与已经渲染给 Native 调用方的紧凑 JSON 一致:`{ goal: null }` 或 `{ goal: { id, revision, objective, phase, roundsStarted, maxGoalRounds, blockedReason? }, activation }`。因此,编程消费方无需解析渲染后的 JSON,即可收到相同领域结构。 -自主 Goal Round 成功报告 `complete` 或 `blocked` 时,会用 `concludeTurn()` 标记该次工具执行,使物理轮次在该步骤后停止。人类直接变更绝不会导致这种停止:assistant 可以确认变更,循环仍可接收并发的人类 steering(中途引导)。 +自主 Goal Round 成功报告 `complete` 或 `blocked` 时,会在该次工具结果上附带一条收尾注入指令,要求模型面向用户写出最终收尾消息、不再调用工具,之后轮次经由常规的无工具调用停止路径结束。人类直接变更不会收到这条指令:assistant 可以确认变更,循环仍可接收并发的人类 steering(中途引导)。 ## 权限 @@ -61,11 +61,11 @@ Use goal tools for one long-running completion objective in the current session. #### 模型看到的内容 -生成的 [`get_goal`、`create_goal` 和 `update_goal` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-goal)。成功结果是紧凑 JSON。变更结果之后是工具批次结束后由 goal 领域产生的原始 `` 快照。结果中的 `activation` 是实时观察值,绝不会成为回放权限依据。 +生成的 [`get_goal`、`create_goal` 和 `update_goal` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-goal)。成功结果是紧凑 JSON。变更结果之后是工具批次结束后由 goal 领域产生的原始 `` 快照。结果中的 `activation` 是实时观察值,绝不会成为回放权限依据。Goal Round 的 `complete`/`blocked` 结果还会额外注入一条 ``/`` 收尾指令,要求模型向用户写出有依据的收尾消息且不再调用工具。 #### Token 影响 -固定 schema 成本,加上每次调用的一条紧凑结果。变更还会保留领域快照,直到压缩(compaction)。 +固定 schema 成本,加上每次调用的一条紧凑结果。变更还会保留领域快照,直到压缩(compaction)。Goal Round 的终态更新会增加注入的收尾指令和一次额外的模型请求用于收尾消息——每个 goal 生命周期一次,而非每轮一次。 #### KV Cache 影响 diff --git a/packages/goal/tool-goal/src/index.ts b/packages/goal/tool-goal/src/index.ts index e62510b06f..9ceed21bc9 100644 --- a/packages/goal/tool-goal/src/index.ts +++ b/packages/goal/tool-goal/src/index.ts @@ -8,7 +8,7 @@ import type { Context } from 'cordis' import z from 'schemastery' import { GoalId } from '@deepseek-ai/dsh-goal' import type { GoalRef, GoalView } from '@deepseek-ai/dsh-goal' -import { HarnessError } from '@deepseek-ai/dsh-llm' +import { createUserMessage, HarnessError } from '@deepseek-ai/dsh-llm' import { defineTool } from '@deepseek-ai/dsh-tools' import type { GenericCallView } from '@deepseek-ai/dsh-tools' import type {} from '@deepseek-ai/dsh-system-prompt' @@ -17,6 +17,7 @@ import { goalToolExecution, requireDirectHuman, } from './authority.ts' +import { renderWrapupContext } from './wrapup.ts' export const name = 'tool-goal' export const inject = ['agents', 'goals', 'tools', 'systemPrompt'] @@ -309,7 +310,14 @@ export function apply(ctx: Context, config: Config): void { code: 'model-reported', message: args.blocked_reason as string, }) - if (authority.kind === 'goal-round') exec.concludeTurn() + if (authority.kind === 'goal-round') { + exec.deferContext(createUserMessage({ + content: args.action === 'complete' + ? renderWrapupContext(goal.objective) + : renderWrapupContext(goal.objective, args.blocked_reason as string), + source: { kind: 'plugin', plugin: 'tool-goal' }, + })) + } return Promise.resolve(goalValue(goal)) }, presentCall: args => present( diff --git a/packages/goal/tool-goal/src/wrapup.ts b/packages/goal/tool-goal/src/wrapup.ts new file mode 100644 index 0000000000..4f16fdd924 --- /dev/null +++ b/packages/goal/tool-goal/src/wrapup.ts @@ -0,0 +1,40 @@ +/** Model-visible wrap-up instruction for a terminal autonomous goal update. */ + +import type { ContentBlock } from '@deepseek-ai/dsh-llm' + +const GROUNDING = + 'Report only what earlier rounds and tool results in this session actually establish; ' + + 'when a detail is not in the session, say so instead of inventing it. ' + +/** + * Render the closing-message instruction injected after an autonomous goal + * round reports `complete` or `blocked`, replacing the former hard turn stop + * so the model still addresses the user once before the turn ends. + * @param objective - the terminal goal's objective, echoed for grounding. + * @param blockedReason - the validated report for `blocked`; omitted for `complete`. + * @returns a fresh one-block context for `ToolRunContext.deferContext()`. + */ +export function renderWrapupContext(objective: string, blockedReason?: string): ContentBlock[] { + const heading = `Objective: ${JSON.stringify(objective)}\n` + const text = blockedReason === undefined + ? '\n' + + heading + + 'The goal is marked complete and this autonomous run is ending. Write the closing ' + + 'message to the user now: state the outcome, summarize what was done and how it was ' + + 'verified, and point to the concrete results (files, commits, or other artifacts). ' + + GROUNDING + + 'Note anything the user should review or do next. Address the user directly. Do not ' + + 'call any more tools.\n' + + '' + : '\n' + + heading + + `Blocked: ${JSON.stringify(blockedReason)}\n` + + 'The goal is marked blocked and this autonomous run is ending. Write the closing ' + + 'message to the user now: state what has been completed so far, describe the concrete ' + + 'blocking condition and what you tried, and say exactly what you need from the user to ' + + 'continue. ' + + GROUNDING + + 'Address the user directly. Do not call any more tools.\n' + + '' + return [{ type: 'text', text }] +} diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index 4278fc5a50..2461a7d5fc 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -347,7 +347,7 @@ describe('goal tool state transitions', () => { expect(goal).toMatchObject({ phase: 'active', revision: 4 }) }) - it('terminal-stops an autonomous completion but leaves a human pause interactive', async () => { + it('injects one wrap-up instruction for an autonomous completion but leaves a human pause interactive', async () => { const { ctx, root } = await harness() const humanTurn = openTurn(root, { kind: 'user' }) const created = ctx.goals.create(root.agent, { objective: 'pause cleanly' }) @@ -356,6 +356,7 @@ describe('goal tool state transitions', () => { }, root.agent) expect(resultGoal(paused)).toMatchObject({ phase: 'paused' }) expect(paused.concludesTurn).toBeUndefined() + expect(paused.additionalContexts).toBeUndefined() const resumed = resultGoal(await execute(ctx, 'update_goal', { goal_id: created.id, revision: 2, action: 'resume', }, root.agent)) @@ -368,7 +369,27 @@ describe('goal tool state transitions', () => { goal_id: created.id, revision: resumed['revision'], action: 'complete', }, root.agent) expect(resultGoal(complete)).toMatchObject({ phase: 'complete' }) - expect(complete.concludesTurn).toBe(true) + expect(complete.concludesTurn).toBeUndefined() + const contexts = complete.additionalContexts ?? [] + expect(contexts).toHaveLength(1) + expect(contexts[0]?.source).toEqual({ kind: 'plugin', plugin: 'tool-goal' }) + const block = contexts[0]?.content[0] + if (block?.type !== 'text') throw new Error('expected one text wrap-up block') + expect(block.text).toContain('') + expect(block.text).toContain('"pause cleanly"') + expect(block.text).toContain('Do not call any more tools.') + }) + + it('completes without a wrap-up instruction under direct human authority', async () => { + const { ctx, root } = await harness() + openTurn(root, { kind: 'user' }) + const created = ctx.goals.create(root.agent, { objective: 'finish now' }) + const complete = await execute(ctx, 'update_goal', { + goal_id: created.id, revision: created.revision, action: 'complete', + }, root.agent) + expect(resultGoal(complete)).toMatchObject({ phase: 'complete' }) + expect(complete.concludesTurn).toBeUndefined() + expect(complete.additionalContexts).toBeUndefined() }) it('rearms a restored active goal only after a new direct human prompt', async () => { @@ -550,6 +571,14 @@ describe('goal tool state transitions', () => { blockedReason: { code: 'model-reported', message: 'The required credential is still unavailable.' }, roundsStarted: 3, }) + expect(blocked.concludesTurn).toBeUndefined() + const contexts = blocked.additionalContexts ?? [] + expect(contexts).toHaveLength(1) + const block = contexts[0]?.content[0] + if (block?.type !== 'text') throw new Error('expected one text wrap-up block') + expect(block.text).toContain('') + expect(block.text).toContain('The required credential is still unavailable.') + expect(block.text).toContain('Do not call any more tools.') }) it('lets direct human authority block before the model threshold', async () => { From 8f77ab467f7517d845797ae074ab4f8c3abf400f Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Sun, 2 Aug 2026 06:12:13 -0700 Subject: [PATCH 17/19] test(web): harden live thinking tail observation --- apps/web/tests/lifecycle-chrome.e2e.ts | 7 ++++--- .../client/ui-conversation/src/client/chat/ToolRow.tsx | 3 ++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts index 65098063ad..8378bf6f5b 100644 --- a/apps/web/tests/lifecycle-chrome.e2e.ts +++ b/apps/web/tests/lifecycle-chrome.e2e.ts @@ -159,7 +159,8 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () const settled = scaffold.whenTurnSettled() await input.fill(PROMPT) const observeTurn = async () => { - if (MODE !== 'record') await page.setViewportSize({ width: 640, height: 1000 }) + const originalViewport = page.viewportSize() ?? { width: 1680, height: 1000 } + if (MODE !== 'record') await page.setViewportSize({ width: 480, height: 1000 }) try { await input.press('Enter') if (MODE !== 'record') { @@ -167,11 +168,11 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () await expect.poll(async () => await liveTail.evaluate(element => ( element.scrollWidth > element.clientWidth && element.scrollLeft >= element.scrollWidth - element.clientWidth - 1 - )), { timeout: 10_000 }).toBe(true) + )), { timeout: 10_000, interval: 10 }).toBe(true) } return await settled } finally { - if (MODE !== 'record') await page.setViewportSize({ width: 1680, height: 1000 }) + if (MODE !== 'record') await page.setViewportSize(originalViewport) } } const sessionId = await observeTurn() diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx index 4ec07aa6fc..fa4be6179a 100644 --- a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx +++ b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx @@ -5,7 +5,8 @@ // Enter / Space, icon→chevron hover preview). The collapsed row is always // one line; every row with body, output, or a card material (terminal, diff, // read, search, web) is expandable; the summary stays inline while open, -// except Think, whose body opens with the same first line and would repeat it. +// except Think, where the running collapsed row follows the latest line at its +// scroll end and the summary yields while open to avoid repeating the body. // The expanded body — an IN/OUT gutter-labeled card (figma 1249:35657) for // text input/output, the run_code program through CodeBlock, or a card // primitive (TerminalBlock, DiffBlock, ReadBlock, SearchBlock, WebBlock) for a From 820a5a97f1ca54a40ea62b62af7879cebbc5d4f4 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Sun, 2 Aug 2026 07:19:26 -0700 Subject: [PATCH 18/19] review(tool-goal,llm-replay): address ds-review-bot round one - fromRequest placeholder: the last two braces of a consecutive } run now terminate the placeholder, so patterns may end with a brace quantifier (bot warning; the truncated pattern could even silently mis-match since an unclosed { is literal in JS regexes) - document that derived JSONL entries pass through the same resolution - widen ToolRunContext/deferContext seam docs beyond composite-only usage (source JSDoc, README pair, core-data-structures type-equiv blocks) - pin direct-human blocked as uninstructed, completing the goal-round/direct-human x complete/blocked test quadrant --- docs/config-catalog.md | 4 ++-- docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/tools.i18n.yaml | 4 ++-- docs/core-data-structures/tools.md | 17 ++++++++++------- docs/core-data-structures/tools.zh.md | 17 ++++++++++------- packages/core/tools/README.i18n.yaml | 4 ++-- packages/core/tools/README.md | 2 +- packages/core/tools/README.zh.md | 2 +- packages/core/tools/src/index.ts | 15 +++++++++------ packages/goal/tool-goal/tests/tool-goal.spec.ts | 2 ++ packages/support/llm-replay/README.i18n.yaml | 4 ++-- packages/support/llm-replay/README.md | 2 +- packages/support/llm-replay/README.zh.md | 2 +- packages/support/llm-replay/src/index.ts | 10 ++++++++-- .../support/llm-replay/tests/llm-replay.spec.ts | 6 ++++++ 15 files changed, 58 insertions(+), 35 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index c9fa141dd6..cc85531d4d 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -765,7 +765,7 @@ export interface ReplayModelConfig { Depends on: [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) -Source: [`packages/support/llm-replay/src/index.ts:701`](../packages/support/llm-replay/src/index.ts) +Source: [`packages/support/llm-replay/src/index.ts:707`](../packages/support/llm-replay/src/index.ts) ## `@deepseek-ai/dsh-llm-retry` @@ -2039,7 +2039,7 @@ export interface Config { export type ToolPresentationMode = 'native' | 'code' | 'both' ``` -Source: [`packages/core/tools/src/index.ts:589`](../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:592`](../packages/core/tools/src/index.ts) ## `@deepseek-ai/dsh-tui` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 96b876a0eb..ba32a8dded 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2428,7 +2428,7 @@ async execute(exec: ToolExecutionInput): Promise Types: [ScopeKey](../core-data-structures/scope.md) · [ToolDefinition](../core-data-structures/tools.md) · [ToolExecutionInput](../core-data-structures/tools.md) · [ToolExecutionMode](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolGuard](../core-data-structures/tools.md) · [ToolRestriction](../core-data-structures/tools.md) · [ToolSchema](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:711`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:714`](../../packages/core/tools/src/index.ts) ## `ctx.tui` — `TuiExtensionService` (abstract seam) diff --git a/docs/core-data-structures/tools.i18n.yaml b/docs/core-data-structures/tools.i18n.yaml index 156324472b..a955adc963 100644 --- a/docs/core-data-structures/tools.i18n.yaml +++ b/docs/core-data-structures/tools.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/tools.md -tools.md: 98b642b846b23e2b29e6c6d800fe4106235eda85 -tools.zh.md: 1ef90c1e76ace7485ed6267de5ee82cbb4de6aa6 +tools.md: acaf5d32dd5481aec495ac49f727c9b64f25211e +tools.zh.md: 5c39226fdf5d1406dab7383d40227c26ff1e447c diff --git a/docs/core-data-structures/tools.md b/docs/core-data-structures/tools.md index 98b642b846..acaf5d32dd 100644 --- a/docs/core-data-structures/tools.md +++ b/docs/core-data-structures/tools.md @@ -200,20 +200,23 @@ interface ToolExecutionInput { } ``` -A tool body receives the runtime extension. `deferContext()` is the composite-tool channel: it records nested-dispatch context without injecting inside the still-open outer call. +A tool body receives the runtime extension. `deferContext()` attaches context to the execution's own result — the composite-tool nested-dispatch channel, also usable by a leaf tool minting a plugin-sourced instruction — without injecting inside the still-open outer call. ```ts type-equiv /** * Runtime context handed to a tool implementation after the registry has - * accepted a {@link ToolExecution}. A composite tool uses - * {@link deferContext} to ferry context produced by nested dispatches back to - * the outer result; the loop appends it only after the outer `tool/result`. + * accepted a {@link ToolExecution}. {@link deferContext} attaches context to + * this execution's own result — a composite tool ferries nested-dispatch + * context back to the outer result, and a leaf tool may mint a fresh + * plugin-sourced instruction; the loop appends it only after the + * `tool/result`. */ interface ToolRunContext extends ToolExecution { /** - * Defer one nested-dispatch context until this tool's final result reaches - * the agent loop. Contexts retain their individual source and metadata and - * are emitted in call order. + * Defer one context — typically a nested-dispatch context ferried by a + * composite tool, or a fresh plugin-sourced instruction — until this tool's + * final result reaches the agent loop. Contexts retain their individual + * source and metadata and are emitted in call order. */ deferContext(context: UserMessage): void /** diff --git a/docs/core-data-structures/tools.zh.md b/docs/core-data-structures/tools.zh.md index 1ef90c1e76..5c39226fdf 100644 --- a/docs/core-data-structures/tools.zh.md +++ b/docs/core-data-structures/tools.zh.md @@ -200,20 +200,23 @@ interface ToolExecutionInput { } ``` -工具函数体接收运行时扩展。`deferContext()` 是组合工具的通道:它记录嵌套分派产生的上下文,而不会在外层调用尚未结束时注入这些上下文。 +工具函数体接收运行时扩展。`deferContext()` 把上下文附着到本次执行自己的结果上——既是组合工具转运嵌套分派上下文的通道,也可供叶子工具铸造插件来源指令——而不会在外层调用尚未结束时注入这些上下文。 ```ts type-equiv /** * Runtime context handed to a tool implementation after the registry has - * accepted a {@link ToolExecution}. A composite tool uses - * {@link deferContext} to ferry context produced by nested dispatches back to - * the outer result; the loop appends it only after the outer `tool/result`. + * accepted a {@link ToolExecution}. {@link deferContext} attaches context to + * this execution's own result — a composite tool ferries nested-dispatch + * context back to the outer result, and a leaf tool may mint a fresh + * plugin-sourced instruction; the loop appends it only after the + * `tool/result`. */ interface ToolRunContext extends ToolExecution { /** - * Defer one nested-dispatch context until this tool's final result reaches - * the agent loop. Contexts retain their individual source and metadata and - * are emitted in call order. + * Defer one context — typically a nested-dispatch context ferried by a + * composite tool, or a fresh plugin-sourced instruction — until this tool's + * final result reaches the agent loop. Contexts retain their individual + * source and metadata and are emitted in call order. */ deferContext(context: UserMessage): void /** diff --git a/packages/core/tools/README.i18n.yaml b/packages/core/tools/README.i18n.yaml index d26f891f91..8fa82352b8 100644 --- a/packages/core/tools/README.i18n.yaml +++ b/packages/core/tools/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/tools/README.md -README.md: 15fc5839a3b0e3fa2d20c5a9cc50577e9807ffda -README.zh.md: 8547ee4a796dcd93945dfa40373c14c10d7d0c8a +README.md: 80ea3cc93437d48a7ea0ffba0ff4d2ef2407755f +README.zh.md: 1f0791c5df7afd4a3479afdd827c4fc148cf8883 diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index 15fc5839a3..80ea3cc934 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -43,7 +43,7 @@ The live registry pipeline has three transformable waterfalls, then the definiti - `ToolExecutionInput` — the caller-supplied call description: `{ callId, name, arguments, signal, agent?, parent? }`; `signal` is required and readonly, callers may pass an enclosing execution's opaque token as `parent`, and callers never choose the new execution's own token. - `ToolExecutionToken` — a fresh branded `Symbol` assigned by the registry. It supports equality correlation only and never crosses a model, log, or worker boundary. - `ToolExecution` — the readonly pipeline view: immutable `{ token, callId, name, arguments, signal, agent?, parent? }`; the registry separately retains and re-fuses the original caller signal. `ToolDispatchExecution` is the `tools/execute`-only view whose required signal is mutable, so a wrapper may replace and restore it but cannot delete it. A nested call's `parent` is a `ToolExecutionToken`, not an execution object. -- `ToolRunContext` — the execution passed to a tool body, extending `ToolExecution` with `deferContext(context)`. Composite tools use it to ferry context produced by nested dispatches to the outer result even when the tool later throws or cancellation wins; it never injects immediately. +- `ToolRunContext` — the execution passed to a tool body, extending `ToolExecution` with `deferContext(context)`. It defers one context until the tool's final result reaches the loop — typically a nested-dispatch context ferried by a composite tool, or a fresh plugin-sourced instruction minted by a leaf tool (`tool-goal`'s wrap-up) — even when the tool later throws or cancellation wins; it never injects immediately. - `ToolExecutionResult` — discriminated execution-local outcome. Success is `{ isError:false, value:JsonValue, content, meta?, additionalContexts? }`; failure is `{ isError:true, error:{ message, info? }, content, meta?, additionalContexts? }` and has no value. Call identity stays on the immutable `ToolExecution`. The registry snapshots, validates, and freezes the canonical value before rendering, then materializes the durable presentation fields before final observation. `ToolFailure.info` carries an internal `{ name, code }` for a `HarnessError`; `additionalContexts` preserves every deferred or post-execute identified `UserMessage` for the loop's post-result FIFO. - `PreToolDecision` — `{kind:'allow'}` | `{kind:'deny', reason}` | `{kind:'ask', reason?}`. Input rewrite is deliberately not offered; `ask` is serviced by [`ctx.approval`](../../ui/user-approval/README.md) when mounted and otherwise degrades to deny. - `PostToolDecision` — accept may replace `content` or `value`, never both, and may attach `additionalContexts`; block turns feedback into a valueless failure. Content replacement preserves the canonical value and metadata. Value replacement is revalidated and rerenders content/metadata. Accept preserves tool-deferred contexts before decision contexts; block discards tool-deferred contexts and exposes only contexts explicitly supplied by the blocking decision. diff --git a/packages/core/tools/README.zh.md b/packages/core/tools/README.zh.md index 8547ee4a79..1f0791c5df 100644 --- a/packages/core/tools/README.zh.md +++ b/packages/core/tools/README.zh.md @@ -43,7 +43,7 @@ tools: - `ToolExecutionInput`:调用方提供的调用描述:`{ callId, name, arguments, signal, agent?, parent? }`;`signal` 必填且只读,调用方可以将外层执行的不透明 token 作为 `parent` 传入,但绝不能选择新执行自身的 token。 - `ToolExecutionToken`:注册表分配的全新带品牌 `Symbol`。它只支持通过相等性进行关联,绝不会跨越模型、日志或 worker 边界。 - `ToolExecution`:只读流水线视图:不可变的 `{ token, callId, name, arguments, signal, agent?, parent? }`;注册表会另行保留并重新融合调用方的原始信号。`ToolDispatchExecution` 是仅供 `tools/execute` 使用的视图,其必填信号可变,因此包装层可以替换并还原它,但不能删除它。嵌套调用的 `parent` 是 `ToolExecutionToken`,而不是执行对象。 -- `ToolRunContext`:传给工具主体的执行上下文,在 `ToolExecution` 基础上增加 `deferContext(context)`。组合工具借此把嵌套分发产生的上下文传递到外层结果,即使工具后来抛出或取消胜出也不例外;该方法绝不会立即注入上下文。 +- `ToolRunContext`:传给工具主体的执行上下文,在 `ToolExecution` 基础上增加 `deferContext(context)`。它把一条上下文推迟到该工具的最终结果抵达循环时——通常是组合工具转运的嵌套分发上下文,也可以是叶子工具铸造的全新插件来源指令(如 `tool-goal` 的收尾注入)——即使工具后来抛出或取消胜出也不例外;该方法绝不会立即注入上下文。 - `ToolExecutionResult`:可辨识的执行局部结果。成功形态为 `{ isError:false, value:JsonValue, content, meta?, additionalContexts? }`;失败形态为 `{ isError:true, error:{ message, info? }, content, meta?, additionalContexts? }`,且不含值。调用身份保留在不可变的 `ToolExecution` 上。注册表会在呈现前快照、验证并冻结规范值,随后在最终观测前实体化持久呈现字段。`ToolFailure.info` 携带内部的 `{ name, code }`,用于表示 `HarnessError`;`additionalContexts` 会保留每个通过延迟或 post-execute 加入且带标识的 `UserMessage`,供循环在结果后按 FIFO 顺序处理。 - `PreToolDecision`:`{kind:'allow'}` | `{kind:'deny', reason}` | `{kind:'ask', reason?}`。该类型有意不提供输入改写;`ask` 在挂载 [`ctx.approval`](../../ui/user-approval/README.md) 时由它处理,否则退化为拒绝。 - `PostToolDecision`:接受决定可以替换 `content` 或 `value`(不能同时替换),并可附加 `additionalContexts`;阻止决定会把反馈变成无值失败。替换内容会保留规范值和元数据。替换值会重新验证,并重新呈现内容/元数据。接受决定会先保留工具延迟的上下文,再附加决定上下文;阻止决定会丢弃工具延迟的上下文,只公开阻止决定显式提供的上下文。 diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index f30dce6cd0..72254e2abd 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -344,15 +344,18 @@ export interface ToolDispatchExecution extends Omit { /** * Runtime context handed to a tool implementation after the registry has - * accepted a {@link ToolExecution}. A composite tool uses - * {@link deferContext} to ferry context produced by nested dispatches back to - * the outer result; the loop appends it only after the outer `tool/result`. + * accepted a {@link ToolExecution}. {@link deferContext} attaches context to + * this execution's own result — a composite tool ferries nested-dispatch + * context back to the outer result, and a leaf tool may mint a fresh + * plugin-sourced instruction; the loop appends it only after the + * `tool/result`. */ export interface ToolRunContext extends ToolExecution { /** - * Defer one nested-dispatch context until this tool's final result reaches - * the agent loop. Contexts retain their individual source and metadata and - * are emitted in call order. + * Defer one context — typically a nested-dispatch context ferried by a + * composite tool, or a fresh plugin-sourced instruction — until this tool's + * final result reaches the agent loop. Contexts retain their individual + * source and metadata and are emitted in call order. */ deferContext(context: UserMessage): void /** diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index 2461a7d5fc..6d3957e982 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -599,5 +599,7 @@ describe('goal tool state transitions', () => { }, roundsStarted: 0, }) + expect(blocked.concludesTurn).toBeUndefined() + expect(blocked.additionalContexts).toBeUndefined() }) }) diff --git a/packages/support/llm-replay/README.i18n.yaml b/packages/support/llm-replay/README.i18n.yaml index b6a31ebb99..507c3da1a5 100644 --- a/packages/support/llm-replay/README.i18n.yaml +++ b/packages/support/llm-replay/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/support/llm-replay/README.md -README.md: ea52525ee85aae58006c852afe93291ea70807d5 -README.zh.md: e8c0ec225df29fc6f5493776d75bc7f9e3e078de +README.md: 85aa56705929e7630e4cfb6c2a3c9cbbd0d843a6 +README.zh.md: 751f75dea197ffb112cfa703e3a5dbfaffb8c0b2 diff --git a/packages/support/llm-replay/README.md b/packages/support/llm-replay/README.md index ea52525ee8..85aa567059 100644 --- a/packages/support/llm-replay/README.md +++ b/packages/support/llm-replay/README.md @@ -12,7 +12,7 @@ The fixture IS the persisted session log (`/session.jsonl`). Its `assi Two failure modes are not reconstructable from `assistant/chunk` alone — a pure throw before any chunk (e.g. an HTTP 401, where the log holds only a `turn/end {error}` and no chunks) and a cancel/hang (timing, not chunk content). A scenario that needs those supplies an optional sidecar (`/replay.override.json`) that either replaces the derived script (a bare `ReplayEntry[]`) or augments it (`{ patches: [{ at, entry }] }`: keep every JSONL-derived call and swap the named 0-based call indexes; `at` equal to the derived length appends the retry attempt after an injected transient throw). Patch indexes must be unique. The override document, each patch and entry, and every chunk discriminant are validated when the file loads. A `hang` entry may name `readyFile`; replay writes that empty marker after its prefix chunks reach the loop and before it waits for cancellation, so an external driver can cancel deterministically without observing a presentation update. -A scripted string may embed `{{fromRequest:}}` to fill a value no static sidecar can know — for example a randomly minted goal id the model must echo back into `update_goal`. At stream time every placeholder resolves against the live request: the corpus is every string leaf of the request messages joined by newlines, the pattern's LAST corpus match wins, and its first capture group (or the whole match without one) substitutes in place. A pattern that matches nothing, an invalid pattern, and an unterminated placeholder each fail loud; the first `}}` ends the placeholder, so patterns cannot contain `}}`. +A scripted string may embed `{{fromRequest:}}` to fill a value no static sidecar can know — for example a randomly minted goal id the model must echo back into `update_goal`. At stream time every placeholder resolves against the live request: the corpus is every string leaf of the request messages joined by newlines, the pattern's LAST corpus match wins, and its first capture group (or the whole match without one) substitutes in place. A pattern that matches nothing, an invalid pattern, and an unterminated placeholder each fail loud. The last two braces of a consecutive `}` run terminate the placeholder, so a pattern may end with a brace quantifier (`[0-9a-f]{4}`) but cannot contain `}}` followed by further pattern content. Resolution applies to every scripted entry, including ones derived from the recorded JSONL — a recorded fixture whose text legitimately contains the literal marker must be expressed through a sidecar without it. ## Nested agents: per-session keying diff --git a/packages/support/llm-replay/README.zh.md b/packages/support/llm-replay/README.zh.md index e8c0ec225d..751f75dea1 100644 --- a/packages/support/llm-replay/README.zh.md +++ b/packages/support/llm-replay/README.zh.md @@ -12,7 +12,7 @@ fixture 就是持久化的会话日志(`/session.jsonl`)。其 `as 有两种失败模式无法仅根据 `assistant/chunk` 重建:在产生任何分片前直接抛出异常(例如 HTTP 401,此时日志只有 `turn/end {error}` 而没有分片),以及取消或挂起(差异在时序,而非分片内容)。需要这些行为的场景可提供伴随文件(`/replay.override.json`):它可以替换派生脚本(裸 `ReplayEntry[]`),也可以增补派生脚本(`{ patches: [{ at, entry }] }`:保留所有从 JSONL 派生的调用,只替换指定的从 0 开始计数的调用索引;当 `at` 等于派生长度时,则在注入瞬态异常后的重试位置追加一次调用)。补丁索引不得重复。文件加载时会校验覆写文档、每个补丁和条目,以及每个分片的判别标签。`hang` 条目可以指定 `readyFile`;当前缀分片到达循环后、开始等待取消前,回放会写入这个空标记,使外部驱动程序无需观察展示层更新即可确定性地取消。 -脚本字符串可以内嵌 `{{fromRequest:}}`,用来填入静态伴随文件不可能预知的值——例如模型必须原样回填到 `update_goal` 的随机生成 goal id。回放时每个占位符针对实时请求解析:语料是请求消息的所有字符串叶子按换行拼接的结果,取该模式在语料中的最后一次匹配,用其第一个捕获组(无捕获组时用整个匹配)原位替换。模式匹配不到内容、模式非法、占位符未闭合都会明确报错;第一个 `}}` 即结束占位符,因此模式本身不能包含 `}}`。 +脚本字符串可以内嵌 `{{fromRequest:}}`,用来填入静态伴随文件不可能预知的值——例如模型必须原样回填到 `update_goal` 的随机生成 goal id。回放时每个占位符针对实时请求解析:语料是请求消息的所有字符串叶子按换行拼接的结果,取该模式在语料中的最后一次匹配,用其第一个捕获组(无捕获组时用整个匹配)原位替换。模式匹配不到内容、模式非法、占位符未闭合都会明确报错。连续右花括号串的最后两个花括号才是占位符结束符,因此模式可以以花括号量词收尾(如 `[0-9a-f]{4}`),但不能在 `}}` 之后还有后续模式内容。解析作用于所有脚本条目,包括从已记录 JSONL 派生的条目——若录制文本本身合法地含有该字面量标记,需改用不含标记的伴随文件表达。 ## 嵌套 agent:每会话键控 diff --git a/packages/support/llm-replay/src/index.ts b/packages/support/llm-replay/src/index.ts index 70dae88446..4b89a844bf 100644 --- a/packages/support/llm-replay/src/index.ts +++ b/packages/support/llm-replay/src/index.ts @@ -283,10 +283,13 @@ function substituteString(text: string, corpus: string): string { while (true) { const open = text.indexOf(FROM_REQUEST_OPEN, cursor) if (open === -1) return result + text.slice(cursor) - const close = text.indexOf(FROM_REQUEST_CLOSE, open + FROM_REQUEST_OPEN.length) + let close = text.indexOf(FROM_REQUEST_CLOSE, open + FROM_REQUEST_OPEN.length) if (close === -1) { throw new Error(`llm-replay: fromRequest placeholder is unterminated in ${JSON.stringify(text)}`) } + // The last two braces of a consecutive `}` run terminate the placeholder, + // so a pattern may end with a brace quantifier like `[0-9a-f]{4}`. + while (text[close + FROM_REQUEST_CLOSE.length] === '}') close += 1 const pattern = text.slice(open + FROM_REQUEST_OPEN.length, close) result += text.slice(cursor, open) + resolveFromRequest(pattern, corpus) cursor = close + FROM_REQUEST_CLOSE.length @@ -313,7 +316,10 @@ function substituteValue(value: unknown, corpus: string): unknown { * Scenario sidecars use this to script arguments no static file can know, * such as a randomly minted goal id the model must echo back. A pattern that * matches nothing, an invalid pattern, and an unterminated placeholder each - * fail loud. Patterns cannot contain `}}` — the first `}}` ends the placeholder. + * fail loud. The last two braces of a consecutive `}` run terminate the + * placeholder, so a pattern may end with a brace quantifier but cannot + * contain `}}` followed by further pattern content. Derived entries pass + * through the same resolution as sidecar entries. * @param entry - the scripted entry about to replay. * @param messages - the live request messages searched by the placeholders. * @returns the entry itself when no placeholder appears, else a resolved deep copy. diff --git a/packages/support/llm-replay/tests/llm-replay.spec.ts b/packages/support/llm-replay/tests/llm-replay.spec.ts index 345aa22679..8113dbc922 100644 --- a/packages/support/llm-replay/tests/llm-replay.spec.ts +++ b/packages/support/llm-replay/tests/llm-replay.spec.ts @@ -350,6 +350,12 @@ describe('installLlmReplay (through the real LlmService)', () => { expect(delta).toMatchObject({ argumentsDelta: '{"goal_id":"goal-42ab"}' }) }) + it('keeps a trailing brace quantifier inside the pattern (terminator is the run tail)', async () => { + const streamed = await streamScripted('{"goal_id":"{{fromRequest:goal-[0-9a-z]{4}}}"}') + const delta = streamed.find(chunk => chunk.type === 'tool-call-delta') + expect(delta).toMatchObject({ argumentsDelta: '{"goal_id":"goal-42ab"}' }) + }) + it('fails loud when a placeholder matches nothing in the request', async () => { await expect(streamScripted('{"goal_id":"{{fromRequest:task-[0-9]+}}"}')) .rejects.toThrow(/fromRequest.*matched nothing/) From e72978ba9880fc8ed1b440a820c07550604424f8 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Sun, 2 Aug 2026 07:29:18 -0700 Subject: [PATCH 19/19] fix(tool-goal): scope the wrap-up no-more-tools clause to the current run The injected instruction persists as a durable user message, so an unscoped 'Do not call any more tools.' stays in every later request's history. Scope it: '...in this run; further work waits for the user's next instruction.' A/B probes on deepseek-v4-pro show the scoped wording is non-inferior in-turn (4/4 zero tool calls, closing quality unchanged) and next-turn tool use is unaffected under both wordings; the scoped form states the instruction's actual lifetime. --- .../tests/goal-snapshots/goal-wrapup/session.expected.jsonl | 2 +- packages/goal/tool-goal/src/wrapup.ts | 5 +++-- packages/goal/tool-goal/tests/tool-goal.spec.ts | 4 ++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/examples/acp-agent/tests/goal-snapshots/goal-wrapup/session.expected.jsonl b/examples/acp-agent/tests/goal-snapshots/goal-wrapup/session.expected.jsonl index bf708d211b..4355ccb070 100644 --- a/examples/acp-agent/tests/goal-snapshots/goal-wrapup/session.expected.jsonl +++ b/examples/acp-agent/tests/goal-snapshots/goal-wrapup/session.expected.jsonl @@ -37,7 +37,7 @@ {"type":"tool/call","seq":35,"time":0,"data":{"turn":2,"step":1,"callId":"call_goal_complete","name":"update_goal","arguments":"{\"goal_id\":\"goal-{{sessionId}}\",\"revision\":1,\"action\":\"complete\"}"}} {"type":"tool/result","seq":36,"time":0,"data":{"turn":2,"step":1,"message":{"source":{"kind":"tool","callId":"call_goal_complete"},"content":[{"type":"tool-result","toolCallId":"call_goal_complete","content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":2,\"objective\":\"Finish the ACP goal wrap-up snapshot proof\",\"phase\":\"complete\",\"roundsStarted\":1,\"maxGoalRounds\":2},\"activation\":\"disarmed\"}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[35],"surfaceOp":"append"} {"type":"user/message","seq":37,"time":0,"data":{"content":[{"type":"text","text":"{\"goal\":{\"id\":\"goal-{{sessionId}}\",\"revision\":2,\"objective\":\"Finish the ACP goal wrap-up snapshot proof\",\"phase\":\"complete\",\"maxGoalRounds\":2},\"roundsStarted\":1,\"createdAt\":0,\"updatedAt\":0}"}],"source":{"kind":"goal","goalId":"goal-{{sessionId}}","revision":2,"round":0,"change":{"kind":"goal/change","version":1,"operation":"complete","goal":{"id":"goal-{{sessionId}}","revision":2,"objective":"Finish the ACP goal wrap-up snapshot proof","phase":"complete","maxGoalRounds":2},"roundsStarted":1,"createdAt":0,"updatedAt":0}},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"user/message","seq":38,"time":0,"data":{"content":[{"type":"text","text":"\nObjective: \"Finish the ACP goal wrap-up snapshot proof\"\nThe goal is marked complete and this autonomous run is ending. Write the closing message to the user now: state the outcome, summarize what was done and how it was verified, and point to the concrete results (files, commits, or other artifacts). Report only what earlier rounds and tool results in this session actually establish; when a detail is not in the session, say so instead of inventing it. Note anything the user should review or do next. Address the user directly. Do not call any more tools.\n"}],"source":{"kind":"plugin","plugin":"tool-goal"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","seq":38,"time":0,"data":{"content":[{"type":"text","text":"\nObjective: \"Finish the ACP goal wrap-up snapshot proof\"\nThe goal is marked complete and this autonomous run is ending. Write the closing message to the user now: state the outcome, summarize what was done and how it was verified, and point to the concrete results (files, commits, or other artifacts). Report only what earlier rounds and tool results in this session actually establish; when a detail is not in the session, say so instead of inventing it. Note anything the user should review or do next. Address the user directly. Do not call any more tools in this run; further work waits for the user's next instruction.\n"}],"source":{"kind":"plugin","plugin":"tool-goal"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"step/end","seq":39,"time":0,"data":{"turn":2,"step":1}} {"type":"step/start","seq":40,"time":0,"data":{"turn":2,"step":2}} {"type":"assistant/chunk","seq":41,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} diff --git a/packages/goal/tool-goal/src/wrapup.ts b/packages/goal/tool-goal/src/wrapup.ts index 4f16fdd924..158f17e886 100644 --- a/packages/goal/tool-goal/src/wrapup.ts +++ b/packages/goal/tool-goal/src/wrapup.ts @@ -24,7 +24,7 @@ export function renderWrapupContext(objective: string, blockedReason?: string): + 'verified, and point to the concrete results (files, commits, or other artifacts). ' + GROUNDING + 'Note anything the user should review or do next. Address the user directly. Do not ' - + 'call any more tools.\n' + + "call any more tools in this run; further work waits for the user's next instruction.\n" + '' : '\n' + heading @@ -34,7 +34,8 @@ export function renderWrapupContext(objective: string, blockedReason?: string): + 'blocking condition and what you tried, and say exactly what you need from the user to ' + 'continue. ' + GROUNDING - + 'Address the user directly. Do not call any more tools.\n' + + 'Address the user directly. Do not call any more tools in this run; further work ' + + "waits for the user's next instruction.\n" + '' return [{ type: 'text', text }] } diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index 6d3957e982..9363081f85 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -377,7 +377,7 @@ describe('goal tool state transitions', () => { if (block?.type !== 'text') throw new Error('expected one text wrap-up block') expect(block.text).toContain('') expect(block.text).toContain('"pause cleanly"') - expect(block.text).toContain('Do not call any more tools.') + expect(block.text).toContain("Do not call any more tools in this run; further work waits for the user's next instruction.") }) it('completes without a wrap-up instruction under direct human authority', async () => { @@ -578,7 +578,7 @@ describe('goal tool state transitions', () => { if (block?.type !== 'text') throw new Error('expected one text wrap-up block') expect(block.text).toContain('') expect(block.text).toContain('The required credential is still unavailable.') - expect(block.text).toContain('Do not call any more tools.') + expect(block.text).toContain("Do not call any more tools in this run; further work waits for the user's next instruction.") }) it('lets direct human authority block before the model threshold', async () => {