diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml index 46b84213b9..54bfa56d7a 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md -2026-07-22-unified-send-and-coalesced-user-messages.md: ed171735cf483938c70291963a6e68dc02d7bde2 -2026-07-22-unified-send-and-coalesced-user-messages.zh.md: 8b2a3ebabb493954e653255e876255b9c0810c19 +2026-07-22-unified-send-and-coalesced-user-messages.md: 3b2187f9e9ae24f3a03c1418daf1c0aec255b314 +2026-07-22-unified-send-and-coalesced-user-messages.zh.md: 02a21193ce63926b6f04a02cb7ea8a64fe6603cb diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md index ed171735cf..3b2187f9e9 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md @@ -22,7 +22,7 @@ Separately, `context/message` and `user/message` had converged: the surface proj **`send` does not return identity.** Callers already own the complete message and its opaque `MessageId`; creation and freezing are owned by the [identified immutable message decision](2026-07-28-identified-immutable-message-values.md), not by routing. -**Three inbox events replace agent/queued.** `agent/inbox/enqueue` (an item entered a FIFO), `agent/inbox/dequeue` (the driver claimed one), and `agent/inbox/discard` (`cancel()` dropped pending items) carry the accepted `UserMessage`. Enqueue and dequeue also carry the resolved `queued | steering` placement captured at acceptance, so observers and reconnect mirrors retire repeated message identities from the correct FIFO without reconstructing routing from later status or session history. Injection never touches a FIFO and emits none of these. Every FIFO entry publishes an enqueue, including steering submitted by an `agent/turn-stopping` listener, so the ledger stays balanced with its later dequeue or discard. The `dsh-agent` invariant companion asserts FIFO conservation: a per-agent outstanding count that dequeue and discard can never drive negative. +**Inbox lifecycle events carry occurrence identities.** `agent/inbox/enqueue` (an item entered a FIFO), `agent/inbox/update` (a pending item was edited or promoted), `agent/inbox/dequeue` (the driver claimed one), and `agent/inbox/discard` (pending items were dropped) carry an `InboxItem`: an occurrence-local `InboxItemId`, the accepted `UserMessage`, and the resolved `queued | steering` placement captured at acceptance. The occurrence identity lets observers and reconnect mirrors distinguish repeated sends of the same `MessageId` without reconstructing routing from later status or session history. Injection never touches a FIFO and emits none of these. Every FIFO entry publishes one enqueue and exactly one terminal dequeue or discard; updates are non-terminal. The `dsh-agent` invariant companion asserts this FIFO conservation. **Admission accepts next-step input without becoming a turn.** The loop opens a private next-step acceptance window before `agent/prompt-submit`, keeps it open through the turn, and closes it before `turn/end`. Steering and injection received during admission therefore remain together in the outbox and join an allowed turn. If admission blocks or fails, a context-only caller batch takes idle injection's immediate append, while steering and context staged beside it remain available to retry; neither path writes the rejected prompt. When a later prompt is admitted, retained outbox input enters its turn before that prompt, while input accepted during the current admission remains after the prompt. Closing the window before `turn/end` preserves the rule that reentrant late steering becomes an independent queued turn. `Agent.acceptsNextStep` exposes whether a `next-step` send would currently join this window; `status` remains the broader activity signal rather than a routing predicate. @@ -43,7 +43,7 @@ Separately, `context/message` and `user/message` had converged: the surface proj The delivery surface is now one primitive plus three self-documenting presets, and the (`target` × `wakeup`) matrix makes previously-unreachable combinations explicit. One durable message type serves prompts, injected context, and goal rounds, so the surface projection and every "human prompt?" check simplify to a `source` test. The `Agent` contract remains an interface, so alternate implementations and object-literal test fakes implement the same minimal structural surface. The goal fold's channel split moved from event type to `source.round`, and every consumer that filtered `context/message` now filters `user/message` by source. An idle injection appends `user/message` between turns without opening a turn or running the model. -`wakeup` is the "should the model run" signal, so the inbox distinguishes waking queued work from anything available to dequeue: a lone `next-turn`/no-wakeup item stays parked at idle and rides along the next waking send, and `whenIdle`/`cancel` settle quiescence off the waking signal. Every FIFO exit publishes exactly one lifecycle event, while domain-specific durable facts travel in typed message sources rather than a parallel metadata channel. The direct pending-item representation keeps public lifecycle events correlated without maintaining a second steering wrapper or allowing its durable data to diverge. +`wakeup` is the "should the model run" signal, so the inbox distinguishes waking queued work from anything available to dequeue: a lone `next-turn`/no-wakeup item stays parked at idle and rides along the next waking send, and `whenIdle`/`cancel` settle quiescence off the waking signal. Every FIFO exit publishes exactly one lifecycle event, while domain-specific durable facts travel in typed message sources rather than a parallel metadata channel. The direct pending-item representation keeps public lifecycle events correlated without maintaining a second steering wrapper or allowing its durable data to diverge. The later [addressable queue operations](../feature/2026-07-29-addressable-queue-operations.md) decision adds live mutations over that occurrence identity without changing the one-message-per-turn or durable-message contracts. ## Related diff --git a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md index 8b2a3ebabb..02a21193ce 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md @@ -22,7 +22,7 @@ agent 的对外驱动接口逐渐长出三个近乎平行的动词——`send` **`send` 不返回标识。** 调用方已经持有完整消息及其不透明的 `MessageId`;消息的创建与冻结由[带标识的不可变消息值决策](2026-07-28-identified-immutable-message-values.md)负责,而不是由路由负责。 -**三个 inbox 事件取代 agent/queued。** `agent/inbox/enqueue`(一个队列项进入某个 FIFO)、`agent/inbox/dequeue`(驱动器认领了一个)和 `agent/inbox/discard`(`cancel()` 丢弃了待处理项)都会携带已接受的 `UserMessage`。enqueue 和 dequeue 还会携带生产方在接受消息时捕获的已解析 `queued | steering` 放置方式,因此观察方和重连镜像可以从正确的 FIFO 中结算重复出现的消息标识,无需根据后续状态或会话历史重建路由。注入从不触及 FIFO,也不发出这些事件中的任何一个。每一次 FIFO 入队都会发布一个 enqueue 事件,包括 `agent/turn-stopping` 监听器提交的 steering,因此账目会与其后的 dequeue 或 discard 保持平衡。`dsh-agent` 的不变量配套断言 FIFO 守恒:一个按 agent 计的未结算计数,dequeue 和 discard 永远无法把它压到负数。 +**Inbox 生命周期事件携带单次入队标识。** `agent/inbox/enqueue`(一个队列项进入某个 FIFO)、`agent/inbox/update`(待处理项被编辑或前移)、`agent/inbox/dequeue`(驱动器认领一个项)和 `agent/inbox/discard`(待处理项被丢弃)都会携带一个 `InboxItem`:仅属于本次入队的 `InboxItemId`、已接受的 `UserMessage`,以及生产方在接受消息时捕获的已解析 `queued | steering` 放置方式。单次入队标识让观察方和重连镜像能够区分同一 `MessageId` 的多次发送,无需根据后续状态或会话历史重建路由。注入从不触及 FIFO,也不发出这些事件中的任何一个。每次 FIFO 入队都会发布一个 enqueue,并且恰好发布一个终态 dequeue 或 discard;update 不是终态。`dsh-agent` 的不变量配套断言这种 FIFO 守恒。 **准入接受 next-step 输入,但不会因此成为一个轮次。** 循环会在 `agent/prompt-submit` 前打开一个私有的 next-step 接受窗口,使其贯穿整个轮次,并在 `turn/end` 前关闭。因此,在准入期间收到的 steering 和注入会一起留在 outbox 中并加入获准轮次。如果准入被阻止或失败,仅含调用方上下文的批次会采用空闲注入的立即追加行为,而 steering 及与其一同暂存的上下文仍可重试;两种路径都不会写入被拒绝的提示词。后续提示词获准时,保留在 outbox 中的输入会先于该提示词进入其轮次,而当前准入期间接受的输入则留在提示词之后。在 `turn/end` 前关闭窗口,可以保留这样的规则:可重入的晚到 steering 会成为一个独立的排队轮次。`Agent.acceptsNextStep` 会公开一次 `next-step` 发送当前是否会加入该窗口;`status` 仍是更宽泛的活动信号,而非路由判据。 @@ -43,7 +43,7 @@ agent 的对外驱动接口逐渐长出三个近乎平行的动词——`send` 投递接口现在是一个原语加三个自解释的预设,(`target` × `wakeup`) 矩阵把此前无法表达的组合显式化。一种持久消息类型同时服务提示词、注入的上下文和 goal 轮次,因此对外接口的投影和每一处“是否人类提示词?”检查都简化为一次 `source` 判断。`Agent` 契约仍是接口,因此其他实现和对象字面量形式的测试替身只需实现同一个最小结构接口。goal 折叠的通道区分从事件类型改到了 `source.round`;此前过滤 `context/message` 的每个消费方现在改为按来源过滤 `user/message`。空闲状态下的注入会在两个轮次之间追加 `user/message`,既不打开轮次,也不运行模型。 -`wakeup` 是“模型是否应当运行”的信号,因此 inbox 会区分能唤醒的排队工作与任何可 dequeue 的项:一个孤立的 `next-turn`/no-wakeup 队列项会停泊在空闲状态,并随下一次唤醒 send 一同带出,而 `whenIdle`/`cancel` 依据唤醒信号来结算静默。每一次 FIFO 退出都恰好发布一个生命周期事件,特定于领域的持久事实则通过类型化消息 source 传递,而非通过平行的元数据通道。直接使用待处理项的表示方式,使公开生命周期事件保持可关联,既无需维护第二个 steering 包装层,也避免其持久数据发生分歧。 +`wakeup` 是“模型是否应当运行”的信号,因此 inbox 会区分能唤醒的排队工作与任何可 dequeue 的项:一个孤立的 `next-turn`/no-wakeup 队列项会停泊在空闲状态,并随下一次唤醒 send 一同带出,而 `whenIdle`/`cancel` 依据唤醒信号来结算完全停稳。每一次 FIFO 退出都恰好发布一个生命周期事件,特定于领域的持久事实则通过类型化消息 source 传递,而非通过平行的元数据通道。直接使用待处理项的表示方式,使公开生命周期事件保持可关联,既无需维护第二个 steering 包装层,也避免其持久数据发生分歧。后续的[可寻址队列操作](../feature/2026-07-29-addressable-queue-operations.md)决策在该单次入队标识上增加了实时变更,但不改变单消息单轮次或持久消息契约。 ## 相关 diff --git a/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.i18n.yaml new file mode 100644 index 0000000000..fe91b8ccda --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.md +2026-07-29-addressable-queue-operations.md: 7462b882dde0c3b25ddfb321ab339b6cd51bd170 +2026-07-29-addressable-queue-operations.zh.md: ac442421a1e21b2e09bb003ca9be1a0412374d7f diff --git a/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.md b/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.md new file mode 100644 index 0000000000..7462b882dd --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.md @@ -0,0 +1,41 @@ +# Agent Note: Address pending queue occurrences for edit, remove, and promotion + +Status: implemented + +English | [中文](2026-07-29-addressable-queue-operations.zh.md) + +## Problem + +The Web queue rendered pending messages but could not act on one row. `MessageId` was insufficient as an address because callers may enqueue the same immutable message more than once. The browser also inferred queue retirement from turn and status events, so a row operation racing with driver claim had no authoritative outcome. + +“Send now” introduced a separate semantic choice: it could mean reorder the next independent turn, interrupt the current turn as steering, or cancel current work. Only the first interpretation preserves the queue row’s original delivery contract. + +## Decision + +**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 and steering FIFOs. Edit replaces frozen content while preserving `InboxItemId`, `MessageId`, source, placement, wake policy, and position. Remove emits the occurrence’s terminal discard. Promote moves it to the front of its current FIFO; an ordinary queued item also becomes waking. The driver removes an occurrence before prompt admission or steering drain, so a later mutation returns `not-found` and never rewrites durable history. + +**The live ledger is authoritative.** `agent/inbox/enqueue`, `update`, `dequeue`, and `discard` maintain a Host mirror. The wire sends complete `session/queue` snapshots rather than incremental guesses. Reconnect sends the current baseline, and every live mutation or terminal event replaces it. The client applies no optimistic edit and never retires a row from `turn/start`, `steering/message`, or status changes. + +**Web actions preserve delivery kind.** QueueDock projects only `queued` occurrences; pending `steering` occurrences remain in the authoritative snapshot but wait for a dedicated Web interaction. It exposes edit and delete, but no send-now control. 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. Protocol-level promotion remains available without being presented as a Web interaction; it never converts queued work into steering or cancels active work. + +## Alternatives considered + +**Address rows by `MessageId`.** Rejected because one immutable message may be sent repeatedly; editing or deleting by message identity would affect an ambiguous occurrence. + +**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. + +**Treat send-now as steering.** Rejected because it would change a queued independent turn into current-turn context, bypass ordinary prompt admission, and alter the one-send-one-turn guarantee. Promotion changes priority, not delivery semantics. + +**Cancel the active turn before promotion.** Rejected because a row-local action must not destroy unrelated in-flight work. + +## Verification + +AgentLoop contract tests hold prompt admission while editing, removing, and promoting exact occurrences, then verify the resulting independent-turn order and terminal lifecycle events. Host schema and proxy tests cover authoritative snapshots, reconnect, typed not-found errors, and the RPC transport. Client runtime and QueueDock tests cover non-optimistic projection, queued-only Web projection, text-only editing, save and cancel affordances, removal, retirement races, disabled mixed-content editing, and the absent send-now control. Keyless browser scenarios drive the exposed edit and delete actions and keep accepted pending steering hidden until it becomes a durable transcript event through the built Web composition and real HTTP/SSE wire. + +## Consequences + +Pending work gains precise row operations without becoming durable session history. Occurrence identity is a live process-local capability and disappears at claim, cancellation, disposal, or restart; reconnect recovers only items still held by the live Agent. Send-now is intentionally weaker than interruption, and editing intentionally excludes mixed content until an editor can preserve every block. + +The protocol now carries full queue snapshots on each change. Queues are expected to remain short, so deterministic recovery and multi-client convergence are preferred over an incremental mutation protocol. diff --git a/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.zh.md b/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.zh.md new file mode 100644 index 0000000000..ac442421a1 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.zh.md @@ -0,0 +1,41 @@ +# Agent Note(agent 决策记录):为待处理队列项提供编辑、移除与前移操作 + +Status: implemented + +[English](2026-07-29-addressable-queue-operations.md) | 中文 + +## 问题 + +Web 队列能够渲染待处理消息,但无法操作其中某一行。`MessageId` 不足以充当寻址标识,因为调用方可以多次将同一条不可变消息加入队列。浏览器还会根据轮次和状态事件推断队列项已退役,因此当行操作与驱动器认领发生竞态时,系统无法给出权威结果。 + +“立即发送”还引入了另一项语义选择:它可以表示重新排序下一个独立轮次、以 steering(中途引导)方式打断当前轮次,或取消当前工作。只有第一种解释能够保留该队列行原有的投递契约。 + +## 决策 + +**每次获准进入 FIFO 的项都有独立标识。** AgentLoop 会铸造不透明的 `InboxItemId`,并发布一个 `InboxItem`,其中包含该 id、已有标识的 `UserMessage`,以及接受时确定的 `queued | steering` 放置方式。复用同一个 `MessageId` 会创建不同的 inbox 标识。注入绕过 FIFO,因此不会获得 inbox 标识。 + +**变更边界止于驱动器认领。** `Agent.updateInbox(id, action)` 会同步搜索仍处于待处理状态的 queued 和 steering FIFO。编辑会替换已冻结的内容,同时保留 `InboxItemId`、`MessageId`、来源、放置方式、唤醒策略和位置。移除会发出该次入队项的终态 discard。前移会把它移至当前 FIFO 的队首;普通 queued 项还会变为可唤醒。驱动器会在提示词接纳或排空 steering 之前移除该项,因此之后的变更会返回 `not-found`,绝不会改写持久历史。 + +**实时账本是权威状态。** `agent/inbox/enqueue`、`update`、`dequeue` 和 `discard` 共同维护 Host 镜像。协议发送完整的 `session/queue` 快照,而非增量猜测。重连会发送当前基线,每次实时变更或终态事件都会整体替换它。客户端不会进行乐观编辑,也绝不根据 `turn/start`、`steering/message` 或状态变化退役队列行。 + +**Web 操作保持投递类型。** QueueDock 只投影 `queued` 入队项;待处理的 `steering` 入队项仍保留在权威快照中,等待 Web 提供专用交互。它只暴露编辑和删除,不提供立即发送控件。仅当所有内容块都是文本时才提供编辑功能;编辑器不能静默丢弃非文本块。编辑中的行只展示保存和取消操作,对应的键盘操作分别是 Enter 和 Escape。删除会移除对应的精确入队项。协议层仍保留前移操作,但不会把它呈现为 Web 交互;该操作绝不会把 queued 工作转换为 steering,也不会取消活动工作。 + +## 考虑过的替代方案 + +**通过 `MessageId` 寻址行。** 不予采纳,因为同一条不可变消息可以重复发送;按消息标识编辑或删除会无法确定应操作哪一次入队。 + +**在浏览器中进行乐观变更。** 不予采纳,因为驱动器认领或另一个客户端可能先于 Host 操作完成。等待权威快照可以显式呈现所有权边界,并让 `queue-item-not-found` 报告真实竞态。 + +**把立即发送视为 steering。** 不予采纳,因为这会把一个独立的排队轮次变成当前轮次的上下文,绕过普通提示词接纳,并改变单次 send 单轮次保证。前移只改变优先级,不改变投递语义。 + +**前移前取消活动轮次。** 不予采纳,因为仅影响某一行的操作不应破坏无关的进行中工作。 + +## 验证 + +AgentLoop 契约测试会在编辑、移除和前移对应的精确入队项时阻塞提示词接纳,随后验证所得独立轮次顺序及终态生命周期事件。Host schema 与代理测试覆盖权威快照、重连、类型化 not-found 错误和 RPC 传输。客户端运行时和 QueueDock 测试覆盖非乐观投影、Web 仅投影 queued 项、仅文本编辑、保存与取消入口、移除、退役竞态、禁用混合内容编辑,以及不提供立即发送控件。无密钥浏览器场景会通过构建后的 Web 组合和真实 HTTP/SSE 协议操作公开的编辑和删除,并使已接受的待处理 steering 在成为持久 transcript(文本记录)事件之前保持隐藏。 + +## 后果 + +待处理工作获得精确的行操作,但不会因此成为持久会话历史。单次入队标识是进程本地的实时寻址凭据,会在认领、取消、dispose 或重启时消失;重连只能恢复仍由活跃 Agent 持有的项。立即发送有意弱于打断,而编辑也有意排除混合内容,直至编辑器能够保留每个块。 + +现在,协议会在每次变更时携带完整队列快照。队列预期保持较短,因此系统优先选择确定性恢复和多客户端收敛,而非增量变更协议。 diff --git a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml index 60181a2ddb..7d841e0bf9 100644 --- a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.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/simplification/2026-07-17-one-send-one-turn.md -2026-07-17-one-send-one-turn.md: dcc6c0aa483a0e53205dbaeef4e2b903f5f6a215 -2026-07-17-one-send-one-turn.zh.md: 8c12481defe6608c13ee81132b432b4d8b17b681 +2026-07-17-one-send-one-turn.md: 3ae43f137206f25bdbc563875c17e24211f17d6b +2026-07-17-one-send-one-turn.zh.md: 5ccdb2192048ecf795415bcd427f967df6a609fb diff --git a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md index dcc6c0aa48..3ae43f1372 100644 --- a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md +++ b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md @@ -16,7 +16,7 @@ This grouping changes behavior, not just the number of model calls. One ordinary The rule is simple: each successful `send()` creates one independent FIFO queue item. If that item runs, it is the only ordinary message in its turn. An item can be dropped before it starts, so the precise guarantee is at most one turn rather than exactly one; two sends are never silently combined. -Before enqueueing an item, `send()` checks the agent state and makes a detached, deeply frozen snapshot of the content and resolved source. After enqueueing it, `send()` publishes `agent/queued`. +Before enqueueing an item, `send()` checks the agent state and accepts an already identified, deeply frozen message. It mints an occurrence-local `InboxItemId` and publishes `agent/inbox/enqueue`; the pending occurrence remains addressable under the [addressable queue operations](../feature/2026-07-29-addressable-queue-operations.md) decision until the driver claims or discards it. If messages A and B are both processed, B's turn starts only after A records `turn/end` and A's durability checkpoint settles. B's request therefore sees whatever closed result A left in the same session log. A checkpoint error is reported, but settlement only releases this ordering barrier; it does not make a failed write durable. Broad `cancel()`, disposal, or a failure before `turn/start` can instead discard an unstarted item without opening an empty turn. @@ -40,6 +40,6 @@ The no-batching rule applies only to ordinary `send()`. Running `steer()` puts i ## Consequences -Ordinary turn boundaries are predictable: messages A and B stay separate, and B runs only after A has closed and reached its checkpoint. Callers still do not receive a per-send completion or cancellation handle; broad cancellation can discard the entire unstarted tail, while status and quiescence remain agent-wide observations. +Ordinary turn boundaries are predictable: messages A and B stay separate, and B runs only after A has closed and reached its checkpoint. Callers still do not receive a per-send completion handle; a pending occurrence can be removed through its live `InboxItemId`, broad cancellation can discard the entire unstarted tail, and status and quiescence remain agent-wide observations. The trade-off is more model requests and more checkpoints. A busy queue can take longer to drain and can grow under sustained producers. Ordinary-send batching returns only through an explicit, measured contract. diff --git a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md index 8c12481def..5ccdb21920 100644 --- a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md @@ -16,7 +16,7 @@ Status: implemented 规则很简单:一次成功的 `send()` 创建一个独立的 FIFO 队列项。该队列项如果运行,就是所在轮次中唯一的普通消息。队列项可能在启动前被丢弃,因此精确保证是最多一个轮次,而不是必定一个轮次;两次 send 绝不会被悄悄合并。 -队列项入队之前,`send()` 会检查 agent 状态,并为内容和解析后的来源创建一份脱离调用方对象、经过深度冻结的快照。队列项入队之后,`send()` 发布 `agent/queued`。 +队列项入队之前,`send()` 会检查 agent 状态,并接受已有标识且经过深度冻结的消息。它会铸造一个仅属于本次入队的 `InboxItemId`,并发布 `agent/inbox/enqueue`;根据[可寻址队列操作](../feature/2026-07-29-addressable-queue-operations.md)决策,在驱动器认领或丢弃该项之前,这次待处理入队始终可以被寻址。 如果消息 A、B 都进入处理,B 的轮次只能在 A 记录 `turn/end` 且 A 的持久性检查点处理结束后开始。因此,B 的请求能看到 A 在同一会话日志中留下的已关闭结果。检查点错误会照常报告,但处理结束只表示解除这道顺序屏障,不表示失败的写入已经持久化。广义 `cancel()`、dispose(资源释放)或 `turn/start` 之前的失败也可能丢弃尚未启动的队列项,而不打开一个空轮次。 @@ -40,6 +40,6 @@ Status: implemented ## 后果 -普通轮次的边界可预测:消息 A、B 始终分开,B 只能在 A 关闭并到达检查点后运行。调用方仍然拿不到逐次 send 的完成或取消句柄;广义取消可以丢弃整个尚未启动的队尾,状态和静止性也仍是面向整个 agent 的观察。 +普通轮次的边界可预测:消息 A、B 始终分开,B 只能在 A 关闭并到达检查点后运行。调用方仍然拿不到逐次 send 的完成句柄;待处理项可通过其仍有效的 `InboxItemId` 移除,广义取消可以丢弃整个尚未启动的队尾,而状态与完全停稳仍是面向整个 agent 的观察。 代价是模型请求和检查点都会增加。繁忙队列可能需要更长时间才能清空;如果生产方持续提交消息,队列也可能增长。只有建立显式且经过测量的契约后,才能重新引入普通 send 批处理。 diff --git a/apps/web/tests/queue-actions.e2e.ts b/apps/web/tests/queue-actions.e2e.ts new file mode 100644 index 0000000000..be5e73f9a6 --- /dev/null +++ b/apps/web/tests/queue-actions.e2e.ts @@ -0,0 +1,117 @@ +// Keyless browser coverage for pending queue actions through the shipped Web +// composition and real HTTP/SSE wire. A replay override parks the active turn +// so two ordinary follow-ups remain addressable while the page edits one and +// removes one. The queue uses an existing recorded model +// call; this scenario owns only the user-visible mid-turn golden. +import { existsSync } from 'node:fs' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterEach, describe, expect, it, onTestFailed } from 'vitest' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { + assertFixtureInventory, captureStableAria, compareOrRefreshGolden, + launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { connectFreshWorkspace, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/queue-actions', import.meta.url)) +const FIXTURE = fileURLToPath(new URL('./snapshots/live-interactions/session.jsonl', import.meta.url)) +const EDITING_EXPECTED = join(SNAPSHOT_DIR, 'editing.expected.md') +const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md') +const MODE = webSnapshotMode() + +const ACTIVE_PROMPT = 'Reply with a one-sentence description of event sourcing, then stop.' +const REMOVE = 'Queue item to remove' +const EDIT = 'Queue item to edit' +const EDITED = 'Edited queue item' + +describe('web e2e: queue row actions', () => { + let scaffold: WebScaffold | undefined + let browser: Browser | undefined + let page: Page + let overrideDir: string | undefined + + afterEach(async () => { + const failures: unknown[] = [] + await browser?.close().catch((error: unknown) => failures.push(error)) + browser = undefined + const closing = scaffold + scaffold = undefined + await closing?.close().catch((error: unknown) => failures.push(error)) + if (overrideDir !== undefined) { + await rm(overrideDir, { recursive: true, force: true }) + .catch((error: unknown) => failures.push(error)) + } + overrideDir = undefined + if (failures.length === 1) throw failures[0] + if (failures.length > 1) throw new AggregateError(failures, 'queue-actions teardown failed') + }) + + it.skipIf(MODE === 'record')('edits and removes exact pending occurrences', async () => { + overrideDir = await mkdtemp(join(tmpdir(), 'dsh-web-queue-actions-')) + const readyFile = join(overrideDir, '.hang-ready') + const overridePath = join(overrideDir, 'replay.override.json') + await writeFile(overridePath, JSON.stringify({ + patches: [{ at: 0, entry: { kind: 'hang', readyFile } }], + })) + + const sessionEvents: SessionEvent[] = [] + scaffold = await launchWebScaffold({ replayFixture: FIXTURE, replayOverride: overridePath }) + scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) }) + browser = await chromium.launch() + page = await browser.newPage({ viewport: { width: 1680, height: 1000 } }) + const tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await connectFreshWorkspace(page) + onTestFailed(() => saveFailureShot(page, 'web-e2e-queue-actions')) + + const input = page.locator('textarea').first() + const settled = scaffold.whenTurnSettled() + await input.fill(ACTIVE_PROMPT) + await input.press('Enter') + await expect.poll(() => existsSync(readyFile), { timeout: 15_000 }).toBe(true) + + for (const text of [REMOVE, EDIT]) { + await input.fill(text) + await input.press('Enter') + } + await expect.poll( + () => page.getByRole('button', { name: '删除排队消息' }).count(), + { timeout: 10_000 }, + ).toBe(2) + + const editRow = page.getByText(EDIT, { exact: true }).locator('..') + await editRow.getByRole('button', { name: '编辑排队消息' }).click() + const editor = page.getByRole('textbox', { name: '编辑排队消息' }) + await editor.fill(EDITED) + const editingSnapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(EDITING_EXPECTED, editingSnapshot, MODE) + await page.getByRole('button', { name: '保存排队消息' }).click() + await page.getByText(EDITED, { exact: true }).waitFor() + + const removeRow = page.getByText(REMOVE, { exact: true }).locator('..') + await removeRow.getByRole('button', { name: '删除排队消息' }).click() + await expect.poll(() => page.getByText(REMOVE, { exact: true }).count()).toBe(0) + + const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + expect(sessionEvents.filter(event => event.type === 'user/message')).toHaveLength(1) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + + const editedRow = page.getByText(EDITED, { exact: true }).locator('..') + await editedRow.getByRole('button', { name: '删除排队消息' }).click() + await expect.poll(() => page.getByText(EDITED, { exact: true }).count()).toBe(0) + await page.getByRole('button', { name: 'Stop generating' }).click() + await settled + }, 120_000) + + it.skipIf(MODE === 'record')('keeps its snapshot inventory closed', async () => { + await assertFixtureInventory(SNAPSHOT_DIR, ['editing.expected.md', 'ui.expected.md']) + }) +}) diff --git a/apps/web/tests/snapshots/queue-actions/editing.expected.md b/apps/web/tests/snapshots/queue-actions/editing.expected.md new file mode 100644 index 0000000000..a057d65afd --- /dev/null +++ b/apps/web/tests/snapshots/queue-actions/editing.expected.md @@ -0,0 +1,40 @@ +- banner: + - navigation "Session hierarchy": + - button "Reply with a one-sentence description" [disabled] + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" + - tab "Waterfall" +- text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} +- button "复制": + - img +- button "在新对话中分支": + - img +- button "编辑": + - img +- paragraph: partial +- list: + - listitem: + - text: Queue item to remove + - button "编辑排队消息": + - img + - button "删除排队消息": + - img + - listitem: + - textbox "编辑排队消息": Edited queue item + - button "保存排队消息": + - img + - button "取消编辑": + - img +- textbox "Message the agent" +- button "Add attachment": + - img +- text: Danger Full Access +- combobox "Access mode": + - option "Read Only" + - option "Workspace Write" + - option "Danger Full Access" [selected] +- button "选择模型,当前 DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash + - img +- button "Stop generating" diff --git a/apps/web/tests/snapshots/queue-actions/ui.expected.md b/apps/web/tests/snapshots/queue-actions/ui.expected.md new file mode 100644 index 0000000000..096f98dd8a --- /dev/null +++ b/apps/web/tests/snapshots/queue-actions/ui.expected.md @@ -0,0 +1,34 @@ +- banner: + - navigation "Session hierarchy": + - button "Reply with a one-sentence description" [disabled] + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" + - tab "Waterfall" +- text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} +- button "复制": + - img +- button "在新对话中分支": + - img +- button "编辑": + - img +- paragraph: partial +- list: + - listitem: + - text: Edited queue item + - button "编辑排队消息": + - img + - button "删除排队消息": + - img +- textbox "Message the agent" +- button "Add attachment": + - img +- text: Danger Full Access +- combobox "Access mode": + - option "Read Only" + - option "Workspace Write" + - option "Danger Full Access" [selected] +- button "选择模型,当前 DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash + - img +- button "Stop generating" diff --git a/apps/web/tests/steering.e2e.ts b/apps/web/tests/steering.e2e.ts index dc1bc657ad..f5e2942860 100644 --- a/apps/web/tests/steering.e2e.ts +++ b/apps/web/tests/steering.e2e.ts @@ -1,8 +1,8 @@ -// Web e2e scenario: mid-turn steering, end to end. The composer locks while a -// turn runs, so the product UI has no steering gesture yet — the steer is -// POSTed from the page itself over the same same-origin /api transport the -// client uses (TODO(web-steer-composer): drive this through a composer -// gesture once one exists). Everything downstream is product: the gateway +// Web e2e scenario: mid-turn steering, end to end. The product composer +// deliberately exposes Queue only, so the steer is POSTed from the page +// itself over the same same-origin /api transport the client uses. +// TODO(web-steer-ui): Drive this through a dedicated steering interaction +// once one exists. Everything downstream is product: the gateway // routes mode:'steer' to Agent.steer, the loop drains it at the step // boundary into a durable steering/message event, the SSE mux pushes it, and // the transcript renders the badged interjection bubble. The question @@ -122,6 +122,8 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => { // blocks, alone. The DOM is stable here (no further SSE frames can // arrive until the question is answered), making this state capturable. expect(await page.getByText('插话').count()).toBe(0) + expect(await page.getByText(STEER, { exact: true }).count()).toBe(0) + expect(await page.getByRole('button', { name: '编辑排队消息' }).count()).toBe(0) const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(MID_EXPECTED, snapshot, MODE) } diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 276251910c..d1c73bee76 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -36,7 +36,8 @@ "tests/sidebar-scrollbar.e2e.ts", "tests/code-mode-round.e2e.ts", "tests/cordis-tool-round.e2e.ts", - "tests/message-actions.e2e.ts" + "tests/message-actions.e2e.ts", + "tests/queue-actions.e2e.ts" ], "references": [ { diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index c296e10fb1..92e13a56c0 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/architecture.md -architecture.md: 2ae982eba49b6dbd2365496915f9917071167813 -architecture.zh.md: abaef961504ff64dbcd1e8e8ba9bd002406fa7f4 +architecture.md: 9237063da9ede73be014457ebd85a1b5c59ba96f +architecture.zh.md: 2fe6e4b8b406c1010531b2709000ceffd10fb793 diff --git a/docs/architecture.md b/docs/architecture.md index 2ae982eba4..9237063da9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -77,8 +77,8 @@ choose declarative identity and fresh/resume path -> enter session + agent -> session/created -> agent/created -> enable driving -> agent/session-start(source) -> start driver forever: - wait for a queued message - claim message -> emit agent/status(running) if starting an interval + wait for queued occurrence + claim (edit/remove/promote end) -> emit agent/status(running) if starting an interval open the next-step acceptance window -> agent/prompt-submit blocked or failed prompt -> close the window without opening a turn diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index abaef96150..2fe6e4b8b4 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -77,8 +77,8 @@ choose declarative identity and fresh/resume path -> enter session + agent -> session/created -> agent/created -> enable driving -> agent/session-start(source) -> start driver forever: - wait for a queued message - claim message -> emit agent/status(running) if starting an interval + wait for queued occurrence + claim (edit/remove/promote end) -> emit agent/status(running) if starting an interval open the next-step acceptance window -> agent/prompt-submit blocked or failed prompt -> close the window without opening a turn diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 967241fbf6..b023555e72 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -32,7 +32,7 @@ Effective broad cancellation was requested, before queued/outbox work is cleared Types: [Agent](../core-data-structures/core.md) · [AgentCancelCause](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:286`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:326`](../../packages/core/agent/src/types.ts) ### `agent/created` — emit @@ -54,7 +54,7 @@ A fully configured agent and live session were published. Setup is composition-o Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:218`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:250`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit @@ -74,7 +74,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence and sco Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:227`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:259`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -96,7 +96,7 @@ A step or turn errored. The machine reports a failure here (plus the logger) eve Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:400`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:440`](../../packages/core/agent/src/types.ts) ### `agent/inbox/dequeue` — emit @@ -108,18 +108,16 @@ The driver claimed one item out of the inbox: a queued item at a turn boundary, * boundary, or steering drained between steps. Fires after the item leaves * its FIFO and before it becomes a durable message. * @param agent - the agent whose inbox item was claimed. - * @param message - the claimed message. - * @param placement - the FIFO that claimed this occurrence; together with - * `message.id`, it matches the earliest outstanding enqueue in that FIFO. + * @param item - the exact claimed occurrence. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ -'agent/inbox/dequeue'( this: Scoped, agent: Agent, message: UserMessage, placement: InboxPlacement, ): void +'agent/inbox/dequeue'(this: Scoped, agent: Agent, item: InboxItem): void ``` -Types: [Agent](../core-data-structures/core.md) · [InboxPlacement](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md) +Types: [Agent](../core-data-structures/core.md) · [InboxItem](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:259`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:304`](../../packages/core/agent/src/types.ts) ### `agent/inbox/discard` — emit @@ -133,16 +131,16 @@ Pending inbox items were dropped without delivering them, so every enqueue occur * emits this after `agent/cancel-requested` when applicable and before * aborting the active work. Fires once per drop with every dropped item. * @param agent - the agent whose inbox items were dropped. - * @param messages - the discarded messages in FIFO order (queued then steering); never empty. + * @param items - the discarded occurrences in FIFO order (queued then steering); never empty. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ -'agent/inbox/discard'(this: Scoped, agent: Agent, messages: UserMessage[]): void +'agent/inbox/discard'(this: Scoped, agent: Agent, items: InboxItem[]): void ``` -Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md) +Types: [Agent](../core-data-structures/core.md) · [InboxItem](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:276`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:316`](../../packages/core/agent/src/types.ts) ### `agent/inbox/enqueue` — emit @@ -154,17 +152,38 @@ An item entered the queued or steering inbox. `placement` is the acceptance-time * acceptance-time routing result; listeners must not reconstruct it from * later agent or session state. * @param agent - the owning agent. - * @param message - accepted content, source, and correlation identity. - * @param placement - resolved queued or steering placement. + * @param item - accepted occurrence, message, and resolved placement. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ -'agent/inbox/enqueue'(this: Scoped, agent: Agent, message: UserMessage, placement: InboxPlacement): void +'agent/inbox/enqueue'(this: Scoped, agent: Agent, item: InboxItem): void ``` -Types: [Agent](../core-data-structures/core.md) · [InboxPlacement](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md) +Types: [Agent](../core-data-structures/core.md) · [InboxItem](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:247`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:278`](../../packages/core/agent/src/types.ts) + +### `agent/inbox/update` — emit + +A still-pending inbox item changed content or position. The item id and placement remain stable; edit carries the replacement message, while promote makes this occurrence first in its current FIFO. + +```ts cordis-catalog +/** + * A still-pending inbox item changed content or position. The item id and + * placement remain stable; edit carries the replacement message, while + * promote makes this occurrence first in its current FIFO. + * @param agent - the owning agent. + * @param item - the complete post-update occurrence. + * @param action - the applied non-terminal operation. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode emit + */ +'agent/inbox/update'( this: Scoped, agent: Agent, item: InboxItem, action: 'edit' | 'promote', ): void +``` + +Types: [Agent](../core-data-structures/core.md) · [InboxItem](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) + +Source: [`packages/core/agent/src/types.ts:289`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall @@ -187,7 +206,7 @@ Allow, rewrite, or block one claimed prompt before it becomes a user message or Types: [Agent](../core-data-structures/core.md) · [PromptDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md) -Source: [`packages/core/agent/src/types.ts:313`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:353`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -211,7 +230,7 @@ Replace the frozen call configuration. `await next()` yields the config the mach Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:339`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:379`](../../packages/core/agent/src/types.ts) ### `agent/request-error` — waterfall @@ -241,7 +260,7 @@ Handle a model-request failure after its failed step has closed but before the f Types: [Agent](../core-data-structures/core.md) · [LlmFailure](../core-data-structures/llm-streaming.md) · [RequestError](../core-data-structures/core.md) · [RequestErrorAction](../core-data-structures/core.md) · [ResolvedRetryPolicy](../core-data-structures/llm-streaming.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:358`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:398`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -263,7 +282,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SessionStartSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:299`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:339`](../../packages/core/agent/src/types.ts) ### `agent/settled` — emit @@ -288,7 +307,7 @@ One drain chain reached its terminal turn: that turn's `turn/end` is already com Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SettleReason](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:387`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:427`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -308,7 +327,7 @@ Agent status changed (`idle` ⇄ `running`). `send()` does not enter `running` s Types: [Agent](../core-data-structures/core.md) · [AgentStatus](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:236`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:268`](../../packages/core/agent/src/types.ts) ### `agent/step` — serial @@ -332,7 +351,7 @@ Awaited serial checkpoint before EVERY request of a turn is built (the first as Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:326`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:366`](../../packages/core/agent/src/types.ts) ### `agent/turn-stopping` — serial @@ -358,7 +377,7 @@ The turn is about to close: the model owes no response (no live tool calls, no f Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:373`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:413`](../../packages/core/agent/src/types.ts) ## `agent-loop/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index db31daaf06..847bfdd8fc 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -216,7 +216,7 @@ roots(): Agent[] Types: [Agent](../core-data-structures/core.md) · [SessionId](../core-data-structures/core.md) -Source: [`packages/core/agent/src/index.ts:215`](../../packages/core/agent/src/index.ts) +Source: [`packages/core/agent/src/index.ts:216`](../../packages/core/agent/src/index.ts) ## `ctx.approval` — `ApprovalService` diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml index 9f6970d88d..5d2725f049 100644 --- a/docs/core-data-structures/core.i18n.yaml +++ b/docs/core-data-structures/core.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/core.md -core.md: b9df539136c2661537775ba9a425bdf7ef1fd958 -core.zh.md: 1c75e8484dd1184077fe0194b2b6088230d1bbf5 +core.md: 10c138877d25bd0c74af02a64245a48876a81255 +core.zh.md: b32849bf0737f98102266fa21b7335857229e546 diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index b9df539136..10c138877d 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -430,6 +430,33 @@ type SendTarget = 'next-turn' | 'next-step' type InboxPlacement = 'queued' | 'steering' ``` +`InboxItemId` is a process-local branded string minted for each accepted FIFO occurrence. It is intentionally distinct from `MessageId`: sending the same immutable message twice creates two independently addressable pending items. + +```ts type-equiv +/** One independently addressable accepted occurrence in an agent inbox. */ +interface InboxItem { + /** Agent-loop-minted occurrence identity. */ + readonly id: InboxItemId + /** Identified message delivered by the caller. */ + readonly message: UserMessage + /** Acceptance-time FIFO classification. */ + readonly placement: InboxPlacement +} +``` + +```ts type-equiv +/** A user-requested mutation of one still-pending inbox item. */ +type InboxAction = + | { readonly kind: 'edit'; readonly content: ContentBlock[] } + | { readonly kind: 'remove' } + | { readonly kind: 'promote' } +``` + +```ts type-equiv +/** Result of applying an inbox action at the synchronous ownership boundary. */ +type InboxActionResult = 'applied' | 'not-found' +``` + ```ts type-equiv /** * Options for the unified {@link Agent.send} primitive over the @@ -453,7 +480,7 @@ interface SendOptions { } ``` -The fixed-preset aliases own `target` and `wakeup`; their already identified `UserMessage` carries role, content, and provenance. Its `MessageId` remains stable across that message's `agent/inbox/*` events without being returned by the delivery methods. Injection bypasses the FIFOs and never appears on those events. +The fixed-preset aliases own `target` and `wakeup`; their already identified `UserMessage` carries role, content, and provenance. Its `MessageId` remains stable when an edit replaces the message content, while the enclosing `InboxItemId` identifies one accepted occurrence across `agent/inbox/enqueue`, `agent/inbox/update`, and its terminal dequeue or discard. Injection bypasses the FIFOs and never appears on those events. ```ts type-equiv /** Options for {@link Agent.cancel}. */ @@ -518,6 +545,18 @@ interface Agent { */ send(message: UserMessage, options: SendOptions): void + /** + * Mutate one still-pending inbox occurrence synchronously. Editing preserves + * the message identity and queue position; removal publishes its terminal + * discard; promotion moves it to the front of its current FIFO and makes a + * queued item waking. A driver-claimed item is no longer pending and returns + * `not-found`. + * @param id - independently addressable inbox occurrence. + * @param action - edit, remove, or promote operation. + * @returns whether the pending occurrence was found and updated. + */ + updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult + /** * Clear queued and steering work — unless `keepInbox` — and abort the active * turn. An effective call first emits `agent/cancel-requested` with the diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md index 1c75e8484d..b32849bf07 100644 --- a/docs/core-data-structures/core.zh.md +++ b/docs/core-data-structures/core.zh.md @@ -438,6 +438,33 @@ type SendTarget = 'next-turn' | 'next-step' type InboxPlacement = 'queued' | 'steering' ``` +`InboxItemId` 是为每次获准进入 FIFO 的项铸造的进程本地品牌字符串。它有意区别于 `MessageId`:同一条不可变消息发送两次,会创建两个可独立寻址的待处理项。 + +```ts type-equiv +/** One independently addressable accepted occurrence in an agent inbox. */ +interface InboxItem { + /** Agent-loop-minted occurrence identity. */ + readonly id: InboxItemId + /** Identified message delivered by the caller. */ + readonly message: UserMessage + /** Acceptance-time FIFO classification. */ + readonly placement: InboxPlacement +} +``` + +```ts type-equiv +/** A user-requested mutation of one still-pending inbox item. */ +type InboxAction = + | { readonly kind: 'edit'; readonly content: ContentBlock[] } + | { readonly kind: 'remove' } + | { readonly kind: 'promote' } +``` + +```ts type-equiv +/** Result of applying an inbox action at the synchronous ownership boundary. */ +type InboxActionResult = 'applied' | 'not-found' +``` + ```ts type-equiv /** * Options for the unified {@link Agent.send} primitive over the @@ -461,7 +488,7 @@ interface SendOptions { } ``` -固定预设的别名方法自带 `target` 与 `wakeup`;其已有标识的 `UserMessage` 会携带角色、内容与 provenance。投递方法不会返回其 `MessageId`,但该 id 在这条消息的各个 `agent/inbox/*` 事件中保持稳定。注入绕过两个 FIFO,从不出现在这些事件中。 +固定预设的别名方法自带 `target` 与 `wakeup`;其已有标识的 `UserMessage` 会携带角色、内容与 provenance。编辑替换消息内容时,其 `MessageId` 保持稳定;外层 `InboxItemId` 则在 `agent/inbox/enqueue`、`agent/inbox/update` 及终态 dequeue 或 discard 之间标识同一次入队。注入绕过两个 FIFO,从不出现在这些事件中。 ```ts type-equiv /** Options for {@link Agent.cancel}. */ @@ -526,6 +553,18 @@ interface Agent { */ send(message: UserMessage, options: SendOptions): void + /** + * Mutate one still-pending inbox occurrence synchronously. Editing preserves + * the message identity and queue position; removal publishes its terminal + * discard; promotion moves it to the front of its current FIFO and makes a + * queued item waking. A driver-claimed item is no longer pending and returns + * `not-found`. + * @param id - independently addressable inbox occurrence. + * @param action - edit, remove, or promote operation. + * @returns whether the pending occurrence was found and updated. + */ + updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult + /** * Clear queued and steering work — unless `keepInbox` — and abort the active * turn. An effective call first emits `agent/cancel-requested` with the diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 71238305e5..82ea842c9d 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,21 +8,22 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:148`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) | -| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:286`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal-session`](../packages/goal/goal-session) | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:218`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:227`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:400`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tui`](../packages/ui/tui) | -| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:259`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`tui`](../packages/ui/tui) | -| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:276`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`tui`](../packages/ui/tui) | -| `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:247`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session) | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:313`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`tui`](../packages/ui/tui) | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:339`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:358`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:299`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-context`](../packages/context/workspace-context) | -| `agent/settled` | `emit` | [`packages/core/agent/src/types.ts:387`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`compact-basic`](../packages/compact/compact-basic) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:236`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/step` | `serial` | [`packages/core/agent/src/types.ts:326`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`plan-mode`](../packages/plan/plan-mode), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`time-context`](../packages/context/time-context), [`tool-skill`](../packages/skill/tool-skill), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | -| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:373`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:326`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal-session`](../packages/goal/goal-session) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:250`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:259`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:440`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tui`](../packages/ui/tui) | +| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:304`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`tui`](../packages/ui/tui) | +| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:316`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`tui`](../packages/ui/tui) | +| `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:278`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session) | +| `agent/inbox/update` | `emit` | [`packages/core/agent/src/types.ts:289`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy` | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:353`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`tui`](../packages/ui/tui) | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:379`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:398`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:339`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-context`](../packages/context/workspace-context) | +| `agent/settled` | `emit` | [`packages/core/agent/src/types.ts:427`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`compact-basic`](../packages/compact/compact-basic) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:268`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | +| `agent/step` | `serial` | [`packages/core/agent/src/types.ts:366`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`plan-mode`](../packages/plan/plan-mode), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`time-context`](../packages/context/time-context), [`tool-skill`](../packages/skill/tool-skill), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | +| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:413`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` | | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:154`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy`, [`tui`](../packages/ui/tui) | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `apiproxy`, [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) | diff --git a/packages/acp/acp/tests/turns.spec.ts b/packages/acp/acp/tests/turns.spec.ts index db2234317b..70ebfae020 100644 --- a/packages/acp/acp/tests/turns.spec.ts +++ b/packages/acp/acp/tests/turns.spec.ts @@ -87,8 +87,8 @@ describe('ACP prompt lifecycle', () => { const sessionId = await newSession(harness) const agent = harness.ctx.agents.get(SessionId(sessionId))! let inserted = false - harness.ctx.on('agent/inbox/enqueue', (subject, message) => { - if (subject !== agent || message.source.kind !== 'user' || inserted) return + harness.ctx.on('agent/inbox/enqueue', (subject, item) => { + if (subject !== agent || item.message.source.kind !== 'user' || inserted) return inserted = true const source = { kind: 'plugin', plugin: 'test' } as const agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source } }) diff --git a/packages/client/connection/src/client/api.ts b/packages/client/connection/src/client/api.ts index a8e561ba33..b58718134d 100644 --- a/packages/client/connection/src/client/api.ts +++ b/packages/client/connection/src/client/api.ts @@ -12,7 +12,7 @@ export type { WorkspaceApi, WorkspaceId, WorkspaceView, CommandsApi, CommandDescriptor, SkillsApi, SkillEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, - ModelReasoningEffort, ModelTarget, SessionModels, + InboxItemId, ModelReasoningEffort, ModelTarget, QueueAction, QueuedInboxItem, SessionModels, GoalsApi, GoalRef, } from '@deepseek-ai/dsh-host-apiproxy/api' export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation' diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index cab616aabd..35b404bebc 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -1051,6 +1051,11 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { ) return ok(request, { accepted: true as const }) }, + updateQueue: request => err(request, { + code: 'queue-item-not-found', + message: 'fixture has no pending queue item', + details: { itemId: request.payload.itemId }, + }), cancel: (request) => { const replay = replays.get(request.payload.sessionId) if (replay !== undefined) { @@ -1487,6 +1492,7 @@ export class FixtureApiClient extends AbstractApiClient { case 'session.models': return this.api.sessions.models(request) case 'session.selectModel': return this.api.sessions.selectModel(request) case 'session.prompt': return this.api.sessions.prompt(request) + case 'session.updateQueue': return this.api.sessions.updateQueue(request) case 'session.cancel': return this.api.sessions.cancel(request) case 'host.describe': return this.api.host.describe(request) case 'host.pickDirectory': return this.api.host.pickDirectory(request, new AbortController().signal) diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index 0aa2cc3f82..7d26b1c526 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -17,7 +17,7 @@ export type { ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView, CommandsApi, CommandDescriptor, SkillsApi, SkillEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, - ModelReasoningEffort, ModelTarget, SessionModels, + InboxItemId, ModelReasoningEffort, ModelTarget, QueueAction, QueuedInboxItem, SessionModels, RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode, ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt, IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk, diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index 6a876c8ed4..650c206f34 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -62,6 +62,7 @@ export class FakeApiClient implements IApiClient { => Promise> = payload => Promise.resolve(ok({ selected: { provider: payload.provider, model: payload.model } })) onPrompt: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) + onUpdateQueue: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) onCancel: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) onDescribe: (payload: unknown) => Promise> = () => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 })) @@ -97,6 +98,7 @@ export class FakeApiClient implements IApiClient { selectModel: (payload: ModelTarget & { sessionId: SessionId }) => this.record('session.selectModel', payload, this.onSelectModel(payload)), prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)), + updateQueue: (payload: unknown) => this.record('session.updateQueue', payload, this.onUpdateQueue(payload)), cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)), } diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index 429ae8f0a9..e608ac1bb8 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: 25eb60e2c95059ae918669c9f5169b6b8e9c6816 -README.zh.md: e3085f91750503aeaffda41d86c40c62943b4ba9 +README.md: fbef8ddbb1b46d2c2ce5b4226c59a79b965c57c4 +README.zh.md: 597b0140266e2cc351e646cd2439b09fbf9ddb7b diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 25eb60e2c9..fbef8ddbb1 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -16,6 +16,10 @@ SlotsService gives the renderer separate bare observables for `useSessions` and `WorkspacesService.connectWorkspace(workspaceId)` resolves the session a New Session flow lands in: it reuses the workspace's existing blank session from the list mirror (`blank && cwd == workspace.path`) or calls `session.create({workspaceId})`, returning the session id for the caller to open. `SessionSummary.blank` mirrors the host's derived empty-log bit and only ever lowers on the client: seeded by `session.list` / the `host/session-added` frame, flipped false by the first ACCEPTED local `prompt()` (on the RPC success response — acceptance proves the user message is in the host log; a rejected first prompt keeps the session blank and reusable) and by any `running: true` status frame, re-aligned by every list re-pull. List surfaces hide blank rows; the store carries every row. `SessionsService.create` accepts an optional caller-preallocated SessionId and throws `SessionCreateError` (carrying `requestedSessionId`) on failure. +## Pending queue projection + +`ConversationSnapshot.queue` is the Host's authoritative transient inbox snapshot. Each row carries its `InboxItemId`, complete editable text when every content block is text, a flattened preview, and the accepted queued-or-steering placement. `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/promote operations without optimistic mutation, so the next Host snapshot is the sole visible commit and a claim race can surface `queue-item-not-found`. + ## Code Mode sub-dispatch index `ConversationSnapshot.codeDispatches` groups a `run_code` call's sub-dispatches under their parent callId, in start order, using the native call-block shapes: a `tool/code-dispatch-start` event lands the `RunningToolCall` form (rows derive the running ring from the shape) and its `tool/code-dispatch` settlement replaces it in place with the `ToolResultNode` form, `callTime` carrying the paired start's time. A settle whose start fell outside the replay window appends directly with `callTime: null` (duration unknown — never a fabricated zero). Live mux frames and history replay build the identical index; sub-calls never join the surface `nodes` flow; per-parent array and map references are memo-stable across unrelated snapshot swaps. diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index e3085f9175..597b014026 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -16,6 +16,10 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 `WorkspacesService.connectWorkspace(workspaceId)` 解析 New Session 流程最终落入的会话:先在列表镜像中复用该 workspace 的既有空会话(`blank && cwd == workspace.path`),未命中则调用 `session.create({workspaceId})`,返回会话 id 由调用方 open。`SessionSummary.blank` 镜像主机派生的空日志位,在客户端只降不升:由 `session.list`/`host/session-added` 帧播种,本地首次**受理成功**的 `prompt()`(RPC 成功响应时——受理即证明用户消息已入主机日志;首讯被拒则会话保持 blank、保持可复用)与任何 `running: true` 状态帧翻为 false,每次列表重拉重新对齐。列表表面隐藏 blank 行;store 保留全部行。`SessionsService.create` 接受可选的、由调用方预先分配的 SessionId,失败时抛出 `SessionCreateError`(携带 `requestedSessionId`)。 +## 待处理队列投影 + +`ConversationSnapshot.queue` 是 Host 提供的权威瞬态 inbox 快照。每行都携带其 `InboxItemId`、所有内容块均为文本时的完整可编辑文本、扁平化预览,以及接受时确定的 queued 或 steering 放置方式。`session/queue` 会整体替换该投影;重连缓冲只保留最新快照,持久轮次事件和 running 状态变化都不会猜测某个项已被认领。`Session.updateQueue()` 发送编辑/移除/前移操作,不进行乐观更新,因此下一份 Host 快照是唯一可见的提交结果,认领竞态则会返回 `queue-item-not-found`。 + ## Code Mode 子调用索引 `ConversationSnapshot.codeDispatches` 按父调用的 callId 和启动顺序,用原生调用块形状组织一个 `run_code` 调用的子调用:`tool/code-dispatch-start` 事件落成 `RunningToolCall` 形状(行组件从该形状推导运行中的转圈状态),其 `tool/code-dispatch` 完结事件原位替换为 `ToolResultNode` 形状,`callTime` 携带成对 start 事件的时间。start 落在回放窗口之外的完结事件则直接追加,`callTime: null`(耗时未知——绝不伪造零耗时)。live mux 帧与历史回放构建相同的索引;子调用永不进入 surface `nodes` 流;无关快照交换不会改变每个父调用对应的数组引用和映射引用,两者均保持 memo 稳定。 diff --git a/packages/client/runtime/src/client/contract/session.ts b/packages/client/runtime/src/client/contract/session.ts index 31335c388c..34003eaa2c 100644 --- a/packages/client/runtime/src/client/contract/session.ts +++ b/packages/client/runtime/src/client/contract/session.ts @@ -8,7 +8,9 @@ * dispatch) stay on the class, invisible out here. */ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' -import type { RpcResult, SessionId } from '@deepseek-ai/dsh-client-connection/client' +import type { + InboxItemId, QueueAction, RpcResult, SessionId, +} from '@deepseek-ai/dsh-client-connection/client' import type { ConversationSnapshot } from '../sessions/conversation.ts' import type { ObservableSnapshot } from './store.ts' @@ -36,6 +38,13 @@ export interface ISession { * @returns acceptance, or the business error (also mirrored into snapshot.promptError). */ prompt(content: ContentBlock[], mode: 'queue' | 'steer'): Promise> + /** + * Apply one mutation to a still-pending queue occurrence. + * @param itemId - agent-owned inbox occurrence identity. + * @param action - edit, remove, or promote operation. + * @returns acceptance, or a business/transport error. + */ + updateQueue(itemId: InboxItemId, action: QueueAction): Promise> /** * Cancel the running turn. * @returns acceptance, or the business error. diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index f5f0717236..a2f8eba658 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -7,7 +7,7 @@ import type { CommandId } from '@deepseek-ai/dsh-commands/brand' import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' import type { TodoItem } from '@deepseek-ai/dsh-session/types' import type { - RpcError, SessionId, ToolCallView, ToolResultView, + InboxItemId, RpcError, SessionId, ToolCallView, ToolResultView, } from '@deepseek-ai/dsh-client-connection/client' import type { PendingInteraction } from './pending.ts' @@ -185,10 +185,13 @@ export interface RunningToolCall { } -/** One queued-message row mirrored from `session/queued` frames (key: the enqueueing prompt's rpcId when wire-sourced). */ +/** One independently addressable row from the transient queue snapshot. */ export interface QueuedMessage { - readonly key: string + readonly id: InboxItemId readonly preview: string + /** Complete editable text; null when the message contains non-text blocks. */ + readonly text: string | null + readonly placement: 'queued' | 'steering' } /** In-progress assistant output (chunk accumulator product). */ @@ -246,7 +249,7 @@ export interface ConversationSnapshot { */ codeDispatches: ReadonlyMap pending: readonly PendingInteraction[] - /** Read-only inbox mirror (session/queued frames + mux-open baseline; cleared by the leave-running flip). */ + /** Authoritative transient inbox snapshot, replaced after every host-side change. */ queue: readonly QueuedMessage[] running: boolean /** Input-area shape (see {@link ComposerPhase}); derived here, switched on by consumers. */ diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index 396c0be6e7..ff68e21921 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -351,14 +351,14 @@ export class SessionManager { // them so last-wins cannot pin a phantom value over recomputed truth. this.projectionStores.get(frame.sessionId)?.truncate(frame.lastSeq) this.notifier.markDirty() - // New mux-generation baseline: buffered session/queued frames belong to + // New mux-generation baseline: buffered session/queue frames belong to // the previous generation and the host is about to resend the live // snapshot — drop them, or every reconnect appends a duplicate batch // (and enough reconnects push real approval/question frames past the // cap). Same re-baseline signal Session uses for its own mirror. const buffered = this.pendingBuffers.get(frame.sessionId) if (buffered !== undefined) { - const kept = buffered.filter(item => item.payload.type !== 'session/queued') + const kept = buffered.filter(item => item.payload.type !== 'session/queue') if (kept.length !== buffered.length) { if (kept.length === 0) this.pendingBuffers.delete(frame.sessionId) else this.pendingBuffers.set(frame.sessionId, kept) @@ -383,7 +383,7 @@ export class SessionManager { } const session = this.sessions.get(frame.sessionId) if (session === undefined) { - // Approval/question/queued frames never hit history: buffer for replay on + // Approval/question/queue frames never hit history: buffer for replay on // instantiation; everything else drops (not instantiated — history fully // backfills on open). switch (frame.type) { @@ -391,8 +391,12 @@ export class SessionManager { case 'approval/resolved': case 'question/requested': case 'question/resolved': - case 'session/queued': { + case 'session/queue': { const buffer = this.pendingBuffers.get(frame.sessionId) ?? [] + const prior = frame.type === 'session/queue' + ? buffer.findIndex(item => item.payload.type === 'session/queue') + : -1 + if (prior !== -1) buffer.splice(prior, 1) buffer.push(envelope) if (buffer.length > PENDING_BUFFER_CAP) buffer.splice(0, buffer.length - PENDING_BUFFER_CAP) this.pendingBuffers.set(frame.sessionId, buffer) diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 0f5d39ac8e..582d51cd54 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -4,8 +4,8 @@ import type { Context } from 'cordis' import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type { - HistoryEntry, IApiClient, MuxFrame, RpcError, RpcId, RpcResult, - SessionId, ToolEventView, + HistoryEntry, IApiClient, InboxItemId, MuxFrame, QueueAction, RpcError, + RpcId, RpcResult, SessionId, ToolEventView, } from '@deepseek-ai/dsh-client-connection/client' // Value import from the inline-safe wire layer (not the connection plugin): // plugin-to-plugin value imports are a bundle purity error. @@ -48,14 +48,6 @@ export interface SessionOptions { /** Queue-row preview cap: the dock renders one line, the full content never leaves the host mirror. */ const QUEUE_PREVIEW_CHARS = 200 -/** Internal inbox-mirror entry: the snapshot row plus the retirement-matching fields the frames carry. */ -interface QueuedEntry { - row: QueuedMessage - steering: boolean - /** JSON-serialized MessageSource (steering retirement matches by source, the host-mirror precedent). */ - sourceJson: string -} - /** Single-line queue-row preview: text blocks flattened, non-text as tags, capped by code point. */ function queuePreviewOf(content: readonly ContentBlock[]): string { const flat = content @@ -65,6 +57,12 @@ function queuePreviewOf(content: readonly ContentBlock[]): string { return chars.length > QUEUE_PREVIEW_CHARS ? `${chars.slice(0, QUEUE_PREVIEW_CHARS).join('')}…` : flat } +/** Recover complete composer text only when editing cannot discard non-text blocks. */ +function queueTextOf(content: readonly ContentBlock[]): string | null { + if (!content.every(block => block.type === 'text')) return null + return content.map(block => block.text).join('') +} + /** * Owns a session's event window, derived conversation state, and observable * snapshot. React bindings remain outside this data layer. Features see only @@ -102,9 +100,8 @@ export class Session implements SessionFace { private callsCache: { rev: number; value: RunningToolCall[] } | null = null private pendingRev = 0 private pendingCache: { rev: number; value: PendingInteraction[] } | null = null - /** Inbox mirror (session/queued frames + mux-open baseline). Queue frames never hit history, - * so this is stream-only state: reconnect clears it and the fresh baseline re-populates. */ - private queued: QueuedEntry[] = [] + /** Authoritative stream-only inbox snapshot; pending work never hits history. */ + private queued: QueuedMessage[] = [] private queueRev = 0 private queueCache: { rev: number; value: QueuedMessage[] } | null = null private frozenRev = 0 @@ -234,6 +231,15 @@ export class Session implements SessionFace { return result } + /** Apply one operation to a still-pending queue occurrence. */ + async updateQueue(itemId: InboxItemId, action: QueueAction): Promise> { + try { + return (await this.api.sessions.updateQueue({ sessionId: this.sessionId, itemId, action })).result + } catch (error) { + return transportError(error) + } + } + /** * Stop: contract session.cancel 1:1; failures land in promptError (same error-strip display slot). * @returns the cancel result. @@ -374,20 +380,16 @@ export class Session implements SessionFace { handleMuxEnvelope(rpcId: RpcId, frame: MuxFrame): void { switch (frame.type) { case 'session/event': { - this.retireQueued(frame.event) this.acceptLiveEvent(frame.event, frame.view) return } - case 'session/queued': { - const message = frame.message - // Row key: the enqueueing prompt's rpcId when it rode this wire (the - // provisional-echo reconciliation key); otherwise the frame envelope id. - const key = 'rpcId' in message.source ? String(message.source.rpcId) : `f:${rpcId}` - this.queued.push({ - row: { key, preview: queuePreviewOf(message.content) }, - steering: frame.steering, - sourceJson: JSON.stringify(message.source), - }) + case 'session/queue': { + this.queued = frame.items.map(item => ({ + id: item.id, + preview: queuePreviewOf(item.message.content), + text: queueTextOf(item.message.content), + placement: item.placement, + })) this.queueRev++ this.notifier.markDirty() return @@ -440,15 +442,6 @@ export class Session implements SessionFace { * @param running - the new running state. */ handleRunning(running: boolean): void { - // Leave-running sweep (host queuedMirror precedent): discard paths (cancel, - // terminal steering drop) have no per-entry frame, so ANY not-running signal - // with a nonempty mirror clears it — checked before the equality return so a - // stale replay on an already-idle session still sweeps. - if (!running && this.queued.length > 0) { - this.queued = [] - this.queueRev++ - this.notifier.markDirty() - } // Turn-start conversion: a blank session never runs, so the first // running:true proves another端's first message landed (设计稿 2.2). if (running && this.blankBit) { @@ -613,27 +606,6 @@ export class Session implements SessionFace { } } - /** Consumption-event retirement, mirroring the host queuedMirror rules: a message-triggered - * turn/start claims the oldest non-steering entry; a steering/message drains the oldest - * steering entry with the same source (loop-authored steering matches nothing and drops none). */ - private retireQueued(event: SessionEvent): void { - if (this.queued.length === 0) return - let index = -1 - if (event.type === 'turn/start') { - if (event.data.trigger.kind !== 'message') return - index = this.queued.findIndex(entry => !entry.steering) - } else if (event.type === 'steering/message') { - const source = JSON.stringify(event.data.message.source) - index = this.queued.findIndex(entry => entry.steering && entry.sourceJson === source) - } else { - return - } - if (index < 0) return - this.queued.splice(index, 1) - this.queueRev++ - this.notifier.markDirty() - } - /** Per-event side effects (right column of the §A.9 dispatch table): * chunk accumulation / partial clear on finalize / openCalls add-remove. */ private applyEventSideEffects(event: SessionEvent, view?: ToolEventView): void { @@ -813,7 +785,7 @@ export class Session implements SessionFace { this.dispatchesCache = { rev: this.dispatchesRev, value: new Map(this.codeDispatches) } } if (this.queueCache === null || this.queueCache.rev !== this.queueRev) { - this.queueCache = { rev: this.queueRev, value: this.queued.map(entry => entry.row) } + this.queueCache = { rev: this.queueRev, value: this.queued } } const partial = this.partial?.toPartial() ?? null return { diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index 0a1de3f7e1..e1ac0edd03 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -80,6 +80,7 @@ export class FakeApiClient implements IApiClient { Promise> = payload => Promise.resolve(ok({ selected: { provider: payload.provider, model: payload.model } })) onPrompt: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) + onUpdateQueue: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) onCancel: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) onDescribe: (payload: unknown) => Promise> = @@ -116,6 +117,7 @@ export class FakeApiClient implements IApiClient { selectModel: (payload: { provider: string; model: string }) => this.record('session.selectModel', payload, this.onSelectModel(payload)), prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)), + updateQueue: (payload: unknown) => this.record('session.updateQueue', payload, this.onUpdateQueue(payload)), cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)), } diff --git a/packages/client/runtime/tests/queue-store.spec.ts b/packages/client/runtime/tests/queue-store.spec.ts index 7a734ef393..02fab86728 100644 --- a/packages/client/runtime/tests/queue-store.spec.ts +++ b/packages/client/runtime/tests/queue-store.spec.ts @@ -1,32 +1,43 @@ /** - * Queue mirror semantics (web input-triggers queue cut 1): session/queued - * intake, host-rule retirement (message turn/start claims oldest non-steering; - * steering/message drains by source), leave-running sweep, reconnect reset, - * pre-instantiation buffering, and snapshot reference stability. + * Queue snapshot semantics: authoritative replacement after every host-side + * change, reconnect re-baselining, pre-instantiation buffering, editable-text + * projection, and snapshot reference stability. */ import { describe, expect, it } from 'vitest' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' -import type { MuxFrame, RpcId, SessionId } from '@deepseek-ai/dsh-client-connection/client' +import type { + InboxItemId, MuxFrame, RpcId, SessionId, +} from '@deepseek-ai/dsh-client-connection/client' import { Session } from '../src/client/sessions/session.ts' import { SessionManager } from '../src/client/sessions/manager.ts' import { FakeApiClient } from './fake-api.ts' -import { ev } from './event-script.ts' const SID = 'fk-q1' as SessionId -const text = (t: string): ContentBlock[] => [{ type: 'text', text: t }] +const text = (value: string): ContentBlock[] => [{ type: 'text', text: value }] const rid = (id: string): RpcId => id as RpcId +const iid = (id: string): InboxItemId => id as InboxItemId -/** session/queued frame with the wire-sourced rpcId key (the host prompt path). */ -function queuedFrame(body: string, rpcId: string, steering = false): MuxFrame { +interface QueueFixture { + id: string + body: string + placement?: 'queued' | 'steering' + content?: ContentBlock[] +} + +/** Build one authoritative queue snapshot. */ +function queueFrame(items: QueueFixture[]): MuxFrame { return { - type: 'session/queued', + type: 'session/queue', sessionId: SID, - message: createUserMessage({ - content: text(body), - source: { kind: 'user', rpcId: rid(rpcId) } as never, - }), - steering, + items: items.map(item => ({ + id: iid(item.id), + message: createUserMessage({ + content: item.content ?? text(item.body), + source: { kind: 'user', rpcId: rid(`rpc-${item.id}`) } as never, + }), + placement: item.placement ?? 'queued', + })), } } @@ -34,201 +45,133 @@ function makeSession(): Session { return new Session(SID, new FakeApiClient()) } -describe('queue intake', () => { - it('lands a queued frame as a row keyed by the source rpcId with a flat preview', () => { +describe('queue snapshot intake', () => { + it('projects stable ids, flat previews, complete text, and placement', () => { const session = makeSession() - session.handleMuxEnvelope(rid('env-1'), queuedFrame('第一条 排队\n消息', 'p-1')) - const queue = session.getSnapshot().queue - expect(queue).toEqual([{ key: 'p-1', preview: '第一条 排队 消息' }]) + session.handleMuxEnvelope(rid('env-1'), queueFrame([ + { id: 'q-1', body: '第一条 排队\n消息' }, + { id: 'q-2', body: '插话', placement: 'steering' }, + ])) + expect(session.getSnapshot().queue).toEqual([ + { id: 'q-1', preview: '第一条 排队 消息', text: '第一条 排队\n消息', placement: 'queued' }, + { id: 'q-2', preview: '插话', text: '插话', placement: 'steering' }, + ]) }) - it('falls back to the envelope rpcId when the source carries none, and tags non-text blocks', () => { + it('marks mixed-content messages non-editable while retaining their preview', () => { const session = makeSession() - session.handleMuxEnvelope(rid('env-2'), { - type: 'session/queued', - sessionId: SID, - message: createUserMessage({ - content: [{ type: 'text', text: 'hi' }, { type: 'image', data: 'x' } as never], - source: { kind: 'plugin', plugin: 'loop' }, - }), - steering: false, - }) - expect(session.getSnapshot().queue).toEqual([{ key: 'f:env-2', preview: 'hi [image]' }]) + session.handleMuxEnvelope(rid('env-2'), queueFrame([{ + id: 'q-image', + body: '', + content: [{ type: 'text', text: 'hi' }, { type: 'image', data: 'x' } as never], + }])) + expect(session.getSnapshot().queue).toEqual([ + { id: 'q-image', preview: 'hi [image]', text: null, placement: 'queued' }, + ]) }) - it('caps the preview at 200 code points with an ellipsis', () => { + it('caps previews at 200 code points and preserves the full editable text', () => { const session = makeSession() - session.handleMuxEnvelope(rid('env-3'), queuedFrame('长'.repeat(201), 'p-cap')) - const preview = session.getSnapshot().queue[0]?.preview ?? '' - expect(Array.from(preview)).toHaveLength(201) // 200 + … - expect(preview.endsWith('…')).toBe(true) + const body = '长'.repeat(201) + session.handleMuxEnvelope(rid('env-3'), queueFrame([{ id: 'q-cap', body }])) + const row = session.getSnapshot().queue[0] + expect(Array.from(row?.preview ?? '')).toHaveLength(201) + expect(row?.preview.endsWith('…')).toBe(true) + expect(row?.text).toBe(body) + }) + + it('replaces content, order, and membership from each authoritative frame', () => { + const session = makeSession() + session.handleMuxEnvelope(rid('env-4'), queueFrame([ + { id: 'q-1', body: 'one' }, + { id: 'q-2', body: 'two' }, + ])) + session.handleMuxEnvelope(rid('env-5'), queueFrame([ + { id: 'q-2', body: 'two edited' }, + ])) + expect(session.getSnapshot().queue).toEqual([ + { id: 'q-2', preview: 'two edited', text: 'two edited', placement: 'queued' }, + ]) + session.handleMuxEnvelope(rid('env-6'), queueFrame([])) + expect(session.getSnapshot().queue).toEqual([]) }) it('keeps the queue array reference stable across unrelated snapshot swaps', () => { const session = makeSession() - session.handleMuxEnvelope(rid('env-4'), queuedFrame('稳定', 'p-s')) + session.handleMuxEnvelope(rid('env-7'), queueFrame([{ id: 'q-stable', body: '稳定' }])) const before = session.getSnapshot().queue - session.handleAgentError('unrelated') // dirties the snapshot without touching the queue + session.handleAgentError('unrelated') expect(session.getSnapshot().queue).toBe(before) }) }) -describe('queue retirement (host queuedMirror rules)', () => { - it('a message-triggered turn/start claims the oldest non-steering row', () => { - const session = makeSession() - session.handleMuxEnvelope(rid('e1'), queuedFrame('先', 'p-1')) - session.handleMuxEnvelope(rid('e2'), queuedFrame('后', 'p-2')) - session.handleMuxEnvelope(rid('e3'), { type: 'session/event', sessionId: SID, event: ev.turnStart(0, 0) }) - expect(session.getSnapshot().queue.map(r => r.key)).toEqual(['p-2']) - }) +describe('queue operation transport', () => { + it('addresses the session.updateQueue RPC without optimistic local mutation', async () => { + const api = new FakeApiClient() + const session = new Session(SID, api) + session.handleMuxEnvelope(rid('env-op'), queueFrame([{ id: 'q-op', body: 'pending' }])) + const before = session.getSnapshot().queue - it('an injection-triggered turn/start claims nothing', () => { - const session = makeSession() - session.handleMuxEnvelope(rid('e1'), queuedFrame('留', 'p-1')) - const injection = { - ...ev.turnStart(0, 0), - data: { turn: 0, trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'x' } } }, - } as never - session.handleMuxEnvelope(rid('e2'), { type: 'session/event', sessionId: SID, event: injection }) - expect(session.getSnapshot().queue).toHaveLength(1) - }) - - it('steering/message drains the source-matched steering row only', () => { - const session = makeSession() - session.handleMuxEnvelope(rid('e1'), queuedFrame('普通', 'p-1')) // idle → non-steering - session.handleMuxEnvelope(rid('e3'), queuedFrame('插话', 'p-2', true)) - // Loop-authored steering (different source) must not consume the user entry. - const foreignSteering = { - seq: 0, time: 1, - type: 'steering/message', surfaceOp: 'append', - data: { - turn: 0, - message: createUserMessage({ - content: text('loop'), - source: { kind: 'plugin', plugin: 'loop' }, - }), - }, - } as never - session.handleMuxEnvelope(rid('e4'), { type: 'session/event', sessionId: SID, event: foreignSteering }) - expect(session.getSnapshot().queue).toHaveLength(2) - const matchedSteering = { - seq: 1, time: 2, - type: 'steering/message', surfaceOp: 'append', - data: { - turn: 0, - message: createUserMessage({ - content: text('插话'), - source: { kind: 'user', rpcId: rid('p-2') }, - }), - }, - } as never - session.handleMuxEnvelope(rid('e5'), { type: 'session/event', sessionId: SID, event: matchedSteering }) - expect(session.getSnapshot().queue.map(r => r.key)).toEqual(['p-1']) - }) - - it('a leave-running flip sweeps the whole mirror (cancel/terminal-drop cover)', () => { - const session = makeSession() - session.handleRunning(true) - session.handleMuxEnvelope(rid('e1'), queuedFrame('一', 'p-1')) - session.handleMuxEnvelope(rid('e2'), queuedFrame('二', 'p-2')) - session.handleRunning(false) - expect(session.getSnapshot().queue).toEqual([]) - }) - - it('a stale not-running relay on an idle session still sweeps replayed rows', () => { - const session = makeSession() - session.handleMuxEnvelope(rid('e1'), queuedFrame('孤儿', 'p-1')) - session.handleRunning(false) // running already false: equality path must not skip the sweep - expect(session.getSnapshot().queue).toEqual([]) + await expect(session.updateQueue(iid('q-op'), { kind: 'promote' })) + .resolves.toEqual({ ok: true, value: { accepted: true } }) + expect(api.callsOf('session.updateQueue')).toEqual([{ + sessionId: SID, + itemId: 'q-op', + action: { kind: 'promote' }, + }]) + expect(session.getSnapshot().queue).toBe(before) }) }) describe('queue reconnect semantics', () => { - it('session/subscribed re-baselines the mirror: stale rows drop, the following snapshot lands fresh', () => { + it('session/subscribed clears stale state before the fresh snapshot lands', () => { const session = makeSession() - session.handleMuxEnvelope(rid('e1'), queuedFrame('旧连接', 'p-old')) - // New mux generation: subscribed arrives first on the same stream... + session.handleMuxEnvelope(rid('e1'), queueFrame([{ id: 'q-old', body: '旧连接' }])) session.handleMuxEnvelope(rid('e2'), { type: 'session/subscribed', sessionId: SID, lastSeq: 10 }) expect(session.getSnapshot().queue).toEqual([]) - // ...then the queue snapshot replays the live inbox. - session.handleMuxEnvelope(rid('e3'), queuedFrame('新基线', 'p-new')) - expect(session.getSnapshot().queue.map(r => r.key)).toEqual(['p-new']) + session.handleMuxEnvelope(rid('e3'), queueFrame([{ id: 'q-new', body: '新基线' }])) + expect(session.getSnapshot().queue.map(row => row.id)).toEqual(['q-new']) }) - it('resync must NOT clear the mirror (regression: onConnected races the mux baseline)', async () => { + it('resync does not clear a baseline that raced ahead of the host connection signal', async () => { const session = makeSession() - // Reconnect ordering that broke: mux opened first and already delivered - // the fresh generation's baseline; host stream (and with it onConnected → - // resync) lands after. The host never resends — clearing here left the - // dock empty until the next enqueue. session.handleMuxEnvelope(rid('e1'), { type: 'session/subscribed', sessionId: SID, lastSeq: 5 }) - session.handleMuxEnvelope(rid('e2'), queuedFrame('新基线', 'p-fresh')) + session.handleMuxEnvelope(rid('e2'), queueFrame([{ id: 'q-fresh', body: '新基线' }])) await session.resync() - expect(session.getSnapshot().queue.map(r => r.key)).toEqual(['p-fresh']) + expect(session.getSnapshot().queue.map(row => row.id)).toEqual(['q-fresh']) }) - it('replayed steering retires without a replayed turn/start', () => { + it('running-status changes never guess at queue retirement', () => { const session = makeSession() - session.handleMuxEnvelope(rid('e1'), { type: 'session/subscribed', sessionId: SID, lastSeq: 5 }) - session.handleMuxEnvelope(rid('e2'), queuedFrame('重连插话', 'p-steer', true)) - const committed = { - seq: 6, time: 2, - type: 'steering/message', surfaceOp: 'append', - data: { - turn: 1, - message: createUserMessage({ - content: text('重连插话'), - source: { kind: 'user', rpcId: rid('p-steer') }, - }), - }, - } as never - session.handleMuxEnvelope(rid('e3'), { type: 'session/event', sessionId: SID, event: committed }) - expect(session.getSnapshot().queue).toEqual([]) + session.handleMuxEnvelope(rid('e1'), queueFrame([{ id: 'q-live', body: '保留' }])) + session.handleRunning(true) + session.handleRunning(false) + expect(session.getSnapshot().queue.map(row => row.id)).toEqual(['q-live']) }) }) -describe('manager buffering of queued frames', () => { - it('buffers session/queued for uninstantiated sessions and replays before the running sync', () => { - const api = new FakeApiClient() - const manager = new SessionManager(api) - manager.handleMuxEnvelope({ rpcId: rid('b1'), payload: queuedFrame('预热', 'p-b1') }) - // Instantiation replays the buffer; no summary exists, so no running sweep runs. - const session = manager.get(SID) - expect(session.getSnapshot().queue.map(r => r.key)).toEqual(['p-b1']) - // The buffer is consumed: a second get must not double-replay. - expect(manager.get(SID).getSnapshot().queue).toHaveLength(1) +describe('manager buffering of queue snapshots', () => { + it('replays only the latest snapshot for an uninstantiated session', () => { + const manager = new SessionManager(new FakeApiClient()) + manager.handleMuxEnvelope({ rpcId: rid('b1'), payload: queueFrame([{ id: 'q-old', body: '旧' }]) }) + manager.handleMuxEnvelope({ rpcId: rid('b2'), payload: queueFrame([{ id: 'q-new', body: '新' }]) }) + expect(manager.get(SID).getSnapshot().queue.map(row => row.id)).toEqual(['q-new']) }) - it('a not-running list summary sweeps replayed rows at instantiation', async () => { - const api = new FakeApiClient() - api.onList = () => Promise.resolve(ok([{ sessionId: SID, updatedAt: 1, running: false }])) - const manager = new SessionManager(api) - await manager.refreshList() - manager.handleMuxEnvelope({ rpcId: rid('b2'), payload: queuedFrame('该扫掉', 'p-b2') }) - expect(manager.get(SID).getSnapshot().queue).toEqual([]) - }) - - it('subscribed re-baselines the uninstantiated buffer: stale queued frames drop, non-queue frames survive (regression: reconnect duplication)', () => { - const api = new FakeApiClient() - const manager = new SessionManager(api) - // Generation 1 baseline lands while the session is uninstantiated, along - // with a pending approval (never re-derivable from history). - manager.handleMuxEnvelope({ rpcId: rid('g1a'), payload: queuedFrame('第一代', 'p-g1') }) + it('subscribed drops the prior-generation snapshot while preserving answerable frames', () => { + const manager = new SessionManager(new FakeApiClient()) + manager.handleMuxEnvelope({ rpcId: rid('g1a'), payload: queueFrame([{ id: 'q-g1', body: '第一代' }]) }) manager.handleMuxEnvelope({ rpcId: rid('g1b'), payload: { type: 'approval/requested', sessionId: SID, approvalId: 'ap-1' as never, toolName: 'bash' }, }) - // Reconnect: generation 2 replays subscribed + the SAME live queue entry. - manager.handleMuxEnvelope({ rpcId: rid('g2a'), payload: { type: 'session/subscribed', sessionId: SID, lastSeq: 3 } }) - manager.handleMuxEnvelope({ rpcId: rid('g2b'), payload: queuedFrame('第一代', 'p-g1') }) + manager.handleMuxEnvelope({ + rpcId: rid('g2a'), + payload: { type: 'session/subscribed', sessionId: SID, lastSeq: 3 }, + }) + manager.handleMuxEnvelope({ rpcId: rid('g2b'), payload: queueFrame([{ id: 'q-g2', body: '第二代' }]) }) const snapshot = manager.get(SID).getSnapshot() - // One queue row (no duplicate batch); the approval survived the re-baseline. - expect(snapshot.queue.map(r => r.key)).toEqual(['p-g1']) - expect(snapshot.pending.map(p => p.kind)).toEqual(['approval']) + expect(snapshot.queue.map(row => row.id)).toEqual(['q-g2']) + expect(snapshot.pending.map(pending => pending.kind)).toEqual(['approval']) }) }) - -/** ok wrapper with a typed items payload (the shared helper pins value to never[]). */ -function ok(items: { sessionId: SessionId; updatedAt: number; running: boolean }[]) { - return { rpcId: rid(`ok-${items.length}`), result: { ok: true as const, value: { items: items as never[] } } } -} diff --git a/packages/client/test-runtime/src/sessions.ts b/packages/client/test-runtime/src/sessions.ts index 5ec4652aef..c9deb546ae 100644 --- a/packages/client/test-runtime/src/sessions.ts +++ b/packages/client/test-runtime/src/sessions.ts @@ -84,6 +84,14 @@ export class FixtureSession implements SessionFace { throw new Error(`test session "${this.sessionId}": prompt is not stubbed — supply it on the fixture's session face`) } + /** + * Fail-loud stub; supply `updateQueue` on the fixture's session face to exercise it. + * @returns never — always throws. + */ + updateQueue(): never { + throw new Error(`test session "${this.sessionId}": updateQueue is not stubbed — supply it on the fixture's session face`) + } + /** * Fail-loud stub; supply `cancel` on the fixture's session face to exercise it. * @returns never — always throws. diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts index 9b93feae8b..77714a67a3 100644 --- a/packages/client/tsdown.client.ts +++ b/packages/client/tsdown.client.ts @@ -127,6 +127,8 @@ export function clientBundle(id: string, libEntry: readonly string[]): UserConfi async load(virtualId: string) { if (!virtualId.startsWith(CSS_VIRTUAL_PREFIX)) return null const fileId = virtualId.slice(CSS_VIRTUAL_PREFIX.length, -CSS_VIRTUAL_SUFFIX.length) + // The virtual id otherwise hides the physical stylesheet from Rolldown's watch graph. + this.addWatchFile(fileId) const source = await readFile(fileId) const { code, exports: cssExports } = transform({ filename: fileId, diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index c9f0551797..4105ad863d 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: e2148cfca658196540e3800912dccd0568ae8d0e -README.zh.md: 45f05ce2e03e015701e85f2853a4a656511058a9 +README.md: 1d5c3704a86ad11ce35b2bd2d404391301400016 +README.zh.md: 33a1f5187c0ae51ab18833766856fda33ba1ffde diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index e2148cfca6..1d5c3704a8 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -38,3 +38,5 @@ None; this package neither assembles nor sends a provider request. - **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export. - **The approval panel's "Always allow this type" is deferred** — durable grants need a grant-storage design; only allow-once/reject answer today. - **TodoPanel truncates long item text to one ellipsized line** — the figma strip has no wrap or expand affordance; full text is not readable inline. +- **Queue edit is text-only** — rows containing non-text blocks still show a flattened preview, but their edit control is disabled because the inline editor cannot preserve those blocks. A text row's edit mode replaces delete with save and cancel; Enter saves and Escape cancels. QueueDock exposes no send-now control; protocol-level promotion remains separate from the Web interaction. +- **Web exposes pending Queue only** — QueueDock omits pending steering until steering has its own interaction. A consumed `steering/message` still renders in the durable transcript so external steering remains truthful on replay. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 45f05ce2e0..33a1f5187c 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -38,3 +38,5 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插 - **others 工具行的闪光图标是手绘近似版本**:无法在本地导出设计字形的矢量几何;等到存在精确导出后再将其提升到 ui-primitives。 - **审批面板的「始终允许此类」暂缓**:持久授权需要授权存储设计;今天只能回答允许一次/拒绝。 - **TodoPanel 将过长条目截成单行省略号**:figma 条没有换行或展开入口,完整文本无法在行内读完。 +- **Queue 编辑仅支持文本**:包含非文本块的行仍显示扁平化预览,但由于内联编辑器无法保留这些块,其编辑控件会被禁用。文本行进入编辑模式后,删除会替换为保存和取消;Enter 保存,Escape 取消。QueueDock 不提供立即发送控件;协议层的前移操作与 Web 交互保持分离。 +- **Web 仅暴露待处理 Queue**:在 steering(中途引导)拥有专用交互之前,QueueDock 不展示待处理的 steering。已消费的 `steering/message` 仍会渲染到持久 transcript(文本记录)中,因此从外部提交的 steering 在回放时仍能如实呈现。 diff --git a/packages/client/ui-conversation/src/client/input/contract.ts b/packages/client/ui-conversation/src/client/input/contract.ts index 4454662cd6..f88ea80633 100644 --- a/packages/client/ui-conversation/src/client/input/contract.ts +++ b/packages/client/ui-conversation/src/client/input/contract.ts @@ -6,6 +6,7 @@ * (machine.ts) is package-private and never exported. */ import type { ClientContext, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { InboxItemId } from '@deepseek-ai/dsh-client-connection/client' import type { ArbitrateKey, ArbitrateOutcome, CommandClaim, ConsumeTokenRequest, PickOutcome, ReferenceInsert, SubmitOutcome, TokenSpan, @@ -99,11 +100,12 @@ export interface ComposerKeyboard { dismissPopup(): void } -/** One queued-message row projected from the session/queued frames (T9 supplies the store). */ +/** One independently addressable row projected from the transient queue snapshot. */ export interface QueuedMessage { - /** Stable row key: the enqueueing prompt's rpcId. */ - readonly key: string + readonly id: InboxItemId readonly preview: string + readonly text: string | null + readonly placement: 'queued' | 'steering' } /** Guard union of the scoped consume-token event, checked by the machine. */ diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.module.css b/packages/client/ui-conversation/src/client/queue/QueueDock.module.css index adc0c42b48..4c05c2cbca 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.module.css +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.module.css @@ -1,30 +1,114 @@ -/* Neutral stacked strip above the input (queue rows are informational, not a warn state). */ +/* Figma .FileContainerText 1:791: 776px wrapper around the inset 752px panel. */ .dock { - margin: 6px 0; - padding: 8px 12px; - border: 1px solid var(--dsw-alias-separator-primary); - border-radius: 10px; - background: var(--dsw-alias-bg-base); + box-sizing: border-box; + flex: none; + width: 100%; + max-width: 776px; + /* Eat InputBar's 6px top padding and tuck the panel 2px under the card; + the later composer sibling paints its surface and shadow over this edge. */ + margin: 0 auto -10px; + padding: 2px 12px; } -.title { - font-size: 12px; - font-weight: 500; - color: var(--dsw-alias-label-secondary); +.panel { + position: relative; + overflow: hidden; + width: 100%; + padding-top: 2px; + border-radius: 14px 14px 0 0; + background: var(--dsw-specific-tip); +} + +.panel::after { + position: absolute; + inset: 0; + border: 1px solid var(--dsw-alias-border-l1); + border-bottom: none; + border-radius: inherit; + content: ''; + pointer-events: none; } .list { - margin: 4px 0 0; + margin: 0; padding: 0; list-style: none; } .row { - overflow: hidden; - font-size: 12px; - line-height: 20px; - color: var(--dsw-alias-label-primary); - white-space: nowrap; - text-overflow: ellipsis; + box-sizing: border-box; + display: flex; + align-items: center; + gap: 10px; + width: 100%; + height: 36px; + padding: 4px 5px 4px 12px; + border-radius: 8px; +} + +.preview, +.editor { + flex: 1 1 auto; + min-width: 0; + font: var(--dsw-font-xs-13); + font-family: Inter, var(--dsw-font-family); +} + +.preview { + overflow: hidden; + color: var(--dsw-alias-label-primary-dimmed); + text-overflow: ellipsis; + white-space: nowrap; + word-break: break-word; +} + +.editor { + box-sizing: border-box; + height: 28px; + padding: 0 8px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 6px; + outline: none; + background: var(--dsw-alias-bg-base); + color: var(--dsw-alias-label-primary); +} + +.editor:focus { + border-color: var(--dsw-alias-state-business-primary); +} + +.actions { + display: flex; + flex: none; + align-items: center; + gap: 10px; +} + +.action { + display: grid; + flex: none; + place-items: center; + width: 28px; + height: 28px; + padding: 0; + border: none; + border-radius: 999px; + background: transparent; + color: var(--dsw-alias-label-tertiary); + cursor: pointer; +} + +.action:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover); +} + +.action:focus-visible { + outline: 2px solid var(--dsw-alias-label-tertiary); + outline-offset: -2px; +} + +.action:disabled { + cursor: default; + opacity: 0.45; } diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx index f5b5047c7b..858133a0d6 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx @@ -1,48 +1,188 @@ -// Read-only queue dock entry (design v4 queue cut 1): renders the session's -// inbox mirror (session/queued frames + connect baseline) as one stacked -// strip above the input. No per-row actions — the host inbox has no -// addressable entries yet (queue cut 2 ledger). +// Queue dock entry: renders the authoritative transient inbox snapshot and +// addresses per-row mutations through the session-scoped conversation face. // // The 'conversation.input.dock' SlotMap declaration lives in // ../contract/slots.ts beside the other input-region slots. import type { Context } from 'cordis' +import { useEffect, useMemo, useState } from 'react' import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' -import type {} from '@deepseek-ai/dsh-client-runtime/client' +import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { InboxItemId, QueueAction } from '@deepseek-ai/dsh-client-connection/client' +import { + IconCheckOutline16, IconCloseOutline16, IconEditOutline16, IconTrashOutline16, +} from '@deepseek-ai/dsh-client-ui-primitives' import css from './QueueDock.module.css' +/** Queue operations injected by the session-scoped registration. */ +export interface QueueDockInjected { + updateQueue: (itemId: InboxItemId, action: QueueAction) => Promise + notify: (level: 'info' | 'error', text: string) => void +} + /** Full props of a dock entry: InputZone owner share + session standard kit + global seat. */ -export type QueueDockProps = PropsRuntime<'conversation.input.dock'> +export type QueueDockProps = PropsRuntime<'conversation.input.dock'> & QueueDockInjected /** Queue strip: one preview line per queued message; renders null when the queue is empty. */ -export function QueueDock({ useSession }: QueueDockProps) { - const queue = useSession(s => s.queue) +export function QueueDock({ useSession, updateQueue, notify }: QueueDockProps) { + const inbox = useSession(s => s.queue) + // TODO(web-steer-ui): Give pending steering its own interaction before + // exposing it; QueueDock owns only independent queued turns. + const queue = useMemo(() => inbox.filter(row => row.placement === 'queued'), [inbox]) + const [editing, setEditing] = useState<{ id: InboxItemId; text: string } | null>(null) + const [busy, setBusy] = useState(null) + + useEffect(() => { + if (editing !== null && !queue.some(row => row.id === editing.id)) setEditing(null) + }, [editing, queue]) + if (queue.length === 0) return null + + const applyAction = async ( + itemId: InboxItemId, + action: QueueAction, + failure: string, + ): Promise => { + setBusy(itemId) + try { + await updateQueue(itemId, action) + return true + } catch { + notify('error', failure) + return false + } finally { + setBusy(current => current === itemId ? null : current) + } + } + + const saveEdit = async (): Promise => { + if (editing === null || editing.text.trim() === '') return + if (await applyAction( + editing.id, + { kind: 'edit', content: [{ type: 'text', text: editing.text }] }, + '编辑失败:这条消息可能已经开始发送。', + )) setEditing(null) + } + return (
-
已排队 {queue.length} 条
-
    - {queue.map(row => ( -
  • {row.preview}
  • - ))} -
