feat(web): steer queued messages into active turns
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-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
|
||||
|
||||
@@ -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 `"<n> 条排队消息"` 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 `"<n> 条排队消息"` 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.
|
||||
|
||||
@@ -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 在队列为空时隐藏,只有一个待处理项时直接渲染该行,存在两个或更多待处理项时则默认收起为可展开或收起完整列表的 `"<n> 条排队消息"` 表头。表头暴露 `aria-expanded` 和 `aria-controls`;展开后的列表以 180px 为高度上限,并可滚动。存在进行中的编辑或变更时,列表行会保持可见;队列清空后,下一次出现队列时会恢复默认收起状态。可见行暴露编辑和删除操作,不提供立即发送控件。UI 从运行时 `SessionFace` 契约派生队列行与变更类型,而不是导入连接插件,因此插件仍通过服务和快照协作。仅当所有内容块都是文本时才提供编辑功能;编辑器不能静默丢弃非文本块。编辑中的行只展示保存和取消操作,对应的键盘操作分别是 Enter 和 Escape。删除会移除对应的精确入队项。Web 停止操作会保留待处理 Queue 工作;只有在被中断轮次达到完全停稳后,AgentLoop 才认领下一个可唤醒入队项,其出队事件会退役该行,无需浏览器重发。
|
||||
**Web 操作只面向 Queue。** Host 从 `session/queue` 中排除待处理 steering;steering 消费后仍沿用既有的持久 transcript(文本记录)路径。QueueDock 在队列为空时隐藏,只有一个待处理项时直接渲染该行,存在两个或更多待处理项时则默认收起为可展开或收起完整列表的 `"<n> 条排队消息"` 表头。表头暴露 `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 则不属于此投影和操作接口。
|
||||
|
||||
现在,协议会在每次变更时携带完整队列快照。队列预期保持较短,因此系统优先选择确定性恢复和多客户端收敛,而非增量变更协议。
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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;有针对性的回归覆盖会保护这一顺序。
|
||||
Reference in New Issue
Block a user