feat(web): surface and configure composer steering
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;有针对性的回归覆盖会保护这一顺序。
|
||||
|
||||
Reference in New Issue
Block a user