From 7d08e43720f9a2f0e4123271ecbffe6d72721d96 Mon Sep 17 00:00:00 2001 From: imccyu Date: Sun, 2 Aug 2026 17:50:00 +0800 Subject: [PATCH] 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') })