+
+
    + {queue.map(row => ( +
  • + {editing?.id === row.id + ? ( + { setEditing({ id: row.id, text: event.currentTarget.value }) }} + onKeyDown={(event) => { + if (event.key === 'Escape') { + setEditing(null) + return + } + if (event.key === 'Enter' && !event.nativeEvent.isComposing) { + event.preventDefault() + void saveEdit() + } + }} + /> + ) + : {row.preview}} +
    + {editing?.id === row.id + ? ( + <> + + + + ) + : ( + <> + + + + )} +
    +
  • + ))} +
+
) } /** - * The dock entry as a plain registrant plugin (bash posture). - * `inject: ['conversation']` is the ordering seam: the conversation service - * mounts after ui-conversation's slot registrations, so the - * 'conversation.input.dock' declaration is on the ledger by then. + * The dock entry as a plain registrant plugin. The conversation service is the + * ordering and action seam; session scopes provide the exact queue owner. */ export const queueDockEntry = { name: 'conversation-queue-dock', - inject: ['slots', 'conversation'], + inject: ['slots', 'conversation', 'sessions'], /** * Register the queue strip into the input dock (list entry, order 0). * @param ctx - registrant context (disposal rides ctx.effect inside slots.register). */ apply(ctx: Context): void { - ctx.slots.register({ name: 'conversation.input.dock', id: 'queue', order: 0 }, QueueDock) + ctx.slots.register({ + name: 'conversation.input.dock', + id: 'queue', + order: 0, + inject: (sessionId: SessionId): QueueDockInjected => { + const actx = ctx.sessions.scope(sessionId) + if (actx === undefined) throw new Error(`queue dock: session "${sessionId}" resolved no scope`) + const conversation = actx.get('conversation') + if (conversation === undefined) throw new Error('queue dock: conversation service unavailable') + return { + updateQueue: (itemId, action) => conversation.updateQueue(itemId, action), + notify: (level, text) => { conversation.input.for(actx).notify(level, text) }, + } + }, + }, QueueDock) }, } diff --git a/packages/client/ui-conversation/src/client/queue/store.ts b/packages/client/ui-conversation/src/client/queue/store.ts index 6f084ea39d..536523465b 100644 --- a/packages/client/ui-conversation/src/client/queue/store.ts +++ b/packages/client/ui-conversation/src/client/queue/store.ts @@ -11,8 +11,8 @@ import type { QueuedMessage } from '../input/contract.ts' /** * Project a session's queue rows as a bare observable (subscribe/getSnapshot). * The wiring layer (T5) overlays this onto InputState.queue; the runtime - * QueuedMessage and the input-contract QueuedMessage are structurally the - * same frozen shape ({key, preview}). + * QueuedMessage and the input-contract QueuedMessage are structurally + * identical. * @param session - the resident session face. * @returns the queue read face (snapshot reference stable while the queue is unchanged). */ diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index 7b119b15ff..31da0b167b 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -13,6 +13,7 @@ import type { Context } from 'cordis' // error, so scope resolution goes through the sessions service (scopeOf // method) instead of the standalone helper. import type { ISessions, SessionFace, SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { InboxItemId, QueueAction } from '@deepseek-ai/dsh-client-connection/client' import type { InputService } from './input/contract.ts' /** @@ -30,6 +31,13 @@ export interface IConversation { * @returns completion; business failures reject (and land in promptError). */ send(text: string, mode: 'queue' | 'steer'): Promise + /** + * Apply one operation to a pending queue occurrence. + * @param itemId - agent-owned inbox occurrence identity. + * @param action - edit, remove, or promote operation. + * @returns completion; business failures reject. + */ + updateQueue(itemId: InboxItemId, action: QueueAction): Promise /** * Cancel the scoped session's in-flight turn. * @returns completion; failures reject as in send. @@ -71,6 +79,15 @@ export class ConversationService extends Service implements IConversation { if (!result.ok) throw new Error(`conversation.send failed: ${result.error.code}: ${result.error.message}`) } + /** Apply one operation to a pending queue occurrence. */ + async updateQueue(itemId: InboxItemId, action: QueueAction): Promise { + const session = this.scopedSession('updateQueue') + const result = await session.updateQueue(itemId, action) + if (!result.ok) { + throw new Error(`conversation.updateQueue failed: ${result.error.code}: ${result.error.message}`) + } + } + /** Cancel the scoped session's in-flight turn (failures land in promptError and reject, as in send). */ async cancel(): Promise { const session = this.scopedSession('cancel') diff --git a/packages/client/ui-conversation/tests/queue-dock.spec.tsx b/packages/client/ui-conversation/tests/queue-dock.spec.tsx index c63d3628e5..9e83d13ccc 100644 --- a/packages/client/ui-conversation/tests/queue-dock.spec.tsx +++ b/packages/client/ui-conversation/tests/queue-dock.spec.tsx @@ -1,20 +1,27 @@ // @vitest-environment jsdom /** - * QueueDock rendering (web input-triggers queue cut 1): empty queue renders - * nothing, rows render one preview line each keyed by rpcId, and the strip - * follows queue changes through the useSession selector. + * QueueDock rendering and operations: authoritative rows, inline editing, + * removal, failure notices, and live retirement. */ -import { afterEach, describe, expect, it } from 'vitest' -import { act, cleanup, render } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react' import { useSyncExternalStore } from 'react' -import type { ConversationSnapshot, QueuedMessage, SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client' +import type { + ConversationSnapshot, QueuedMessage, SessionId, SessionListState, +} from '@deepseek-ai/dsh-client-runtime/client' +import type { InboxItemId } from '@deepseek-ai/dsh-client-connection/client' import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' import type { InputState } from '../src/client/input/contract.ts' -import { QueueDock, queueDockEntry } from '../src/client/queue/QueueDock.tsx' +import { QueueDock, queueDockEntry, type QueueDockInjected } from '../src/client/queue/QueueDock.tsx' afterEach(cleanup) const SID = 's1' as SessionId +const iid = (id: string): InboxItemId => id as InboxItemId + +function row(id: string, text: string | null, preview = text ?? '[image]'): QueuedMessage { + return { id: iid(id), preview, text, placement: 'queued' } +} function snapshotWith(queue: QueuedMessage[]): ConversationSnapshot { return { @@ -24,31 +31,30 @@ function snapshotWith(queue: QueuedMessage[]): ConversationSnapshot { } } -/** Minimal live source backing the useSession stub (queue swaps notify subscribers). */ +/** Minimal live source backing the useSession stub. */ function liveSession(initial: ConversationSnapshot) { let snapshot = initial const listeners = new Set<() => void>() - const useSession: SnapshotSelectorHook = sel => + const useSession: SnapshotSelectorHook = selector => useSyncExternalStore( - (fn) => { - listeners.add(fn) - return () => listeners.delete(fn) + (listener) => { + listeners.add(listener) + return () => listeners.delete(listener) }, - () => sel(snapshot), + () => selector(snapshot), ) return { useSession, push(next: ConversationSnapshot): void { snapshot = next - for (const fn of [...listeners]) fn() + for (const listener of [...listeners]) listener() }, } } -/** InputZone owner stub (the dock reads useSession only; the zone fields satisfy the owner share). */ const INPUT_STATE: InputState = { draft: '', draftRev: 0, phase: 'plain', occurrences: [], queue: [] } -function kitFor(snapshot: ConversationSnapshot) { +function kitFor(snapshot: ConversationSnapshot, injected: Partial = {}) { return { sessionId: SID, useSessions: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook, @@ -58,6 +64,9 @@ function kitFor(snapshot: ConversationSnapshot) { inputActions: { setDraft: () => {}, submit: () => {} } as never, session: snapshot, input: INPUT_STATE, + updateQueue: vi.fn(() => Promise.resolve()), + notify: vi.fn(), + ...injected, } } @@ -69,20 +78,131 @@ describe('QueueDock', () => { expect(container.innerHTML).toBe('') }) - it('renders one preview row per queued message with the count strip', () => { + it('hides pending steering until it has a dedicated Web interaction', () => { + const steering = { ...row('i-steer', 'steer separately'), placement: 'steering' as const } + const snap = snapshotWith([steering]) + const source = liveSession(snap) + const { container } = render() + expect(container.innerHTML).toBe('') + + act(() => { source.push(snapshotWith([steering, row('i-queue', 'queue visibly')])) }) + expect(container.textContent).toContain('queue visibly') + expect(container.textContent).not.toContain('steer separately') + expect(container.querySelectorAll('button')).toHaveLength(2) + }) + + it('renders active actions and disables editing for mixed-content rows', () => { const snap = snapshotWith([ - { key: 'p-1', preview: '第一条排队消息' }, - { key: 'p-2', preview: 'second queued line' }, + row('i-1', '第一条排队消息'), + row('i-2', null, 'image [image]'), ]) const source = liveSession(snap) const { container } = render() - expect(container.textContent).toContain('已排队 2 条') - const rows = [...container.querySelectorAll('li')] - expect(rows.map(r => r.textContent)).toEqual(['第一条排队消息', 'second queued line']) + expect([...container.querySelectorAll('li')].map(item => item.textContent)) + .toEqual(['第一条排队消息', 'image [image]']) + expect(container.querySelectorAll('button')).toHaveLength(4) + expect(container.querySelectorAll('[aria-label="编辑排队消息"]')).toHaveLength(2) + expect(container.querySelectorAll('[aria-label="删除排队消息"]')).toHaveLength(2) + expect(container.querySelectorAll('[aria-label="立即发送排队消息"]')).toHaveLength(0) + expect((container.querySelectorAll('[aria-label="编辑排队消息"]')[0] as HTMLButtonElement).disabled).toBe(false) + expect((container.querySelectorAll('[aria-label="编辑排队消息"]')[1] as HTMLButtonElement).disabled).toBe(true) + expect(container.querySelectorAll('[aria-label="编辑排队消息"]')[1]?.getAttribute('title')) + .toBe('包含非文本内容,暂不支持编辑') }) - it('follows queue changes: retirement empties the strip back to null', () => { - const snap = snapshotWith([{ key: 'p-1', preview: '在场' }]) + it('edits text inline with save and cancel controls, then saves with the same item identity', async () => { + const snap = snapshotWith([row('i-edit', 'before')]) + const source = liveSession(snap) + const updateQueue = vi.fn(() => Promise.resolve()) + const { getByLabelText, queryByLabelText } = render( + , + ) + + fireEvent.click(getByLabelText('编辑排队消息')) + const editor = getByLabelText('编辑排队消息') as HTMLInputElement + expect(getByLabelText('保存排队消息')).toBeTruthy() + expect(getByLabelText('取消编辑')).toBeTruthy() + expect(queryByLabelText('删除排队消息')).toBeNull() + fireEvent.change(editor, { target: { value: 'after' } }) + fireEvent.keyDown(editor, { key: 'Enter' }) + + await waitFor(() => { + expect(updateQueue).toHaveBeenCalledWith(iid('i-edit'), { + kind: 'edit', + content: [{ type: 'text', text: 'after' }], + }) + }) + }) + + it('cancels an edit by button or Escape without mutating the queue', () => { + const snap = snapshotWith([row('i-edit', 'before')]) + const source = liveSession(snap) + const updateQueue = vi.fn(() => Promise.resolve()) + const { getByLabelText, getByText } = render( + , + ) + + fireEvent.click(getByLabelText('编辑排队消息')) + fireEvent.change(getByLabelText('编辑排队消息'), { target: { value: 'abandoned' } }) + fireEvent.click(getByLabelText('取消编辑')) + expect(getByText('before')).toBeTruthy() + + fireEvent.click(getByLabelText('编辑排队消息')) + fireEvent.keyDown(getByLabelText('编辑排队消息'), { key: 'Escape' }) + expect(getByText('before')).toBeTruthy() + expect(updateQueue).not.toHaveBeenCalled() + }) + + it('keeps editing during IME composition and disables a blank save', () => { + const snap = snapshotWith([row('i-edit', 'before')]) + const source = liveSession(snap) + const updateQueue = vi.fn(() => Promise.resolve()) + const { getByLabelText } = render( + , + ) + + fireEvent.click(getByLabelText('编辑排队消息')) + const editor = getByLabelText('编辑排队消息') + fireEvent.change(editor, { target: { value: ' ' } }) + expect(getByLabelText('保存排队消息')).toHaveProperty('disabled', true) + fireEvent.change(editor, { target: { value: '输入中' } }) + fireEvent.keyDown(editor, { key: 'Enter', isComposing: true }) + expect(updateQueue).not.toHaveBeenCalled() + expect(getByLabelText('编辑排队消息')).toBeTruthy() + }) + + it('removes the addressed row', async () => { + const snap = snapshotWith([row('i-1', 'one'), row('i-2', 'two')]) + const source = liveSession(snap) + const updateQueue = vi.fn(() => Promise.resolve()) + const { getAllByLabelText } = render( + , + ) + + fireEvent.click(getAllByLabelText('删除排队消息')[0]!) + await waitFor(() => { + expect(updateQueue).toHaveBeenCalledWith(iid('i-1'), { kind: 'remove' }) + }) + }) + + it('keeps the row and surfaces a notice when an operation loses the claim race', async () => { + const snap = snapshotWith([row('i-race', 'pending')]) + const source = liveSession(snap) + const notify = vi.fn() + const updateQueue = vi.fn(() => Promise.reject(new Error('not found'))) + const { getByLabelText, getByText } = render( + , + ) + + fireEvent.click(getByLabelText('删除排队消息')) + await waitFor(() => { + expect(notify).toHaveBeenCalledWith('error', '删除失败:这条消息可能已经开始发送。') + }) + expect(getByText('pending')).toBeTruthy() + }) + + it('follows authoritative retirement back to null', () => { + const snap = snapshotWith([row('i-1', '在场')]) const source = liveSession(snap) const { container } = render() expect(container.textContent).toContain('在场') @@ -90,11 +210,9 @@ describe('QueueDock', () => { expect(container.innerHTML).toBe('') }) - it('ships the registrant plugin shape (list entry into conversation.input.dock)', () => { - // Registration itself runs under T5's slot declaration; here we pin the - // frozen registration surface so the wiring layer can mount it verbatim. + it('ships the session-scoped registrant plugin shape', () => { expect(queueDockEntry.name).toBe('conversation-queue-dock') - expect(queueDockEntry.inject).toEqual(['slots', 'conversation']) + expect(queueDockEntry.inject).toEqual(['slots', 'conversation', 'sessions']) expect(typeof queueDockEntry.apply).toBe('function') }) }) diff --git a/packages/client/ui-conversation/tests/service-orchestration.spec.ts b/packages/client/ui-conversation/tests/service-orchestration.spec.ts index 4897fe5769..209cdee212 100644 --- a/packages/client/ui-conversation/tests/service-orchestration.spec.ts +++ b/packages/client/ui-conversation/tests/service-orchestration.spec.ts @@ -12,11 +12,12 @@ import { InputHub } from '../src/client/input/hub.ts' async function bench() { const runtime = await SlotTestRuntime.create() const prompt = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } })) + const updateQueue = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } })) const cancel = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } })) const loadOlder = vi.fn(() => Promise.resolve()) await runtime.sessions.add({ id: 's1', - session: { prompt, cancel, loadOlder }, + session: { prompt, updateQueue, cancel, loadOlder }, }) // config.input is required (the apply shares its hub with the inject // factories); the bench passes its own instance explicitly. @@ -26,16 +27,18 @@ async function bench() { await fiber.await() const root = runtime.ctx.get('conversation') as ConversationService const scoped = runtime.sessions.scope('s1')!.get('conversation') as ConversationService - return { runtime, root, scoped, prompt, cancel, loadOlder } + return { runtime, root, scoped, prompt, updateQueue, cancel, loadOlder } } describe('ConversationService', () => { it('routes operations through the public Session binding', async () => { const b = await bench() await b.scoped.send('hello', 'steer') + await b.scoped.updateQueue('item-1' as never, { kind: 'remove' }) await b.scoped.cancel() await b.scoped.loadOlder() expect(b.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'hello' }], 'steer') + expect(b.updateQueue).toHaveBeenCalledWith('item-1', { kind: 'remove' }) expect(b.cancel).toHaveBeenCalledOnce() expect(b.loadOlder).toHaveBeenCalledOnce() await b.runtime.dispose() diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts index ef2abf1a45..2b87fad68d 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -49,6 +49,7 @@ function sessionAgent(session: Session, id = 'agent'): Agent { session.append('user/message', input, { surfaceOp: 'append' }) }, send: () => {}, + updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(), } diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index 3ff6b9b310..af921c6bfb 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -184,6 +184,7 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent { session.append('user/message', input, { surfaceOp: 'append' }) }, send: () => {}, + updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(), } diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 7d9b087b8f..e744c93687 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1108,24 +1108,31 @@ export const EVENT_API: readonly EventApiEntry[] = [ { name: 'agent/inbox/dequeue', mode: 'emit', - signature: '\'agent/inbox/dequeue\'( this: Scoped, agent: Agent, message: UserMessage, placement: InboxPlacement, ): void', - jsDoc: '/**\n * The driver claimed one item out of the inbox: a queued item at a turn\n * boundary, or steering drained between steps. Fires after the item leaves\n * its FIFO and before it becomes a durable message.\n * @param agent - the agent whose inbox item was claimed.\n * @param message - the claimed message.\n * @param placement - the FIFO that claimed this occurrence; together with\n * `message.id`, it matches the earliest outstanding enqueue in that FIFO.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + signature: '\'agent/inbox/dequeue\'(this: Scoped, agent: Agent, item: InboxItem): void', + jsDoc: '/**\n * The driver claimed one item out of the inbox: a queued item at a turn\n * boundary, or steering drained between steps. Fires after the item leaves\n * its FIFO and before it becomes a durable message.\n * @param agent - the agent whose inbox item was claimed.\n * @param item - the exact claimed occurrence.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', summary: 'The driver claimed one item out of the inbox: a queued item at a turn boundary, or steering drained between steps.', }, { name: 'agent/inbox/discard', mode: 'emit', - signature: '\'agent/inbox/discard\'(this: Scoped, agent: Agent, messages: UserMessage[]): void', - jsDoc: '/**\n * Pending inbox items were dropped without delivering them, so every\n * enqueue occurrence receives exactly one terminal `agent/inbox/dequeue` OR\n * `agent/inbox/discard`. `cancel()` without `keepInbox`, including disposal,\n * emits this after `agent/cancel-requested` when applicable and before\n * aborting the active work. Fires once per drop with every dropped item.\n * @param agent - the agent whose inbox items were dropped.\n * @param messages - the discarded messages in FIFO order (queued then steering); never empty.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + signature: '\'agent/inbox/discard\'(this: Scoped, agent: Agent, items: InboxItem[]): void', + jsDoc: '/**\n * Pending inbox items were dropped without delivering them, so every\n * enqueue occurrence receives exactly one terminal `agent/inbox/dequeue` OR\n * `agent/inbox/discard`. `cancel()` without `keepInbox`, including disposal,\n * emits this after `agent/cancel-requested` when applicable and before\n * aborting the active work. Fires once per drop with every dropped item.\n * @param agent - the agent whose inbox items were dropped.\n * @param items - the discarded occurrences in FIFO order (queued then steering); never empty.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', summary: 'Pending inbox items were dropped without delivering them, so every enqueue occurrence receives exactly one terminal `agent/inbox/dequeue` OR `agent/inbox/discard`.', }, { name: 'agent/inbox/enqueue', mode: 'emit', - signature: '\'agent/inbox/enqueue\'(this: Scoped, agent: Agent, message: UserMessage, placement: InboxPlacement): void', - jsDoc: '/**\n * An item entered the queued or steering inbox. `placement` is the\n * acceptance-time routing result; listeners must not reconstruct it from\n * later agent or session state.\n * @param agent - the owning agent.\n * @param message - accepted content, source, and correlation identity.\n * @param placement - resolved queued or steering placement.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + signature: '\'agent/inbox/enqueue\'(this: Scoped, agent: Agent, item: InboxItem): void', + jsDoc: '/**\n * An item entered the queued or steering inbox. `placement` is the\n * acceptance-time routing result; listeners must not reconstruct it from\n * later agent or session state.\n * @param agent - the owning agent.\n * @param item - accepted occurrence, message, and resolved placement.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', summary: 'An item entered the queued or steering inbox.', }, + { + name: 'agent/inbox/update', + mode: 'emit', + signature: '\'agent/inbox/update\'( this: Scoped, agent: Agent, item: InboxItem, action: \'edit\' | \'promote\', ): void', + jsDoc: '/**\n * A still-pending inbox item changed content or position. The item id and\n * placement remain stable; edit carries the replacement message, while\n * promote makes this occurrence first in its current FIFO.\n * @param agent - the owning agent.\n * @param item - the complete post-update occurrence.\n * @param action - the applied non-terminal operation.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */', + summary: 'A still-pending inbox item changed content or position.', + }, { name: 'agent/prompt-submit', mode: 'waterfall', @@ -1433,7 +1440,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ export const TYPE_API: readonly TypeApiEntry[] = [ { name: 'Agent', - declaration: 'export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n}', + declaration: 'export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n}', }, { name: 'AgentCancelCause', @@ -1827,6 +1834,18 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'GoalView', declaration: 'export interface GoalView extends GoalSnapshot {\n readonly roundsStarted: number;\n readonly createdAt: number;\n readonly updatedAt: number;\n readonly activation: GoalActivation;\n}', }, + { + name: 'InboxAction', + declaration: 'export type InboxAction = {\n readonly kind: \'edit\';\n readonly content: ContentBlock[];\n} | {\n readonly kind: \'remove\';\n} | {\n readonly kind: \'promote\';\n};', + }, + { + name: 'InboxActionResult', + declaration: 'export type InboxActionResult = \'applied\' | \'not-found\';', + }, + { + name: 'InboxItemId', + declaration: 'export type InboxItemId = Branded<\'InboxItemId\'>;', + }, { name: 'InvariantFailure', declaration: 'export type InvariantFailure = (message: string) => never;', diff --git a/packages/core/agent-loop/README.i18n.yaml b/packages/core/agent-loop/README.i18n.yaml index 75edc1357f..f7cbc7b387 100644 --- a/packages/core/agent-loop/README.i18n.yaml +++ b/packages/core/agent-loop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/agent-loop/README.md -README.md: 6bb8b12af69f54c2a75cd672e4d3802887808c76 -README.zh.md: f9eb8aa3cdead427a88492e35c00eab80ba12f91 +README.md: f38fc5232cab49cdd6311cb24de9b83e62bc19a6 +README.zh.md: 4f81bfd182e739ba7645e364a873cc461b2aefe5 diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 6bb8b12af6..f38fc5232c 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -55,7 +55,9 @@ Configured agents start automatically. A model call requires both `provider` and The concrete `ReactLoopAgent`, its queued input, outbox, and run controls are package-internal. The package root exports only the plugin/service/config contract, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than naming, constructing, or starting driver internals. One prepared session can be claimed by only one concrete driver, and everything observable happens through session events and the `agent/*` event taxonomy. -The unified `send()` primitive routes content and source by (`target` × `wakeup`); `followup`/`steer`/`inject` are its fixed-preset aliases. A `next-turn` item joins the queued FIFO, waking the driver unless `wakeup: false`; admission happens before any turn opens. The loop opens a private next-step acceptance window before `agent/prompt-submit` and closes it before `turn/end`. During that window, `steer()` and `inject()` stage in one outbox; an allowed admission opens the turn, records the prompt and returned `additionalContexts`, then drains the staged input before the first request. A blocked or failed admission writes no prompt or hook-produced context. A caller-staged context-only batch then takes idle injection's immediate append, while steering and context staged beside it remain pending for retry or a later admitted prompt. Outside the window, steering becomes a waking queued prompt and injection immediately appends `user/message` without opening a turn or running the model. Every inbox enqueue publishes `agent/inbox/enqueue` with the resolved queued-or-steering placement; taking it publishes `agent/inbox/dequeue` with the same placement, and `cancel()` without `keepInbox` publishes `agent/inbox/discard`. +The unified `send()` primitive routes content and source by (`target` × `wakeup`); `followup`/`steer`/`inject` are its fixed-preset aliases. A `next-turn` item joins the queued FIFO, waking the driver unless `wakeup: false`; admission happens before any turn opens. The loop opens a private next-step acceptance window before `agent/prompt-submit` and closes it before `turn/end`. During that window, `steer()` and `inject()` stage in one outbox; an allowed admission opens the turn, records the prompt and returned `additionalContexts`, then drains the staged input before the first request. A blocked or failed admission writes no prompt or hook-produced context. A caller-staged context-only batch then takes idle injection's immediate append, while steering and context staged beside it remain pending for retry or a later admitted prompt. Outside the window, steering becomes a waking queued prompt and injection immediately appends `user/message` without opening a turn or running the model. + +Every FIFO acceptance mints an `InboxItemId` and publishes `agent/inbox/enqueue` with the complete occurrence. `updateInbox()` owns the synchronous pending-item boundary: edit freezes replacement content without changing message identity or position, remove publishes discard, and promote moves the occurrence to the head of its queued or steering FIFO; promoting queued work also makes it waking. Edit and promote publish `agent/inbox/update`. Claim publishes `agent/inbox/dequeue` and irrevocably removes the live address before prompt admission, so a racing update returns `not-found`; `cancel()` without `keepInbox` publishes `agent/inbox/discard`. ### Loop lifecycle (`agent.ts`) diff --git a/packages/core/agent-loop/README.zh.md b/packages/core/agent-loop/README.zh.md index f9eb8aa3cd..4f81bfd182 100644 --- a/packages/core/agent-loop/README.zh.md +++ b/packages/core/agent-loop/README.zh.md @@ -55,7 +55,9 @@ interface Config { 实体 `ReactLoopAgent`、其排队输入、outbox 与运行控制均为包内部实现。包根只导出插件/服务/配置契约,包导出映射不提供 `./src/*` 逃逸路径;生命周期拥有方通过 `ctx.agents` 创建 agent,而不是点名、构造或启动驱动器内部组件。一个准备完成的会话只能由一个实体驱动器认领;所有可观测行为都通过会话事件和 `agent/*` 事件分类体系发生。 -统一的 `send()` 原语按(`target` × `wakeup`)路由内容与来源;`followup`/`steer`/`inject` 是它的固定预设别名。`next-turn` 项加入排队 FIFO,除非 `wakeup: false`,否则会唤醒驱动器;接纳发生在任何轮次开启之前。循环在 `agent/prompt-submit` 之前打开一个私有的 next-step 接收窗口,并在 `turn/end` 之前关闭它。在该窗口内,`steer()` 与 `inject()` 会暂存到同一个 outbox;接纳获准后会开启轮次,记录提示词及其返回的 `additionalContexts`,再于首次请求前排空暂存输入。接纳被阻止或失败时,不会写入提示词或钩子生成的上下文。之后,仅含调用方暂存上下文的批次会采用空闲注入的立即追加行为,而 steering(中途引导)及与其一同暂存的上下文则继续待处理,以供重试或之后获准的提示词使用。窗口之外,steering 会成为唤醒驱动器的排队提示词,而注入会立即追加 `user/message`,不开启轮次也不运行模型。每次 inbox 入队都会发布 `agent/inbox/enqueue`,并携带解析出的 queued 或 steering 路由归类;取走它会发布 `agent/inbox/dequeue`,并携带相同的路由归类;`cancel()` 在不带 `keepInbox` 时会发布 `agent/inbox/discard`。 +统一的 `send()` 原语按(`target` × `wakeup`)路由内容与来源;`followup`/`steer`/`inject` 是它的固定预设别名。`next-turn` 项加入排队 FIFO,除非 `wakeup: false`,否则会唤醒驱动器;接纳发生在任何轮次开启之前。循环在 `agent/prompt-submit` 之前打开一个私有的 next-step 接收窗口,并在 `turn/end` 之前关闭它。在该窗口内,`steer()` 与 `inject()` 会暂存到同一个 outbox;接纳获准后会开启轮次,记录提示词及其返回的 `additionalContexts`,再于首次请求前排空暂存输入。接纳被阻止或失败时,不会写入提示词或钩子生成的上下文。之后,仅含调用方暂存上下文的批次会采用空闲注入的立即追加行为,而 steering(中途引导)及与其一同暂存的上下文则继续待处理,以供重试或之后获准的提示词使用。窗口之外,steering 会成为唤醒驱动器的排队提示词,而注入会立即追加 `user/message`,不开启轮次也不运行模型。 + +每次 FIFO 接受项时都会铸造一个 `InboxItemId`,并通过 `agent/inbox/enqueue` 发布完整的单次入队项。`updateInbox()` 持有同步待处理项边界:编辑会冻结替换内容,但不改变消息标识或位置;移除会发布 discard;前移会把该项移至其 queued 或 steering FIFO 的队首,其中 queued 工作还会变为可唤醒。编辑和前移会发布 `agent/inbox/update`。认领操作会发布 `agent/inbox/dequeue`,并在提示词接纳前不可逆地移除实时寻址标识,因此竞态中的更新会返回 `not-found`;`cancel()` 在不带 `keepInbox` 时会发布 `agent/inbox/discard`。 ### 循环生命周期(`agent.ts`) diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 949e9801c4..5ac0389702 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -8,13 +8,18 @@ */ import type { Context } from 'cordis' -import { agentCarrier, assembleContextFor, emitAgentEvent } from '@deepseek-ai/dsh-agent' +import { randomUUID } from 'node:crypto' +import { agentCarrier, assembleContextFor, emitAgentEvent, InboxItemId } from '@deepseek-ai/dsh-agent' import { createScope } from '@deepseek-ai/dsh-scope' import type { Scope } from '@deepseek-ai/dsh-scope' import type { Agent, CancelOptions, AgentInterruptReason, + InboxAction, + InboxActionResult, + InboxItem, + InboxItemId as InboxItemIdType, InboxPlacement, AgentOptions, AgentStatus, @@ -55,9 +60,9 @@ type StepOutcome = */ export class ReactLoopAgent implements Agent { /** Prompts awaiting individual turns. */ - private queued: { message: UserMessage; wakeup: boolean }[] = [] + private queued: { item: InboxItem; wakeup: boolean }[] = [] /** Input taken into the session log at step boundaries. */ - private outbox: { message: UserMessage; steering: boolean }[] = [] + private outbox: { message: UserMessage; steering: boolean; item?: InboxItem }[] = [] /** Whether observers see a running interval; consecutive turns share it. */ private busy = false @@ -115,16 +120,77 @@ export class ReactLoopAgent implements Agent { } const placement: InboxPlacement = target === 'next-step' && this.acceptsNextStep ? 'steering' : 'queued' + const item: InboxItem = Object.freeze({ + id: InboxItemId(randomUUID()), + message, + placement, + }) if (placement === 'steering') { - this.outbox.push({ message, steering: true }) + this.outbox.push({ message, steering: true, item }) } else { - this.queued.push({ message, wakeup }) + this.queued.push({ item, wakeup }) } // Preserve the routing decision for every send in this synchronous caller // stack, while installing quiescence ownership before enqueue observers // can cancel or dispose. if (placement === 'queued' && wakeup) this.scheduleKick() - emitAgentEvent(this.loopCtx, this, 'agent/inbox/enqueue', message, placement) + emitAgentEvent(this.loopCtx, this, 'agent/inbox/enqueue', item) + } + + /** Apply one synchronous mutation to a still-pending inbox occurrence. */ + updateInbox(id: InboxItemIdType, action: InboxAction): InboxActionResult { + const queuedIndex = this.queued.findIndex(candidate => candidate.item.id === id) + const outboxIndex = queuedIndex === -1 + ? this.outbox.findIndex(candidate => candidate.item?.id === id) + : -1 + if (queuedIndex === -1 && outboxIndex === -1) return 'not-found' + + const pending = queuedIndex === -1 ? this.outbox[outboxIndex] : this.queued[queuedIndex] + if (pending === undefined || pending.item === undefined) { + throw new Error(`agent "${this.id}" inbox index changed during synchronous update`) + } + + switch (action.kind) { + case 'edit': { + const item: InboxItem = Object.freeze({ + ...pending.item, + message: freezeMessage({ ...pending.item.message, content: action.content }), + }) + if (queuedIndex !== -1) { + const queued = this.queued[queuedIndex] + if (queued === undefined) throw new Error(`agent "${this.id}" queued item disappeared during edit`) + this.queued[queuedIndex] = { ...queued, item } + } else { + const outbox = this.outbox[outboxIndex] + if (outbox === undefined) throw new Error(`agent "${this.id}" steering item disappeared during edit`) + this.outbox[outboxIndex] = { ...outbox, message: item.message, item } + } + emitAgentEvent(this.loopCtx, this, 'agent/inbox/update', item, 'edit') + return 'applied' + } + case 'remove': { + if (queuedIndex !== -1) this.queued.splice(queuedIndex, 1) + else this.outbox.splice(outboxIndex, 1) + emitAgentEvent(this.loopCtx, this, 'agent/inbox/discard', [pending.item]) + return 'applied' + } + case 'promote': { + if (queuedIndex !== -1) { + const queued = this.queued.splice(queuedIndex, 1)[0] + if (queued === undefined) throw new Error(`agent "${this.id}" queued item disappeared during promotion`) + this.queued.unshift({ item: queued.item, wakeup: true }) + this.scheduleKick() + } else { + const outbox = this.outbox.splice(outboxIndex, 1)[0] + if (outbox === undefined) throw new Error(`agent "${this.id}" steering item disappeared during promotion`) + this.outbox.unshift(outbox) + } + emitAgentEvent(this.loopCtx, this, 'agent/inbox/update', pending.item, 'promote') + return 'applied' + } + default: + return assertNever(action) + } } /** Queue one ordinary prompt turn and wake the driver. */ @@ -169,9 +235,9 @@ export class ReactLoopAgent implements Agent { if (cause.kind !== 'disposed') emitAgentEvent(this.loopCtx, this, 'agent/cancel-requested', cause) } if (!options.keepInbox) { - const discarded = this.queued.map(item => item.message) + const discarded = this.queued.map(item => item.item) for (const item of this.outbox) { - if (item.steering) discarded.push(item.message) + if (item.steering && item.item !== undefined) discarded.push(item.item) } // Clear before abort observers run: replacement work belongs to the next turn. this.queued.length = 0 @@ -222,7 +288,8 @@ export class ReactLoopAgent implements Agent { // The some() guard above proves the queue is non-empty; the non-null // assertion expresses that invariant. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const { message } = this.queued.shift()! + const { item } = this.queued.shift()! + const { message } = item const inheritedOutboxLength = this.outbox.length const admission = new AbortController() @@ -293,7 +360,7 @@ export class ReactLoopAgent implements Agent { // Published only after the abort owner and pending done are installed: a // dequeue listener that cancels or disposes must find live cancellation // and quiescence ownership, not the previous activity's settled state. - emitAgentEvent(this.loopCtx, this, 'agent/inbox/dequeue', message, 'queued') + emitAgentEvent(this.loopCtx, this, 'agent/inbox/dequeue', item) } /** @@ -643,7 +710,8 @@ export class ReactLoopAgent implements Agent { for (const item of this.outbox.splice(0, limit)) { if (item.steering) { steered = true - emitAgentEvent(this.loopCtx, this, 'agent/inbox/dequeue', item.message, 'steering') + if (item.item === undefined) throw new Error(`agent "${this.id}" steering outbox item has no inbox identity`) + emitAgentEvent(this.loopCtx, this, 'agent/inbox/dequeue', item.item) this.session.append( 'steering/message', { turn, message: item.message }, diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index 9ac2405187..82e1ced2a5 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -4,7 +4,7 @@ import LlmService, { createUserMessage, CallId, MessageSource, ProviderRequestId import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineContentToolFixture, type PostToolDecision } from '@deepseek-ai/dsh-tools' -import AgentRegistry, { type Agent, type InboxPlacement } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent, type InboxItem, type InboxPlacement } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { ReactLoopAgent } from '../src/agent.ts' import InvariantService from '@deepseek-ai/dsh-invariants' @@ -53,6 +53,79 @@ function send(agent: Agent, text: string) { agent.followup(createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } })) } +function inboxText(item: InboxItem): string { + return item.message.content + .flatMap(block => block.type === 'text' ? [block.text] : []) + .join('') +} + +describe('addressable inbox operations', () => { + it('edits in place, removes exactly one item, and promotes the next independent turn', async () => { + const adapter = new MockAdapter([ + textResponse('first reply'), + textResponse('promoted reply'), + textResponse('edited reply'), + ]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('inbox-actions'), { provider: 'mock', model: 'mock' }) + const admission = Promise.withResolvers() + const release = Promise.withResolvers() + ctx.on('agent/prompt-submit', async (_subject, message, _signal, next) => { + if (message.content[0]?.type === 'text' && message.content[0].text === 'first') { + admission.resolve(undefined) + await release.promise + } + return next() + }) + + const pending: InboxItem[] = [] + const updates: { id: string; action: string; text: string }[] = [] + const discards: string[][] = [] + ctx.on('agent/inbox/enqueue', (subject, item) => { + if (subject === agent && inboxText(item) !== 'first') pending.push(item) + }) + ctx.on('agent/inbox/update', (subject, item, action) => { + if (subject === agent) updates.push({ id: item.id, action, text: inboxText(item) }) + }) + ctx.on('agent/inbox/discard', (subject, items) => { + if (subject === agent) discards.push(items.map(item => item.id)) + }) + + send(agent, 'first') + await admission.promise + send(agent, 'remove me') + send(agent, 'edit me') + send(agent, 'promote me') + expect(pending.map(inboxText)).toEqual(['remove me', 'edit me', 'promote me']) + + const remove = pending[0]! + const edit = pending[1]! + const promote = pending[2]! + expect(agent.updateInbox(edit.id, { + kind: 'edit', + content: [{ type: 'text', text: 'edited' }], + })).toBe('applied') + expect(agent.updateInbox(remove.id, { kind: 'remove' })).toBe('applied') + expect(agent.updateInbox(promote.id, { kind: 'promote' })).toBe('applied') + expect(updates).toEqual([ + { id: edit.id, action: 'edit', text: 'edited' }, + { id: promote.id, action: 'promote', text: 'promote me' }, + ]) + expect(discards).toEqual([[remove.id]]) + + const idle = waitForIdle(ctx, agent) + release.resolve(undefined) + await idle + expect(agent.session.events + .filter(event => event.type === 'user/message') + .map(event => event.type === 'user/message' + ? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('') + : '')) + .toEqual(['first', 'promote me', 'edited']) + expect(agent.updateInbox(promote.id, { kind: 'remove' })).toBe('not-found') + }) +}) + describe('assistant replay provenance', () => { it('records adapter replay state with the assembled assistant content', async () => { const response = textResponse('unchanged') @@ -502,10 +575,10 @@ describe('adapter registration, routing, and accepted-input ownership', () => { const queuedSources: MessageSource[] = [] const queuedShapes: string[][] = [] const placements: InboxPlacement[] = [] - ctx.on('agent/inbox/enqueue', (_agent, message, placement) => { - queuedSources.push(message.source) - queuedShapes.push(Object.keys(message).sort()) - placements.push(placement) + ctx.on('agent/inbox/enqueue', (_agent, item) => { + queuedSources.push(item.message.source) + queuedShapes.push(Object.keys(item.message).sort()) + placements.push(item.placement) }) send(agent, 'go') // no explicit source → default {kind:'user'} must be visible diff --git a/packages/core/agent-loop/tests/interception.spec.ts b/packages/core/agent-loop/tests/interception.spec.ts index 205c1a5752..faa23a2657 100644 --- a/packages/core/agent-loop/tests/interception.spec.ts +++ b/packages/core/agent-loop/tests/interception.spec.ts @@ -86,8 +86,9 @@ describe('agent/prompt-submit', () => { const entered = Promise.withResolvers() const decision = Promise.withResolvers() const observed: UserMessage[] = [] - ctx.on('agent/inbox/enqueue', (subject, message) => { + ctx.on('agent/inbox/enqueue', (subject, item) => { if (subject !== agent) return + const message = item.message expect(Object.isFrozen(message)).toBe(true) expect(Object.isFrozen(message.content)).toBe(true) expect(Object.isFrozen(message.content[0])).toBe(true) @@ -97,8 +98,8 @@ describe('agent/prompt-submit', () => { if (block?.type === 'text') block.text = 'listener mutation' }).toThrow() }) - ctx.on('agent/inbox/enqueue', (subject, message) => { - if (subject === agent) observed.push(message) + ctx.on('agent/inbox/enqueue', (subject, item) => { + if (subject === agent) observed.push(item.message) }) ctx.on('agent/prompt-submit', async () => { entered.resolve(undefined) @@ -240,8 +241,8 @@ describe('agent/prompt-submit', () => { entered.resolve(undefined) return decision.promise }) - ctx.on('agent/inbox/enqueue', (subject, _message, placement) => { - if (subject === agent) placements.push(placement) + ctx.on('agent/inbox/enqueue', (subject, item) => { + if (subject === agent) placements.push(item.placement) }) const idle = waitForIdle(ctx, agent) diff --git a/packages/core/agent/README.i18n.yaml b/packages/core/agent/README.i18n.yaml index 59dcdac249..3d8c91c59d 100644 --- a/packages/core/agent/README.i18n.yaml +++ b/packages/core/agent/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/agent/README.md -README.md: 9ca79f28506b133a555bd7d1e984386c715fd9d6 -README.zh.md: 165f71f1b395bdf0c229e2c4b1a30e89347b6be1 +README.md: e71d3454d22fa829af9617083c5e68586970c70a +README.zh.md: 76bb63afa6cf0f3690094e4c020efd7dc68505ce diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 9ca79f2850..e71d3454d2 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -60,7 +60,8 @@ Turn and step boundaries and the model token stream are durable `session/event` The handle every plugin programs against: -- `agent.send(message, options)` — the one delivery primitive over the (`target` × `wakeup`) matrix. `message` is an already identified, frozen `UserMessage`; callers normally create it with `createUserMessage()` before routing begins. `SendOptions` owns only the `target` and `wakeup` policy. The agent publishes or queues the complete value as-is without minting or replacing its identity. The message's `agent/inbox/enqueue`/`dequeue`/`discard` events carry it so callers can correlate a queued item with its lifecycle; enqueue and dequeue also carry the resolved `queued | steering` placement so repeated message identities retire from the correct FIFO. `target: 'next-turn'` queues one independent FIFO item that, if admitted, becomes the sole ordinary prompt in its turn. `target: 'next-step'` with `wakeup: true` submits steering, while `target: 'next-step'` with `wakeup: false` injects durable context without running the model. The [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the turn rationale. +- `agent.send(message, options)` — the one delivery primitive over the (`target` × `wakeup`) matrix. `message` is an already identified, frozen `UserMessage`; callers normally create it with `createUserMessage()` before routing begins. `SendOptions` owns only the `target` and `wakeup` policy. Each accepted FIFO occurrence receives its own `InboxItemId`, even when callers reuse a `MessageId`; `agent/inbox/enqueue`/`update` and the terminal `dequeue` or `discard` carry that complete `InboxItem`. `target: 'next-turn'` queues one independent FIFO item that, if admitted, becomes the sole ordinary prompt in its turn. `target: 'next-step'` with `wakeup: true` submits steering, while `target: 'next-step'` with `wakeup: false` injects durable context without running the model. The [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the turn rationale. +- `agent.updateInbox(itemId, action)` — synchronously edits, removes, or promotes one still-pending occurrence. Edit keeps its `MessageId`, `InboxItemId`, source, placement, and FIFO position while replacing frozen content. Remove emits the occurrence's terminal discard. Promote moves it to the front of its current FIFO and makes an ordinary queued item waking. A claimed item has crossed the ownership boundary and returns `not-found`. - `agent.followup(input)` — the `next-turn`/wakeup preset of `send()`: queue an ordinary follow-up turn and wake the driver. - `agent.steer(input)` — the `next-step`/wakeup preset: during prompt admission or an open turn, stage steering for the next safe boundary without dispatching `agent/prompt-submit`; outside that acceptance window, delegate to a woken follow-up. Admission failure leaves staged steering for retry or a later admitted prompt, while cancellation or disposal may discard it. - `agent.inject(input)` — the `next-step`/no-wakeup preset: append model-facing context without running the model; the next request sees a verbatim user-role message whose provenance is carried by the required `input.source`. During prompt admission or an open turn, injection waits in the outbox for the next safe boundary. Outside that acceptance window, it appends immediately without opening a turn; a context-only admission batch takes this fallback if admission closes without a turn, while context staged beside steering remains pending with it. Persistence reacts to `session/event` independently. Injection emits no `agent/inbox/*` event. diff --git a/packages/core/agent/README.zh.md b/packages/core/agent/README.zh.md index 165f71f1b3..76bb63afa6 100644 --- a/packages/core/agent/README.zh.md +++ b/packages/core/agent/README.zh.md @@ -60,7 +60,8 @@ Agent *创建* 由实现 `AgentFactory` 的插件(`dsh-agent-loop`)提供, 每个插件面向的 handle: -- `agent.send(message, options)`:覆盖(`target` × `wakeup`)矩阵的唯一投递原语。`message` 是已有标识且已冻结的 `UserMessage`;调用方通常会在开始路由前使用 `createUserMessage()` 创建它。`SendOptions` 只持有 `target` 与 `wakeup` 策略。agent 会原样发布或排队完整值,不会生成或替换其标识。该消息的 `agent/inbox/enqueue`/`dequeue`/`discard` 事件会携带完整消息,调用方可据此把排队项与其生命周期关联;入队与出队事件还会携带解析出的 `queued | steering` 路由归类,使重复出现的消息标识能在正确的 FIFO 中完成结算。`target: 'next-turn'` 排队一条独立 FIFO 项,获准后成为其轮次中唯一的普通提示词。`target: 'next-step'` 且 `wakeup: true` 提交 steering(中途引导),而 `target: 'next-step'` 且 `wakeup: false` 注入持久上下文,不运行模型。轮次原理由 [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md)拥有。 +- `agent.send(message, options)`:覆盖(`target` × `wakeup`)矩阵的唯一投递原语。`message` 是已有标识且已冻结的 `UserMessage`;调用方通常会在开始路由前使用 `createUserMessage()` 创建它。`SendOptions` 只持有 `target` 与 `wakeup` 策略。每次获准进入 FIFO 的项都会获得独立的 `InboxItemId`,即使调用方复用了同一个 `MessageId`;`agent/inbox/enqueue`/`update` 及终态 `dequeue` 或 `discard` 都会携带这一完整 `InboxItem`。`target: 'next-turn'` 排队一条独立 FIFO 项,获准后成为其轮次中唯一的普通提示词。`target: 'next-step'` 且 `wakeup: true` 提交 steering(中途引导),而 `target: 'next-step'` 且 `wakeup: false` 注入持久上下文,不运行模型。轮次原理由 [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md)拥有。 +- `agent.updateInbox(itemId, action)`:同步编辑、移除或前移一个仍处于待处理状态的项。编辑会替换已冻结的内容,同时保留其 `MessageId`、`InboxItemId`、来源、放置方式与 FIFO 位置。移除会发出该项的终态 discard。前移会把它移至当前 FIFO 的队首,并使普通 queued 项能够唤醒驱动器。已被认领的项已经跨越所有权边界,因此返回 `not-found`。 - `agent.followup(input)`:`send()` 的 `next-turn`/wakeup 预设:排队一个普通后续轮次并唤醒驱动器。 - `agent.steer(input)`:`next-step`/wakeup 预设:提示词接纳期间或轮次打开时,为下一个安全边界暂存 steering,且不分发 `agent/prompt-submit`;该接收窗口之外则委托给会唤醒的后续轮次。接纳失败会保留暂存的 steering,以供重试或之后获准的提示词使用,而取消或 dispose 可能丢弃它。 - `agent.inject(input)`:`next-step`/不唤醒预设:追加面向模型的上下文而不运行模型;下一次请求会看到一条逐字的 user role 消息,其来源由必填的 `input.source` 携带。提示词接纳期间或轮次打开时,注入会在 outbox 中等待下一个安全边界。该接收窗口之外,它会立即追加而不开启轮次;如果接纳结束却未开启轮次,仅含上下文的接纳批次会采用这一回退,而与 steering 一同暂存的上下文则会随其继续待处理。持久化独立地响应 `session/event`。注入不发出 `agent/inbox/*` 事件。 diff --git a/packages/core/agent/package.json b/packages/core/agent/package.json index f40a5da441..9113030eef 100644 --- a/packages/core/agent/package.json +++ b/packages/core/agent/package.json @@ -15,12 +15,17 @@ "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }, + "./brand": { + "types": "./lib/types/brand.d.ts", + "default": "./lib/types/brand.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", "lib/invariant.js", + "lib/types/**/*.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" diff --git a/packages/core/agent/src/brand.ts b/packages/core/agent/src/brand.ts new file mode 100644 index 0000000000..58d50259c1 --- /dev/null +++ b/packages/core/agent/src/brand.ts @@ -0,0 +1,23 @@ +/** + * dsh-agent's owned branded ids for live inbox occurrences. + * + * @module @deepseek-ai/dsh-agent/brand + */ + +import type { Branded } from '@deepseek-ai/dsh-brand' + +/** + * Identifies one accepted occurrence in an agent inbox. Re-sending the same + * message creates a distinct item id, so pending work remains independently + * addressable. + */ +export type InboxItemId = Branded<'InboxItemId'> + +/** + * Brand a string as an {@link InboxItemId}. + * @param id - the agent-loop-minted occurrence identifier. + * @returns the same string, branded; no validation is performed. + */ +export function InboxItemId(id: string): InboxItemId { + return id as InboxItemId +} diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index 17b115a40c..89db587166 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -15,6 +15,7 @@ import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' import type { Agent, AgentOptions } from './types.ts' export * from './types.ts' +export * from './brand.ts' export * from './llm-target.ts' export { agentCarrier, agentEvents, assembleContextFor, emitAgentEvent } from './dispatch.ts' export type { AgentEventDispatch, AgentSubjectEvent } from './dispatch.ts' diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 28b4ca4c71..242bfd00e4 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -9,6 +9,7 @@ import type { Context } from 'cordis' import type { Scoped } from '@deepseek-ai/dsh-scope' import type { ContentBlock, LlmCallConfig, LlmFailure, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm' import type { Session, SessionId, UserMessage } from '@deepseek-ai/dsh-session' +import type { InboxItemId } from './brand.ts' import type {} from '@deepseek-ai/dsh-system-prompt' declare module '@deepseek-ai/dsh-system-prompt' { interface AssembleContext { @@ -39,6 +40,25 @@ export type SendTarget = 'next-turn' | 'next-step' /** Resolved inbox placement reported when an accepted message is enqueued. */ export type InboxPlacement = 'queued' | 'steering' +/** One independently addressable accepted occurrence in an agent inbox. */ +export interface InboxItem { + /** Agent-loop-minted occurrence identity. */ + readonly id: InboxItemId + /** Identified message delivered by the caller. */ + readonly message: UserMessage + /** Acceptance-time FIFO classification. */ + readonly placement: InboxPlacement +} + +/** A user-requested mutation of one still-pending inbox item. */ +export type InboxAction = + | { readonly kind: 'edit'; readonly content: ContentBlock[] } + | { readonly kind: 'remove' } + | { readonly kind: 'promote' } + +/** Result of applying an inbox action at the synchronous ownership boundary. */ +export type InboxActionResult = 'applied' | 'not-found' + /** * Options for the unified {@link Agent.send} primitive over the * (`target` × `wakeup`) matrix. Named presets: {@link Agent.followup} @@ -156,6 +176,18 @@ export interface Agent { */ send(message: UserMessage, options: SendOptions): void + /** + * Mutate one still-pending inbox occurrence synchronously. Editing preserves + * the message identity and queue position; removal publishes its terminal + * discard; promotion moves it to the front of its current FIFO and makes a + * queued item waking. A driver-claimed item is no longer pending and returns + * `not-found`. + * @param id - independently addressable inbox occurrence. + * @param action - edit, remove, or promote operation. + * @returns whether the pending occurrence was found and updated. + */ + updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult + /** * Clear queued and steering work — unless `keepInbox` — and abort the active * turn. An effective call first emits `agent/cancel-requested` with the @@ -239,29 +271,37 @@ declare module 'cordis' { * acceptance-time routing result; listeners must not reconstruct it from * later agent or session state. * @param agent - the owning agent. - * @param message - accepted content, source, and correlation identity. - * @param placement - resolved queued or steering placement. + * @param item - accepted occurrence, message, and resolved placement. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ - 'agent/inbox/enqueue'(this: Scoped, agent: Agent, message: UserMessage, placement: InboxPlacement): void + 'agent/inbox/enqueue'(this: Scoped, agent: Agent, item: InboxItem): void + /** + * A still-pending inbox item changed content or position. The item id and + * placement remain stable; edit carries the replacement message, while + * promote makes this occurrence first in its current FIFO. + * @param agent - the owning agent. + * @param item - the complete post-update occurrence. + * @param action - the applied non-terminal operation. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode emit + */ + 'agent/inbox/update'( + this: Scoped, + agent: Agent, + item: InboxItem, + action: 'edit' | 'promote', + ): void /** * The driver claimed one item out of the inbox: a queued item at a turn * boundary, or steering drained between steps. Fires after the item leaves * its FIFO and before it becomes a durable message. * @param agent - the agent whose inbox item was claimed. - * @param message - the claimed message. - * @param placement - the FIFO that claimed this occurrence; together with - * `message.id`, it matches the earliest outstanding enqueue in that FIFO. + * @param item - the exact claimed occurrence. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ - 'agent/inbox/dequeue'( - this: Scoped, - agent: Agent, - message: UserMessage, - placement: InboxPlacement, - ): void + 'agent/inbox/dequeue'(this: Scoped, agent: Agent, item: InboxItem): void /** * Pending inbox items were dropped without delivering them, so every * enqueue occurrence receives exactly one terminal `agent/inbox/dequeue` OR @@ -269,11 +309,11 @@ declare module 'cordis' { * emits this after `agent/cancel-requested` when applicable and before * aborting the active work. Fires once per drop with every dropped item. * @param agent - the agent whose inbox items were dropped. - * @param messages - the discarded messages in FIFO order (queued then steering); never empty. + * @param items - the discarded occurrences in FIFO order (queued then steering); never empty. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode emit */ - 'agent/inbox/discard'(this: Scoped, agent: Agent, messages: UserMessage[]): void + 'agent/inbox/discard'(this: Scoped, agent: Agent, items: InboxItem[]): void /** * Effective broad cancellation was requested, before queued/outbox work * is cleared or the active turn is aborted. This observe-only notification diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index bd560c7c99..0f54718ce4 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -24,6 +24,7 @@ function stubAgent(rawId: string, overrides: Partial = {}): Agent { acceptsNextStep: false, ctx: new Context(), send: () => {}, + updateInbox: () => 'not-found', followup: () => {}, steer: () => {}, inject: () => {}, diff --git a/packages/core/agent/tests/invariant.spec.ts b/packages/core/agent/tests/invariant.spec.ts index a55bdba34f..7744726465 100644 --- a/packages/core/agent/tests/invariant.spec.ts +++ b/packages/core/agent/tests/invariant.spec.ts @@ -1,7 +1,7 @@ import { freezeMessage, MessageId } from '@deepseek-ai/dsh-llm' import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import { type Agent } from '@deepseek-ai/dsh-agent' +import { InboxItemId, type Agent, type InboxItem, type InboxPlacement } from '@deepseek-ai/dsh-agent' import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant' import { scopeTarget } from '@deepseek-ai/dsh-scope' import InvariantService from '@deepseek-ai/dsh-invariants' @@ -46,11 +46,16 @@ describe('agent status invariants', () => { }) describe('agent inbox invariants', () => { - const info = () => freezeMessage({ - id: MessageId('m'), - role: 'user' as const, - content: [], - source: { kind: 'user' as const }, + let nextItem = 0 + const info = (placement: InboxPlacement = 'queued'): InboxItem => ({ + id: InboxItemId(`i-${nextItem++}`), + message: freezeMessage({ + id: MessageId('m'), + role: 'user' as const, + content: [], + source: { kind: 'user' as const }, + }), + placement, }) it('accepts a dequeue and a discard covered by prior enqueues', async () => { @@ -58,9 +63,9 @@ describe('agent inbox invariants', () => { const agent = mockAgent('i1') const at = scopeTarget(agent, agent) expect(() => { - ctx.emit(at, 'agent/inbox/enqueue', agent, info(), 'queued') - ctx.emit(at, 'agent/inbox/enqueue', agent, info(), 'steering') - ctx.emit(at, 'agent/inbox/dequeue', agent, info(), 'queued') + ctx.emit(at, 'agent/inbox/enqueue', agent, info()) + ctx.emit(at, 'agent/inbox/enqueue', agent, info('steering')) + ctx.emit(at, 'agent/inbox/dequeue', agent, info()) ctx.emit(at, 'agent/inbox/discard', agent, [info()]) }).not.toThrow() }) @@ -68,7 +73,7 @@ describe('agent inbox invariants', () => { it('rejects a dequeue with no outstanding item', async () => { const ctx = await setup() const agent = mockAgent('i2') - expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/inbox/dequeue', agent, info(), 'queued') }) + expect(() => { ctx.emit(scopeTarget(agent, agent), 'agent/inbox/dequeue', agent, info()) }) .toThrow(/without a matching prior enqueue/) }) @@ -76,7 +81,7 @@ describe('agent inbox invariants', () => { const ctx = await setup() const agent = mockAgent('i3') const at = scopeTarget(agent, agent) - ctx.emit(at, 'agent/inbox/enqueue', agent, info(), 'queued') + ctx.emit(at, 'agent/inbox/enqueue', agent, info()) expect(() => { ctx.emit(at, 'agent/inbox/discard', agent, [info(), info()]) }) .toThrow(/dropped 2 items but only 1 were outstanding/) }) diff --git a/packages/core/scope/src/scoped-events.generated.ts b/packages/core/scope/src/scoped-events.generated.ts index 89515bf3c2..fff3107f48 100644 --- a/packages/core/scope/src/scoped-events.generated.ts +++ b/packages/core/scope/src/scoped-events.generated.ts @@ -15,6 +15,7 @@ const scopedSubjectResolvers: Readonly args[0], 'agent/inbox/discard': args => args[0], 'agent/inbox/enqueue': args => args[0], + 'agent/inbox/update': args => args[0], 'agent/prompt-submit': args => args[0], 'agent/request': args => args[0], 'agent/request-error': args => args[0], diff --git a/packages/core/scope/tests/invariant.spec.ts b/packages/core/scope/tests/invariant.spec.ts index 54f2e1e17b..4057d248d1 100644 --- a/packages/core/scope/tests/invariant.spec.ts +++ b/packages/core/scope/tests/invariant.spec.ts @@ -2,7 +2,7 @@ import { freezeMessage, MessageId } from '@deepseek-ai/dsh-llm' import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import type { Events } from 'cordis' -import { type Agent } from '@deepseek-ai/dsh-agent' +import { InboxItemId, type Agent } from '@deepseek-ai/dsh-agent' import { scopeTarget } from '@deepseek-ai/dsh-scope' import * as ScopeInvariant from '@deepseek-ai/dsh-scope/invariant' import InvariantService from '@deepseek-ai/dsh-invariants' @@ -44,12 +44,14 @@ describe('scoped-dispatch invariants', () => { content: [], source: { kind: 'user' }, }) + const item = { id: InboxItemId('i'), message, placement: 'queued' as const } const agentRows = { 'agent/created': [agent], 'agent/disposed': [agent], 'agent/status': [agent, 'idle'], - 'agent/inbox/enqueue': [agent, message, 'queued'], - 'agent/inbox/dequeue': [agent, message, 'queued'], + 'agent/inbox/enqueue': [agent, item], + 'agent/inbox/update': [agent, item, 'edit'], + 'agent/inbox/dequeue': [agent, item], 'agent/inbox/discard': [agent, []], 'agent/cancel-requested': [agent, { kind: 'user' }], 'agent/session-start': [agent, 'startup'], diff --git a/packages/goal/command-goal/tests/command-goal.spec.ts b/packages/goal/command-goal/tests/command-goal.spec.ts index 61734ada01..0a09a71f71 100644 --- a/packages/goal/command-goal/tests/command-goal.spec.ts +++ b/packages/goal/command-goal/tests/command-goal.spec.ts @@ -38,6 +38,7 @@ function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session } get status() { return status }, get acceptsNextStep() { return status === 'running' }, send: () => {}, + updateInbox: () => 'not-found', followup: () => {}, steer: () => {}, inject(input) { appendInjection(session, input) }, diff --git a/packages/goal/goal-session/src/index.ts b/packages/goal/goal-session/src/index.ts index 31dbe9c4e6..fc66878800 100644 --- a/packages/goal/goal-session/src/index.ts +++ b/packages/goal/goal-session/src/index.ts @@ -307,10 +307,10 @@ export function apply(ctx: Context): void { requestDrive(state) } }) - ctx.on('agent/inbox/enqueue', (agent, info) => { + ctx.on('agent/inbox/enqueue', (agent, item) => { const state = stateFor(agent) const attempt = state.attempt - if (attempt !== undefined && sameQueued(info.content, info.source, attempt)) return + if (attempt !== undefined && sameQueued(item.message.content, item.message.source, attempt)) return state.competingQueued = true if (attempt?.phase === 'queued') attempt.stale = true }) diff --git a/packages/goal/goal-session/tests/goal-session.spec.ts b/packages/goal/goal-session/tests/goal-session.spec.ts index 6a11633384..5a3f0bdca2 100644 --- a/packages/goal/goal-session/tests/goal-session.spec.ts +++ b/packages/goal/goal-session/tests/goal-session.spec.ts @@ -287,7 +287,7 @@ describe('same-session goal driving', () => { it('pauses and drops a reserved round when cancellation lands before admission', async () => { const test = await harness([]) const cancel = test.ctx.on('agent/inbox/enqueue', (agent, info) => { - if (agent === test.agent && info.source.kind === 'goal') { + if (agent === test.agent && info.message.source.kind === 'goal') { cancel() agent.cancel({ kind: 'user' }) } @@ -340,7 +340,7 @@ describe('same-session goal driving', () => { test.ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof test.ctx.logger.warn let inserted = false test.ctx.on('agent/inbox/enqueue', (agent, info) => { - if (agent !== test.agent || info.source.kind !== 'goal' || inserted) return + if (agent !== test.agent || info.message.source.kind !== 'goal' || inserted) return inserted = true const lastStart = agent.session.events.findLast(event => event.type === 'turn/start') const turn = (lastStart?.data.turn ?? 0) + 1 @@ -363,7 +363,7 @@ describe('same-session goal driving', () => { const test = await harness([textResponse('human batch'), textResponse('later goal')]) let inserted = false test.ctx.on('agent/inbox/enqueue', (agent, info) => { - if (agent !== test.agent || info.source.kind !== 'goal' || inserted) return + if (agent !== test.agent || info.message.source.kind !== 'goal' || inserted) return inserted = true agent.followup(createUserMessage({ content: [{ type: 'text', text: 'human joined the pending batch' }], source: { kind: 'user' } })) }) @@ -381,7 +381,7 @@ describe('same-session goal driving', () => { const test = await harness([textResponse('new revision')]) let edited = false test.ctx.on('agent/inbox/enqueue', (agent, info) => { - if (agent !== test.agent || info.source.kind !== 'goal' || edited) return + if (agent !== test.agent || info.message.source.kind !== 'goal' || edited) return edited = true const current = test.ctx.goals.get(agent) if (current === undefined) throw new Error('missing goal during queued edit') @@ -661,7 +661,7 @@ describe('same-session goal driving', () => { const test = await harness([textResponse('retry after containment')]) let armed = true test.ctx.on('agent/inbox/enqueue', (agent, info) => { - if (agent !== test.agent || info.source.kind !== 'goal' || !armed) return + if (agent !== test.agent || info.message.source.kind !== 'goal' || !armed) return armed = false vi.spyOn(test.ctx.goals, 'get').mockImplementationOnce(() => { throw new Error('admission projection failed') @@ -737,7 +737,7 @@ describe('same-session goal driving', () => { it('falls back to disarming when a cancelled reservation cannot be paused', async () => { const test = await harness([]) const cancel = test.ctx.on('agent/inbox/enqueue', (agent, info) => { - if (agent !== test.agent || info.source.kind !== 'goal') return + if (agent !== test.agent || info.message.source.kind !== 'goal') return cancel() vi.spyOn(test.ctx.goals, 'pause').mockImplementationOnce(() => { throw new Error('pause failed') @@ -791,7 +791,7 @@ describe('same-session goal driving', () => { const test = await harness([]) let unloading: Promise | undefined test.ctx.on('agent/inbox/enqueue', (agent, info) => { - if (agent === test.agent && info.source.kind === 'goal' && unloading === undefined) { + if (agent === test.agent && info.message.source.kind === 'goal' && unloading === undefined) { unloading = Promise.resolve(test.driver.dispose()) } }) diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index c681c307e5..0e42cba851 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/packages/goal/goal/tests/goal.spec.ts @@ -48,6 +48,7 @@ function stubAgentForSession(session: Session): StubAgent { get status() { return status }, get acceptsNextStep() { return status === 'running' }, send: () => {}, + updateInbox: () => 'not-found', followup: () => {}, steer: () => {}, inject(input) { diff --git a/packages/goal/goal/tests/projection.spec.ts b/packages/goal/goal/tests/projection.spec.ts index 75c01295ce..82569e72d3 100644 --- a/packages/goal/goal/tests/projection.spec.ts +++ b/packages/goal/goal/tests/projection.spec.ts @@ -39,6 +39,7 @@ function liveAgent(ctx: Context, session: Session): Agent { get status() { return status }, get acceptsNextStep() { return false }, send: () => {}, + updateInbox: () => 'not-found', followup: () => {}, steer: () => {}, inject(input: UserMessage) { diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index 1293b8642b..443a0f616a 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -33,6 +33,7 @@ function stubAgent(rawId: string, supplied?: Session): StubAgent { get acceptsNextStep() { return status === 'running' }, ctx: new Context(), send: () => {}, + updateInbox: () => 'not-found', followup: () => {}, steer: () => {}, inject(input) { diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 73b0845370..4c7571b7df 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md -README.md: ca4471454f5be5d3fcba38ce665d4fb3fbd85e74 -README.zh.md: 953539e1198a52b2bf7cdd9ca1b0d263cc2ae6f9 +README.md: 9ecc6282a8f249491d70331a4d978968b3585300 +README.zh.md: 4a49faff63d642a2a665767b45ab4ccaa801be43 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index ca4471454f..9ecc6282a8 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -16,6 +16,8 @@ Session titles ride the generic projection pair like every other domain — the Session model routing is a session-domain contract. `session.models` returns the selected provider/model/reasoning target with provider-grouped advisory models, exact-route reasoning metadata, and provider-local lookup failures. `session.selectModel` validates the optional adapter-owned reasoning effort and replaces the complete target selected for the next prompt-assembly boundary. Catalog membership is not validation: an adapter may resolve an unlisted model, while an unavailable route or unsupported effort returns `model-unavailable`. +Pending agent input is a live control-plane contract, not session history. The gateway mirrors complete `InboxItem` occurrences from `agent/inbox/*` and broadcasts authoritative `session/queue` snapshots on every change and reconnect. `session.updateQueue` addresses one `InboxItemId`: edit replaces pending content, remove discards it, and promote moves it to the front of its current FIFO while waking ordinary queued work. A driver claim wins races by retiring the address before admission; a later operation returns `queue-item-not-found`. The client never infers retirement from turn or status events. + Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`. Directory picking delegates to the composed `ctx.directoryPicker` backend ([the directory-picker seam](../directory-picker/README.md)); a method called outside the composed capability's kind fails with `directory-picker-unavailable` (the client needs no advertisement — the composed picker package's own client half renders the matching interaction). Under `native`, `host.pickDirectory` opens one native chooser and returns its selected path (`null` on cancel); this user-paced method is the sole unary call exempt from the default 30-second timeout, and caller/connection aborts still propagate to the native process. Under `browse`, `host.listDirectory` returns one name-sorted directory level with breadcrumb ancestry, a `home` anchor, and host-owned `hidden` flags (absent path = home directory), and `host.createDirectory` creates one validated child segment; the backend's typed failures map 1:1 onto the `directory-unreadable`/`directory-exists`/`directory-create-failed` codes. The browser carrier's prefix-wide trust fence (dsh-client-connection) covers all of these like every other `/api` request. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 953539e119..4a49faff63 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -16,6 +16,8 @@ 会话模型路由属于会话领域契约。`session.models` 返回选中的提供方/模型/推理(reasoning)目标,以及按提供方分组的建议性模型、精确路由推理元数据和逐提供方查询失败记录。`session.selectModel` 校验由适配器持有的可选推理强度,并替换将在下一提示词组装边界使用的完整目标。目录成员关系不构成校验:适配器可以解析未列出的模型,而不可用路由或不受支持的推理强度会返回 `model-unavailable`。 +待处理 agent 输入属于实时控制平面契约,而非会话历史。网关镜像来自 `agent/inbox/*` 的完整 `InboxItem` 单次入队项,并在每次变更和重连时广播权威的 `session/queue` 快照。`session.updateQueue` 通过 `InboxItemId` 寻址单个项:编辑会替换待处理内容,移除会将其丢弃,前移会把它移至当前 FIFO 的队首,并使普通 queued 工作能够唤醒驱动器。驱动器在接纳前退役寻址标识,因此认领会赢得竞态;之后的操作返回 `queue-item-not-found`。客户端绝不根据轮次或状态事件推断项已退役。 + Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create` 会创建唯一名称或接纳现有目录,`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。 目录选择委托给组合的 `ctx.directoryPicker` 后端([目录选择 seam](../directory-picker/README.md));调用组合能力 kind 之外的方法会以 `directory-picker-unavailable` 失败(客户端不需要广播——组合的选择器包自己的 client half 渲染匹配的交互)。在 `native` 下,`host.pickDirectory` 打开一个原生选择器并返回选中路径(取消为 `null`);该方法需等待用户完成操作,是唯一不受默认 30 秒超时限制的一元调用,调用方与连接的中止仍会传播至原生进程。在 `browse` 下,`host.listDirectory` 返回一个按名称排序的目录层级,携带面包屑祖先链、`home` 锚点与宿主判定的 `hidden` 标志(不带路径即家目录),`host.createDirectory` 创建一个经校验的子段;后端的类型化失败 1:1 映射为 `directory-unreadable`/`directory-exists`/`directory-create-failed` 错误码。浏览器载体的前缀级信任栅栏(dsh-client-connection)像覆盖其他所有 `/api` 请求一样覆盖上述全部方法。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index f178bfefd0..fee32f4755 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -9,11 +9,11 @@ import { join } from 'node:path' import type { Context } from 'cordis' import { installAgentLlmTarget } from '@deepseek-ai/dsh-agent' import type { - Agent, AgentLlmTarget, AgentLlmTargetRef, AgentStatus, InboxPlacement, + Agent, AgentLlmTarget, AgentLlmTargetRef, AgentStatus, InboxItem, } from '@deepseek-ai/dsh-agent' import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import { errorChain } from '@deepseek-ai/dsh-llm' -import type { MessageId, MessageSource } from '@deepseek-ai/dsh-llm' +import type { MessageSource } from '@deepseek-ai/dsh-llm' import type { Session, SessionEvent, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session' import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace' @@ -511,38 +511,63 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro * inbox event retires one matching occurrence, so repeated sends of the same * identified message remain visible until every occurrence is claimed. */ - const queuedMirror = new Map() + const queuedMirror = new Map() + const publishQueue = (sessionId: SessionId): void => { + const items = queuedMirror.get(sessionId) ?? [] + broadcast({ + type: 'session/queue', + sessionId, + items: items.map(item => ({ + id: item.id, + message: item.message, + placement: item.placement, + })), + }) + } ctx.effect(() => { - const retire = (agent: Agent, id: MessageId, placement?: InboxPlacement): void => { + const retire = (agent: Agent, item: InboxItem): void => { const entries = queuedMirror.get(agent.id) if (entries === undefined) return - const index = entries.findIndex(entry => - entry.message.id === id - && (placement === undefined || entry.steering === (placement === 'steering'))) + const index = entries.findIndex(entry => entry.id === item.id) if (index !== -1) entries.splice(index, 1) if (entries.length === 0) queuedMirror.delete(agent.id) + publishQueue(agent.id) } const disposers = [ - ctx.on('agent/inbox/enqueue', (agent: Agent, message: UserMessage, placement) => { + ctx.on('agent/inbox/enqueue', (agent: Agent, item: InboxItem) => { let entries = queuedMirror.get(agent.id) if (entries === undefined) { entries = [] queuedMirror.set(agent.id, entries) } - const steering = placement === 'steering' - entries.push({ message, steering }) - broadcast({ - type: 'session/queued', - sessionId: agent.id, - message, - steering, - }) + entries.push(item) + publishQueue(agent.id) }), - ctx.on('agent/inbox/dequeue', (agent: Agent, message: UserMessage, placement) => { - retire(agent, message.id, placement) + ctx.on('agent/inbox/update', (agent: Agent, item: InboxItem, action) => { + const entries = queuedMirror.get(agent.id) + if (entries === undefined) return + const index = entries.findIndex(entry => entry.id === item.id) + if (index === -1) return + entries.splice(index, 1) + if (action === 'promote') { + const first = entries.findIndex(entry => entry.placement === item.placement) + entries.splice(first === -1 ? entries.length : first, 0, item) + } else { + entries.splice(index, 0, item) + } + publishQueue(agent.id) }), - ctx.on('agent/inbox/discard', (agent: Agent, messages: UserMessage[]) => { - for (const message of messages) retire(agent, message.id) + ctx.on('agent/inbox/dequeue', (agent: Agent, item: InboxItem) => { + retire(agent, item) + }), + ctx.on('agent/inbox/discard', (agent: Agent, items: InboxItem[]) => { + const entries = queuedMirror.get(agent.id) + if (entries === undefined) return + const ids = new Set(items.map(item => item.id)) + const kept = entries.filter(entry => !ids.has(entry.id)) + if (kept.length === 0) queuedMirror.delete(agent.id) + else queuedMirror.set(agent.id, kept) + publishQueue(agent.id) }), ctx.on('session/disposed', (session: Session) => { queuedMirror.delete(session.id) @@ -1067,6 +1092,20 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro return ok(request, { accepted: true as const }) }, + async updateQueue(request) { + const { sessionId, itemId, action } = request.payload + const found = await agentFor(sessionId) + if ('error' in found) return err(request, found.error) + if (found.agent.updateInbox(itemId, action) === 'not-found') { + return err(request, { + code: 'queue-item-not-found', + message: 'queued item is no longer pending', + details: { itemId }, + }) + } + return ok(request, { accepted: true as const }) + }, + cancel(request) { const { sessionId } = request.payload const agent = ctx.agents.get(sessionId) @@ -1461,15 +1500,16 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro // Queue snapshot baseline (pendingQuestions precedent): frames replayed // in arrival order per session; a reconnecting client rebuilds its // queue view from these alone. - for (const [sessionId, entries] of queuedMirror) { - for (const entry of entries) { - queue.push(frame({ - type: 'session/queued', - sessionId, - message: entry.message, - steering: entry.steering, - })) - } + for (const [sessionId, items] of queuedMirror) { + queue.push(frame({ + type: 'session/queue', + sessionId, + items: items.map(item => ({ + id: item.id, + message: item.message, + placement: item.placement, + })), + })) } // Per-session open-call table for result-view pairing. Bounded by the // per-turn call count: entries clear on turn/end; a table miss (stream diff --git a/packages/host/apiproxy/src/api/events.schema.ts b/packages/host/apiproxy/src/api/events.schema.ts index 4e729b4c41..d9d73b441e 100644 --- a/packages/host/apiproxy/src/api/events.schema.ts +++ b/packages/host/apiproxy/src/api/events.schema.ts @@ -10,7 +10,9 @@ import type { HostFrame, MuxFrame } from './events.ts' import type { Wire } from './rpc.schema.ts' import { rpcErrorSchema, rpcIdSchema } from './rpc.schema.ts' import { approvalRequestIdSchema } from './approvals.schema.ts' -import { contentBlockSchema, sessionEventSchema, sessionIdSchema, toolEventViewSchema } from './sessions.schema.ts' +import { + contentBlockSchema, inboxItemIdSchema, sessionEventSchema, sessionIdSchema, toolEventViewSchema, +} from './sessions.schema.ts' import { workspaceIdSchema, workspaceViewSchema } from './workspace.schema.ts' /** Question shape validated strictly against core dsh-user-interaction. */ @@ -42,7 +44,15 @@ export const muxFrameSchema = z.discriminatedUnion('type', [ // and must fail loud here, not reach the composer. z.object({ type: z.literal('question/requested'), sessionId: sessionIdSchema, questions: z.array(askUserQuestionItemSchema).min(1) }), z.object({ type: z.literal('question/resolved'), sessionId: sessionIdSchema, questionRpcId: rpcIdSchema, outcome: z.union([z.literal('answered'), z.literal('cancelled')]) }), - z.object({ type: z.literal('session/queued'), sessionId: sessionIdSchema, message: messageSchema, steering: z.boolean() }), + z.object({ + type: z.literal('session/queue'), + sessionId: sessionIdSchema, + items: z.array(z.object({ + id: inboxItemIdSchema, + message: messageSchema, + placement: z.union([z.literal('queued'), z.literal('steering')]), + })), + }), // value stays wide: it already passed its unit's own schema on the host, // and deep-validating here would import every domain's schema into the carrier. z.object({ type: z.literal('session/projection'), sessionId: sessionIdSchema, key: z.string().min(1), value: z.unknown(), seq: z.number().int().nonnegative() }), diff --git a/packages/host/apiproxy/src/api/events.ts b/packages/host/apiproxy/src/api/events.ts index 9f56fc1dd5..40e5c5a5ad 100644 --- a/packages/host/apiproxy/src/api/events.ts +++ b/packages/host/apiproxy/src/api/events.ts @@ -9,6 +9,7 @@ import type { AskUserQuestionItem } from '@deepseek-ai/dsh-user-interaction/types' import type { ApprovalOutcome, ApprovalRequestId } from '@deepseek-ai/dsh-user-approval/types' import type { Message } from '@deepseek-ai/dsh-llm/types' +import type { InboxItemId } from '@deepseek-ai/dsh-agent/brand' import type { CallId } from '@deepseek-ai/dsh-llm/brand' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types' import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation' @@ -31,6 +32,16 @@ export type ToolEventView = | { for: 'call'; view: ToolCallView } | { for: 'result'; view: ToolResultView } +/** One pending inbox occurrence in an authoritative queue snapshot. */ +export interface QueuedInboxItem { + /** Agent-owned occurrence identity used by queue mutations. */ + id: InboxItemId + /** Complete pending message; it is not durable until the Agent claims it. */ + message: Message + /** Acceptance-time FIFO classification. */ + placement: 'queued' | 'steering' +} + /** Streaming face of the contract: the two SSE stream openers (mux + host). */ export interface EventsApi { /** @@ -62,18 +73,12 @@ export type MuxFrame = | { type: 'question/requested'; sessionId: SessionId; questions: AskUserQuestionItem[] } | { type: 'question/resolved'; sessionId: SessionId; questionRpcId: RpcId; outcome: 'answered' | 'cancelled' } /** - * A message entered the addressed agent's inbox. A queued message is not - * model-visible, so there is no session event to carry it; this transient - * frame is the only wire signal. On stream open the - * host replays the current queue snapshot for every attached session (same - * refresh-recovery baseline as pending questions); queue clearing on cancel - * has no dedicated frame — clients fold it from the status flip. - * `steering` is the host's acceptance-time queue classification and remains - * authoritative in reconnect snapshots. `message.source` carries the prompt's rpcId - * when the message came over this wire (the client's provisional-echo - * reconciliation key). + * Complete transient inbox state after every enqueue, mutation, claim, or + * discard. Pending work is not model-visible and therefore has no durable + * session event; the whole snapshot makes edit, reorder, deletion, cancel, + * and reconnect converge through one authoritative signal. */ - | { type: 'session/queued'; sessionId: SessionId; message: Message; steering: boolean } + | { type: 'session/queue'; sessionId: SessionId; items: QueuedInboxItem[] } /** * One projection unit's finished value changed (session-projection RFC). * Live push state, never logged — replay recomputes on the host (the diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts index b91ce54e0f..0372c8cc30 100644 --- a/packages/host/apiproxy/src/api/index.ts +++ b/packages/host/apiproxy/src/api/index.ts @@ -29,13 +29,13 @@ export interface ApiProxy { // ---- Domain interfaces and payload entities ---- export type { HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, - ModelReasoningEffort, ModelTarget, SessionModels, SessionProjectionsBlock, SessionsApi, SessionSummary, + ModelReasoningEffort, ModelTarget, QueueAction, SessionModels, SessionProjectionsBlock, SessionsApi, SessionSummary, } from './sessions.ts' export type { DirectoryEntry, DirectoryListing, HostApi } from './host.ts' export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts' export type { CommandsApi, CommandDescriptor } from './commands.ts' export type { SkillsApi, SkillEntry } from './skills.ts' -export type { EventsApi, MuxFrame, HostFrame, ToolCallView, ToolEventView, ToolResultView } from './events.ts' +export type { EventsApi, MuxFrame, HostFrame, QueuedInboxItem, ToolCallView, ToolEventView, ToolResultView } from './events.ts' export type { GoalsApi, GoalId, GoalRef } from './goals.ts' export type { ApprovalResponsePayload } from './approvals.ts' export type { QuestionResponsePayload } from './questions.ts' @@ -56,6 +56,7 @@ export type { // ---- Errors and ids ---- export { RpcId, transportError } from './rpc.ts' export type { RpcError, RpcErrorCode, RpcErrorDetailsMap, RpcResult } from './rpc.ts' +export type { InboxItemId } from '@deepseek-ai/dsh-agent/brand' // ---- Method registry and derived generics ---- export type { RequestPayload, ResponseValue, RpcMethodMap } from './rpc-map.ts' diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index 5394691248..d43824c088 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/packages/host/apiproxy/src/api/rpc-map.ts @@ -24,6 +24,7 @@ export interface RpcMethodMap { 'session.models': SessionsApi['models'] 'session.selectModel': SessionsApi['selectModel'] 'session.prompt': SessionsApi['prompt'] + 'session.updateQueue': SessionsApi['updateQueue'] 'session.cancel': SessionsApi['cancel'] 'host.describe': HostApi['describe'] 'host.pickDirectory': HostApi['pickDirectory'] diff --git a/packages/host/apiproxy/src/api/rpc.schema.ts b/packages/host/apiproxy/src/api/rpc.schema.ts index 58d3a17126..08cea82177 100644 --- a/packages/host/apiproxy/src/api/rpc.schema.ts +++ b/packages/host/apiproxy/src/api/rpc.schema.ts @@ -47,6 +47,7 @@ export const rpcErrorSchema: z.ZodType = z.discriminatedUnion('code', z.object({ code: z.literal('directory-create-failed'), message: z.string(), details: z.object({ path: z.string() }) }), z.object({ code: z.literal('directory-picker-unavailable'), message: z.string(), details: z.object({ capability: z.string() }) }), z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }), + z.object({ code: z.literal('queue-item-not-found'), message: z.string(), details: z.object({ itemId: z.string() }) }), z.object({ code: z.literal('command-error'), message: z.string(), details: z.object({}) }), z.object({ code: z.literal('unknown-command'), message: z.string(), details: z.object({}) }), z.object({ code: z.literal('internal'), message: z.string(), details: z.object({}) }), diff --git a/packages/host/apiproxy/src/api/rpc.ts b/packages/host/apiproxy/src/api/rpc.ts index fd99d015ee..7166c6d56b 100644 --- a/packages/host/apiproxy/src/api/rpc.ts +++ b/packages/host/apiproxy/src/api/rpc.ts @@ -9,6 +9,7 @@ import type { z as zCore } from 'zod' type ZodIssue = zCore.core.$ZodIssue import type { Branded } from '@deepseek-ai/dsh-brand' import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { InboxItemId } from '@deepseek-ai/dsh-agent/brand' /** * Message correlation id: the initiator mints it on a request; a response @@ -44,6 +45,7 @@ export interface RpcErrorDetailsMap { 'directory-create-failed': { path: string } 'directory-picker-unavailable': { capability: string } 'agent-busy': { reason: string } + 'queue-item-not-found': { itemId: InboxItemId } /** A known slash command reported a usage/state error; the message is the command's own text. */ 'command-error': {} /** A leading-/ prompt named no registered command; the message names the token. */ diff --git a/packages/host/apiproxy/src/api/sessions.schema.ts b/packages/host/apiproxy/src/api/sessions.schema.ts index 81c42b8a56..f24f3442c0 100644 --- a/packages/host/apiproxy/src/api/sessions.schema.ts +++ b/packages/host/apiproxy/src/api/sessions.schema.ts @@ -7,6 +7,7 @@ import { z } from 'zod' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types' +import type { InboxItemId } from '@deepseek-ai/dsh-agent/brand' import type { RequestPayload, ResponseValue } from './rpc-map.ts' import type { Wire } from './rpc.schema.ts' import type { @@ -19,6 +20,9 @@ import type { WorkspaceId } from './workspace.ts' /** SessionId: one brand cast after shape validation (the only cast point in this domain). */ export const sessionIdSchema = z.string().min(1) as unknown as z.ZodType +/** InboxItemId: one brand cast after non-empty string validation. */ +export const inboxItemIdSchema = z.string().min(1) as unknown as z.ZodType + /** * WorkspaceId: the workspace domain's one brand cast. Hosted here rather * than in workspace.schema because session.create references it while @@ -202,6 +206,22 @@ export const sessionPromptValueSchema = z.object({ }).optional(), }) satisfies z.ZodType>> +/** session.updateQueue request payload. */ +export const sessionUpdateQueueRequestSchema = z.object({ + sessionId: sessionIdSchema, + itemId: inboxItemIdSchema, + action: z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('edit'), content: z.array(contentBlockSchema) }), + z.object({ kind: z.literal('remove') }), + z.object({ kind: z.literal('promote') }), + ]), +}) as unknown as z.ZodType> + +/** session.updateQueue response value. */ +export const sessionUpdateQueueValueSchema = z.object({ + accepted: z.literal(true), +}) satisfies z.ZodType>> + /** session.cancel request payload. */ export const sessionCancelRequestSchema = z.object({ sessionId: sessionIdSchema, diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts index f952c7c542..60da926613 100644 --- a/packages/host/apiproxy/src/api/sessions.ts +++ b/packages/host/apiproxy/src/api/sessions.ts @@ -5,6 +5,7 @@ */ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' +import type { InboxItemId } from '@deepseek-ai/dsh-agent/brand' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types' // The pure-type outlet: api/ is browser-importable, and the package root's // cordis Context merge (via dsh-agent) must not enter client aggregates. @@ -124,6 +125,12 @@ export interface SessionModels { failures: ModelCatalogFailure[] } +/** A client-requested mutation of one still-pending queue item. */ +export type QueueAction = + | { kind: 'edit'; content: ContentBlock[] } + | { kind: 'remove' } + | { kind: 'promote' } + /** Session list entry (v1 builds no index: list does readdir+stat). */ export interface SessionSummary { sessionId: SessionId @@ -219,6 +226,14 @@ export interface SessionsApi { prompt(request: RpcRequest<{ sessionId: SessionId; mode: 'queue' | 'steer'; content: ContentBlock[] }>): Promise> + /** + * Edits, removes, or promotes one pending inbox occurrence. Promotion means + * first in its current FIFO; a queued item is also made waking, so an idle + * agent starts it and a running agent takes it as the next independent turn. + */ + updateQueue(request: RpcRequest<{ sessionId: SessionId; itemId: InboxItemId; action: QueueAction }>): + Promise> + /** Stops: clears both FIFOs + aborts the current step (1:1 with agent.cancel). */ cancel(request: RpcRequest<{ sessionId: SessionId }>): Promise> diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index 8bcc6f7c3e..bf701a0409 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -25,6 +25,7 @@ import { sessionModelsValueSchema, sessionPromptValueSchema, sessionSelectModelValueSchema, + sessionUpdateQueueValueSchema, } from '../api/sessions.schema.ts' import { workspaceCreateValueSchema, @@ -67,6 +68,7 @@ export interface IApiClient { models(payload: RequestPayload<'session.models'>, signal?: AbortSignal): Promise>> selectModel(payload: RequestPayload<'session.selectModel'>, signal?: AbortSignal): Promise>> prompt(payload: RequestPayload<'session.prompt'>, signal?: AbortSignal): Promise>> + updateQueue(payload: RequestPayload<'session.updateQueue'>, signal?: AbortSignal): Promise>> cancel(payload: RequestPayload<'session.cancel'>, signal?: AbortSignal): Promise>> } host: { @@ -117,6 +119,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType this.callUnary('session.models', payload, signal), selectModel: (payload, signal) => this.callUnary('session.selectModel', payload, signal), prompt: (payload, signal) => this.callUnary('session.prompt', payload, signal), + updateQueue: (payload, signal) => this.callUnary('session.updateQueue', payload, signal), cancel: (payload, signal) => this.callUnary('session.cancel', payload, signal), } diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index e1340aad5b..046a2e5eea 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -22,6 +22,7 @@ import { sessionModelsRequestSchema, sessionPromptRequestSchema, sessionSelectModelRequestSchema, + sessionUpdateQueueRequestSchema, } from '../api/sessions.schema.ts' import { hostCreateDirectoryRequestSchema, hostDescribeRequestSchema, @@ -69,6 +70,7 @@ const UNARY_ROUTES: UnaryRoutes = { 'session.models': { schema: sessionModelsRequestSchema, invoke: (api, r) => api.sessions.models(r) }, 'session.selectModel': { schema: sessionSelectModelRequestSchema, invoke: (api, r) => api.sessions.selectModel(r) }, 'session.prompt': { schema: sessionPromptRequestSchema, invoke: (api, r) => api.sessions.prompt(r) }, + 'session.updateQueue': { schema: sessionUpdateQueueRequestSchema, invoke: (api, r) => api.sessions.updateQueue(r) }, 'session.cancel': { schema: sessionCancelRequestSchema, invoke: (api, r) => api.sessions.cancel(r) }, 'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) }, 'host.pickDirectory': { schema: hostPickDirectoryRequestSchema, invoke: (api, r, signal) => api.host.pickDirectory(r, signal) }, diff --git a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts index b555c5cef6..5db3e9884d 100644 --- a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts @@ -11,8 +11,8 @@ import { MessageId, freezeMessage } from '@deepseek-ai/dsh-llm' import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import AgentRegistry, {} from '@deepseek-ai/dsh-agent' -import type { Agent } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { InboxItemId } from '@deepseek-ai/dsh-agent' +import type { Agent, InboxItem, InboxPlacement } from '@deepseek-ai/dsh-agent' import SessionStore from '@deepseek-ai/dsh-session' import type { SessionId, UserMessage } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' @@ -256,84 +256,112 @@ function inboxMessage(id: string, text: string, rpcId?: string): UserMessage { }) } -describe('session/queued frames', () => { - it('forwards live enqueue events and replays the snapshot on a later mux open', async () => { +/** Build one addressable inbox occurrence around a frozen message. */ +function inboxItem(id: string, message: UserMessage, placement: InboxPlacement): InboxItem { + return { id: InboxItemId(id), message, placement } +} + +describe('session.updateQueue', () => { + it('routes an addressable action and reports a lost claim race', async () => { + const ctx = await harness() + const agent = stubAgent(ctx) + const seen: unknown[] = [] + agent.updateInbox = (id, action) => { + seen.push({ id, action }) + return id === InboxItemId('present') ? 'applied' : 'not-found' + } + const api = createApiProxy(ctx, DEFAULTS) + + const applied = await api.sessions.updateQueue({ + rpcId: RpcId('q-apply'), + payload: { + sessionId: agent.id, + itemId: InboxItemId('present'), + action: { kind: 'promote' }, + }, + }) + expect(expectOk(applied)).toEqual({ accepted: true }) + const missing = await api.sessions.updateQueue({ + rpcId: RpcId('q-missing'), + payload: { + sessionId: agent.id, + itemId: InboxItemId('claimed'), + action: { kind: 'remove' }, + }, + }) + expect(expectErr(missing)).toMatchObject({ code: 'queue-item-not-found' }) + expect(seen).toEqual([ + { id: 'present', action: { kind: 'promote' } }, + { id: 'claimed', action: { kind: 'remove' } }, + ]) + }) +}) + +describe('session/queue frames', () => { + it('publishes complete live snapshots and replays the latest snapshot on reconnect', async () => { const ctx = await harness() const api = createApiProxy(ctx, DEFAULTS) const agent = stubAgent(ctx) const live = new AbortController() const liveStream = api.events.mux({ rpcId: RpcId('t-mux-live'), payload: {} }, live.signal) - // subscribed baseline + 2 queued frames + // subscribed baseline + 2 queue snapshots const liveCollected = collect(liveStream, 3, live) - const queued = inboxMessage('m-1', 'queued prompt') - const steering = inboxMessage('m-2', 'queued prompt') - ctx.emit('agent/inbox/enqueue', agent, queued, 'queued') - ctx.emit('agent/inbox/enqueue', agent, steering, 'steering') + const queued = inboxItem('i-1', inboxMessage('m-1', 'queued prompt'), 'queued') + const steering = inboxItem('i-2', inboxMessage('m-2', 'steering prompt'), 'steering') + ctx.emit('agent/inbox/enqueue', agent, queued) + ctx.emit('agent/inbox/enqueue', agent, steering) - const liveFrames = (await liveCollected).filter(f => f.type === 'session/queued') + const liveFrames = (await liveCollected).filter(f => f.type === 'session/queue') expect(liveFrames).toEqual([ - { type: 'session/queued', sessionId: agent.id, message: queued, steering: false }, - { type: 'session/queued', sessionId: agent.id, message: steering, steering: true }, + { type: 'session/queue', sessionId: agent.id, items: [queued] }, + { type: 'session/queue', sessionId: agent.id, items: [queued, steering] }, ]) - // A fresh mux connection replays the still-pending entries as its baseline. + // A fresh mux connection replays only the current authoritative snapshot. const replay = new AbortController() const replayFrames = await collect( - api.events.mux({ rpcId: RpcId('t-mux-replay'), payload: {} }, replay.signal), 3, replay) - expect(replayFrames.filter(f => f.type === 'session/queued')).toEqual(liveFrames) + api.events.mux({ rpcId: RpcId('t-mux-replay'), payload: {} }, replay.signal), 2, replay) + expect(replayFrames.filter(f => f.type === 'session/queue')).toEqual([liveFrames[1]]) }) - it('retires mirror entries on their terminal dequeue', async () => { + it('publishes edit and promotion in the authoritative order', async () => { const ctx = await harness() const api = createApiProxy(ctx, DEFAULTS) const agent = stubAgent(ctx) - const queued = inboxMessage('m-3', 'x') - const steering = inboxMessage('m-4', 'x', 'r-1') - ctx.emit('agent/inbox/enqueue', agent, queued, 'queued') - ctx.emit('agent/inbox/enqueue', agent, steering, 'steering') - ctx.emit('agent/inbox/dequeue', agent, queued, 'queued') - ctx.emit('agent/inbox/dequeue', agent, steering, 'steering') - const abort = new AbortController() - const frames = await collect( - api.events.mux({ rpcId: RpcId('t-mux-after'), payload: {} }, abort.signal), 1, abort) - expect(frames.filter(f => f.type === 'session/queued')).toHaveLength(0) - }) + const collected = collect( + api.events.mux({ rpcId: RpcId('t-mux-updates'), payload: {} }, abort.signal), 6, abort) + const first = inboxItem('i-a', inboxMessage('m-a', 'a'), 'queued') + const second = inboxItem('i-b', inboxMessage('m-b', 'b'), 'queued') + const edited = inboxItem('i-b', inboxMessage('m-b', 'b edited'), 'queued') + ctx.emit('agent/inbox/enqueue', agent, first) + ctx.emit('agent/inbox/enqueue', agent, second) + ctx.emit('agent/inbox/update', agent, edited, 'edit') + ctx.emit('agent/inbox/update', agent, edited, 'promote') + ctx.emit('agent/inbox/dequeue', agent, edited) - it('retires the matching placement when one message identity is queued and steering', async () => { - const ctx = await harness() - const api = createApiProxy(ctx, DEFAULTS) - const agent = stubAgent(ctx) - const repeated = inboxMessage('m-repeat', 'same prompt') - ctx.emit('agent/inbox/enqueue', agent, repeated, 'queued') - ctx.emit('agent/inbox/enqueue', agent, repeated, 'steering') - ctx.emit('agent/inbox/dequeue', agent, inboxMessage('unknown', 'not queued'), 'queued') - ctx.emit('agent/inbox/dequeue', agent, repeated, 'steering') - - const abort = new AbortController() - const frames = await collect( - api.events.mux({ rpcId: RpcId('t-mux-repeat'), payload: {} }, abort.signal), 2, abort) - expect(frames.filter(f => f.type === 'session/queued')).toEqual([ - { type: 'session/queued', sessionId: agent.id, message: repeated, steering: false }, + const frames = (await collected).filter(frame => frame.type === 'session/queue') + expect(frames.map(frame => frame.items)).toEqual([ + [first], + [first, second], + [first, edited], + [edited, first], + [first], ]) }) - it('retires mirror entries on a batch discard (cancel path)', async () => { + it('publishes an empty snapshot after terminal discard', async () => { const ctx = await harness() const api = createApiProxy(ctx, DEFAULTS) const agent = stubAgent(ctx) - const doomed = inboxMessage('m-5', 'doomed') - const survivor = inboxMessage('m-6', 'survivor') - ctx.emit('agent/inbox/enqueue', agent, doomed, 'queued') - ctx.emit('agent/inbox/enqueue', agent, survivor, 'queued') + const doomed = inboxItem('i-doomed', inboxMessage('m-5', 'doomed'), 'queued') + ctx.emit('agent/inbox/enqueue', agent, doomed) ctx.emit('agent/inbox/discard', agent, [doomed]) const abort = new AbortController() const frames = await collect( - api.events.mux({ rpcId: RpcId('t-mux-swept'), payload: {} }, abort.signal), 2, abort) - const remaining = frames.filter(f => f.type === 'session/queued') - expect(remaining).toHaveLength(1) - expect(remaining[0]).toMatchObject({ message: survivor }) + api.events.mux({ rpcId: RpcId('t-mux-swept'), payload: {} }, abort.signal), 1, abort) + expect(frames.filter(frame => frame.type === 'session/queue')).toHaveLength(0) }) }) diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts index 3968cba5c7..f520f8766a 100644 --- a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts @@ -51,6 +51,7 @@ function stubAgent(session: Session): Agent { steer: () => {}, inject: () => {}, send: () => {}, + updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(), } diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index d807557d00..1e648c8552 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -47,6 +47,7 @@ function scriptedApi(overrides: { selected: { provider: r.payload.provider, model: r.payload.model }, }), prompt: r => ok(r, { accepted: true as const }), + updateQueue: r => ok(r, { accepted: true as const }), cancel: r => ok(r, { accepted: true as const }), ...overrides.sessions, }, diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index d7528072f7..f4c41fbeab 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -70,6 +70,9 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra async prompt(request) { return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } } }, + async updateQueue(request) { + return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } } + }, async cancel(request) { return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } } }, diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index fd1f107a80..7315325bd7 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -11,6 +11,7 @@ import { sessionIdSchema, sessionListRequestSchema, sessionListValueSchema, sessionModelsRequestSchema, sessionModelsValueSchema, sessionPromptRequestSchema, sessionPromptValueSchema, sessionSelectModelRequestSchema, sessionSelectModelValueSchema, sessionSummarySchema, + sessionUpdateQueueRequestSchema, sessionUpdateQueueValueSchema, } from '../src/api/sessions.schema.ts' import { hostCreateDirectoryRequestSchema, hostCreateDirectoryValueSchema, @@ -68,6 +69,7 @@ describe('rpcErrorSchema', () => { details: { provider: 'p', model: 'm' }, }).code).toBe('model-unavailable') expect(rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: { reason: 'r' } }).code).toBe('agent-busy') + expect(rpcErrorSchema.parse({ code: 'queue-item-not-found', message: 'm', details: { itemId: 'i' } }).code).toBe('queue-item-not-found') expect(rpcErrorSchema.parse({ code: 'command-error', message: 'm', details: {} }).code).toBe('command-error') expect(rpcErrorSchema.parse({ code: 'unknown-command', message: 'm', details: {} }).code).toBe('unknown-command') expect(rpcErrorSchema.parse({ code: 'internal', message: 'm', details: {} }).code).toBe('internal') @@ -219,7 +221,19 @@ describe('sessions domain schemas', () => { expect(sessionPromptValueSchema.parse({ accepted: true, command: { kind: 'success' } }).command).toEqual({ kind: 'success' }) expect(() => sessionPromptValueSchema.parse({ accepted: true, command: { kind: 'failure' } })).toThrow() expect(sessionCancelRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1') + expect(sessionUpdateQueueRequestSchema.parse({ + sessionId: 's1', + itemId: 'i1', + action: { kind: 'edit', content: [{ type: 'text', text: 'next' }] }, + }).action.kind).toBe('edit') + expect(sessionUpdateQueueRequestSchema.parse({ + sessionId: 's1', itemId: 'i1', action: { kind: 'remove' }, + }).action.kind).toBe('remove') + expect(() => sessionUpdateQueueRequestSchema.parse({ + sessionId: 's1', itemId: '', action: { kind: 'promote' }, + })).toThrow() expect(sessionCancelValueSchema.parse({ accepted: true }).accepted).toBe(true) + expect(sessionUpdateQueueValueSchema.parse({ accepted: true }).accepted).toBe(true) expect(contentBlockSchema.parse({ type: 'text', text: 'x', extra: 1 })).toMatchObject({ extra: 1 }) }) }) @@ -363,8 +377,10 @@ describe('events frame schemas', () => { { type: 'approval/resolved', sessionId: 's', approvalId: 'a', outcome: 'allowed-once' }, { type: 'question/requested', sessionId: 's', questions: [{ id: 'q', question: 'Q?', options: [{ label: 'L' }], multiSelect: true }] }, { type: 'question/resolved', sessionId: 's', questionRpcId: 'r', outcome: 'answered' }, - { type: 'session/queued', sessionId: 's', message: { id: 'm1', role: 'user', content: [{ type: 'text', text: 'queued prompt' }], source: { kind: 'user', rpcId: 'r9' } }, steering: false }, - { type: 'session/queued', sessionId: 's', message: { id: 'm2', role: 'user', content: [{ type: 'text', text: 'steer' }], source: { kind: 'user' } }, steering: true }, + { type: 'session/queue', sessionId: 's', items: [ + { id: 'i1', message: { id: 'm1', role: 'user', content: [{ type: 'text', text: 'queued prompt' }], source: { kind: 'user', rpcId: 'r9' } }, placement: 'queued' }, + { id: 'i2', message: { id: 'm2', role: 'user', content: [{ type: 'text', text: 'steer' }], source: { kind: 'user' } }, placement: 'steering' }, + ] }, { type: 'session/projection', sessionId: 's', key: 'todos', value: [{ content: 'x', status: 'pending' }], seq: 7 }, { type: 'stream/error', error: { code: 'internal', message: 'm', details: {} } }, ] @@ -382,10 +398,10 @@ describe('events frame schemas', () => { expect(() => muxFrameSchema.parse({ type: 'question/requested', sessionId: 's', questions: [] })).toThrow() }) - it('rejects a queued frame missing its members', () => { - expect(() => muxFrameSchema.parse({ type: 'session/queued', sessionId: 's', message: 'x', steering: false })).toThrow() - expect(() => muxFrameSchema.parse({ type: 'session/queued', sessionId: 's', message: { id: 'm', role: 'user', content: [], source: { kind: 'user' } } })).toThrow() - expect(() => muxFrameSchema.parse({ type: 'session/queued', sessionId: 's', message: { id: 'm', role: 'user', content: [], source: {} }, steering: false })).toThrow() + it('rejects a queue snapshot with malformed items', () => { + expect(() => muxFrameSchema.parse({ type: 'session/queue', sessionId: 's', items: 'x' })).toThrow() + expect(() => muxFrameSchema.parse({ type: 'session/queue', sessionId: 's', items: [{ id: '', message: {}, placement: 'queued' }] })).toThrow() + expect(() => muxFrameSchema.parse({ type: 'session/queue', sessionId: 's', items: [{ id: 'i', message: { id: 'm', role: 'user', content: [], source: {} }, placement: 'later' }] })).toThrow() }) it('accepts every host frame branch', () => { diff --git a/packages/pty/pty-local/tests/index.spec.ts b/packages/pty/pty-local/tests/index.spec.ts index 153e29842d..ad517c4314 100644 --- a/packages/pty/pty-local/tests/index.spec.ts +++ b/packages/pty/pty-local/tests/index.spec.ts @@ -42,7 +42,7 @@ function agent(ctx: Context): Agent { const id = SessionId('agent') return { id, options: {}, session: new Session(id), status: 'idle', acceptsNextStep: false, ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(), } } @@ -249,7 +249,7 @@ describe('pty-local plugin shape', () => { const ownerFiber = await ctx.plugin(() => {}) const owner: Agent = { id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx: ownerFiber.ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(), } ctx.agents.register(owner) const providerFiber = await registerStubLocalBackend(ctx, () => stubLocalSession()) @@ -292,7 +292,7 @@ describe('pty-local plugin shape', () => { const ownerFiber = await ctx.plugin(() => {}) const owner: Agent = { id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx: ownerFiber.ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(), } ctx.agents.register(owner) const gate = Promise.withResolvers() diff --git a/packages/pty/pty-local/tests/local.spec.ts b/packages/pty/pty-local/tests/local.spec.ts index d045dc4d26..c3fb33c75c 100644 --- a/packages/pty/pty-local/tests/local.spec.ts +++ b/packages/pty/pty-local/tests/local.spec.ts @@ -35,7 +35,7 @@ function stubAgent(ctx: Context, rawId: string): Agent { const scope = ctx.plugin(() => {}) return { id, options: {}, session: new Session(id), status: 'idle', acceptsNextStep: false, ctx: scope.ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(), } } diff --git a/packages/pty/pty/tests/service.spec.ts b/packages/pty/pty/tests/service.spec.ts index 905708fbb1..301ea798a6 100644 --- a/packages/pty/pty/tests/service.spec.ts +++ b/packages/pty/pty/tests/service.spec.ts @@ -32,6 +32,7 @@ function stubAgent(ctx: Context, rawId: string): Agent { steer: () => {}, inject: () => {}, send: () => {}, + updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(), } diff --git a/packages/pty/tool-pty/tests/loader-composition.spec.ts b/packages/pty/tool-pty/tests/loader-composition.spec.ts index 85d0deefb8..f6ad1084c6 100644 --- a/packages/pty/tool-pty/tests/loader-composition.spec.ts +++ b/packages/pty/tool-pty/tests/loader-composition.spec.ts @@ -40,7 +40,7 @@ function agent(ctx: Context): Agent { const id = SessionId('pty-loader-agent') const value: Agent = { id, options: {}, session: new Session(id), status: 'idle', acceptsNextStep: false, ctx: scope.ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(), } ctx.agents.register(value) return value diff --git a/packages/pty/tool-pty/tests/tools.spec.ts b/packages/pty/tool-pty/tests/tools.spec.ts index ec754aa96c..a09fcac714 100644 --- a/packages/pty/tool-pty/tests/tools.spec.ts +++ b/packages/pty/tool-pty/tests/tools.spec.ts @@ -18,7 +18,7 @@ function fakeAgent(ctx: Context, rawId: string): Agent { const id = SessionId(rawId) const agent: Agent = { id, options: {}, session: new Session(id), status: 'idle', acceptsNextStep: false, ctx: scope.ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(), } ctx.agents.register(agent) return agent diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts index 5348af69b6..61b8fe57f1 100644 --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts +++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts @@ -47,6 +47,7 @@ function agentForCwd(cwd: string): Agent { status: 'idle', acceptsNextStep: false, send: () => {}, + updateInbox: () => 'not-found', followup: () => {}, steer: () => {}, inject(input) { diff --git a/packages/tasks/tasks-local/tests/tasks.spec.ts b/packages/tasks/tasks-local/tests/tasks.spec.ts index 590f752956..fd661d0583 100644 --- a/packages/tasks/tasks-local/tests/tasks.spec.ts +++ b/packages/tasks/tasks-local/tests/tasks.spec.ts @@ -29,6 +29,7 @@ function stubAgent(ctx: Context, rawId: string): Agent { steer: () => {}, inject: () => {}, send: () => {}, + updateInbox: (): 'not-found' => 'not-found', cancel() {}, whenIdle() { return Promise.resolve() }, } diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index d1ba37e61d..9b789af178 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -1232,9 +1232,9 @@ export function createTuiChat( }, { prepend: true }) // Installed before followup(): an enqueue listener can synchronously // cancel and discard before followup() returns its id. - const detachDiscard = ctx.on('agent/inbox/discard', (subject, messages) => { + const detachDiscard = ctx.on('agent/inbox/discard', (subject, items) => { if (subject !== agent) return - for (const message of messages) discarded.add(message.id) + for (const item of items) discarded.add(item.message.id) if (discarded.has(acceptedId)) cleanup() }) // followup() accepts any typed input and contains listener failures; @@ -1448,13 +1448,13 @@ export function createTuiChat( const settlePendingSteering = (id: MessageId): void => { if (pendingSteering.delete(id)) refreshStatus() } - const disposeDequeued = ctx.on('agent/inbox/dequeue', (subject, message) => { - if (subject === agent) settlePendingSteering(message.id) + const disposeDequeued = ctx.on('agent/inbox/dequeue', (subject, item) => { + if (subject === agent) settlePendingSteering(item.message.id) }) - const disposeDiscarded = ctx.on('agent/inbox/discard', (subject, messages) => { + const disposeDiscarded = ctx.on('agent/inbox/discard', (subject, items) => { if (subject !== agent) return let changed = false - for (const message of messages) changed = pendingSteering.delete(message.id) || changed + for (const item of items) changed = pendingSteering.delete(item.message.id) || changed if (changed) refreshStatus() }) const disposeStatus = ctx.on('agent/status', (subject, status) => { diff --git a/packages/ui/tui/tests/harness.ts b/packages/ui/tui/tests/harness.ts index 87282276f8..13b8c2ac1e 100644 --- a/packages/ui/tui/tests/harness.ts +++ b/packages/ui/tui/tests/harness.ts @@ -216,6 +216,7 @@ export async function createTuiTestHarness 'not-found', followup(input) { sent.push(input.content) sentMessages.push(input) diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index e918f9b299..669c75c6ce 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -4,7 +4,10 @@ import { join, resolve } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import { CombinedAutocompleteProvider, visibleWidth, type Terminal } from '@earendil-works/pi-tui' -import AgentRegistry, { agentEvents, assembleContextFor, type Agent } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { + agentEvents, assembleContextFor, InboxItemId, type Agent, type InboxItem, + type InboxPlacement, +} from '@deepseek-ai/dsh-agent' import { createUserMessage, createToolResultMessage, ReasoningEffortId, @@ -51,6 +54,13 @@ const UNUSED_TOOL_OUTPUT: ToolDefinition['output'] = { render: () => [], } +let nextInboxItem = 0 + +/** Wrap one test message in the production inbox occurrence envelope. */ +function inboxItem(message: InboxItem['message'], placement: InboxPlacement): InboxItem { + return { id: InboxItemId(`tui-item-${nextInboxItem++}`), message, placement } +} + class FakeTerminal implements Terminal { columns = 88 rows = 32 @@ -1654,12 +1664,12 @@ describe('pi-tui chat lifecycle and transcript', () => { const drainSteering = (text: string): void => { const id = result.agent.steeredIds.shift() if (id !== undefined) { - result.ctx.emit('agent/inbox/dequeue', result.agent, freezeMessage({ + result.ctx.emit('agent/inbox/dequeue', result.agent, inboxItem(freezeMessage({ id, role: 'user', content: [{ type: 'text', text }], source: { kind: 'user' }, - }), 'steering') + }), 'steering')) } result.session.append('steering/message', { turn: 1, @@ -1673,12 +1683,12 @@ describe('pi-tui chat lifecycle and transcript', () => { // A steering queue for a different agent never touches this status line. const other = { ...result.agent, id: SessionId('other') } as Agent result.terminal.output = '' - result.ctx.emit('agent/inbox/enqueue', other, freezeMessage({ + result.ctx.emit('agent/inbox/enqueue', other, inboxItem(freezeMessage({ id: MessageId('stub'), role: 'user', content: [{ type: 'text', text: 'elsewhere' }], source: { kind: 'user' }, - }), 'queued') + }), 'queued')) await tick() expect(result.terminal.output).not.toContain('queued') @@ -1751,26 +1761,26 @@ describe('pi-tui chat lifecycle and transcript', () => { })) // Another agent's dequeue/discard, and ones naming no pending id, leave // the badge alone. - result.ctx.emit('agent/inbox/dequeue', other, discarded[0]!, 'steering') - result.ctx.emit('agent/inbox/dequeue', result.agent, freezeMessage({ + result.ctx.emit('agent/inbox/dequeue', other, inboxItem(discarded[0]!, 'steering')) + result.ctx.emit('agent/inbox/dequeue', result.agent, inboxItem(freezeMessage({ id: MessageId('never-queued'), role: 'user', content: [{ type: 'text', text: 'x' }], source: { kind: 'user' }, - }), 'steering') - result.ctx.emit('agent/inbox/discard', other, discarded) + }), 'steering')) + result.ctx.emit('agent/inbox/discard', other, discarded.map(message => inboxItem(message, 'steering'))) result.ctx.emit('agent/inbox/discard', result.agent, [ - freezeMessage({ + inboxItem(freezeMessage({ id: MessageId('never-queued'), role: 'user', content: [{ type: 'text', text: 'x' }], source: { kind: 'user' }, - }), + }), 'steering'), ]) await tick() expect(result.terminal.output).toContain('2 queued') result.terminal.output = '' - result.ctx.emit('agent/inbox/discard', result.agent, discarded) + result.ctx.emit('agent/inbox/discard', result.agent, discarded.map(message => inboxItem(message, 'steering'))) await tick() expect(result.terminal.output).not.toContain('queued') @@ -2086,12 +2096,12 @@ describe('pi-tui chat lifecycle and transcript', () => { it('tracks steering drains without a running status line', async () => { const result = await setup() const source = { kind: 'user' as const } - result.ctx.emit('agent/inbox/enqueue', result.agent, freezeMessage({ + result.ctx.emit('agent/inbox/enqueue', result.agent, inboxItem(freezeMessage({ id: MessageId('stub'), role: 'user', content: [{ type: 'text', text: 'early' }], source, - }), 'steering') + }), 'steering')) result.session.append('steering/message', { turn: 1, message: createUserMessage({ @@ -2699,7 +2709,7 @@ describe('pi-tui chat lifecycle and transcript', () => { // no armed listener, and an unrelated admission is untouched. The leak // regression: a listener installed after its cleanup already ran would // survive every future cleanup. - result.ctx.emit('agent/inbox/discard', result.agent, [result.agent.sentMessages[0]!]) + result.ctx.emit('agent/inbox/discard', result.agent, [inboxItem(result.agent.sentMessages[0]!, 'queued')]) const unrelated = await agentEvents(result.ctx, result.agent).waterfall( 'agent/prompt-submit', createUserMessage({ content: [{ type: 'text', text: 'unrelated' }], @@ -2743,9 +2753,9 @@ describe('pi-tui chat lifecycle and transcript', () => { content: structuredClone(input.content), source: structuredClone(input.source), }) - result.ctx.emit('agent/inbox/enqueue', foreign, message, 'queued') - result.ctx.emit('agent/inbox/enqueue', result.agent, message, 'queued') - result.ctx.emit('agent/inbox/discard', result.agent, [message]) + result.ctx.emit('agent/inbox/enqueue', foreign, inboxItem(message, 'queued')) + result.ctx.emit('agent/inbox/enqueue', result.agent, inboxItem(message, 'queued')) + result.ctx.emit('agent/inbox/discard', result.agent, [inboxItem(message, 'queued')]) return message.id } @@ -2827,16 +2837,16 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(passthrough.kind === 'allow' && passthrough.additionalContexts).toBeUndefined() // A foreign agent's discard leaves the wrapper armed. const foreign = { ...result.agent, id: SessionId('foreign') } as unknown as Agent - result.ctx.emit('agent/inbox/discard', foreign, [result.agent.sentMessages.at(-1)!]) + result.ctx.emit('agent/inbox/discard', foreign, [inboxItem(result.agent.sentMessages.at(-1)!, 'queued')]) // An unrelated discard for this agent also leaves the wrapper armed. - result.ctx.emit('agent/inbox/discard', result.agent, [createUserMessage({ + result.ctx.emit('agent/inbox/discard', result.agent, [inboxItem(createUserMessage({ content: [{ type: 'text', text: 'unrelated discard' }], source: { kind: 'user' }, - })]) - result.ctx.emit('agent/inbox/discard', result.agent, [result.agent.sentMessages.at(-1)!]) + }), 'queued')]) + result.ctx.emit('agent/inbox/discard', result.agent, [inboxItem(result.agent.sentMessages.at(-1)!, 'queued')]) await tick() // Idempotent: a repeat discard after cleanup is a no-op. - result.ctx.emit('agent/inbox/discard', result.agent, [result.agent.sentMessages.at(-1)!]) + result.ctx.emit('agent/inbox/discard', result.agent, [inboxItem(result.agent.sentMessages.at(-1)!, 'queued')]) const afterDiscard = await agentEvents(result.ctx, result.agent).waterfall( 'agent/prompt-submit', result.agent.sentMessages.at(-1)!, new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const }), @@ -4715,7 +4725,7 @@ describe('terminal mounting', () => { const session = ctx.sessions.create(SessionId('main')) ctx.agents.register({ id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(), }) const terminal = new FakeTerminal() mountTui(ctx, { theme: { color: false } }, { terminal, exit: vi.fn() }) @@ -4740,7 +4750,7 @@ describe('terminal mounting', () => { const session = ctx.sessions.create(SessionId('main')) ctx.agents.register({ id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(), }) const terminal = new FakeTerminal() // Mirror dsh-tui's own inject (minus loader, the absence under test). @@ -4775,14 +4785,14 @@ describe('terminal mounting', () => { const otherSession = ctx.sessions.create(SessionId('other-session')) ctx.agents.register({ id: otherSession.id, options: {}, session: otherSession, status: 'idle', acceptsNextStep: false, ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(), }) expect(terminal.started).toBe(0) const session = ctx.sessions.create(SessionId('late-session')) const agent = { id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(), } as Agent ctx.agents.register(agent) await tick() @@ -4813,7 +4823,7 @@ describe('terminal mounting', () => { const session = ctx.sessions.create(SessionId('main-session')) ctx.agents.register({ id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(), }) await tick() expect(terminal.started).toBe(0) @@ -4857,7 +4867,7 @@ describe('terminal mounting', () => { session.append('step/start', { turn: 1, step: 1 }) ctx.agents.register({ id: session.id, options: {}, session, status: 'running', acceptsNextStep: true, ctx, - followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(), + followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, updateInbox: () => 'not-found', cancel() {}, whenIdle: () => Promise.resolve(), }) const terminal = new FakeTerminal() terminal.start = () => { throw new Error('terminal startup failed') } diff --git a/scripts/client-bundle-purity.spec.ts b/scripts/client-bundle-purity.spec.ts index 379346134a..d70964bdba 100644 --- a/scripts/client-bundle-purity.spec.ts +++ b/scripts/client-bundle-purity.spec.ts @@ -1,16 +1,19 @@ /** - * Pins the client-bundle purity gate (tsdown preset resolveId classifier), - * the build-time mirror of the module-edge rules: platform module-table - * entries stay external, inline-safe wire layers inline, and every other - * @deepseek-ai value import — including a bare plugin-package name and a - * cross-plugin /client subpath — must fail the build loudly (cross-plugin - * collaboration goes through cordis services, never module imports). + * Pins shared client-bundle preset contracts: the module-edge purity gate and + * the physical watch dependencies hidden behind virtual CSS Modules. */ -import { describe, expect, it } from 'vitest' +import { fileURLToPath } from 'node:url' +import { describe, expect, it, vi } from 'vitest' import { CLIENT_EXTERNALS, clientBundle } from '../packages/client/tsdown.client.ts' type ResolveId = (source: string) => null | { id: string; external: boolean } +interface CssModulePlugin { + name: string + resolveId?: (source: string, importer: string | undefined) => null | string + load?: (this: { addWatchFile: (id: string) => void }, id: string) => Promise +} + function purityResolveId(): ResolveId { // libEntry is spelled at every call site (no default) so the // package-invariants text check can see the invariant entry per package. @@ -21,6 +24,16 @@ function purityResolveId(): ResolveId { return gate.resolveId as ResolveId } +function cssModulePlugin(): CssModulePlugin { + const configs = clientBundle('@deepseek-ai/dsh-client-test', ['lib/types/index.js', 'lib/types/invariant.js']) + const plugins = (configs[1] as { plugins: CssModulePlugin[] }).plugins + const plugin = plugins.find(candidate => candidate.name === 'dsh-css-modules-inline') + if (plugin?.resolveId === undefined || plugin.load === undefined) { + throw new Error('CSS Modules plugin missing from client config') + } + return plugin +} + describe('client bundle purity gate', () => { const resolveId = purityResolveId() @@ -60,3 +73,24 @@ describe('client bundle purity gate', () => { expect(dshClientChannels).toEqual(['@deepseek-ai/dsh-client-runtime/client']) }) }) + +describe('client bundle CSS Modules watch graph', () => { + it('registers the physical stylesheet read behind a virtual module', async () => { + const plugin = cssModulePlugin() + const importer = fileURLToPath(new URL( + '../packages/client/ui-conversation/src/client/queue/QueueDock.tsx', + import.meta.url, + )) + const stylesheet = fileURLToPath(new URL( + '../packages/client/ui-conversation/src/client/queue/QueueDock.module.css', + import.meta.url, + )) + const virtualId = plugin.resolveId?.('./QueueDock.module.css', importer) + if (virtualId === null || virtualId === undefined) throw new Error('CSS Modules import was not resolved') + const addWatchFile = vi.fn() + + await plugin.load?.call({ addWatchFile }, virtualId) + + expect(addWatchFile).toHaveBeenCalledExactlyOnceWith(stylesheet) + }) +}) diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 0f3270f8ee..88ec348abc 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -35,6 +35,7 @@ export const LINK_MAP: Record = { ContinuationDecision: 'core.md', ContinuationStop: 'core.md', GenerateOptions: 'core.md', + InboxItem: 'core.md', InboxPlacement: 'core.md', MessageId: 'core.md', HookContext: 'core.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 5821cd0a01..88838d3d58 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -96,6 +96,21 @@ "symbol": "InboxPlacement", "source": "packages/core/agent/src/types.ts" }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "InboxItem", + "source": "packages/core/agent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "InboxAction", + "source": "packages/core/agent/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "InboxActionResult", + "source": "packages/core/agent/src/types.ts" + }, { "doc": "docs/core-data-structures/core.md", "symbol": "SendOptions", diff --git a/tsconfig.host.json b/tsconfig.host.json index 2287d21a9a..78da3c5fdc 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -24,6 +24,7 @@ "apps/web/tests/code-mode-round.e2e.ts", "apps/web/tests/cordis-tool-round.e2e.ts", "apps/web/tests/message-actions.e2e.ts", + "apps/web/tests/queue-actions.e2e.ts", "apps/cli/tests/**/*.ts", "examples/*/src/**/*.ts", "examples/*/start.ts",