Merge remote-tracking branch 'origin/master' into feature/shared-cli-config-foundation
# Conflicts: # docs/event-producer-consumer.md # packages/host/apiproxy/README.i18n.yaml # tsconfig.base.json
This commit is contained in:
204 files changed
+3183
-912
No files matched your search
+2
-2
@@ -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: 4d0cbeff0c8a07362caa1ec18493267a9f0d2823
|
||||
2026-07-22-unified-send-and-coalesced-user-messages.zh.md: 935a1a78a6bed451c1db646dec2ec5f4f5e87949
|
||||
+2
-2
@@ -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 queued item was edited), `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
|
||||
|
||||
|
||||
+2
-2
@@ -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`(待处理的 queued 项被编辑)、`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)决策在该单次入队标识上增加了实时变更,但不改变单消息单轮次或持久消息契约。
|
||||
|
||||
## 相关
|
||||
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-30-hook-bridges.md
|
||||
2026-06-30-hook-bridges.md: 99c6b1941a10e198ec3028f5fe505dabfff9abbe
|
||||
2026-06-30-hook-bridges.zh.md: 11ed3a5d177661271b30f1a58d034caa577b5348
|
||||
2026-06-30-hook-bridges.zh.md: 66855c3c4f36877aa627173de8e73250546e9621
|
||||
@@ -15,7 +15,7 @@ harness 的扩展面是其类型化的拦截 seam(见[拦截 seam Agent Note](
|
||||
`packages/hooks/` 组下两个独立插件,各为 function/namespace 插件(`name`/`inject`/`Config`/`apply`,无 default export——见[事后复盘 0001](../../../../docs/postmortem/0001-acp-default-export-drops-inject.md)),仅注入 `bash`:
|
||||
|
||||
- **`dsh-hooks-claude`**——CC 方言。Claude Code 当前七个钩子点中的七个:`SessionStart`、`UserPromptSubmit`、`PreToolUse`、`PostToolUse`、`Stop`、`SubagentStart` 和 `SubagentStop`。拥有 CC 形态的每事件 stdin payload(基础字段 `session_id`/`transcript_path`/`cwd`/`hook_event_name` 加每事件字段)、`CLAUDE_PROJECT_DIR` 环境变量加 `${CLAUDE_PLUGIN_ROOT}`/`${CLAUDE_PROJECT_DIR}` 替换,以及字面量或正则的匹配模式。`transcript_path` 是持久化定位器结果或 `''`;stdin 带有**尾部换行**。
|
||||
- **`dsh-hooks-codex`**——Codex 当前五个钩子点中的五个:`PreToolUse`、`PostToolUse`、`SessionStart`、`UserPromptSubmit` 和 `Stop`。使用始终为正则的匹配模式、Codex 形态的 snake_case payload(含 `turn_id`/`model`/`permission_mode` 额外字段),写入时不带尾部换行,不注入 Codex 插件环境变量,不做配置时占位符替换,也没有 pre-tool 审批或重写路径。`transcript_path` 是同一定位器结果或 `null`;工具 payload 在精简后的 `tool_input: { command }` 形态中携带真实的 `tool_name`。
|
||||
- **`dsh-hooks-codex`**——Codex 当前五个钩子点中的五个:`PreToolUse`、`PostToolUse`、`SessionStart`、`UserPromptSubmit` 和 `Stop`。它使用始终按正则解释的 matcher,输出 Codex 形态的 snake_case payload(含 `turn_id`/`model`/`permission_mode` 额外字段)且写入时不带尾部换行,不注入 Codex 插件环境变量,不做配置时占位符替换,也没有 pre-tool 审批或重写路径。`transcript_path` 是同一定位器结果或 `null`;工具 payload 在精简后的 `tool_input: { command }` 形态中携带真实的 `tool_name`。
|
||||
|
||||
### Outcome → Decision 映射
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-30-hook-protocol-lib.md
|
||||
2026-06-30-hook-protocol-lib.md: 33ec23dd4fa6aa8b4966bbe6c0ca5697ec83056c
|
||||
2026-06-30-hook-protocol-lib.zh.md: 8e8c89a4ecca3bea98fb26bc55974765f27f6a11
|
||||
2026-06-30-hook-protocol-lib.md: ce25f40e96ffd5c319d9845e36eab5130cec5857
|
||||
2026-06-30-hook-protocol-lib.zh.md: 062160931f52576e65557b6e0d385ccaac54aceb
|
||||
@@ -15,7 +15,7 @@ This Agent Note introduces `@deepseek-ai/dsh-hook-protocol`, a **library** (not
|
||||
A new `packages/hooks/` group with `hook-protocol` as a pure library. It owns four primitive families and the `hook/*` session events; each bridge plugin (`dsh-hooks-claude`, `dsh-hooks-codex`) owns what genuinely differs.
|
||||
|
||||
**Shared (here):**
|
||||
- **Matcher** — `matchesMatcher(pattern, query, mode)`. The ONE axis the dialects differ on is collapsed to the `mode` parameter: `claude` treats a pure `[A-Za-z0-9_|]+` pattern as a literal (pipe = exact-match alternation) and anything else as a regex; `codex` is always an unanchored regex. Match-all on absent/`''`/`'*'`; an invalid regex matches nothing (never throws into the loop).
|
||||
- **Matcher** — `matcherDiagnostic(pattern, mode)` and `matchesMatcher(pattern, query, mode)`. The ONE axis the dialects differ on is collapsed to the `mode` parameter: `claude` treats a pure `[A-Za-z0-9_|]+` pattern as a literal (pipe = exact-match alternation) and anything else as a regex; `codex` is always an unanchored regex. Match-all on absent/`''`/`'*'`. Each bridge ignores unsupported events before group parsing, discards matcher fields on supported events without matcher subjects, validates the remaining runnable groups, and treats an invalid regex there as a whole-config load failure, with a stable dialect/pattern/event diagnostic; no hook listeners are registered. Runtime matching still contains an invalid regex as a non-match, so a direct library caller never throws into the loop.
|
||||
- **Execution** — `runHook(bash, hook, options)`. Runs a command hook through the `ctx.bash` seam rather than a bespoke `spawn`: the executor already provides the scrubbed-but-overridable env, process-group kills, and timeout the protocol needs, and `dsh-bash`'s `stdin`/`env` fields (added for exactly this) are the trusted-plugin surface an in-process bridge is allowed to use. It serializes the bridge-built payload to stdin (trailing newline iff CC), honors the hook's `timeoutSec` (else `DEFAULT_HOOK_TIMEOUT_MS`, the 10-minute reference default both dialects share), and never throws (an executor rejection becomes a non-blocking-error `HookOutput`).
|
||||
- **Decode** — `parseHookOutput(exit, stdout, stderr)`, the exit-code + structured-stdout codec, producing a dialect-neutral `HookOutput`. Exit `0` → lenient JSON parse of stdout; exit `2` → blocking error with `stderr` as the reason (surfaced as `decision: 'block'` so no caller needs a separate exit-code branch); other → non-blocking error. Parses the CC structured-stdout fields that have a consumer on some path (`continue`/`stopReason`/`decision`/`hookSpecificOutput.{permissionDecision,additionalContext,updatedInput}`/`systemMessage`); the bridge honors only the subset meaningful for its dialect. Fields with no consumer on any path are not parsed at all (CC's `suppressOutput` — hook stdout never enters a transcript here, so there is nothing to suppress; see [the tighten-hook-protocol-contract Agent Note](../simplification/2026-07-04-tighten-hook-protocol-contract.md)).
|
||||
- **Merge** — `mergeHookOutputs(outputs)`, folding multiple matched hooks into one most-restrictive `MergedHookOutcome`: permission precedence **deny > ask > allow**, halt sticky on the first `continue:false`, block reasons joined `\n\n`, context/system-messages accumulated in order.
|
||||
@@ -29,4 +29,4 @@ A new `packages/hooks/` group with `hook-protocol` as a pure library. It owns fo
|
||||
|
||||
## Consequences
|
||||
|
||||
Each bridge parses config, builds its dialect payload, invokes the shared runner and merge logic, maps the decision, and appends `hook/*`. Protocol tests cover every matcher mode, exit-code and codec field, runner plumbing, merge precedence, and audit helper at per-file 100%; bridge tests exercise the library's real load path. `updatedInput` is parsed but only logged and warned until the [input-rewrite proposal](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md) lands.
|
||||
Each bridge parses config atomically, builds its dialect payload, invokes the shared runner and merge logic, maps the decision, and appends `hook/*`. Protocol tests cover every matcher mode and diagnostic, exit-code and codec field, runner plumbing, merge precedence, and audit helper at per-file 100%; bridge tests exercise the library's load path and pin the exact warning. Keyless ACP snapshots boot both bridges through the real Loader/app path with a valid blocking group before an invalid matcher, then prove the request reaches the replay model and persists no `hook/*` rows, so partial registration cannot hide behind a hand-mounted context. `updatedInput` is parsed but only logged and warned until the [input-rewrite proposal](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md) lands.
|
||||
@@ -15,7 +15,7 @@ Status: implemented
|
||||
在 `packages/hooks/` 分组下新建 `hook-protocol` 作为纯库。它拥有四个原语族和 `hook/*` 会话事件;每个桥接插件(`dsh-hooks-claude`、`dsh-hooks-codex`)拥有真正不同的部分。
|
||||
|
||||
**共享(本库):**
|
||||
- **Matcher** — `matchesMatcher(pattern, query, mode)`。两种方言唯一不同的轴被收敛为 `mode` 参数:`claude` 将纯 `[A-Za-z0-9_|]+` 模式视为字面量(管道符 = 精确匹配的多选),其余视为正则;`codex` 始终是无锚定正则。缺省/`''`/`'*'` 匹配一切;无效正则匹配空集(绝不向 agent loop(智能体循环)抛异常)。
|
||||
- **Matcher** — `matcherDiagnostic(pattern, mode)` 与 `matchesMatcher(pattern, query, mode)`。两种方言的唯一差异收敛到 `mode` 参数:`claude` 将纯 `[A-Za-z0-9_|]+` pattern 视为字面量(管道符 = 精确匹配多选),其他 pattern 视为正则;`codex` 始终使用未锚定正则。缺省/`''`/`'*'` 匹配一切。每个桥接插件会在解析 group 前忽略不支持的事件,丢弃受支持但没有 matcher 匹配对象的事件所带字段,校验其余可运行 group;其中任何无效正则都会导致整份配置加载失败,并给出包含方言/pattern/事件的稳定诊断,不会注册任何钩子监听器。运行时匹配仍会将无效正则隔离为不匹配,因此直接调用本库绝不向 agent loop(智能体循环)抛异常。
|
||||
- **执行** — `runHook(bash, hook, options)`。通过 `ctx.bash` seam 而非自建 `spawn` 运行命令钩子:执行器已提供清洗但可覆盖的 env、进程组 kill 和超时,正是协议所需的能力;`dsh-bash` 的 `stdin`/`env` 字段(正是为此添加的)是进程内桥接插件被允许使用的受信插件接口。它将桥接插件构建的 payload 序列化到 stdin(CC 时追加尾部换行),遵守钩子的 `timeoutSec`(否则使用 `DEFAULT_HOOK_TIMEOUT_MS`,即两种方言共享的 10 分钟参考默认值),且从不抛异常(执行器拒绝变为 non-blocking-error 的 `HookOutput`)。
|
||||
- **解码** — `parseHookOutput(exit, stdout, stderr)`,exit-code + structured-stdout 编解码器,产出方言无关的 `HookOutput`。Exit `0` → 宽松 JSON 解析 stdout;exit `2` → blocking error,`stderr` 为原因(以 `decision: 'block'` 呈现,调用方无需单独处理 exit-code 分支);其他 → non-blocking error。解析 CC structured-stdout 中在某条路径上有消费方的字段(`continue`/`stopReason`/`decision`/`hookSpecificOutput.{permissionDecision,additionalContext,updatedInput}`/`systemMessage`);桥接插件只采纳对其方言有意义的子集。在任何路径上都没有消费方的字段不予解析(CC 的 `suppressOutput`——钩子 stdout 在此处从不进入 transcript(文本记录),因此无需抑制;见 [收紧钩子协议契约 Agent Note](../simplification/2026-07-04-tighten-hook-protocol-contract.md))。
|
||||
- **合并** — `mergeHookOutputs(outputs)`,将多个匹配钩子的输出折叠为一个最严格的 `MergedHookOutcome`:权限优先级 **deny > ask > allow**,halt 在首个 `continue:false` 时粘滞,阻止原因以 `\n\n` 拼接,context/system-messages 按序累积。
|
||||
@@ -29,4 +29,4 @@ Status: implemented
|
||||
|
||||
## 后果
|
||||
|
||||
每个桥接插件解析配置、构建方言 payload、调用共享的 runner 与合并逻辑、映射 decision、追加 `hook/*`。协议测试覆盖每种 matcher 模式、exit-code 与编解码器字段、runner 管道、合并优先级和审计辅助函数,逐文件 100% 覆盖率;桥接插件测试验证库的真实加载路径。`updatedInput` 已解析但仅记录日志并发出警告,直到 [input-rewrite 提案](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md)落地。
|
||||
每个桥接插件以原子方式解析配置、构建方言 payload、调用共享的 runner 与合并逻辑、映射 decision、追加 `hook/*`。协议测试覆盖每种 matcher 模式与诊断、exit-code 与编解码器字段、runner 管道、合并优先级和审计辅助函数,逐文件 100% 覆盖率;桥接插件测试验证库的加载路径并锁定精确警告。无密钥 ACP 快照通过真实 Loader/app 路径启动两个桥接插件,在非法 matcher 之前放置一个合法的拦截 group,然后证明请求仍到达 replay 模型且没有持久化任何 `hook/*` 行,从而避免手工挂载 Context 掩盖部分注册。`updatedInput` 已解析但仅记录日志并发出警告,直到 [input-rewrite 提案](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md)落地。
|
||||
@@ -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: 78a7d346163bb7e5e76c989c6e93576b4a6cee64
|
||||
2026-07-29-addressable-queue-operations.zh.md: 050b9755ad4ebe70e2bdcafb711ef279331e27af
|
||||
@@ -0,0 +1,43 @@
|
||||
# Agent Note: Address pending queue occurrences for edit and removal
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-29-addressable-queue-operations.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The Web queue rendered pending messages but could not edit or delete 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.
|
||||
|
||||
## 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 FIFO. Edit replaces frozen content while preserving `InboxItemId`, `MessageId`, source, wake policy, and position. Remove emits the occurrence’s terminal discard. Steering and driver-claimed occurrences return `not-found`, so queue operations never rewrite active-turn input or durable history.
|
||||
|
||||
**The live ledger is authoritative.** `agent/inbox/enqueue`, `update`, `dequeue`, and `discard` maintain a Host mirror of queued occurrences. A synchronously re-entrant update or terminal event may reach the mirror before its outer enqueue listener; the mirror retains that unseen outcome for the current dispatch and folds it into the enqueue, so listener registration order cannot publish stale content or a ghost row. The wire sends complete `session/queue` snapshots rather than incremental guesses. Reconnect sends the current baseline, and every queued mutation or terminal event replaces it. The client applies no optimistic edit and never retires a row from durable turn events or status changes.
|
||||
|
||||
**Queue addresses require a live Agent.** `session.updateQueue` queries only the mounted Agent registry and never resumes a cold session: an `InboxItemId` is process-local and cannot name work after restart or disposal. A missing Agent and a driver-claimed occurrence both return `queue-item-not-found`.
|
||||
|
||||
**Web actions address Queue only.** The Host excludes pending steering from `session/queue`; steering retains its existing durable transcript path after consumption. QueueDock exposes edit and delete, but no send-now control. The UI derives queue row and mutation types from the runtime `SessionFace` contract rather than importing the connection plugin, so plugin cooperation continues through services and snapshots. Edit is available only when all content blocks are text; the editor cannot silently drop non-text blocks. An editing row exposes only save and cancel, with Enter and Escape as their keyboard equivalents. Delete removes the exact occurrence.
|
||||
|
||||
## 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.
|
||||
|
||||
**Include pending steering in the queue mutation protocol.** Rejected because QueueDock has no steering interaction, and editing or deleting active-turn input would widen this feature beyond its current consumer. A dedicated steering interaction owns that delivery contract.
|
||||
|
||||
**Expose a protocol-only promotion operation.** Rejected because no product interaction reorders Queue. A public operation without a current consumer would add ordering semantics and tests for speculative use.
|
||||
|
||||
**Resume a cold Agent for a queue operation.** Rejected because durable session identity does not preserve the process-local inbox capability. Resuming can only produce `not-found` after creating unrelated live state.
|
||||
|
||||
## Verification
|
||||
|
||||
AgentLoop contract tests hold prompt admission while editing and removing exact queued occurrences, reject mutations of steering occurrences, and verify the resulting independent turn and terminal lifecycle events. Host schema and proxy tests cover queued-only authoritative snapshots, synchronous re-entrant mutation order, reconnect, cold-Agent rejection, typed not-found errors, and the RPC transport. Client runtime and QueueDock tests cover non-optimistic projection, text-only editing, save and cancel affordances, removal, retirement races, and disabled mixed-content editing. Keyless browser scenarios drive the exposed edit and delete actions through the built Web composition and real HTTP/SSE wire.
|
||||
|
||||
## Consequences
|
||||
|
||||
Queued 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 queued items still held by the live Agent. Editing excludes mixed content until an editor can preserve every block, while pending steering remains outside this operation surface.
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Agent Note(agent 决策记录):为待处理队列项提供编辑与移除操作
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-29-addressable-queue-operations.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
Web 队列能够渲染待处理消息,但无法编辑或删除其中某一行。`MessageId` 不足以充当寻址标识,因为调用方可以多次将同一条不可变消息加入队列。浏览器还会根据轮次和状态事件推断队列项已退役,因此当行操作与驱动器认领发生竞态时,系统无法给出权威结果。
|
||||
|
||||
## 决策
|
||||
|
||||
**每次获准进入 FIFO 的项都有独立标识。** AgentLoop 会铸造不透明的 `InboxItemId`,并发布一个 `InboxItem`,其中包含该 id、已有标识的 `UserMessage`,以及接受时确定的 `queued | steering` 放置方式。复用同一个 `MessageId` 会创建不同的 inbox 标识。注入绕过 FIFO,因此不会获得 inbox 标识。
|
||||
|
||||
**变更边界止于驱动器认领。** `Agent.updateInbox(id, action)` 会同步搜索待处理的 queued FIFO。编辑会替换已冻结的内容,同时保留 `InboxItemId`、`MessageId`、来源、唤醒策略和位置。移除会发出该次入队项的终态 discard。steering(中途引导)项和已被驱动器认领的项会返回 `not-found`,因此队列操作绝不会改写活动轮次输入或持久历史。
|
||||
|
||||
**实时账本是权威状态。** `agent/inbox/enqueue`、`update`、`dequeue` 和 `discard` 共同维护 queued 入队项的 Host 镜像。同步可重入的 update 或终态事件可能先于外层 enqueue 监听器到达镜像;镜像会在当前分发期间保留这一尚不可见的结果,并在处理 enqueue 时把它合并进去,因此监听器注册顺序不会导致系统发布陈旧内容或不存在的行。协议发送完整的 `session/queue` 快照,而非增量猜测。重连会发送当前基线,每次 queued 变更或终态事件都会整体替换它。客户端不会进行乐观编辑,也绝不根据持久轮次事件或状态变化退役队列行。
|
||||
|
||||
**Queue 寻址要求 Agent 存活。** `session.updateQueue` 只查询已挂载的 Agent 注册表,绝不恢复冷会话:`InboxItemId` 属于进程本地标识,无法在重启或资源释放后继续指向工作。Agent 缺失和单次入队项已被驱动器认领这两种情况都返回 `queue-item-not-found`。
|
||||
|
||||
**Web 操作只面向 Queue。** Host 从 `session/queue` 中排除待处理 steering;steering 消费后仍沿用既有的持久 transcript(文本记录)路径。QueueDock 暴露编辑和删除,不提供立即发送控件。UI 从运行时 `SessionFace` 契约派生队列行与变更类型,而不是导入连接插件,因此插件仍通过服务和快照协作。仅当所有内容块都是文本时才提供编辑功能;编辑器不能静默丢弃非文本块。编辑中的行只展示保存和取消操作,对应的键盘操作分别是 Enter 和 Escape。删除会移除对应的精确入队项。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**通过 `MessageId` 寻址行。** 不予采纳,因为同一条不可变消息可以重复发送;按消息标识编辑或删除会无法确定应操作哪一次入队。
|
||||
|
||||
**在浏览器中进行乐观变更。** 不予采纳,因为驱动器认领或另一个客户端可能先于 Host 操作完成。等待权威快照可以显式呈现所有权边界,并让 `queue-item-not-found` 报告真实竞态。
|
||||
|
||||
**将待处理 steering 纳入队列变更协议。** 不予采纳,因为 QueueDock 没有 steering 交互,而编辑或删除活动轮次输入会把此功能扩展到当前消费方之外。应由专用 steering 交互负责该投递契约。
|
||||
|
||||
**暴露仅协议层的前移操作。** 不予采纳,因为当前没有产品交互会重新排序 Queue。公开一个没有当前消费方的操作,会为了推测性用途引入排序语义和测试。
|
||||
|
||||
**为队列操作恢复冷 Agent。** 不予采纳,因为持久会话标识不会保留进程本地的 inbox 寻址凭据。恢复只能在创建无关的实时状态后得到 `not-found`。
|
||||
|
||||
## 验证
|
||||
|
||||
AgentLoop 契约测试会在编辑和移除精确 queued 入队项时阻塞提示词接纳,拒绝对 steering 入队项的变更,并验证所得独立轮次及终态生命周期事件。Host schema 与代理测试覆盖仅含 queued 项的权威快照、同步可重入变更顺序、重连、拒绝冷 Agent、类型化 not-found 错误和 RPC 传输。客户端运行时和 QueueDock 测试覆盖非乐观投影、仅文本编辑、保存与取消入口、移除、退役竞态,以及禁用混合内容编辑。无密钥浏览器场景会通过构建后的 Web 组合和真实 HTTP/SSE 协议操作公开的编辑和删除。
|
||||
|
||||
## 后果
|
||||
|
||||
queued 工作获得精确的行操作,但不会因此成为持久会话历史。单次入队标识是进程本地的实时寻址凭据,会在认领、取消、dispose 或重启时消失;重连只能恢复仍由活跃 Agent 持有的 queued 项。编辑会排除混合内容,直至编辑器能够保留每个块;待处理 steering 则不属于此操作接口。
|
||||
|
||||
现在,协议会在每次变更时携带完整队列快照。队列预期保持较短,因此系统优先选择确定性恢复和多客户端收敛,而非增量变更协议。
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-29-web-message-icon-actions-and-clock.md
|
||||
2026-07-29-web-message-icon-actions-and-clock.md: e0072458e4c0d3e37998b5564ad14ce17aa41515
|
||||
2026-07-29-web-message-icon-actions-and-clock.zh.md: 1cc25a9656e7a100d78dd3b6b3675ca490f455f9
|
||||
2026-07-29-web-message-icon-actions-and-clock.md: e79662056792c3ab413468ad038dec40455be767
|
||||
2026-07-29-web-message-icon-actions-and-clock.zh.md: 72d3b4e0cda19438f2f46fd402b3b76de3726ae5
|
||||
@@ -10,18 +10,22 @@ The web chat user bubble already had copy / branch / edit IconActions but no clo
|
||||
|
||||
## Decision
|
||||
|
||||
**User bubbles prepend a date-aware local clock to the existing IconActions row; finalized assistant nodes append a copy / branch / clock row with `margin-top: 16px`; both seats re-format at the next local midnight.**
|
||||
**User bubbles prepend a date-aware local clock to the existing IconActions row; finalized assistant *content* nodes (non-empty text blocks) append a copy / branch / clock row with `margin-top: 16px`; both seats stay visible whenever mounted and re-format at the next local midnight.**
|
||||
|
||||
Both seats format `node.time` through `formatMessageClock`: same calendar day → `HH:mm`, earlier this year → `M月D日 HH:mm`, other years → `YYYY年M月D日 HH:mm`. `useCalendarDay` is a component-local day tick (timeout to the next local midnight) so memoized rows re-render when the calendar day changes without a new framework hook. `MessageItem` places the label before copy (figma `388:20051`). `AssistantMarkdown` places it after branch (figma `43:32997`) and only when `streaming` is false with a known event time; the streaming tail omits the row. Copy writes joined text blocks. Branch stays a chrome stub. Hover-capable pointers keep both footers opacity-hidden until hover/focus-within. Clipboard write and the clock helpers live in `message-chrome.ts`. The assembled surface is pinned by `apps/web/tests/message-actions.e2e.ts` (cold-seeded history + aria golden); aria normalization collapses every clock shape to `{{clock}}`.
|
||||
Both seats format `node.time` through `formatMessageClock`: same calendar day → `HH:mm`, earlier this year → `M月D日 HH:mm`, other years → `YYYY年M月D日 HH:mm`. `useCalendarDay` is a component-local day tick (timeout to the next local midnight) so memoized rows re-render when the calendar day changes without a new framework hook. `MessageItem` places the label before copy (figma `388:20051`). `AssistantMarkdown` places it after branch (figma `43:32997`) only when `streaming` is false, the event time is known, and the node has non-empty text content; Think-only nodes and the streaming tail omit the row. Copy writes joined text blocks. Branch stays a chrome stub. Clipboard write and the clock helpers live in `message-chrome.ts`. The assembled surface is pinned by `apps/web/tests/message-actions.e2e.ts` (cold-seeded history + aria golden); aria normalization collapses every clock shape to `{{clock}}`.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Show assistant IconActions during streaming.** Rejected: the request is to reveal the row only after output completes; mid-stream chrome would flicker and invite copying a partial answer.
|
||||
|
||||
**Put IconActions under every finalized assistant node (including Think-only).** Rejected: copy has nothing useful to write without text content, and repeating the chrome under every step/Think row clutters the flow; only content output owns the seat.
|
||||
|
||||
**Hover-reveal the action row on hover-capable pointers.** Rejected: once the row exists it should stay discoverable; opacity hiding made the chrome easy to miss and required parent hover selectors that duplicated the mount gate.
|
||||
|
||||
**Wire branch to a real session fork.** Rejected for this change: same rationale as the archived [user IconActions note](../../archived/feature/2026-07-27-user-message-icon-actions.md) — the mutation path is unspecified; the button reserves the design seat.
|
||||
|
||||
**Publish the calendar day through a chat store or inject hook.** Rejected: the day tick is presentation-only local state with no cross-entry consumers; a component-local timeout matches the client rule that behavioral hooks may own state that does not subscribe to an external source.
|
||||
|
||||
## Consequences
|
||||
|
||||
Settled assistant answers expose copy and the event clock immediately; branch stays a stub. User and assistant clocks share the same day/year widening rules and refresh after midnight without a message mutation. Per-message paging remains a deferred footer seat in the package README. Package tests pin the three clock shapes and the midnight widen; the web e2e scenario pins the assembled IconActions chrome.
|
||||
Settled assistant content answers expose copy and the event clock as soon as the row mounts; Think-only nodes stay chrome-free; branch stays a stub. User and assistant clocks share the same day/year widening rules and refresh after midnight without a message mutation. Per-message paging remains a deferred footer seat in the package README. Package tests pin the three clock shapes, the midnight widen, and the content-only assistant gate; the web e2e scenario pins the assembled IconActions chrome.
|
||||
+7
-3
@@ -10,18 +10,22 @@ Web 聊天的用户气泡已有复制/分支/编辑 IconActions,但没有
|
||||
|
||||
## 决策
|
||||
|
||||
**用户气泡在既有 IconActions 行前追加感知日期的本地时钟;已定稿的 assistant 节点在正文下追加带 `margin-top: 16px` 的复制/分支/时钟;两边都在下一个本地午夜重新格式化。**
|
||||
**用户气泡在既有 IconActions 行前追加感知日期的本地时钟;已定稿的 assistant *内容*节点(非空 text 块)在正文下追加带 `margin-top: 16px` 的复制/分支/时钟;两边只要挂载就保持可见,并在下一个本地午夜重新格式化。**
|
||||
|
||||
两边都通过 `formatMessageClock` 格式化 `node.time`:同一日历日 → `HH:mm`,同年更早 → `M月D日 HH:mm`,跨年 → `YYYY年M月D日 HH:mm`。`useCalendarDay` 是组件本地的日刻度(定时到下一个本地午夜),因此 memo 行在日历日变化时会重渲染,且不新增框架 hook。`MessageItem` 把标签放在复制之前(figma `388:20051`)。`AssistantMarkdown` 把它放在分支之后(figma `43:32997`),且仅在 `streaming` 为 false 且已知事件时间时渲染;流式尾部省略该行。复制写入拼接后的 text 块。分支仍是 chrome stub。具备 hover 能力的指针在 hover/focus-within 前保持两条 footer 透明。剪贴板写入与时钟辅助函数放在 `message-chrome.ts`。组装面由 `apps/web/tests/message-actions.e2e.ts`(冷 seed 历史 + aria golden)钉住;aria 归一化把每种时钟形态折叠为 `{{clock}}`。
|
||||
两边都通过 `formatMessageClock` 格式化 `node.time`:同一日历日 → `HH:mm`,同年更早 → `M月D日 HH:mm`,跨年 → `YYYY年M月D日 HH:mm`。`useCalendarDay` 是组件本地的日刻度(定时到下一个本地午夜),因此 memo 行在日历日变化时会重渲染,且不新增框架 hook。`MessageItem` 把标签放在复制之前(figma `388:20051`)。`AssistantMarkdown` 把它放在分支之后(figma `43:32997`),且仅在 `streaming` 为 false、已知事件时间、且节点含非空 text 内容时渲染;纯 Think 节点与流式尾部省略该行。复制写入拼接后的 text 块。分支仍是 chrome stub。剪贴板写入与时钟辅助函数放在 `message-chrome.ts`。组装面由 `apps/web/tests/message-actions.e2e.ts`(冷 seed 历史 + aria golden)钉住;aria 归一化把每种时钟形态折叠为 `{{clock}}`。
|
||||
|
||||
## 曾考虑的方案
|
||||
|
||||
**在流式过程中展示 assistant IconActions。** 否决:需求是输出完成后才展示该行;中途 chrome 会闪烁,并诱使复制半截回答。
|
||||
|
||||
**给每个已定稿 assistant 节点(含纯 Think)都挂 IconActions。** 否决:没有 text 内容时复制没有可写内容,且在每一步/Think 下重复 chrome 会打乱流程;只有内容输出拥有该座位。
|
||||
|
||||
**在具备 hover 能力的指针上用 hover 才揭示操作行。** 否决:行一旦存在就应保持可发现;用 opacity 隐藏容易漏看,且需要父级 hover 选择器重复挂载门控。
|
||||
|
||||
**把分支接到真实的会话 fork。** 本次否决:与已归档的[用户 IconActions 笔记](../../archived/feature/2026-07-27-user-message-icon-actions.md)同一理由——变更路径尚未规定;按钮只预留设计座位。
|
||||
|
||||
**通过 chat store 或 inject hook 发布日历日。** 否决:日刻度只是展示层本地状态,没有跨入口消费者;组件本地 timeout 符合「行为 hook 可拥有不订阅外部源的状态」这一客户端规则。
|
||||
|
||||
## 后果
|
||||
|
||||
已定稿的 assistant 回答立刻暴露复制与事件时钟;分支仍为 stub。用户与 assistant 时钟共用同一套跨天/跨年加宽规则,并在午夜后无需消息变更即可刷新。逐消息分页仍是包 README 中的暂缓 footer 座位。包级测试钉住三种时钟形态与午夜加宽;Web e2e 场景钉住组装后的 IconActions chrome。
|
||||
已定稿的 assistant 内容回答在行挂载后立刻暴露复制与事件时钟;纯 Think 节点不带 chrome;分支仍为 stub。用户与 assistant 时钟共用同一套跨天/跨年加宽规则,并在午夜后无需消息变更即可刷新。逐消息分页仍是包 README 中的暂缓 footer 座位。包级测试钉住三种时钟形态、午夜加宽与 assistant 仅内容门控;Web e2e 场景钉住组装后的 IconActions chrome。
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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 批处理。
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "Reply with a one-sentence description" [disabled]
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- 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 "给智能体发消息"
|
||||
- button "Add attachment":
|
||||
- img
|
||||
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
|
||||
- button "选择模型,当前 DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "Stop generating"
|
||||
@@ -0,0 +1,29 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "Reply with a one-sentence description" [disabled]
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- 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 "给智能体发消息"
|
||||
- button "Add attachment":
|
||||
- img
|
||||
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
|
||||
- button "选择模型,当前 DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "Stop generating"
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
"tests/code-mode-round.e2e.ts",
|
||||
"tests/cordis-tool-round.e2e.ts",
|
||||
"tests/message-actions.e2e.ts",
|
||||
"tests/queue-actions.e2e.ts",
|
||||
"tests/skill-invocation-policy.e2e.ts"
|
||||
],
|
||||
"references": [
|
||||
|
||||
@@ -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: 1fe5c1dfa4aee8c3bfe5ac634f47bb68f36afe9f
|
||||
architecture.zh.md: d754c6a2ea5bcd38524d31d02bc4f38ca2074942
|
||||
architecture.md: 1fd9bd128d1bcc0dd91d46131981ea4fc331bd74
|
||||
architecture.zh.md: 8521f09c6e415f9f8d1c0a44f7534b59c876decc
|
||||
@@ -78,8 +78,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 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
|
||||
|
||||
@@ -78,8 +78,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 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
|
||||
|
||||
@@ -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:289`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:319`](../../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:221`](../../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:230`](../../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:403`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:433`](../../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: Agent, message: UserMessage, placement: InboxPlacement, ): void
|
||||
'agent/inbox/dequeue'(this: Scoped<Agent>, 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:262`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:297`](../../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: Agent, messages: UserMessage[]): void
|
||||
'agent/inbox/discard'(this: Scoped<Agent>, 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:279`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:309`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/inbox/enqueue` — emit
|
||||
|
||||
@@ -154,17 +152,36 @@ 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: Agent, message: UserMessage, placement: InboxPlacement): void
|
||||
'agent/inbox/enqueue'(this: Scoped<Agent>, 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:250`](../../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 queued item changed content. The item id, placement, and position remain stable while the event carries the replacement message.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* A still-pending queued item changed content. The item id, placement, and
|
||||
* position remain stable while the event carries the replacement message.
|
||||
* @param agent - the owning agent.
|
||||
* @param item - the complete post-update occurrence.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @mode emit
|
||||
*/
|
||||
'agent/inbox/update'(this: Scoped<Agent>, agent: Agent, item: InboxItem): 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:287`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/prompt-submit` — waterfall
|
||||
|
||||
@@ -187,7 +204,7 @@ Allow, rewrite, or block one claimed prompt before it becomes a user message or
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [PromptDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:316`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:346`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/request` — waterfall
|
||||
|
||||
@@ -211,7 +228,7 @@ Replace the frozen call configuration. `await next()` yields the config the mach
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:342`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:372`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/request-error` — waterfall
|
||||
|
||||
@@ -241,7 +258,7 @@ Handle a model-request failure after its failed step has closed but before the f
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [LlmFailure](../core-data-structures/llm-streaming.md) · [RequestError](../core-data-structures/core.md) · [RequestErrorAction](../core-data-structures/core.md) · [ResolvedRetryPolicy](../core-data-structures/llm-streaming.md) · [Scoped](../core-data-structures/scope.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:361`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:391`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/session-start` — emit
|
||||
|
||||
@@ -263,7 +280,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SessionStartSource](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:302`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:332`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/settled` — emit
|
||||
|
||||
@@ -288,7 +305,7 @@ One drain chain reached its terminal turn: that turn's `turn/end` is already com
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SettleReason](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:390`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:420`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/status` — emit
|
||||
|
||||
@@ -308,7 +325,7 @@ Agent status changed (`idle` ⇄ `running`). `send()` does not enter `running` s
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [AgentStatus](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:239`](../../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 +349,7 @@ Awaited serial checkpoint before EVERY request of a turn is built (the first as
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:329`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:359`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/turn-stopping` — serial
|
||||
|
||||
@@ -358,7 +375,7 @@ The turn is about to close: the model owes no response (no live tool calls, no f
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:376`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:406`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
## `agent-loop/*`
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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: 0ab58864bf70a52554d0c4b9da10fa3fc49e9dc2
|
||||
core.zh.md: 5719c603d0d7576e9fc030e73fb9a6857fef458c
|
||||
core.md: dad533cee00646a40f57bd9097b2cceb8e9de9e2
|
||||
core.zh.md: 9e8afac0744fcf0df8c35dad5debce746d6614c6
|
||||
@@ -430,6 +430,32 @@ 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 queued occurrence. */
|
||||
type InboxAction =
|
||||
| { readonly kind: 'edit'; readonly content: ContentBlock[] }
|
||||
| { readonly kind: 'remove' }
|
||||
```
|
||||
|
||||
```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 +479,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}. */
|
||||
@@ -521,6 +547,16 @@ interface Agent {
|
||||
*/
|
||||
send(message: UserMessage, options: SendOptions): void
|
||||
|
||||
/**
|
||||
* Mutate one still-pending queued occurrence synchronously. Editing preserves
|
||||
* the message identity and queue position; removal publishes its terminal
|
||||
* discard. Steering occurrences and driver-claimed items return `not-found`.
|
||||
* @param id - independently addressable queued occurrence.
|
||||
* @param action - edit or remove 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
|
||||
|
||||
@@ -438,6 +438,32 @@ 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 queued occurrence. */
|
||||
type InboxAction =
|
||||
| { readonly kind: 'edit'; readonly content: ContentBlock[] }
|
||||
| { readonly kind: 'remove' }
|
||||
```
|
||||
|
||||
```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 +487,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}. */
|
||||
@@ -529,6 +555,16 @@ interface Agent {
|
||||
*/
|
||||
send(message: UserMessage, options: SendOptions): void
|
||||
|
||||
/**
|
||||
* Mutate one still-pending queued occurrence synchronously. Editing preserves
|
||||
* the message identity and queue position; removal publishes its terminal
|
||||
* discard. Steering occurrences and driver-claimed items return `not-found`.
|
||||
* @param id - independently addressable queued occurrence.
|
||||
* @param action - edit or remove 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
|
||||
|
||||
@@ -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:157`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) |
|
||||
| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:289`](../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:221`](../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:230`](../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:403`](../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:262`](../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:279`](../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:250`](../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:316`](../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:342`](../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:361`](../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:302`](../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:390`](../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:239`](../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:329`](../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), [`tmux-context`](../packages/context/tmux-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:376`](../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:319`](../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:433`](../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:297`](../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:309`](../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:287`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy` |
|
||||
| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:346`](../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:372`](../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:391`](../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:332`](../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:420`](../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:359`](../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), [`tmux-context`](../packages/context/tmux-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:406`](../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) |
|
||||
|
||||
+11
-8
@@ -267,7 +267,6 @@ flowchart TD
|
||||
pkg_client_modules --> pkg_invariants
|
||||
pkg_client_runtime --> pkg_invariants
|
||||
pkg_client_ui_primitives --> pkg_invariants
|
||||
pkg_client_ui_question --> pkg_invariants
|
||||
pkg_client_ui_slots --> pkg_invariants
|
||||
pkg_client_web --> pkg_invariants
|
||||
pkg_client_web_react --> pkg_invariants
|
||||
@@ -303,10 +302,6 @@ flowchart TD
|
||||
pkg_client_ui_settings --> pkg_client_ui_primitives
|
||||
pkg_client_ui_settings --> pkg_client_ui_slots
|
||||
pkg_client_ui_settings --> pkg_invariants
|
||||
pkg_client_ui_sidebar --> pkg_client_runtime
|
||||
pkg_client_ui_sidebar --> pkg_client_ui_primitives
|
||||
pkg_client_ui_sidebar --> pkg_client_ui_slots
|
||||
pkg_client_ui_sidebar --> pkg_invariants
|
||||
pkg_client_ui_trajectory --> pkg_client_ui_primitives
|
||||
pkg_client_ui_trajectory --> pkg_invariants
|
||||
pkg_client_ui_workspace --> pkg_client_runtime
|
||||
@@ -344,12 +339,19 @@ flowchart TD
|
||||
pkg_system_prompt --> pkg_scope
|
||||
pkg_web --> pkg_invariants
|
||||
pkg_web --> pkg_llm
|
||||
pkg_client_ui_question --> pkg_client_locale
|
||||
pkg_client_ui_question --> pkg_invariants
|
||||
pkg_client_ui_settings_general --> pkg_client_locale
|
||||
pkg_client_ui_settings_general --> pkg_client_runtime
|
||||
pkg_client_ui_settings_general --> pkg_client_ui_primitives
|
||||
pkg_client_ui_settings_general --> pkg_client_ui_settings
|
||||
pkg_client_ui_settings_general --> pkg_client_ui_slots
|
||||
pkg_client_ui_settings_general --> pkg_invariants
|
||||
pkg_client_ui_sidebar --> pkg_client_locale
|
||||
pkg_client_ui_sidebar --> pkg_client_runtime
|
||||
pkg_client_ui_sidebar --> pkg_client_ui_primitives
|
||||
pkg_client_ui_sidebar --> pkg_client_ui_slots
|
||||
pkg_client_ui_sidebar --> pkg_invariants
|
||||
pkg_client_ui_slash --> pkg_client_locale
|
||||
pkg_client_ui_slash --> pkg_client_runtime
|
||||
pkg_client_ui_slash --> pkg_client_ui_primitives
|
||||
@@ -617,6 +619,7 @@ flowchart TD
|
||||
pkg_client_ui_goal --> pkg_goal
|
||||
pkg_client_ui_goal --> pkg_invariants
|
||||
pkg_client_ui_model --> pkg_client_connection
|
||||
pkg_client_ui_model --> pkg_client_locale
|
||||
pkg_client_ui_model --> pkg_client_runtime
|
||||
pkg_client_ui_model --> pkg_client_ui_command
|
||||
pkg_client_ui_model --> pkg_client_ui_conversation
|
||||
@@ -982,7 +985,6 @@ flowchart TD
|
||||
| [`client-modules`](../packages/client/modules) | `client` | [`invariants`](../packages/support/invariants) |
|
||||
| [`client-runtime`](../packages/client/runtime) | `client` | [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-primitives`](../packages/client/ui-primitives) | `client` | [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-question`](../packages/client/ui-question) | `client` | [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-slots`](../packages/client/ui-slots) | `client` | [`invariants`](../packages/support/invariants) |
|
||||
| [`client-web`](../packages/client/web) | `client` | [`invariants`](../packages/support/invariants) |
|
||||
| [`client-web-react`](../packages/client/web-react) | `client` | [`invariants`](../packages/support/invariants) |
|
||||
@@ -1002,7 +1004,6 @@ flowchart TD
|
||||
| [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-models`](../packages/client/ui-models) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`client-ui-primitives`](../packages/client/ui-primitives), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) |
|
||||
@@ -1017,7 +1018,9 @@ flowchart TD
|
||||
| [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
|
||||
| [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
|
||||
| [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) |
|
||||
| [`client-ui-question`](../packages/client/ui-question) | `client` | [`client-locale`](../packages/client/locale), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-slash`](../packages/client/ui-slash) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) |
|
||||
@@ -1082,7 +1085,7 @@ flowchart TD
|
||||
| [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) |
|
||||
| [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection), [`user-approval`](../packages/ui/user-approval) |
|
||||
| [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) |
|
||||
| [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) |
|
||||
| [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel) | `telemetry` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/telemetry/session-telemetry) |
|
||||
|
||||
@@ -212,10 +212,16 @@ const SCENARIOS: Scenario[] = [
|
||||
headerClass: 'advanced',
|
||||
configPath: ADVANCED_CONFIG,
|
||||
},
|
||||
// Prompt-submit blocks are authored keylessly. Admission rejects before a
|
||||
// turn opens, so only the ACP stop reason is observable and no log is harvested.
|
||||
// Prompt-submit blocks are authored keylessly with malformed matcher fields,
|
||||
// which these matcherless events must ignore. Admission rejects before a turn
|
||||
// opens, so only the ACP stop reason is observable and no log is harvested.
|
||||
{ name: 'hook-cc-promptsubmit-block', hasModelTurn: false, recorded: false },
|
||||
{ name: 'hook-codex-promptsubmit-block', hasModelTurn: false, recorded: false },
|
||||
// Each invalid matcher follows a runnable prompt blocker. Reaching the replay
|
||||
// model without any hook audit rows proves config loading is atomic through
|
||||
// the real Loader/app path, rather than retaining the earlier valid group.
|
||||
{ name: 'hook-cc-invalid-matcher', hasModelTurn: true, recorded: false },
|
||||
{ name: 'hook-codex-invalid-matcher', hasModelTurn: true, recorded: false },
|
||||
// The mid-turn seams fire during a real model turn, so each is recorded with its hook active
|
||||
// (the model's reaction to a deny/block/force-continue is part of the captured transcript).
|
||||
// SessionStart/SubagentStart are excluded because detached injection races log
|
||||
|
||||
File diff suppressed because one or more lines are too long.
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"steps": [
|
||||
{ "op": "initialize" },
|
||||
{ "op": "newSession" },
|
||||
{ "op": "prompt", "text": "Reply with exactly the word: PONG. Do not use any tools." }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{"type":"session","version":0,"id":"539aa64c-7f37-40ff-abd8-ed45b717be1b","createdAt":1783600629539,"cwd":"{{cwd}}","delegationDepth":0}
|
||||
{"type":"turn/start","seq":0,"time":1783600629541,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
|
||||
{"type":"user/message","seq":1,"time":1783600629541,"data":{"content":[{"type":"text","text":"Reply with exactly the word: PONG. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"5a36df87-da8e-480d-8e0f-61cd2b93bbb8"},"surfaceOp":"append"}
|
||||
{"type":"session/title","seq":2,"time":1783600629541,"data":{"title":"Reply with exactly the word:","messageSeqs":[1],"source":{"kind":"fallback"}}}
|
||||
{"type":"step/start","seq":3,"time":1783600629542,"data":{"turn":1,"step":1}}
|
||||
{"type":"request/header","seq":4,"time":1783600629542,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
|
||||
{"type":"assistant/chunk","seq":5,"time":1783600630819,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
|
||||
{"type":"reasoning-chunks","seq0":6,"time0":1783600630820,"data":{"turn":1,"step":1,"index":0,"dt":[2,30,0,0,0,33,1,40,0,0,0,0,0,18,0,36,0,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," the"," word"," \"","P","ONG","\""," and"," not"," use"," any"," tools","."]}}
|
||||
{"type":"assistant/chunk","seq":26,"time":1783600630980,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
|
||||
{"type":"assistant/chunk","seq":27,"time":1783600630980,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"P"}}}
|
||||
{"type":"assistant/chunk","seq":28,"time":1783600631006,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ONG"}}}
|
||||
{"type":"assistant/chunk","seq":29,"time":1783600631008,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."}}}}
|
||||
{"type":"assistant/chunk","seq":30,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"PONG"}}}}
|
||||
{"type":"assistant/chunk","seq":31,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3091,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":20}}}}
|
||||
{"type":"assistant/chunk","seq":32,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
{"type":"assistant/message","seq":33,"time":1783600631011,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."},{"type":"text","text":"PONG"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"7452a358-8038-4583-9ceb-66564f665bfb"},"usage":{"inputTokens":3091,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":20}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":34,"time":1783600631011,"data":{"turn":1,"step":1}}
|
||||
{"type":"turn/end","seq":35,"time":1783600631011,"data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
@@ -0,0 +1,4 @@
|
||||
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
|
||||
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"PONG"}}}}
|
||||
{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"hooks": {
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"hooks": [
|
||||
{ "type": "command", "command": "echo 'must not run' >&2; exit 2" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "[",
|
||||
"hooks": [
|
||||
{ "type": "command", "command": "exit 2" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
"hooks": {
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"matcher": "[",
|
||||
"hooks": [
|
||||
{ "type": "command", "command": "echo 'blocked by policy hook' >&2; exit 2" }
|
||||
]
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"steps": [
|
||||
{ "op": "initialize" },
|
||||
{ "op": "newSession" },
|
||||
{ "op": "prompt", "text": "Reply with exactly the word: PONG. Do not use any tools." }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{"type":"session","version":0,"id":"539aa64c-7f37-40ff-abd8-ed45b717be1b","createdAt":1783600629539,"cwd":"{{cwd}}","delegationDepth":0}
|
||||
{"type":"turn/start","seq":0,"time":1783600629541,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
|
||||
{"type":"user/message","seq":1,"time":1783600629541,"data":{"content":[{"type":"text","text":"Reply with exactly the word: PONG. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"56715824-b0da-4a73-8d6c-0caa590995e6"},"surfaceOp":"append"}
|
||||
{"type":"session/title","seq":2,"time":1783600629541,"data":{"title":"Reply with exactly the word:","messageSeqs":[1],"source":{"kind":"fallback"}}}
|
||||
{"type":"step/start","seq":3,"time":1783600629542,"data":{"turn":1,"step":1}}
|
||||
{"type":"request/header","seq":4,"time":1783600629542,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
|
||||
{"type":"assistant/chunk","seq":5,"time":1783600630819,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
|
||||
{"type":"reasoning-chunks","seq0":6,"time0":1783600630820,"data":{"turn":1,"step":1,"index":0,"dt":[2,30,0,0,0,33,1,40,0,0,0,0,0,18,0,36,0,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," the"," word"," \"","P","ONG","\""," and"," not"," use"," any"," tools","."]}}
|
||||
{"type":"assistant/chunk","seq":26,"time":1783600630980,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
|
||||
{"type":"assistant/chunk","seq":27,"time":1783600630980,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"P"}}}
|
||||
{"type":"assistant/chunk","seq":28,"time":1783600631006,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ONG"}}}
|
||||
{"type":"assistant/chunk","seq":29,"time":1783600631008,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."}}}}
|
||||
{"type":"assistant/chunk","seq":30,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"PONG"}}}}
|
||||
{"type":"assistant/chunk","seq":31,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3091,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":20}}}}
|
||||
{"type":"assistant/chunk","seq":32,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
{"type":"assistant/message","seq":33,"time":1783600631011,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."},{"type":"text","text":"PONG"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"2d9d88d1-b684-491f-9d5f-73721b7fd5ed"},"usage":{"inputTokens":3091,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":20}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":34,"time":1783600631011,"data":{"turn":1,"step":1}}
|
||||
{"type":"turn/end","seq":35,"time":1783600631011,"data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
@@ -0,0 +1,4 @@
|
||||
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
|
||||
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}}
|
||||
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"PONG"}}}}
|
||||
{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"hooks": {
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"hooks": [
|
||||
{ "type": "command", "command": "echo 'must not run' >&2; exit 2" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "[",
|
||||
"hooks": [
|
||||
{ "type": "command", "command": "exit 2" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+1
@@ -2,6 +2,7 @@
|
||||
"hooks": {
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"matcher": "[",
|
||||
"hooks": [
|
||||
{ "type": "command", "command": "echo 'blocked by codex policy hook' >&2; exit 2" }
|
||||
]
|
||||
|
||||
@@ -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 } })
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -1150,6 +1150,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) {
|
||||
@@ -1587,6 +1592,7 @@ export class FixtureApiClient extends AbstractApiClient {
|
||||
case 'session.selectModel': return this.api.sessions.selectModel(request)
|
||||
case 'session.rename': return this.api.sessions.rename(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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -63,6 +63,7 @@ export class FakeApiClient implements IApiClient {
|
||||
=> Promise<RpcResponse<{ selected: ModelTarget }>> =
|
||||
payload => Promise.resolve(ok({ selected: { provider: payload.provider, model: payload.model } }))
|
||||
onPrompt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
|
||||
onUpdateQueue: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
|
||||
onCancel: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
|
||||
onDescribe: (payload: unknown) => Promise<RpcResponse<{ version: string; cwd: string; attachedSessions: number }>> =
|
||||
() => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 }))
|
||||
@@ -99,6 +100,7 @@ export class FakeApiClient implements IApiClient {
|
||||
this.record('session.selectModel', payload, this.onSelectModel(payload)),
|
||||
rename: (payload: unknown) => this.record('session.rename', payload, this.onRename(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)),
|
||||
}
|
||||
|
||||
|
||||
@@ -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/locale/README.md
|
||||
README.md: 9015af2b44a33771b06863ace139fe97695df616
|
||||
README.zh.md: 12205e21bb75a4433902b8e85c1cf7bdb0147bbf
|
||||
README.md: c2adbcabc77def740094288da4643032873aa5b8
|
||||
README.zh.md: c6ecb31e21d7513a4e7d579b17588107ccd7ea59
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Locale plugin: LocaleService — the browser locale preference (`zh`/`en`, persisted under `dsh.locale`, getter/setter with `locale/change` snapshots) plus the ns×locale dictionary registry (`bind(ns)`→t with a stable function identity; lookup chain active → zh → key).
|
||||
Locale plugin: LocaleService — the browser locale preference (`zh`/`en`, persisted under `dsh.locale`; `locale/change` fires on switches only) plus the ns×locale dictionary registry (typed `register(ns, {zh, en})` checked against `LocaleNamespaceMap`, `bind(ns)`→`TranslateNS<ns>`; lookup chain ns → common → zh → key). The service implements the slot system's `LocaleFace` and installs itself through `ctx.slots.installLocale`, backing the framework-injected `t` standard seat (`Translate`/`TranslateNS` are ui-slots types; import them from there — this package only re-exports for dictionary owners' convenience).
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -14,5 +14,5 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Only the Settings surface is translated** — other pages keep inline copy; repo-wide extraction into dictionaries is deferred.
|
||||
- **Locale switching re-renders subscribed consumers only** — sections not wired to `locale/change` keep their rendered text until remount.
|
||||
- **Most surfaces keep inline copy** — the standard seat is adopted by the Settings rows, sidebar, question composer, and model select; the remaining packages migrate in follow-up PRs.
|
||||
- **Registry-held text reads its translation once** — copy captured at registration time outside the slot render path (e.g. the `/model` command description in the command registry) keeps the language it was registered under until re-registration; slot-rendered copy follows switches live.
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
locale 插件:LocaleService 包含浏览器 locale 偏好(`zh`/`en`,以 `dsh.locale` 为键持久化;提供 getter/setter,并生成 `locale/change` 快照),以及 ns×locale 字典注册表(`bind(ns)`→t 的函数标识稳定;查找链为 active → zh → key)。
|
||||
locale 插件:LocaleService——浏览器 locale 偏好(`zh`/`en`,以 `dsh.locale` 持久化;`locale/change` 仅在切换语言时触发),加上 ns×locale 字典注册表(类型化 `register(ns, {zh, en})` 按 `LocaleNamespaceMap` 校验,`bind(ns)`→`TranslateNS<ns>`;查找链 ns → common → zh → key)。该服务实现 slot 系统的 `LocaleFace` 并经 `ctx.slots.installLocale` 自行安装,支撑框架注入的 `t` 标准席位(`Translate`/`TranslateNS` 是 ui-slots 的类型;请从那里导入——本包的再导出仅为字典所有者提供便利)。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -14,5 +14,5 @@ locale 插件:LocaleService 包含浏览器 locale 偏好(`zh`/`en`,以
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **只有设置界面完成翻译**:其他页面仍保留内联文案;将全仓文案提取到字典的工作暂缓。
|
||||
- **切换 locale 只重新渲染已订阅的消费方**:未接入 `locale/change` 的界面区域会保留已渲染文本,直到重新挂载。
|
||||
- **多数界面仍保留内联文案**——标准席位已由设置行、侧边栏、问题作答器和模型选择接入;其余包在后续 PR 中迁移。
|
||||
- **注册表持有的文本只读取一次翻译**——在 slot 渲染路径之外于注册时捕获的文案(例如 command 注册表中的 `/model` 命令描述)在重新注册前保持注册时的语言;slot 渲染的文案随切换实时更新。
|
||||
@@ -5,23 +5,22 @@
|
||||
* settings surface.
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import type { PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type {} from './settings-contract.ts'
|
||||
import type { createLanguageRowStore } from './settings-store.ts'
|
||||
import css from './LanguageRow.module.css'
|
||||
|
||||
/** Injected business face: namespace-bound translate + the preference write. */
|
||||
/** Injected business face: the preference write (t rides the standard locale seat). */
|
||||
export interface LanguageRowInjected {
|
||||
/** Translate a `settings.locale` dictionary key to the active-locale text. */
|
||||
t: (key: string) => string
|
||||
/** Switch the active locale (a registered locale id). */
|
||||
setLocale: (id: string) => void
|
||||
}
|
||||
|
||||
/** Full component props: runtime share + store share + injected face. */
|
||||
/** Full component props: runtime share + store share + locale seat + injected face. */
|
||||
export type LanguageRowComponentProps =
|
||||
PropsRuntime<'settings.general.item'> & PropsStore<ReturnType<typeof createLanguageRowStore>> & LanguageRowInjected
|
||||
PropsRuntime<'settings.general.item'> & PropsStore<ReturnType<typeof createLanguageRowStore>>
|
||||
& PropsLocale<'settings.locale'> & LanguageRowInjected
|
||||
|
||||
/**
|
||||
* Render the Language row.
|
||||
|
||||
@@ -4,11 +4,21 @@
|
||||
* preference row into the settings General section — the locale feature owns
|
||||
* its own settings surface.
|
||||
*/
|
||||
/* oxlint-disable typescript/no-redundant-type-constituents --
|
||||
* `keyof LocaleNamespaceMap & string` is the declare-merge key pattern (see
|
||||
* ui-slots): in THIS unit the map holds only this package's own merges, but
|
||||
* consumers merge more namespaces in and the intersection keeps them
|
||||
* string-typed. The rule fires on the narrow-map view, not real redundancy. */
|
||||
import type { Context } from 'cordis'
|
||||
import { deferRegistration, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import {
|
||||
deferRegistration,
|
||||
type BoundActions, type LocaleDictOf, type LocaleNamespaceMap, type Translate, type TranslateNS,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { en } from '../locales/en.ts'
|
||||
import { zh } from '../locales/zh.ts'
|
||||
import { en, zh, type CommonKey } from '../locales/index.ts'
|
||||
import {
|
||||
en as settingsEn, zh as settingsZh, type SettingsLocaleKey,
|
||||
} from '../locales/settings.ts'
|
||||
import type { LanguageRowInjected } from './LanguageRow.tsx'
|
||||
import { LanguageRow } from './LanguageRow.tsx'
|
||||
import { createLanguageRowStore } from './settings-store.ts'
|
||||
@@ -16,9 +26,21 @@ import { createLanguageRowStore } from './settings-store.ts'
|
||||
export type { LanguageRowComponentProps, LanguageRowInjected } from './LanguageRow.tsx'
|
||||
export type { LanguageOptionRow, LanguageRowState } from './settings-store.ts'
|
||||
export type { SettingsGeneralItemOwnerProps } from './settings-contract.ts'
|
||||
export type { CommonKey } from '../locales/index.ts'
|
||||
|
||||
/** Translate a key with optional params. */
|
||||
export type Translate = (key: string, params?: Record<string, unknown>) => string
|
||||
// The translate currency lives in ui-slots (the render machinery synthesizes
|
||||
// the seat); re-exported here so dictionary owners import one package.
|
||||
// TranslateNS<'model'> is the namespace-addressed developer-facing form.
|
||||
export type { Translate, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface LocaleNamespaceMap {
|
||||
/** Shared cross-feature vocabulary, consulted by the lookup chain after the entry's own namespace misses. */
|
||||
common: CommonKey
|
||||
/** This feature's own settings-row copy (the Language row). */
|
||||
'settings.locale': SettingsLocaleKey
|
||||
}
|
||||
}
|
||||
|
||||
/** Locale dictionary: flat key to template string ({name} placeholders). */
|
||||
export type LocaleDict = Record<string, string>
|
||||
@@ -50,7 +72,10 @@ declare module 'cordis' {
|
||||
}
|
||||
interface Events {
|
||||
/**
|
||||
* Locale state changed (active locale switched or registry updated).
|
||||
* The active locale switched. Dictionary registrations do NOT emit this
|
||||
* event (listeners may re-register slots in response, and boot registers
|
||||
* one namespace per package); continuous render refresh rides the
|
||||
* LocaleFace revision instead.
|
||||
* @param snapshot - Current immutable locale snapshot.
|
||||
* @mode emit
|
||||
*/
|
||||
@@ -77,16 +102,20 @@ const LOCALES: readonly LocaleDefinition[] = Object.freeze([
|
||||
])
|
||||
|
||||
/**
|
||||
* Dictionary registry plus locale preference. Lookup chain per key: active
|
||||
* locale -> zh fallback -> the key itself (missing text stays visible, fail
|
||||
* loud in the UI rather than blank). Reads go through {@link getLocale};
|
||||
* writes only through {@link setLocale}; continuous sync only through the
|
||||
* `locale/change` event.
|
||||
* Dictionary registry plus locale preference. Lookup chain per key: the
|
||||
* entry's namespace in the active locale -> that namespace's zh fallback ->
|
||||
* the shared common namespace (active, then zh) -> the key itself (missing
|
||||
* text stays visible, fail loud in the UI rather than blank). Reads go
|
||||
* through {@link getLocale}; writes only through {@link setLocale};
|
||||
* continuous sync through the `locale/change` event, or through the
|
||||
* LocaleFace getSnapshot/subscribe pair the render machinery consumes
|
||||
* (installed via `ctx.slots.installLocale`).
|
||||
*/
|
||||
export class LocaleService {
|
||||
private dicts = new Map<string, Map<string, LocaleDict>>()
|
||||
private bound = new Map<string, Translate>()
|
||||
private snapshot: LocaleSnapshot
|
||||
private listeners = new Set<() => void>()
|
||||
private readonly ctx: Context
|
||||
|
||||
/**
|
||||
@@ -105,6 +134,27 @@ export class LocaleService {
|
||||
return this.snapshot
|
||||
}
|
||||
|
||||
/**
|
||||
* LocaleFace getSnapshot: the current snapshot (carries `revision`; stable
|
||||
* reference between changes, uSES-safe).
|
||||
* @returns the current snapshot.
|
||||
*/
|
||||
getSnapshot(): LocaleSnapshot {
|
||||
return this.snapshot
|
||||
}
|
||||
|
||||
/**
|
||||
* LocaleFace subscribe: notified on every snapshot change (locale switch
|
||||
* or dictionary registration — registrations bump the revision so already
|
||||
* rendered outlets pick up late-arriving dictionaries).
|
||||
* @param fn - change callback.
|
||||
* @returns unsubscribe.
|
||||
*/
|
||||
subscribe(fn: () => void): () => void {
|
||||
this.listeners.add(fn)
|
||||
return () => { this.listeners.delete(fn) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch the active locale — the only preference write entry. Persists the
|
||||
* id and emits `locale/change`.
|
||||
@@ -114,44 +164,80 @@ export class LocaleService {
|
||||
const match = this.snapshot.locales.find(l => l.id === id)
|
||||
if (match === undefined) throw new Error(`locale "${id}" is not registered`)
|
||||
if (this.snapshot.active === match.id) return
|
||||
this.snapshot = Object.freeze({
|
||||
active: match.id,
|
||||
locales: this.snapshot.locales,
|
||||
revision: this.snapshot.revision + 1,
|
||||
})
|
||||
persistPreference(match.id)
|
||||
this.ctx.emit('locale/change', this.snapshot)
|
||||
this.publish(match.id, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a dictionary for a namespace and locale. Duplicate (ns, locale)
|
||||
* throws (single occupant; a namespace's texts have one owner).
|
||||
* Register a declared namespace's dictionaries, all locales in one call —
|
||||
* the typed form: each dictionary is checked against the namespace's
|
||||
* {@link LocaleNamespaceMap} key union (a missing or extra key is a
|
||||
* compile error), and every shipped locale is required (bilingual balance
|
||||
* enforced at the seam). Duplicate (ns, locale) throws (single occupant; a
|
||||
* namespace's texts have one owner). Registration bumps the revision so
|
||||
* mounted outlets pick up late-arriving dictionaries.
|
||||
* @param ns - a namespace merged into LocaleNamespaceMap.
|
||||
* @param dicts - complete dictionaries keyed by locale id.
|
||||
* @returns disposer removing every locale registered by this call (idempotent).
|
||||
*/
|
||||
register<N extends keyof LocaleNamespaceMap & string>(ns: N, dicts: Record<LocaleId, LocaleDictOf<N>>): () => void
|
||||
/**
|
||||
* Single-locale untyped form for namespaces outside the merge table
|
||||
* (dynamic composition, tests).
|
||||
* @param ns - namespace.
|
||||
* @param locale - locale tag (zh/en to start).
|
||||
* @param locale - locale tag.
|
||||
* @param dict - dictionary.
|
||||
* @returns disposer (idempotent).
|
||||
*/
|
||||
register(ns: string, locale: string, dict: LocaleDict): () => void {
|
||||
register(ns: string, locale: string, dict: LocaleDict): () => void
|
||||
register(ns: string, localeOrDicts: string | Record<string, LocaleDict>, dict?: LocaleDict): () => void {
|
||||
const pairs: [string, LocaleDict][] = typeof localeOrDicts === 'string'
|
||||
// Overload guarantees dict on the single-locale arm.
|
||||
? [[localeOrDicts, dict as LocaleDict]]
|
||||
: Object.entries(localeOrDicts)
|
||||
let locales = this.dicts.get(ns)
|
||||
if (!locales) {
|
||||
locales = new Map()
|
||||
this.dicts.set(ns, locales)
|
||||
}
|
||||
if (locales.has(locale)) throw new Error(`locale namespace "${ns}" already has locale "${locale}"`)
|
||||
locales.set(locale, dict)
|
||||
for (const [locale] of pairs) {
|
||||
if (locales.has(locale)) throw new Error(`locale namespace "${ns}" already has locale "${locale}"`)
|
||||
}
|
||||
for (const [locale, entries] of pairs) locales.set(locale, entries)
|
||||
this.publish(this.snapshot.active, false)
|
||||
return () => {
|
||||
const owner = this.dicts.get(ns)
|
||||
if (owner?.get(locale) === dict) owner.delete(locale)
|
||||
/* v8 ignore next -- defensive: a namespace's locales map is created on
|
||||
* first register and never removed, so the disposer always finds it. */
|
||||
if (!owner) return
|
||||
let removed = false
|
||||
for (const [locale, entries] of pairs) {
|
||||
if (owner.get(locale) === entries) {
|
||||
owner.delete(locale)
|
||||
removed = true
|
||||
}
|
||||
}
|
||||
if (removed) this.publish(this.snapshot.active, false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind a namespace to a translate function. The returned reference is
|
||||
* stable per namespace (repeat binds return the same function), so it can
|
||||
* ride inject surfaces without breaking memoization.
|
||||
* @param ns - namespace.
|
||||
* @returns the translate function (reads the active locale at call time).
|
||||
* Bind a declared namespace to a translate function typed to its
|
||||
* dictionary key union (plus the shared common vocabulary) — the same key
|
||||
* domain the framework-injected `t` seat carries. The returned reference
|
||||
* is stable per namespace (repeat binds return the same function), so it
|
||||
* can ride inject surfaces without breaking memoization.
|
||||
* @param ns - a namespace merged into LocaleNamespaceMap.
|
||||
* @returns the typed translate function (reads the active locale at call time).
|
||||
*/
|
||||
bind<N extends keyof LocaleNamespaceMap & string>(ns: N): TranslateNS<N>
|
||||
/**
|
||||
* Untyped form for namespaces outside the merge table (dynamic
|
||||
* composition, tests).
|
||||
* @param ns - namespace.
|
||||
* @returns the translate function.
|
||||
*/
|
||||
bind(ns: string): Translate
|
||||
bind(ns: string): Translate {
|
||||
let t = this.bound.get(ns)
|
||||
if (!t) {
|
||||
@@ -163,14 +249,43 @@ export class LocaleService {
|
||||
}
|
||||
|
||||
private translate(ns: string, key: string, params?: Record<string, unknown>): string {
|
||||
const locales = this.dicts.get(ns)
|
||||
const template = locales?.get(this.snapshot.active)?.[key]
|
||||
?? locales?.get(FALLBACK_LOCALE)?.[key]
|
||||
const template = this.lookup(ns, key)
|
||||
?? (ns !== COMMON_NS ? this.lookup(COMMON_NS, key) : undefined)
|
||||
?? key
|
||||
if (!params) return template
|
||||
return template.replace(/\{(\w+)\}/g, (match, name: string) =>
|
||||
name in params ? String(params[name]) : match)
|
||||
}
|
||||
|
||||
private lookup(ns: string, key: string): string | undefined {
|
||||
const locales = this.dicts.get(ns)
|
||||
return locales?.get(this.snapshot.active)?.[key] ?? locales?.get(FALLBACK_LOCALE)?.[key]
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance the snapshot revision and notify LocaleFace subscribers (render
|
||||
* refresh). Only an active-locale switch additionally emits
|
||||
* `locale/change` — dictionary registrations stay off the event so
|
||||
* registration-heavy boot cannot storm event listeners (which may
|
||||
* re-register slots in response).
|
||||
*/
|
||||
private publish(active: LocaleId, localeChanged: boolean): void {
|
||||
this.snapshot = Object.freeze({
|
||||
active,
|
||||
locales: this.snapshot.locales,
|
||||
revision: this.snapshot.revision + 1,
|
||||
})
|
||||
if (localeChanged) this.ctx.emit('locale/change', this.snapshot)
|
||||
for (const fn of [...this.listeners]) {
|
||||
try {
|
||||
fn()
|
||||
} catch (error) {
|
||||
// One throwing subscriber must not strand the rest on a stale
|
||||
// revision (outlets would keep the previous language).
|
||||
console.error('locale subscriber crashed:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the persisted locale id; unknown or unreadable values fall back to zh. */
|
||||
@@ -208,11 +323,12 @@ export const inject = ['slots']
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
const locale = new LocaleService(ctx)
|
||||
locale.register(COMMON_NS, 'zh', zh)
|
||||
locale.register(COMMON_NS, 'en', en)
|
||||
locale.register(SETTINGS_NS, 'zh', { 'language.title': '语言' })
|
||||
locale.register(SETTINGS_NS, 'en', { 'language.title': 'Language' })
|
||||
locale.register(COMMON_NS, { zh, en })
|
||||
locale.register(SETTINGS_NS, { zh: settingsZh, en: settingsEn })
|
||||
ctx.provide('locale', locale)
|
||||
// The service IS the LocaleFace (bind + getSnapshot/subscribe): install it
|
||||
// so the render machinery can synthesize the `t` standard seat.
|
||||
ctx.slots.installLocale(locale)
|
||||
|
||||
const store = createLanguageRowStore()
|
||||
let bound: BoundActions<typeof store> | undefined
|
||||
@@ -230,7 +346,6 @@ export function apply(ctx: ClientContext): void {
|
||||
// first render (the store's revision guard drops stale duplicates).
|
||||
sync(locale.getLocale())
|
||||
return {
|
||||
t: locale.bind(SETTINGS_NS),
|
||||
setLocale: (id) => { locale.setLocale(id) },
|
||||
}
|
||||
}
|
||||
@@ -241,6 +356,7 @@ export function apply(ctx: ClientContext): void {
|
||||
id: 'language',
|
||||
order: 0,
|
||||
store,
|
||||
locale: SETTINGS_NS,
|
||||
inject: injected,
|
||||
}, LanguageRow))
|
||||
return () => { deferred.dispose() }
|
||||
|
||||
@@ -1,2 +1,29 @@
|
||||
/** en base dictionary for the common namespace (starter skeleton; texts land with their features). */
|
||||
export const en: Record<string, string> = {}
|
||||
import type { CommonKey } from './zh.ts'
|
||||
|
||||
/** en base dictionary for the common namespace, checked complete against the zh key set. */
|
||||
export const en = {
|
||||
'ok': 'OK',
|
||||
'cancel': 'Cancel',
|
||||
'close': 'Close',
|
||||
'copy': 'Copy',
|
||||
'copied': 'Copied',
|
||||
'retry': 'Retry',
|
||||
'loading': 'Loading…',
|
||||
'load.failed': 'Failed to load',
|
||||
'submit': 'Submit',
|
||||
'submitting': 'Submitting…',
|
||||
'next': 'Next',
|
||||
'previous': 'Previous',
|
||||
'skip': 'Skip',
|
||||
'delete': 'Delete',
|
||||
'edit': 'Edit',
|
||||
'save': 'Save',
|
||||
'search': 'Search',
|
||||
'more': 'More',
|
||||
'collapse': 'Collapse',
|
||||
'expand': 'Expand',
|
||||
'back': 'Back',
|
||||
'unknown': 'Unknown',
|
||||
'none': 'None',
|
||||
'truncated': 'Truncated',
|
||||
} satisfies Record<CommonKey, string>
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* The common-namespace dictionary pair. zh is the source of truth for the
|
||||
* key set (Chinese-first repo convention); en is checked complete against it
|
||||
* — a missing or extra en key is a compile error.
|
||||
*/
|
||||
export { zh } from './zh.ts'
|
||||
export { en } from './en.ts'
|
||||
export type { CommonKey } from './zh.ts'
|
||||
@@ -0,0 +1,14 @@
|
||||
/** `settings.locale` namespace dictionaries (the Language row's copy). */
|
||||
|
||||
/** Simplified Chinese dictionary (the key-set source of truth). */
|
||||
export const zh = {
|
||||
'language.title': '语言',
|
||||
} satisfies Record<string, string>
|
||||
|
||||
/** The settings.locale namespace key union. */
|
||||
export type SettingsLocaleKey = keyof typeof zh
|
||||
|
||||
/** English dictionary, checked complete against the zh key set. */
|
||||
export const en = {
|
||||
'language.title': 'Language',
|
||||
} satisfies Record<SettingsLocaleKey, string>
|
||||
@@ -1,2 +1,30 @@
|
||||
/** zh base dictionary for the common namespace (starter skeleton; texts land with their features). */
|
||||
export const zh: Record<string, string> = {}
|
||||
/** zh base dictionary for the common namespace: cross-feature standard words. */
|
||||
export const zh = {
|
||||
'ok': '确定',
|
||||
'cancel': '取消',
|
||||
'close': '关闭',
|
||||
'copy': '复制',
|
||||
'copied': '复制成功',
|
||||
'retry': '重试',
|
||||
'loading': '加载中…',
|
||||
'load.failed': '加载失败',
|
||||
'submit': '提交',
|
||||
'submitting': '正在提交…',
|
||||
'next': '下一步',
|
||||
'previous': '上一步',
|
||||
'skip': '跳过',
|
||||
'delete': '删除',
|
||||
'edit': '编辑',
|
||||
'save': '保存',
|
||||
'search': '搜索',
|
||||
'more': '更多',
|
||||
'collapse': '收起',
|
||||
'expand': '展开',
|
||||
'back': '返回',
|
||||
'unknown': '未知',
|
||||
'none': '无',
|
||||
'truncated': '已截断',
|
||||
} satisfies Record<string, string>
|
||||
|
||||
/** The common vocabulary key union (zh is the key-set source of truth). */
|
||||
export type CommonKey = keyof typeof zh
|
||||
@@ -69,16 +69,18 @@ describe('locale apply', () => {
|
||||
// An event ahead of any inject hits the unbound-actions arm.
|
||||
locale.setLocale('en')
|
||||
|
||||
const { instance, face } = faceOf(b.slots)
|
||||
const { entry, instance, face } = faceOf(b.slots)
|
||||
// The inject-time re-sync sealed the init window: the mirror is current.
|
||||
expect(instance.getSnapshot().active).toBe('en')
|
||||
expect(instance.getSnapshot().options.map(o => o.id)).toEqual(['zh', 'en'])
|
||||
expect(face.t('language.title')).toBe('Language')
|
||||
// Copy rides the standard locale seat: the entry declares the namespace.
|
||||
expect(entry.locale).toBe(SETTINGS_NS)
|
||||
expect(locale.bind(SETTINGS_NS)('language.title')).toBe('Language')
|
||||
|
||||
face.setLocale('zh')
|
||||
expect(locale.getLocale().active).toBe('zh')
|
||||
expect(instance.getSnapshot().active).toBe('zh')
|
||||
expect(face.t('language.title')).toBe('语言')
|
||||
expect(locale.bind(SETTINGS_NS)('language.title')).toBe('语言')
|
||||
})
|
||||
|
||||
it('recovers after an HMR collapse of the declaring entry (stale disposer must not block)', async () => {
|
||||
|
||||
@@ -29,6 +29,24 @@ describe('LocaleService', () => {
|
||||
expect(t('missing.key')).toBe('missing.key')
|
||||
})
|
||||
|
||||
it('falls through to the common vocabulary after the namespace misses (production keys)', () => {
|
||||
const { svc } = make()
|
||||
// The shipped common pair is registered by apply; the bench registers it
|
||||
// directly to pin the production chain: ns -> common -> zh -> key.
|
||||
svc.register('common', 'zh', { retry: '重试' })
|
||||
svc.register('common', 'en', { retry: 'Retry' })
|
||||
svc.register('ns', 'zh', { own: '自有' })
|
||||
const t = svc.bind('ns')
|
||||
expect(t('retry')).toBe('重试')
|
||||
svc.setLocale('en')
|
||||
expect(t('retry')).toBe('Retry')
|
||||
expect(t('own')).toBe('自有')
|
||||
// common itself must not recurse: a miss inside common echoes the key.
|
||||
// (Wide-string ns hits the untyped bind overload — the typed one rejects
|
||||
// unknown keys at compile time, which is the point of the seam.)
|
||||
expect(svc.bind('common' as string)('nope')).toBe('nope')
|
||||
})
|
||||
|
||||
it('interpolates {name} params and leaves unknown placeholders intact', () => {
|
||||
const { svc } = make()
|
||||
svc.register('ns', 'zh', { greet: '你好,{name}!第 {n} 次', partial: '{known} 与 {unknown}' })
|
||||
@@ -56,6 +74,47 @@ describe('LocaleService', () => {
|
||||
expect(t('k')).toBe('v2')
|
||||
})
|
||||
|
||||
it('serves the LocaleFace: snapshot revision moves on switch and registration, subscribers fire, unsubscribe stops them', () => {
|
||||
const { svc } = make()
|
||||
const seen: number[] = []
|
||||
const off = svc.subscribe(() => { seen.push(svc.getSnapshot().revision) })
|
||||
expect(svc.getSnapshot()).toBe(svc.getLocale())
|
||||
const r0 = svc.getSnapshot().revision
|
||||
svc.register('ns', 'zh', { k: 'v' })
|
||||
expect(svc.getSnapshot().revision).toBe(r0 + 1)
|
||||
svc.setLocale('en')
|
||||
expect(seen).toEqual([r0 + 1, r0 + 2])
|
||||
off()
|
||||
svc.setLocale('zh')
|
||||
expect(seen).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('isolates a throwing subscriber: the rest still see the new revision', () => {
|
||||
const { svc } = make()
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
try {
|
||||
const seen: number[] = []
|
||||
svc.subscribe(() => { throw new Error('boom') })
|
||||
svc.subscribe(() => { seen.push(svc.getSnapshot().revision) })
|
||||
svc.setLocale('en')
|
||||
expect(seen).toEqual([1])
|
||||
expect(spy).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('register disposer republishes (mounted outlets drop the dead dictionary)', () => {
|
||||
const { svc } = make()
|
||||
const dispose = svc.register('ns', 'zh', { k: 'v' })
|
||||
const before = svc.getSnapshot().revision
|
||||
dispose()
|
||||
expect(svc.getSnapshot().revision).toBe(before + 1)
|
||||
// Second run hits the idempotent arm: nothing removed, no republish.
|
||||
dispose()
|
||||
expect(svc.getSnapshot().revision).toBe(before + 1)
|
||||
})
|
||||
|
||||
it('setLocale persists, republishes an immutable snapshot, and no-ops on same value', () => {
|
||||
const { svc, events } = make()
|
||||
svc.setLocale('en')
|
||||
|
||||
@@ -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: b51cc0276d8635ea9faa506e30246a107c1c1418
|
||||
README.zh.md: 4b2248d875ae37f1b848c51a0009d2497c6b3e61
|
||||
README.md: 766d8516225cd46cb1a3a80c832d1cf55e816140
|
||||
README.zh.md: 9b514afca91f604b3e895187de3b5532bf22a692
|
||||
@@ -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 Queue snapshot; pending steering stays outside this projection. Each row carries its `InboxItemId`, complete editable text when every content block is text, and a flattened preview. `session/queue` replaces the whole projection; reconnect buffering retains only the latest snapshot, and neither durable turn events nor running-status changes guess that an item was claimed. `Session.updateQueue()` sends edit/remove operations without optimistic mutation, so the next Host snapshot is the sole visible commit and a claim race can surface `queue-item-not-found`.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -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` 帧播种,本地首次获 Host 接受的 `prompt()`(RPC 成功响应时——受理即证明用户消息已入主机日志;首讯被拒则会话保持 blank、保持可复用)与任何 `running: true` 状态帧翻为 false,每次列表重拉重新对齐。列表界面隐藏 blank 行;store 保留全部行。`SessionsService.create` 接受可选的、由调用方预先分配的 SessionId,失败时抛出 `SessionCreateError`(携带 `requestedSessionId`)。
|
||||
|
||||
## 待处理队列投影
|
||||
|
||||
`ConversationSnapshot.queue` 是 Host 提供的权威瞬态 Queue 快照;待处理 steering(中途引导)不进入此投影。每行都携带其 `InboxItemId`、所有内容块均为文本时的完整可编辑文本,以及扁平化预览。`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 稳定。
|
||||
|
||||
@@ -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<RpcResult<{ accepted: true }>>
|
||||
/**
|
||||
* Apply one mutation to a still-pending queue occurrence.
|
||||
* @param itemId - agent-owned inbox occurrence identity.
|
||||
* @param action - edit or remove operation.
|
||||
* @returns acceptance, or a business/transport error.
|
||||
*/
|
||||
updateQueue(itemId: InboxItemId, action: QueueAction): Promise<RpcResult<{ accepted: true }>>
|
||||
/**
|
||||
* Cancel the running turn.
|
||||
* @returns acceptance, or the business error.
|
||||
|
||||
@@ -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'
|
||||
export type { TodoItem }
|
||||
@@ -216,10 +216,12 @@ 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
|
||||
}
|
||||
|
||||
/** In-progress assistant output (chunk accumulator product). */
|
||||
@@ -277,7 +279,7 @@ export interface ConversationSnapshot {
|
||||
*/
|
||||
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
|
||||
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. */
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<RpcResult<{ accepted: true }>> {
|
||||
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.
|
||||
@@ -393,20 +399,15 @@ 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),
|
||||
}))
|
||||
this.queueRev++
|
||||
this.notifier.markDirty()
|
||||
return
|
||||
@@ -459,15 +460,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) {
|
||||
@@ -632,27 +624,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 {
|
||||
@@ -832,7 +803,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 {
|
||||
|
||||
@@ -18,7 +18,7 @@ import { Service } from 'cordis'
|
||||
import type { Context } from 'cordis'
|
||||
import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type {
|
||||
OwnerOf, SlotEntryDef, SlotMap, SlotRenderer, SlotRendererHost,
|
||||
LocaleFace, OwnerOf, SlotEntryDef, SlotMap, SlotRenderer, SlotRendererHost,
|
||||
SlotScope, SlotSpec, StoreDecl, StoredEntry, StoreInstanceLike,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
|
||||
@@ -70,6 +70,8 @@ interface ErasedRegisterOptions {
|
||||
select?: (owner: never) => unknown
|
||||
/** Chain-slot explicit ordering override (ascending; registration order otherwise). */
|
||||
priority?: number
|
||||
/** Declared dictionary namespace (the renderer synthesizes the `t` seat from it). */
|
||||
locale?: string
|
||||
registrant?: string
|
||||
}
|
||||
|
||||
@@ -82,6 +84,7 @@ export class SlotsService extends Service {
|
||||
/** Store-instance axis: handle -> mounted scope, refcount, resolved instances. */
|
||||
private readonly _stores = new Map<EngineStoreHandle, StoreAxisRecord>()
|
||||
private _renderer: SlotRenderer | undefined
|
||||
private _locale: LocaleFace | undefined
|
||||
private _host: SlotRendererHost | undefined
|
||||
|
||||
/**
|
||||
@@ -127,6 +130,23 @@ export class SlotsService extends Service {
|
||||
}, 'slots.install()')
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the locale face backing the `t` standard seat (the locale
|
||||
* plugin's product; same boot-once discipline as the renderer install).
|
||||
* Runs through the caller's ctx.effect, so the installing fiber's unload
|
||||
* uninstalls the face.
|
||||
* @param face - namespace binder + revision observable.
|
||||
*/
|
||||
installLocale(face: LocaleFace): void {
|
||||
if (this._locale !== undefined) throw new Error('locale face already installed (installLocale() is boot-once)')
|
||||
this.ctx.effect(() => {
|
||||
this._locale = face
|
||||
return () => {
|
||||
if (this._locale === face) this._locale = undefined
|
||||
}
|
||||
}, 'slots.installLocale()')
|
||||
}
|
||||
|
||||
/**
|
||||
* The single ctx-level render entry: the shell renders 'root'; every other
|
||||
* key renders inside components through the props renderSlot face. All
|
||||
@@ -246,6 +266,12 @@ export class SlotsService extends Service {
|
||||
if (workspaces === undefined) {
|
||||
throw new Error("renderSlot('root') before the workspaces service mounted — boot order puts runtime apply first")
|
||||
}
|
||||
// `locale` is a live getter: the face installs (and, under HMR, swaps)
|
||||
// on the locale plugin's own fiber lifetime, while this host object is
|
||||
// built once — a captured value would strand renders on a dead face. The
|
||||
// alias is required: `this` inside the getter is the host literal.
|
||||
// oxlint-disable-next-line typescript/no-this-alias
|
||||
const service = this
|
||||
this._host = {
|
||||
subscribe: (key, fn) => this._core.subscribe(key, fn),
|
||||
getVersion: key => this._core.getVersion(key),
|
||||
@@ -259,6 +285,7 @@ export class SlotsService extends Service {
|
||||
provideInfo: sessions.currentProvideInfo,
|
||||
},
|
||||
workspaces: { list: workspaces.list },
|
||||
get locale() { return service._locale },
|
||||
}
|
||||
return this._host
|
||||
}
|
||||
|
||||
@@ -81,6 +81,7 @@ export class FakeApiClient implements IApiClient {
|
||||
Promise<RpcResponse<{ selected: ModelTarget }>> =
|
||||
payload => Promise.resolve(ok({ selected: { provider: payload.provider, model: payload.model } }))
|
||||
onPrompt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
|
||||
onUpdateQueue: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
|
||||
onCancel: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
|
||||
|
||||
onDescribe: (payload: unknown) => Promise<RpcResponse<{ version: string; cwd: string; attachedSessions: number }>> =
|
||||
@@ -118,6 +119,7 @@ export class FakeApiClient implements IApiClient {
|
||||
this.record('session.selectModel', payload, this.onSelectModel(payload)),
|
||||
rename: (payload: unknown) => this.record('session.rename', payload, this.onRename(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)),
|
||||
}
|
||||
|
||||
|
||||
@@ -1,32 +1,41 @@
|
||||
/**
|
||||
* 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
|
||||
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,
|
||||
}),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,201 +43,131 @@ 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, and complete text', () => {
|
||||
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消息' },
|
||||
]))
|
||||
expect(session.getSnapshot().queue).toEqual([
|
||||
{ id: 'q-1', preview: '第一条 排队 消息', text: '第一条 排队\n消息' },
|
||||
])
|
||||
})
|
||||
|
||||
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 },
|
||||
])
|
||||
})
|
||||
|
||||
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' },
|
||||
])
|
||||
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: 'edit', content: text('next') }))
|
||||
.resolves.toEqual({ ok: true, value: { accepted: true } })
|
||||
expect(api.callsOf('session.updateQueue')).toEqual([{
|
||||
sessionId: SID,
|
||||
itemId: 'q-op',
|
||||
action: { kind: 'edit', content: text('next') },
|
||||
}])
|
||||
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[] } } }
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -467,6 +467,7 @@ describe('fixture session face', () => {
|
||||
await runtime.sessions.add({ id: 's1' })
|
||||
const bare = runtime.sessions.behavior('s1')
|
||||
expect(() => bare.prompt()).toThrow(/prompt is not stubbed/)
|
||||
expect(() => bare.updateQueue()).toThrow(/updateQueue is not stubbed/)
|
||||
expect(() => bare.cancel()).toThrow(/cancel is not stubbed/)
|
||||
expect(() => bare.command()).toThrow(/command is not stubbed/)
|
||||
expect(() => bare.loadOlder()).toThrow(/loadOlder is not stubbed/)
|
||||
|
||||
@@ -128,6 +128,7 @@ 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({
|
||||
|
||||
@@ -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: 5e24e4aad5154430fa48c80eb439694005df7c6f
|
||||
README.zh.md: 89a34041e156e137d966bdafbc92d86477df166e
|
||||
README.md: 75c181f2e1240f753d1f8c30152d2978151b059f
|
||||
README.zh.md: 6dc167c63af27dfbc37c51b9a55087cf2c50dc7f
|
||||
@@ -36,7 +36,9 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
- **The stats line has no duration segment** — assistant `usage` carries token accounting only; elapsed-time needs a host data source.
|
||||
- **Details panel is the minimal form and currently has no entry point** — selected call args/result raw display; the Input/Output/Metadata switch, Prev/Next stepping, and See-in-trajectory deep link are deferred. Tool rows stopped being details-panel click targets and nothing replaced that gesture, so `ChatViewInjected.openDetails` is implemented but uncalled and the panel (including its terminal card) is unreachable in the assembled application; its rendering stays covered by mounting it with a selection directly.
|
||||
- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized IconActions row (copy / branch / clock) ships; branch remains a chrome stub.
|
||||
- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized content IconActions row (copy / branch / clock) ships under text output only; branch remains a chrome stub.
|
||||
- **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export.
|
||||
- **The approval panel's "Always allow this type" is deferred** — durable grants need a grant-storage design; only allow-once/reject answer today.
|
||||
- **TodoPanel truncates long item text to one ellipsized line** — the figma strip has no wrap or expand affordance; full text is not readable inline.
|
||||
- **Queue edit is text-only** — rows containing non-text blocks still show a flattened preview, but their edit control is disabled because the inline editor cannot preserve those blocks. A text row's edit mode replaces delete with save and cancel; Enter saves and Escape cancels. QueueDock exposes no send-now control.
|
||||
- **Web exposes pending Queue only** — the Host omits pending steering from the Queue snapshot until steering has its own interaction. A consumed `steering/message` still renders in the durable transcript so external steering remains truthful on replay.
|
||||
@@ -36,7 +36,9 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插
|
||||
|
||||
- **统计行没有耗时区段**:assistant `usage` 只携带 token 计数;耗时需要主机数据源。
|
||||
- **详情面板是最小形态,且当前没有入口**:以原始形式显示已选择调用的参数/结果;Input/Output/Metadata 切换、Prev/Next 步进与 See-in-trajectory 深链接暂缓实现。工具行已不再是详情面板的点击目标,且没有任何手势接替它,因此 `ChatViewInjected.openDetails` 虽已实现却无人调用,该面板(含其终端卡片)在组装后的应用中不可达;其渲染仍由直接以选中态挂载它来覆盖。
|
||||
- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的 IconActions 行(复制/分支/时钟)已落地;分支仍是 chrome stub。
|
||||
- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/分支/时钟)只挂在 text 输出下;分支仍是 chrome stub。
|
||||
- **others 工具行的闪光图标是手绘近似版本**:无法在本地导出设计字形的矢量几何;等到存在精确导出后再将其提升到 ui-primitives。
|
||||
- **审批面板的「始终允许此类」暂缓**:持久授权需要授权存储设计;今天只能回答允许一次/拒绝。
|
||||
- **TodoPanel 将过长条目截成单行省略号**:figma 条没有换行或展开入口,完整文本无法在行内读完。
|
||||
- **Queue 编辑仅支持文本**:包含非文本块的行仍显示扁平化预览,但由于内联编辑器无法保留这些块,其编辑控件会被禁用。文本行进入编辑模式后,删除会替换为保存和取消;Enter 保存,Escape 取消。QueueDock 不提供立即发送控件。
|
||||
- **Web 仅暴露待处理 Queue**:在 steering(中途引导)拥有专用交互之前,Host 不会把待处理 steering 纳入 Queue 快照。已消费的 `steering/message` 仍会渲染到持久 transcript(文本记录)中,因此从外部提交的 steering 在回放时仍能如实呈现。
|
||||
@@ -34,11 +34,3 @@
|
||||
/* Optical align with 28px icon hit targets that pad 6px past the glyph. */
|
||||
margin-left: -6px;
|
||||
}
|
||||
|
||||
/* Hover-capable pointers: reveal shared actions on root hover/focus. */
|
||||
@media (hover: hover) {
|
||||
.root:hover .actions,
|
||||
.root:focus-within .actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,8 @@
|
||||
// view groups them into tool rows through its keyed toolview slot (figma
|
||||
// step-summary flow). Shared by finalized nodes and the streaming partial;
|
||||
// the turn-level loading dots live in the chat view's tail, not here.
|
||||
// Finalized nodes append IconActions (copy / branch / clock) once streaming ends.
|
||||
// Finalized content (text) nodes append IconActions once streaming ends;
|
||||
// Think / tool-head-only nodes stay chrome-free.
|
||||
|
||||
import { memo } from 'react'
|
||||
import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -38,6 +39,11 @@ function copyText(blocks: readonly AssistantBlock[]): string {
|
||||
return parts.join('')
|
||||
}
|
||||
|
||||
/** True when the node has model-visible text content worth chrome under. */
|
||||
function hasContentText(blocks: readonly AssistantBlock[]): boolean {
|
||||
return blocks.some(block => block.kind === 'text' && block.text.trim() !== '')
|
||||
}
|
||||
|
||||
/** Reasoning block as the Think variant summary row (figma 39:28304). */
|
||||
function ThinkRow({ text, running }: { text: string; running: boolean }) {
|
||||
return (
|
||||
@@ -64,8 +70,8 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
|
||||
|| interrupted === true
|
||||
|| blocks.some(block => block.kind !== 'tool-call')
|
||||
if (!hasVisible) return null
|
||||
// Footer only after the turn settles with a known event time; streaming omits it.
|
||||
const showActions = !streaming && time !== undefined
|
||||
// Footer only under settled content text; Think-only / streaming omit it.
|
||||
const showActions = !streaming && time !== undefined && hasContentText(blocks)
|
||||
return (
|
||||
<div className={css.root} data-streaming={streaming || undefined}>
|
||||
<div className={css.body}>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* Shared message IconActions row (user + assistant). Parent modules own
|
||||
hover-reveal selectors and layout offsets via the composed className. */
|
||||
layout offsets via the composed className. Always visible when mounted. */
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
@@ -25,14 +25,6 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Hover-capable pointers: hide until a parent hover/focus rule reveals. */
|
||||
@media (hover: hover) {
|
||||
.actions {
|
||||
opacity: 0;
|
||||
transition: opacity var(--ds-transition-duration) var(--ds-ease-in-out);
|
||||
}
|
||||
}
|
||||
|
||||
.action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -18,7 +18,7 @@ export interface MessageIconActionsProps {
|
||||
clock: 'start' | 'end'
|
||||
/** When true, append the stub edit control (user bubble). */
|
||||
edit?: boolean | undefined
|
||||
/** Parent layout / hover-reveal class composed onto the actions row. */
|
||||
/** Parent layout class composed onto the actions row. */
|
||||
className?: string | undefined
|
||||
}
|
||||
|
||||
|
||||
@@ -20,14 +20,6 @@
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
/* Hover-capable pointers: reveal shared MessageIconActions on row hover/focus. */
|
||||
@media (hover: hover) {
|
||||
.userRow:hover .actions,
|
||||
.userRow:focus-within .actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
margin-bottom: 4px;
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
/** Queue contracts derived from the runtime session face and snapshot. */
|
||||
import type {
|
||||
ConversationSnapshot, SessionFace,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/** One address accepted by the runtime session's queue mutation verb. */
|
||||
export type QueueItemId = Parameters<SessionFace['updateQueue']>[0]
|
||||
|
||||
/** One mutation accepted by the runtime session's queue mutation verb. */
|
||||
export type QueueAction = Parameters<SessionFace['updateQueue']>[1]
|
||||
|
||||
/** One row projected by the runtime session's authoritative queue snapshot. */
|
||||
export type QueueRow = ConversationSnapshot['queue'][number]
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
ArbitrateKey, ArbitrateOutcome, CommandClaim, ConsumeTokenRequest, PickOutcome,
|
||||
ReferenceInsert, SubmitOutcome, TokenSpan,
|
||||
} from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
import type { QueueRow } from '../contract/queue.ts'
|
||||
|
||||
/**
|
||||
* The scoped-event application verbs: the hub's bail listeners call these,
|
||||
@@ -99,12 +100,8 @@ export interface ComposerKeyboard {
|
||||
dismissPopup(): void
|
||||
}
|
||||
|
||||
/** One queued-message row projected from the session/queued frames (T9 supplies the store). */
|
||||
export interface QueuedMessage {
|
||||
/** Stable row key: the enqueueing prompt's rpcId. */
|
||||
readonly key: string
|
||||
readonly preview: string
|
||||
}
|
||||
/** One independently addressable row projected from the transient queue snapshot. */
|
||||
export type QueuedMessage = QueueRow
|
||||
|
||||
/** Guard union of the scoped consume-token event, checked by the machine. */
|
||||
export type ConsumeTokenGuard = ConsumeTokenRequest['guard']
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,48 +1,185 @@
|
||||
// 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, 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 {
|
||||
IconCheckOutline16, IconCloseOutline16, IconEditOutline16, IconTrashOutline16,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { QueueAction, QueueItemId } from '../contract/queue.ts'
|
||||
import css from './QueueDock.module.css'
|
||||
|
||||
/** Queue operations injected by the session-scoped registration. */
|
||||
export interface QueueDockInjected {
|
||||
updateQueue: (itemId: QueueItemId, action: QueueAction) => Promise<void>
|
||||
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) {
|
||||
export function QueueDock({ useSession, updateQueue, notify }: QueueDockProps) {
|
||||
const queue = useSession(s => s.queue)
|
||||
const [editing, setEditing] = useState<{ id: QueueItemId; text: string } | null>(null)
|
||||
const [busy, setBusy] = useState<QueueItemId | null>(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: QueueItemId,
|
||||
action: QueueAction,
|
||||
failure: string,
|
||||
): Promise<boolean> => {
|
||||
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<void> => {
|
||||
if (editing === null || editing.text.trim() === '') return
|
||||
if (await applyAction(
|
||||
editing.id,
|
||||
{ kind: 'edit', content: [{ type: 'text', text: editing.text }] },
|
||||
'编辑失败:这条消息可能已经开始发送。',
|
||||
)) setEditing(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css.dock}>
|
||||
<div className={css.title}>已排队 {queue.length} 条</div>
|
||||
<ul className={css.list}>
|
||||
{queue.map(row => (
|
||||
<li key={row.key} className={css.row}>{row.preview}</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className={css.panel}>
|
||||
<ul className={css.list}>
|
||||
{queue.map(row => (
|
||||
<li key={row.id} className={css.row}>
|
||||
{editing?.id === row.id
|
||||
? (
|
||||
<input
|
||||
autoFocus
|
||||
className={css.editor}
|
||||
aria-label="编辑排队消息"
|
||||
value={editing.text}
|
||||
onChange={(event) => { 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()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
: <span className={css.preview}>{row.preview}</span>}
|
||||
<div className={css.actions}>
|
||||
{editing?.id === row.id
|
||||
? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={css.action}
|
||||
aria-label="保存排队消息"
|
||||
title="保存排队消息"
|
||||
disabled={busy !== null || editing.text.trim() === ''}
|
||||
onClick={() => { void saveEdit() }}
|
||||
>
|
||||
<IconCheckOutline16 size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={css.action}
|
||||
aria-label="取消编辑"
|
||||
title="取消编辑"
|
||||
disabled={busy !== null}
|
||||
onClick={() => { setEditing(null) }}
|
||||
>
|
||||
<IconCloseOutline16 size={14} />
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={css.action}
|
||||
aria-label="编辑排队消息"
|
||||
title={row.text === null ? '包含非文本内容,暂不支持编辑' : '编辑排队消息'}
|
||||
disabled={busy !== null || row.text === null}
|
||||
onClick={() => {
|
||||
if (row.text !== null) setEditing({ id: row.id, text: row.text })
|
||||
}}
|
||||
>
|
||||
<IconEditOutline16 size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={css.action}
|
||||
aria-label="删除排队消息"
|
||||
title="删除排队消息"
|
||||
disabled={busy !== null}
|
||||
onClick={() => {
|
||||
void applyAction(
|
||||
row.id,
|
||||
{ kind: 'remove' },
|
||||
'删除失败:这条消息可能已经开始发送。',
|
||||
)
|
||||
}}
|
||||
>
|
||||
<IconTrashOutline16 size={14} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
},
|
||||
}
|
||||
@@ -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).
|
||||
*/
|
||||
|
||||
@@ -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 { QueueAction, QueueItemId } from './contract/queue.ts'
|
||||
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<void>
|
||||
/**
|
||||
* Apply one operation to a pending queue occurrence.
|
||||
* @param itemId - agent-owned inbox occurrence identity.
|
||||
* @param action - edit or remove operation.
|
||||
* @returns completion; business failures reject.
|
||||
*/
|
||||
updateQueue(itemId: QueueItemId, action: QueueAction): Promise<void>
|
||||
/**
|
||||
* 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: QueueItemId, action: QueueAction): Promise<void> {
|
||||
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<void> {
|
||||
const session = this.scopedSession('cancel')
|
||||
|
||||
@@ -177,7 +177,7 @@ describe('small branch tails', () => {
|
||||
expect(view.getByText('one-liner')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('finalized assistant messages expose copy / branch / clock after the body; streaming omits them', () => {
|
||||
it('finalized content messages expose copy / branch / clock; Think-only and streaming omit them', () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
@@ -199,6 +199,17 @@ describe('small branch tails', () => {
|
||||
expect(writeText).toHaveBeenCalledWith('answer body')
|
||||
settled.unmount()
|
||||
|
||||
const thinkOnly = render(
|
||||
<AssistantMarkdown
|
||||
blocks={[{ kind: 'reasoning', text: 'only thinking' }]}
|
||||
streaming={false}
|
||||
time={time}
|
||||
/>,
|
||||
)
|
||||
expect(thinkOnly.queryByRole('button', { name: '复制' })).toBeNull()
|
||||
expect(thinkOnly.queryByText('14:24')).toBeNull()
|
||||
thinkOnly.unmount()
|
||||
|
||||
const streaming = render(
|
||||
<AssistantMarkdown blocks={[{ kind: 'text', text: 'partial' }]} streaming time={time} />,
|
||||
)
|
||||
|
||||
@@ -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 { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { QueueItemId } from '../src/client/contract/queue.ts'
|
||||
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): QueueItemId => id as QueueItemId
|
||||
|
||||
function row(id: string, text: string | null, preview = text ?? '[image]'): QueuedMessage {
|
||||
return { id: iid(id), preview, text }
|
||||
}
|
||||
|
||||
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<ConversationSnapshot> = sel =>
|
||||
const useSession: SnapshotSelectorHook<ConversationSnapshot> = 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<QueueDockInjected> = {}) {
|
||||
return {
|
||||
sessionId: SID,
|
||||
useSessions: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<SessionListState>,
|
||||
@@ -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,118 @@ describe('QueueDock', () => {
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('renders one preview row per queued message with the count strip', () => {
|
||||
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(<QueueDock {...kitFor(snap)} useSession={source.useSession} />)
|
||||
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(
|
||||
<QueueDock {...kitFor(snap, { updateQueue })} useSession={source.useSession} />,
|
||||
)
|
||||
|
||||
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(
|
||||
<QueueDock {...kitFor(snap, { updateQueue })} useSession={source.useSession} />,
|
||||
)
|
||||
|
||||
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(
|
||||
<QueueDock {...kitFor(snap, { updateQueue })} useSession={source.useSession} />,
|
||||
)
|
||||
|
||||
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(
|
||||
<QueueDock {...kitFor(snap, { updateQueue })} useSession={source.useSession} />,
|
||||
)
|
||||
|
||||
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(
|
||||
<QueueDock {...kitFor(snap, { updateQueue, notify })} useSession={source.useSession} />,
|
||||
)
|
||||
|
||||
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(<QueueDock {...kitFor(snap)} useSession={source.useSession} />)
|
||||
expect(container.textContent).toContain('在场')
|
||||
@@ -90,11 +197,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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-command"
|
||||
],
|
||||
@@ -36,6 +37,7 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-client-connection": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-locale": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-command": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "^0.0.1",
|
||||
@@ -49,6 +51,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-command": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
|
||||
|
||||
@@ -18,6 +18,7 @@ import type { ModelReasoningEffort, ModelTarget } from '@deepseek-ai/dsh-client-
|
||||
import {
|
||||
IconCheckOutline16, IconChevronDownOutline14, IconChevronRightOutline14,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ModelSelectInjected } from './slots.ts'
|
||||
import css from './ModelSelect.module.css'
|
||||
|
||||
@@ -34,10 +35,13 @@ interface EffortChoice {
|
||||
|
||||
/**
|
||||
* Render the composer model seat.
|
||||
* @param props - owner share (locked) + injected face (shared directory store/verbs).
|
||||
* @param props - owner share (locked) + injected face (shared directory
|
||||
* store/verbs) + the standard locale seat.
|
||||
* @returns the trigger and, while open, the two-level menu.
|
||||
*/
|
||||
export function ModelSelect({ locked, directory, load, select }: ModelSelectInjected & { locked: boolean }) {
|
||||
export function ModelSelect(
|
||||
{ locked, directory, load, select, t }: ModelSelectInjected & { locked: boolean } & PropsLocale<'model'>,
|
||||
) {
|
||||
const state = useSyncExternalStore(
|
||||
fn => directory.subscribe(fn),
|
||||
() => directory.getSnapshot(),
|
||||
@@ -70,13 +74,13 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje
|
||||
const effortLabel = reasoning === undefined
|
||||
? undefined
|
||||
: effectiveEffort === undefined
|
||||
? 'Provider default'
|
||||
? t('effort.providerDefault')
|
||||
: reasoning.efforts.find(level => level.id === effectiveEffort)?.name ?? effectiveEffort
|
||||
const effortChoices = useMemo<readonly EffortChoice[]>(() => reasoning === undefined
|
||||
? []
|
||||
: [
|
||||
...reasoning.defaultEffort === undefined
|
||||
? [{ key: 'provider-default', effort: undefined, label: 'Provider default' }]
|
||||
? [{ key: 'provider-default', effort: undefined, label: t('effort.providerDefault') }]
|
||||
: [],
|
||||
...reasoning.efforts.map((effort: ModelReasoningEffort) => ({
|
||||
key: `effort:${effort.id}`,
|
||||
@@ -84,7 +88,7 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje
|
||||
label: effort.name,
|
||||
...effort.description === undefined ? {} : { description: effort.description },
|
||||
})),
|
||||
], [reasoning])
|
||||
], [reasoning, t])
|
||||
const busy = state.status === 'selecting'
|
||||
|
||||
// Mount-time load resolves the trigger label; every open refreshes.
|
||||
@@ -165,7 +169,7 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje
|
||||
})
|
||||
}
|
||||
|
||||
const modelLabel = choices[selectedIndex]?.model.name ?? state.current?.model ?? '选择模型'
|
||||
const modelLabel = choices[selectedIndex]?.model.name ?? state.current?.model ?? t('trigger.fallback')
|
||||
const triggerLabel = effortLabel === undefined ? modelLabel : `${modelLabel} · ${effortLabel}`
|
||||
itemRefs.current = []
|
||||
let itemIndex = 0
|
||||
@@ -180,7 +184,9 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className={css.trigger}
|
||||
aria-label={`选择模型,当前 ${modelLabel}${effortLabel === undefined ? '' : `,推理等级 ${effortLabel}`}`}
|
||||
aria-label={effortLabel === undefined
|
||||
? t('trigger.aria', { model: modelLabel })
|
||||
: t('trigger.ariaEffort', { model: modelLabel, effort: effortLabel })}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
aria-controls={open ? `${id}-menu` : undefined}
|
||||
@@ -204,19 +210,19 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje
|
||||
id={`${id}-menu`}
|
||||
className={css.menu}
|
||||
role="menu"
|
||||
aria-label="模型与推理等级"
|
||||
aria-label={t('menu.aria')}
|
||||
aria-busy={state.status === 'loading' || busy}
|
||||
>
|
||||
{pane === 'root' && (
|
||||
<>
|
||||
<button ref={itemRef()} type="button" role="menuitem" className={css.cell} onClick={() => { setPane('model') }}>
|
||||
<span className={css.cellLabel}>Model</span>
|
||||
<span className={css.cellLabel}>{t('menu.model')}</span>
|
||||
<span className={css.cellValue}>{modelLabel}</span>
|
||||
<IconChevronRightOutline14 className={css.cellChevron} />
|
||||
</button>
|
||||
{reasoning !== undefined && (
|
||||
<button ref={itemRef()} type="button" role="menuitem" className={css.cell} onClick={() => { setPane('effort') }}>
|
||||
<span className={css.cellLabel}>Effort</span>
|
||||
<span className={css.cellLabel}>{t('menu.effort')}</span>
|
||||
<span className={css.cellValue}>{effortLabel}</span>
|
||||
<IconChevronRightOutline14 className={css.cellChevron} />
|
||||
</button>
|
||||
@@ -227,18 +233,18 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje
|
||||
{pane === 'model' && (
|
||||
<>
|
||||
{state.status === 'loading' && (
|
||||
<div className={css.status}>正在刷新模型列表…</div>
|
||||
<div className={css.status}>{t('status.loading')}</div>
|
||||
)}
|
||||
{state.error !== null && (
|
||||
<div className={css.error}>
|
||||
<span>模型操作失败:{state.error}</span>
|
||||
<button type="button" className={css.retry} onClick={() => { load() }}>重试</button>
|
||||
<span>{t('error.action', { message: state.error })}</span>
|
||||
<button type="button" className={css.retry} onClick={() => { load() }}>{t('retry')}</button>
|
||||
</div>
|
||||
)}
|
||||
{state.failures.map(failure => (
|
||||
<div className={css.warning} key={failure.id}>
|
||||
<span>{failure.name} 加载失败:{failure.message}</span>
|
||||
<button type="button" className={css.retry} onClick={() => { load() }}>重试</button>
|
||||
<span>{t('warning.groupLoad', { name: failure.name, message: failure.message })}</span>
|
||||
<button type="button" className={css.retry} onClick={() => { load() }}>{t('retry')}</button>
|
||||
</div>
|
||||
))}
|
||||
<div className={clsx(css.groups, 'scrollable')}>
|
||||
@@ -267,7 +273,7 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje
|
||||
<span className={css.description}>{model.description}</span>
|
||||
)}
|
||||
{model.unlisted === true && (
|
||||
<span className={css.unlisted}>当前模型 · 未列入目录</span>
|
||||
<span className={css.unlisted}>{t('option.currentUnlisted')}</span>
|
||||
)}
|
||||
</span>
|
||||
<span className={css.check}>
|
||||
@@ -281,7 +287,7 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje
|
||||
})}
|
||||
</div>
|
||||
{state.status === 'ready' && choices.length === 0 && (
|
||||
<div className={css.empty}>没有可用的模型。</div>
|
||||
<div className={css.empty}>{t('empty.models')}</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@@ -290,12 +296,12 @@ export function ModelSelect({ locked, directory, load, select }: ModelSelectInje
|
||||
<>
|
||||
{state.error !== null && (
|
||||
<div className={css.error}>
|
||||
<span>模型操作失败:{state.error}</span>
|
||||
<button type="button" className={css.retry} onClick={() => { load() }}>重新加载</button>
|
||||
<span>{t('error.action', { message: state.error })}</span>
|
||||
<button type="button" className={css.retry} onClick={() => { load() }}>{t('action.reload')}</button>
|
||||
</div>
|
||||
)}
|
||||
{effortChoices.length === 0
|
||||
? <div className={css.empty}>当前模型未提供推理等级。</div>
|
||||
? <div className={css.empty}>{t('empty.efforts')}</div>
|
||||
: effortChoices.map(level => (
|
||||
<button
|
||||
ref={itemRef()}
|
||||
|
||||
@@ -14,15 +14,27 @@ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { CommandServiceContract, SelectOption } from '@deepseek-ai/dsh-client-ui-command/client'
|
||||
// Type-only: pulls the ui-conversation SlotMap merge (the input.model seat).
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ModelDirectoryState } from './directory.ts'
|
||||
import { ModelService } from './service.ts'
|
||||
import type { ModelSelectInjected } from './slots.ts'
|
||||
import { ModelSelect } from './ModelSelect.tsx'
|
||||
import { en, zh, type ModelKey } from './locales.ts'
|
||||
|
||||
export { ModelDirectory } from './directory.ts'
|
||||
export type { ModelDirectoryState } from './directory.ts'
|
||||
export { ModelService } from './service.ts'
|
||||
export type { ModelSelectInjected } from './slots.ts'
|
||||
export type { ModelKey } from './locales.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface LocaleNamespaceMap {
|
||||
/** The model selection surfaces' copy (/model popup + composer seat). */
|
||||
model: ModelKey
|
||||
}
|
||||
}
|
||||
|
||||
/** One selectable row's id: an opaque row key (resolved by lookup, never parsed). */
|
||||
function rowId(providerId: string, modelId: string): string {
|
||||
@@ -30,7 +42,7 @@ function rowId(providerId: string, modelId: string): string {
|
||||
}
|
||||
|
||||
/** Flatten the directory into popup rows; failure rows are listed for visibility but never selectable. */
|
||||
function optionsOf(directory: SessionModels): SelectOption[] {
|
||||
function optionsOf(directory: SessionModels, t: TranslateNS<'model'>): SelectOption[] {
|
||||
const rows: SelectOption[] = []
|
||||
for (const group of directory.groups) {
|
||||
for (const model of group.models) {
|
||||
@@ -38,7 +50,7 @@ function optionsOf(directory: SessionModels): SelectOption[] {
|
||||
id: rowId(group.id, model.id),
|
||||
label: model.name,
|
||||
detail: model.unlisted === true
|
||||
? `${group.name} · 未列入目录`
|
||||
? t('option.unlisted', { group: group.name })
|
||||
: model.description !== undefined ? `${group.name} · ${model.description}` : group.name,
|
||||
...(directory.current.provider === group.id && directory.current.model === model.id
|
||||
? { active: true } : {}),
|
||||
@@ -46,7 +58,11 @@ function optionsOf(directory: SessionModels): SelectOption[] {
|
||||
}
|
||||
}
|
||||
for (const failure of directory.failures) {
|
||||
rows.push({ id: `failure/${failure.id}`, label: failure.name, detail: `目录加载失败:${failure.message}` })
|
||||
rows.push({
|
||||
id: `failure/${failure.id}`,
|
||||
label: failure.name,
|
||||
detail: t('option.loadError', { message: failure.message }),
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
@@ -76,28 +92,40 @@ function targetOf(state: ModelDirectoryState, id: string): ModelTarget | undefin
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Required services: the contribution registry, the seat's slot registry, and the service's own faces. */
|
||||
export const inject = ['command', 'connection', 'sessions', 'slots']
|
||||
/** Dictionary namespace owned by this plugin. */
|
||||
const NS = 'model'
|
||||
|
||||
/** Required services: the contribution registry, the seat's slot registry, locale, and the service's own faces. */
|
||||
export const inject = ['command', 'connection', 'locale', 'sessions', 'slots']
|
||||
|
||||
/**
|
||||
* Client plugin body: mount ModelService, then register the /model popup
|
||||
* contribution and the composer model seat over it.
|
||||
* Client plugin body: mount ModelService, register the `model` dictionaries,
|
||||
* then register the /model popup contribution and the composer model seat
|
||||
* over the service.
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
ctx.plugin(ModelService)
|
||||
|
||||
// Entry 1: the /model popupSelect over the shared directory.
|
||||
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-model: dictionaries')
|
||||
|
||||
// Non-slot faces (the command description, the popup option builder) read
|
||||
// through the bound translate; the seat component reads the standard seat.
|
||||
const t = ctx.locale.bind(NS)
|
||||
|
||||
// Entry 1: the /model popupSelect over the shared directory. The command
|
||||
// description is registry-held text: it reads t() once at registration and
|
||||
// refreshes only on re-registration, not on locale change.
|
||||
ctx.inject(['command', 'models'], (scope: ClientContext) => {
|
||||
const command = scope.get('command') as CommandServiceContract
|
||||
const models = scope.models
|
||||
scope.effect(() => command.register({
|
||||
name: 'model',
|
||||
description: 'Select the model for this conversation',
|
||||
description: t('command.description'),
|
||||
available: () => true,
|
||||
ui: {
|
||||
kind: 'popupSelect',
|
||||
options: async session => optionsOf(await models.directoryFor(session.sessionId).load()),
|
||||
options: async session => optionsOf(await models.directoryFor(session.sessionId).load(), t),
|
||||
onSelect: async (option, session) => {
|
||||
const directory = models.directoryFor(session.sessionId)
|
||||
const target = targetOf(directory.store.getSnapshot(), option.id)
|
||||
@@ -117,6 +145,7 @@ export function apply(ctx: ClientContext): void {
|
||||
const models = scope.models
|
||||
scope.effect(() => scope.slots.register({
|
||||
name: 'conversation.input.model',
|
||||
locale: NS,
|
||||
inject: (sessionId): ModelSelectInjected => {
|
||||
const directory = models.directoryFor(sessionId)
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/** `model` namespace dictionaries. */
|
||||
|
||||
/** Simplified Chinese dictionary (the key-set source of truth). */
|
||||
export const zh = {
|
||||
'command.description': '选择本会话使用的模型',
|
||||
'option.unlisted': '{group} · 未列入目录',
|
||||
'option.loadError': '目录加载失败:{message}',
|
||||
'trigger.fallback': '选择模型',
|
||||
'trigger.aria': '选择模型,当前 {model}',
|
||||
'trigger.ariaEffort': '选择模型,当前 {model},推理等级 {effort}',
|
||||
'menu.aria': '模型与推理等级',
|
||||
'menu.model': '模型',
|
||||
'menu.effort': '推理等级',
|
||||
'effort.providerDefault': '服务商默认',
|
||||
'status.loading': '正在刷新模型列表…',
|
||||
'error.action': '模型操作失败:{message}',
|
||||
'action.reload': '重新加载',
|
||||
'warning.groupLoad': '{name} 加载失败:{message}',
|
||||
'option.currentUnlisted': '当前模型 · 未列入目录',
|
||||
'empty.models': '没有可用的模型。',
|
||||
'empty.efforts': '当前模型未提供推理等级。',
|
||||
} satisfies Record<string, string>
|
||||
|
||||
/** The model namespace key union. */
|
||||
export type ModelKey = keyof typeof zh
|
||||
|
||||
/** English dictionary, checked complete against the zh key set. */
|
||||
export const en = {
|
||||
'command.description': 'Select the model for this conversation',
|
||||
'option.unlisted': '{group} · Not in catalog',
|
||||
'option.loadError': 'Catalog failed to load: {message}',
|
||||
'trigger.fallback': 'Select model',
|
||||
'trigger.aria': 'Select model, current {model}',
|
||||
'trigger.ariaEffort': 'Select model, current {model}, reasoning effort {effort}',
|
||||
'menu.aria': 'Model and reasoning effort',
|
||||
'menu.model': 'Model',
|
||||
'menu.effort': 'Effort',
|
||||
'effort.providerDefault': 'Provider default',
|
||||
'status.loading': 'Refreshing model list…',
|
||||
'error.action': 'Model operation failed: {message}',
|
||||
'action.reload': 'Reload',
|
||||
'warning.groupLoad': '{name} failed to load: {message}',
|
||||
'option.currentUnlisted': 'Current model · Not in catalog',
|
||||
'empty.models': 'No models available.',
|
||||
'empty.efforts': 'This model provides no reasoning effort levels.',
|
||||
} satisfies Record<ModelKey, string>
|
||||
@@ -12,6 +12,7 @@ import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createScope } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { ModelTarget } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { CommandContribution, SelectOption } from '@deepseek-ai/dsh-client-ui-command/client'
|
||||
import type { ModelSelectInjected } from '../src/client/slots.ts'
|
||||
@@ -79,14 +80,18 @@ async function bench() {
|
||||
return () => { contribution = undefined }
|
||||
},
|
||||
})
|
||||
const seats = new Map<string, { inject: ((sessionId: SessionId) => ModelSelectInjected) | undefined }>()
|
||||
const seats = new Map<string, {
|
||||
inject: ((sessionId: SessionId) => ModelSelectInjected) | undefined
|
||||
locale: string | undefined
|
||||
}>()
|
||||
ctx.provide('slots', {
|
||||
register(options: { name: string; inject?: (sessionId: SessionId) => ModelSelectInjected }) {
|
||||
seats.set(options.name, { inject: options.inject })
|
||||
register(options: { name: string; locale?: string; inject?: (sessionId: SessionId) => ModelSelectInjected }) {
|
||||
seats.set(options.name, { inject: options.inject, locale: options.locale })
|
||||
return () => { seats.delete(options.name) }
|
||||
},
|
||||
})
|
||||
ctx.provide('conversation', {})
|
||||
ctx.provide('locale', new LocaleService(ctx))
|
||||
const scopes = new Map<SessionId, Context>()
|
||||
ctx.provide('sessions', { scope: (id: SessionId) => scopes.get(id) })
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
@@ -114,6 +119,8 @@ describe('ui-model dual entry', () => {
|
||||
expect(b.contribution().name).toBe('model')
|
||||
expect(b.contribution().ui.kind).toBe('popupSelect')
|
||||
expect(b.seat().inject).toBeTypeOf('function')
|
||||
// Copy rides the standard locale seat.
|
||||
expect(b.seat().locale).toBe('model')
|
||||
})
|
||||
|
||||
it('popup options mark the host current active with the provider group in the detail', async () => {
|
||||
|
||||
@@ -3,8 +3,22 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/re
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ModelTarget } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ComponentProps } from 'react'
|
||||
import type { ModelDirectoryState } from '../src/client/directory.ts'
|
||||
import { ModelSelect } from '../src/client/ModelSelect.tsx'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
|
||||
// The seat's key domain is model ∪ common; the stub mirrors the real lookup
|
||||
// chain: package dictionary, then common vocabulary, then the key.
|
||||
const t: ComponentProps<typeof ModelSelect>['t'] = (key, params) => {
|
||||
const template = (zh as Record<string, string>)[key]
|
||||
?? (commonZh as Record<string, string>)[key]
|
||||
?? key
|
||||
return params === undefined
|
||||
? template
|
||||
: template.replace(/\{(\w+)\}/g, (match, name: string) => name in params ? String(params[name]) : match)
|
||||
}
|
||||
|
||||
const reasoning = {
|
||||
efforts: [
|
||||
@@ -44,13 +58,14 @@ describe('ModelSelect reasoning effort', () => {
|
||||
directory={directory}
|
||||
load={vi.fn()}
|
||||
select={select}
|
||||
t={t}
|
||||
/>)
|
||||
|
||||
const trigger = screen.getByRole('button', {
|
||||
name: '选择模型,当前 DeepSeek-V4-Flash,推理等级 High',
|
||||
})
|
||||
fireEvent.click(trigger)
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /Effort/ }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /推理等级/ }))
|
||||
expect(screen.getAllByRole('menuitemradio').map(item => item.textContent))
|
||||
.toEqual(['Off', 'High', 'MaxLargest budget'])
|
||||
|
||||
@@ -83,13 +98,14 @@ describe('ModelSelect reasoning effort', () => {
|
||||
directory={directory}
|
||||
load={vi.fn()}
|
||||
select={vi.fn().mockResolvedValue(true)}
|
||||
t={t}
|
||||
/>)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', {
|
||||
name: '选择模型,当前 Model,推理等级 Provider default',
|
||||
name: '选择模型,当前 Model,推理等级 服务商默认',
|
||||
}))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /Effort/ }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /推理等级/ }))
|
||||
expect(screen.getAllByRole('menuitemradio').map(item => item.textContent))
|
||||
.toEqual(['Provider default', 'Standard'])
|
||||
.toEqual(['服务商默认', 'Standard'])
|
||||
})
|
||||
})
|
||||
@@ -14,6 +14,9 @@
|
||||
{
|
||||
"path": "../connection"
|
||||
},
|
||||
{
|
||||
"path": "../locale"
|
||||
},
|
||||
{
|
||||
"path": "../runtime"
|
||||
},
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-ui-conversation"
|
||||
],
|
||||
"platform": "web"
|
||||
@@ -44,11 +45,13 @@
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-client-locale": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
|
||||
@@ -4,7 +4,10 @@ import {
|
||||
Button, IconCheckOutline16, IconChevronLeftOutline14, IconChevronRightOutline14,
|
||||
IconCloseOutline16, IconEditOutline16, MarkdownText,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { PendingQuestion, type QuestionAnswer, type QuestionComposerProps } from './contract/slots.ts'
|
||||
import {
|
||||
PendingQuestion,
|
||||
type QuestionAnswer, type QuestionComposerProps,
|
||||
} from './contract/slots.ts'
|
||||
import css from './QuestionComposer.module.css'
|
||||
|
||||
interface DraftAnswer {
|
||||
@@ -52,17 +55,20 @@ export function QuestionComposer(props: QuestionComposerProps) {
|
||||
// Domain-face mint rides the carrier's stable identity (never minted in a
|
||||
// select/render dispatch — per-dispatch minting would churn memo identity).
|
||||
const question = useMemo(() => new PendingQuestion(props.matched), [props.matched])
|
||||
return <QuestionFlow key={question.key} pending={question} />
|
||||
return <QuestionFlow key={question.key} pending={question} t={props.t} />
|
||||
}
|
||||
|
||||
function QuestionFlow({ pending }: { pending: PendingQuestion }) {
|
||||
function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick<QuestionComposerProps, 't'>) {
|
||||
const questions = pending.questions
|
||||
const [index, setIndex] = useState(0)
|
||||
const [drafts, setDrafts] = useState<DraftAnswer[]>(() => questions.map(question => ({
|
||||
selected: [], custom: '', customOpen: (question.options?.length ?? 0) === 0, skipped: false,
|
||||
})))
|
||||
const [busy, setBusy] = useState<'answer' | 'cancel' | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
// Validation feedback is stored as a dictionary KEY and translated at
|
||||
// render, so already-shown feedback follows a locale switch; runtime
|
||||
// failure messages (finished strings from the wire) pass through verbatim.
|
||||
const [error, setError] = useState<{ key: 'error.incomplete' | 'error.unanswered' } | { text: string } | null>(null)
|
||||
// index stays in bounds (every setIndex site clamps) and drafts mirrors questions 1:1.
|
||||
// oxlint-disable-next-line typescript/no-non-null-assertion
|
||||
const question = questions[index]!
|
||||
@@ -75,7 +81,7 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
|
||||
setError(null)
|
||||
void pending.cancel().catch((cause: unknown) => {
|
||||
setBusy(null)
|
||||
setError(cause instanceof Error ? cause.message : String(cause))
|
||||
setError({ text: cause instanceof Error ? cause.message : String(cause) })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -111,7 +117,7 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
|
||||
const missing = values.findIndex(item => !completed(item))
|
||||
if (missing >= 0) {
|
||||
setIndex(missing)
|
||||
setError('请先完成这道问题。')
|
||||
setError({ key: 'error.incomplete' })
|
||||
return
|
||||
}
|
||||
const answer: QuestionAnswer = {
|
||||
@@ -130,13 +136,13 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
|
||||
setError(null)
|
||||
void pending.answer(answer).catch((cause: unknown) => {
|
||||
setBusy(null)
|
||||
setError(cause instanceof Error ? cause.message : String(cause))
|
||||
setError({ text: cause instanceof Error ? cause.message : String(cause) })
|
||||
})
|
||||
}
|
||||
|
||||
const continueFlow = (): void => {
|
||||
if (!answered(draft)) {
|
||||
setError('请选择一个选项或填写自定义答案。')
|
||||
setError({ key: 'error.unanswered' })
|
||||
return
|
||||
}
|
||||
if (index < questions.length - 1) {
|
||||
@@ -174,28 +180,30 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
|
||||
<span>{question.multiSelect === true
|
||||
? parseQuestionTitle(question.question)
|
||||
: question.question}</span>
|
||||
{question.multiSelect === true && <span className={css.multiSelectHint}>可多选</span>}
|
||||
{question.multiSelect === true && (
|
||||
<span className={css.multiSelectHint}>{t('title.multi')}</span>
|
||||
)}
|
||||
</h2>
|
||||
</div>
|
||||
<div className={css.headerActions}>
|
||||
<span className={css.progress}>{index + 1} / {questions.length}</span>
|
||||
<button
|
||||
type="button" className={css.iconButton} aria-label="上一题"
|
||||
type="button" className={css.iconButton} aria-label={t('nav.prev')}
|
||||
disabled={index === 0 || busy !== null}
|
||||
onClick={() => { setIndex(index - 1); setError(null) }}
|
||||
>
|
||||
<IconChevronLeftOutline14 />
|
||||
</button>
|
||||
<button
|
||||
type="button" className={css.iconButton} aria-label="下一题"
|
||||
type="button" className={css.iconButton} aria-label={t('nav.next')}
|
||||
disabled={index === questions.length - 1 || busy !== null}
|
||||
onClick={() => { setIndex(index + 1); setError(null) }}
|
||||
>
|
||||
<IconChevronRightOutline14 />
|
||||
</button>
|
||||
<button
|
||||
type="button" className={css.iconButton} aria-label="放弃整组问题"
|
||||
title="放弃整组问题"
|
||||
type="button" className={css.iconButton} aria-label={t('nav.cancel')}
|
||||
title={t('nav.cancel')}
|
||||
disabled={busy !== null} onClick={cancelFlow}
|
||||
>
|
||||
<IconCloseOutline16 />
|
||||
@@ -230,7 +238,9 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
|
||||
<span className={css.optionCopy}>
|
||||
<span className={css.optionLine}>
|
||||
<span className={css.optionLabel}>{display.label}</span>
|
||||
{display.recommended && <span className={css.badge}>推荐</span>}
|
||||
{display.recommended && (
|
||||
<span className={css.badge}>{t('option.recommended')}</span>
|
||||
)}
|
||||
{option.description !== undefined && (
|
||||
<span className={css.description}>{option.description}</span>
|
||||
)}
|
||||
@@ -255,7 +265,7 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
|
||||
aria-expanded={draft.customOpen}
|
||||
>
|
||||
<span className={css.number}><IconEditOutline16 /></span>
|
||||
<span>其他,请填写自定义答案</span>
|
||||
<span>{t('option.custom')}</span>
|
||||
</button>
|
||||
)}
|
||||
{draft.customOpen && (
|
||||
@@ -265,7 +275,7 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
|
||||
value={draft.custom}
|
||||
disabled={busy !== null}
|
||||
rows={2}
|
||||
placeholder="输入你的答案"
|
||||
placeholder={t('custom.placeholder')}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value
|
||||
updateDraft(current => ({
|
||||
@@ -285,18 +295,18 @@ function QuestionFlow({ pending }: { pending: PendingQuestion }) {
|
||||
</div>
|
||||
|
||||
<footer className={css.footer}>
|
||||
<div className={css.feedback} role="status">{error}</div>
|
||||
<div className={css.feedback} role="status">{error === null ? null : 'key' in error ? t(error.key) : error.text}</div>
|
||||
<div className={css.footerActions}>
|
||||
<Button variant="ghost" size="sm" disabled={busy !== null} onClick={skipQuestion}>
|
||||
跳过本题
|
||||
{t('action.skip')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary" size="sm"
|
||||
disabled={busy !== null || !answered(draft)} onClick={continueFlow}
|
||||
>
|
||||
{busy === 'answer'
|
||||
? '正在提交…'
|
||||
: index === questions.length - 1 ? '提交' : '下一题'}
|
||||
? t('submitting')
|
||||
: index === questions.length - 1 ? t('submit') : t('action.next')}
|
||||
</Button>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* cancelled error encoding, receipt checks — lives HERE, with the package
|
||||
* that consumes it.
|
||||
*/
|
||||
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
// Also pulls ui-conversation's SlotMap merge (the 'conversation.composer'
|
||||
// entry) into every program that sees this contract, so PropsRuntime resolves.
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
@@ -70,8 +70,9 @@ export class PendingQuestion {
|
||||
/**
|
||||
* Full component props: the framework runtime share (chain currency +
|
||||
* session/global standard kit) plus the chain `matched` share — the entry's
|
||||
* selector result, already narrowed to the question carrier. No injected
|
||||
* share: the carrier plus the domain face above carry the whole behavior
|
||||
* surface.
|
||||
* selector result, already narrowed to the question carrier — plus the
|
||||
* standard locale seat; the carrier plus the domain face above carry the
|
||||
* whole behavior surface.
|
||||
*/
|
||||
export type QuestionComposerProps = PropsRuntime<'conversation.composer'> & { matched: QuestionWait }
|
||||
export type QuestionComposerProps =
|
||||
PropsRuntime<'conversation.composer'> & { matched: QuestionWait } & PropsLocale<'question'>
|
||||
Loaded 100 of 204 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in New Issue
Block a user