diff --git a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml
index 0d6a23bebe..8193e5e839 100644
--- a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md
-2026-06-21-bounded-llm-request-recovery.md: 83d47e3a7d91bbcd2ceaf7b11cf13316142eb3ed
-2026-06-21-bounded-llm-request-recovery.zh.md: 00dcbad3d1023ad33a22297bfe938b94bce839d4
+2026-06-21-bounded-llm-request-recovery.md: 5c76ed5d754ea40f41dff78cb56ee7fc139a32b1
+2026-06-21-bounded-llm-request-recovery.zh.md: 1fa56f3fe0405cab663c2843d423a78d910170dd
diff --git a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md
index 83d47e3a7d..5c76ed5d75 100644
--- a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md
+++ b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md
@@ -60,11 +60,11 @@ For an eligible failure with budget remaining, the one-based transient retry cou
The plugin owns a lifetime `AbortController` and tracks every active recovery callback, including delegated waterfall work and backoff. Effect cleanup first unregisters the listener, then aborts and awaits the active callbacks; abort wins over a late delegated retry decision, and a captured callback can neither retry nor enter the rest of its waterfall after disposal. This makes HMR disposal quiescent even though Cordis has already captured the listener.
-Before sleeping, `dsh-llm-retry` appends one non-surface `llm/retry` session event containing the turn, failed step, provider, policy mode, complete resolved-policy key, provider-policy retry number, mode-specific finite maximum when present, scheduled delay, and `LlmFailure`. The key sorts the code set and separates retry histories when a provider route is replaced by a behaviorally different same-mode policy. The plugin owns the `SessionEventMap` augmentation; `dsh-session` remains generic persistence and does not absorb the optional policy's vocabulary. The event says what was scheduled, not that the next request completed; cancellation during the delay is subsequently visible on `turn/end`. The event ships only with a production renderer and replay/snapshot coverage, because its purpose is operational state rather than trace collection.
+Before sleeping, `dsh-llm-retry` appends one non-surface `llm/retry` session event containing the turn, failed step, provider, policy mode, complete resolved-policy key, provider-policy retry number, mode-specific finite maximum when present, scheduled delay, and `LlmFailure`. The key sorts the code set and separates retry histories when a provider route is replaced by a behaviorally different same-mode policy. The plugin owns the `SessionEventMap` augmentation and exports the payload through its browser-safe `./types` subpath; `dsh-session` remains generic persistence and does not absorb the optional policy's vocabulary. The event says what was scheduled, not that the next request completed; cancellation during the delay is subsequently visible on `turn/end`. The event ships with production renderers and replay/snapshot coverage, because its purpose is operational state rather than trace collection.
The listener calls `next()` for a non-transient code, an exhausted policy budget, or an over-cap provider delay. This preserves composition with context-overflow recovery and later policy plugins. For an owned failure it records and awaits the delay, then returns `{ kind: 'retry' }` without delegating. Turn cancellation and plugin disposal end the wait without returning a retry; the loop's cancellation/disposal checks remain authoritative.
-The agent-spine demo bundle loads the plugin so the shared stdio/TUI, one-shot CLI, and ACP example compositions use the same provider-routed policy. Library consumers retain explicit plugin composition: omitting the plugin leaves request failures terminal.
+The agent-spine demo bundle loads the plugin so the shared stdio/TUI, one-shot CLI, ACP, and headless example compositions use the same provider-routed policy. The shipped Web composition also loads it, so browser and command-line requests use the same provider defaults. Library consumers retain explicit plugin composition: omitting the plugin leaves request failures terminal.
### Make one layer own visible attempts
@@ -82,7 +82,7 @@ Boundary tests prove termination at both actual transports. The hand-written ada
### Keep attempts separate in the existing log
-A failed attempt may leave `assistant/chunk` events in its closed step, but it never appends `assistant/message` and never dispatches a tool. A retry closes the failed turn, opens the next numbered turn, reconstructs the request from the durable surface, and produces its own chunks. UIs may render live chunks while a step is open, then mark or clear that transient view when `llm/retry` identifies the failed step or `turn/end` records failure; message derivation continues to ignore the failed chunks.
+A failed attempt may leave `assistant/chunk` events in its closed step, but it never appends `assistant/message` and never dispatches a tool. A retry closes the failed turn, opens the next numbered turn, reconstructs the request from the durable surface, and produces its own chunks. UIs may render live chunks while a step is open, then mark or clear that transient view when `llm/retry` identifies the failed step or `turn/end` records failure. Web validates the complete retry payload contract, clears the failed partial at `llm/retry`, projects consecutive retry-turn events into one stable row updated to the latest attempt, and derives scheduled, started, or cancelled status from subsequent turn facts. Its countdown anchors the scheduled delay to browser receipt rather than the Host event clock, uses ceiling-rounded seconds with a one-second floor, animates only while unresolved, and keeps exact latest failure details collapsed behind the row. Retry nodes anchor their own trajectory turn even when the failed attempt has no assistant node. Message derivation continues to ignore the failed chunks, and Web applies the same projection during history rebuild so refreshing cannot resurrect discarded partials or duplicate retry rows.
If recovery is exhausted, the final failure is stored once on `turn/end.reason` with the structured facts. If transient recovery continues, `llm/retry` is the durable home for that attempt's failure and delay. No standalone final-error event or response-id vocabulary is added.
@@ -116,7 +116,7 @@ If recovery is exhausted, the final failure is stored once on `turn/end.reason`
- Pure unit tests cover transient-code selection, exponential backoff and jitter bounds, valid and over-cap `Retry-After`, exhausted budgets, deterministic timer/random seams, and abort during backoff.
- Real agent-loop tests cover failure before chunks, partial chunks then failure, thrown and in-band failures, retry to success in a new turn, exhaustion to structured `turn/end.reason`, and composition with `dsh-compact-basic` context-overflow recovery.
- The partial-chunk integration test proves failed chunks remain attributed to the failed step, no assistant message or tool side effect is committed for that step, and the successful retry has distinct provenance.
-- The plugin-owned `llm/retry` event is non-surface, survives JSONL and SQLite round trips, is ignored by message derivation, and drives TUI retraction plus scheduled-retry rendering. Keyless snapshots cover scheduling, cancellation, success, and exhaustion; ACP automation snapshots confirm that a discarded attempt stays off the wire while the recovered reply is emitted.
+- The plugin-owned `llm/retry` event is non-surface, survives JSONL and SQLite round trips, is ignored by message derivation, and drives TUI and Web retraction plus scheduled-retry rendering. Client tests cover complete wire validation, clock-independent countdown, cancellation versus completed retry labels, and trajectory attribution; keyless UI snapshots cover Web scheduling and success, a real Web composition test covers partial transport failure through recovery, and ACP automation snapshots confirm that a discarded attempt stays off the wire while the recovered reply is emitted.
- Idle-watchdog tests prove the stable signal is rearmed only while `next()` is outstanding, disarmed during consumer think time and in `finally`, and classified separately from a total-call deadline and an earlier caller abort; adapter tests prove the signal stops the underlying request rather than merely detaching it.
- Direct `ctx.llm.stream()` callers remain single-attempt and receive the same structured failure facts.
diff --git a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md
index 00dcbad3d1..1fa56f3fe0 100644
--- a/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md
+++ b/.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md
@@ -60,11 +60,11 @@ agent loop(智能体循环)会保留 `RequestError` 作为该精确的错误
插件拥有一个全生命期 `AbortController`,并跟踪每个活跃的恢复回调,包括委托的 waterfall(瀑布式事件)工作与退避。effect 清理会先注销监听器,再中止并等待活跃回调;中止会胜过较晚到达的委托重试决策,被捕获的回调在插件释放后既不能重试,也不能进入其 waterfall 的剩余部分。尽管 Cordis 已捕获该监听器,此设计仍能使 HMR(热模块替换)释放达到完全停稳。
-休眠前,`dsh-llm-retry` 会追加一条不进入表层的 `llm/retry` 会话事件,其中包含轮次、失败步骤、提供方、策略 mode、完整的解析策略 key、提供方策略重试编号、该 mode 存在时的有限上限、计划延迟和 `LlmFailure`。该 key 会对 code 集排序,并在提供方路由被行为不同但 mode 相同的策略替换时分隔重试历史。该插件拥有 `SessionEventMap` 声明合并;`dsh-session` 继续负责通用持久化,不会吸收可选策略的词汇。事件记录已安排的内容,而不是下一个请求已完成;延迟期间取消随后会在 `turn/end` 中可见。因为该事件的目的是表示运行状态,而不是收集跟踪数据,所以它仅与生产渲染器及回放/快照覆盖一起交付。
+休眠前,`dsh-llm-retry` 会追加一条不进入表层的 `llm/retry` 会话事件,其中包含轮次、失败步骤、提供方、策略 mode、完整的解析策略 key、提供方策略重试编号、该 mode 存在时的有限上限、计划延迟和 `LlmFailure`。该 key 会对 code 集排序,并在提供方路由被行为不同但 mode 相同的策略替换时分隔重试历史。该插件拥有 `SessionEventMap` 声明合并,并通过其浏览器安全的 `./types` 子路径导出载荷;`dsh-session` 继续负责通用持久化,不会吸收可选策略的词汇。事件记录已安排的内容,而不是下一个请求已完成;延迟期间取消随后会在 `turn/end` 中可见。因为该事件的目的是表示运行状态,而不是收集跟踪数据,所以它会与生产渲染器及回放/快照覆盖一起交付。
对非暂时性 code、耗尽的策略预算或超出上限的提供方延迟,监听器会调用 `next()`。这保留了与上下文溢出恢复及后续策略插件的组合能力。对自身处理的失败,它会记录并等待延迟,然后在不委托的情况下返回 `{ kind: 'retry' }`。轮次取消和插件释放会结束等待且不返回重试动作,此后仍以循环的取消/释放检查为准。
-agent-spine 演示组合包加载该插件,因此共享的 stdio/TUI、一次性 CLI(命令行界面)和 ACP(Agent Client Protocol)示例组合使用同一套按提供方路由的策略。库消费方仍需显式组合插件:省略该插件时,请求失败保持终态。
+agent-spine 演示组合包加载该插件,因此共享的 stdio/TUI、一次性 CLI(命令行界面)、ACP(Agent Client Protocol)和 headless 示例组合使用同一套按提供方路由的策略。随产品交付的 Web 组合也会加载该插件,因此浏览器请求与命令行请求使用相同的提供方默认值。库消费方仍需显式组合插件:省略该插件时,请求失败保持终态。
### 由单一层负责可见的尝试
@@ -82,7 +82,7 @@ agent-spine 演示组合包加载该插件,因此共享的 stdio/TUI、一次
### 在现有日志中分隔尝试
-一次失败尝试可以在已关闭的步骤中留下 `assistant/chunk` 事件,但绝不会追加 `assistant/message`,也不会分发工具。重试会关闭失败轮次,开启下一个编号轮次,从持久表层重建请求,并生成自己的分片。步骤仍处于打开状态时,UI 可以渲染实时分片;当 `llm/retry` 标识失败步骤,或 `turn/end` 记录失败时,UI 再标记或清除这份暂时视图。消息派生仍会忽略失败分片。
+一次失败尝试可以在已关闭的步骤中留下 `assistant/chunk` 事件,但绝不会追加 `assistant/message`,也不会分发工具。重试会关闭失败轮次,开启下一个编号轮次,从持久表层重建请求,并生成自己的分片。步骤仍处于打开状态时,UI 可以渲染实时分片;当 `llm/retry` 标识失败步骤,或 `turn/end` 记录失败时,UI 再标记或清除这份暂时视图。Web 会验证完整的重试载荷契约,在 `llm/retry` 到达时清除失败的部分输出,将连续重试轮次的事件投影为稳定的一行,并用最新一次尝试更新该行,再从后续轮次事实派生 scheduled、started 或 cancelled 状态。倒计时以浏览器收到事件的时刻为计划延迟的起点,而不是使用 Host 事件时钟;它按向上取整且不低于 1 秒的秒数显示,仅在重试尚未结束时显示动画,并把最近一次失败的准确详情折叠在该行之后。即使失败尝试没有 assistant 节点,重试节点也会锚定自身的轨迹轮次。消息派生仍会忽略失败分片;Web 在重建历史时也会应用同一投影,因此刷新页面不会让已丢弃的部分输出重新出现,也不会生成重复的重试行。
如果恢复预算耗尽,最终失败会连同结构化事实在 `turn/end.reason` 中存储一次。如果暂时性恢复继续,`llm/retry` 就是该次尝试的失败与延迟的持久归属位置。本决策不增加独立的最终错误事件或响应 id 词汇。
@@ -116,7 +116,7 @@ agent-spine 演示组合包加载该插件,因此共享的 stdio/TUI、一次
- 纯单元测试覆盖暂时性 code 选择、指数退避和抖动边界、有效及超出上限的 `Retry-After`、耗尽的预算、确定性定时器/随机数 seam,以及退避期间中止。
- 真实 agent-loop 测试覆盖分片前失败、部分分片后失败、抛出及带内失败、在新轮次中重试至成功、耗尽后写入结构化 `turn/end.reason`,以及与 `dsh-compact-basic` 上下文溢出恢复的组合。
- 部分分片集成测试证明:失败分片仍归属于失败步骤,该步骤不会提交 assistant 消息或工具副作用,成功的重试具有不同的来源信息。
-- 插件拥有的不进入表层的 `llm/retry` 事件可在 JSONL 和 SQLite 往返后保留,被消息派生忽略,并驱动 TUI 撤回和计划重试渲染。无密钥快照覆盖调度、取消、成功和耗尽;ACP 自动化快照确认,被丢弃的尝试不会通过协议发出,而恢复后的回复会正常发出。
+- 插件拥有的不进入表层的 `llm/retry` 事件可在 JSONL 和 SQLite 往返后保留,被消息派生忽略,并驱动 TUI 和 Web 撤回及计划重试渲染。客户端测试覆盖完整的 wire 验证、独立于时钟的倒计时、已取消与已完成重试标签的区别以及轨迹归属;无密钥 UI 快照覆盖 Web 的调度与成功,真实 Web 组合测试覆盖部分传输失败直至恢复,ACP 自动化快照确认,被丢弃的尝试不会通过协议发出,而恢复后的回复会正常发出。
- 空闲看门狗测试证明:只有 `next()` 尚未完成时才会重新布防稳定信号;在消费方思考期间及 `finally` 中会解除布防;它与总调用 deadline 以及更早发生的调用方中止分开分类。适配器测试证明该信号会终止底层请求,而不只是与其脱离。
- `ctx.llm.stream()` 的直接调用方仍只尝试一次,并收到相同的结构化失败事实。
diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml
index 70618612a2..e603129715 100644
--- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md
-2026-07-19-gui-layering-and-rpc-protocol.md: b9718da4725316c64686adef24827e2984d8723d
-2026-07-19-gui-layering-and-rpc-protocol.zh.md: 2add148054e8f97c65600cd719fb4f8e0283f52d
+2026-07-19-gui-layering-and-rpc-protocol.md: b7081591cf7e5e3c586c74c5a71b4317376135cb
+2026-07-19-gui-layering-and-rpc-protocol.zh.md: 89557182ca7781f4fb59b8daf866aaca96cf20ee
diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md
index b9718da472..b7081591cf 100644
--- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md
+++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md
@@ -204,7 +204,7 @@ The same domain tree as `ApiProxy`, but unary methods **take the business payloa
| `callUnary` | mint → tap → POST full form → `serverResponseSchema` parse → **rpcId echo check** (mismatch throws) → tap → emit narrow form |
| `readSse` | streaming fetch (not EventSource), `\n\n` framing, `data:` concatenation, ServerRequest full-form parse, tap, emit narrow `RpcRequest` |
| `respond` | client-response passthrough (rpcId is an echo — never minted here); response body parsed by `rpcReceiptSchema` |
-| unary timeout | `AbortSignal.timeout` (default 30s, constructor-tunable); streams have no timeout (long-lived by nature) |
+| unary deadline | Ordinary unary calls use `AbortSignal.timeout` (default 30s, constructor-tunable); user-paced `host.pickDirectory` and `command.execute` omit that deadline but keep caller/connection cancellation; streams have no deadline |
| `resolveBase` | browser = same-origin origin; no-location environment (Node) = the `http://dsh.internal` fake authority |
### The instance-level envelope observation aspect
@@ -234,7 +234,7 @@ All four quadrant full forms pass through `onEnvelope`; the base implementation
## Consequences
-Every client shape consumes one contract: adding a unary method is a five-step mechanical change radiating from a single signature, swapping a carrier touches only a `doFetch` subclass, and every wire message is zod-validated, observable through the envelope tap, and reconcilable by rpcId. The accepted costs: two groups of packages need explicit tsconfig paths entries, and the reserved seams (fork/inject/task.list/listModels/hostInstanceId) stay dormant until a real consumer arrives.
+Every client shape consumes one contract: adding a unary method is a five-step mechanical change radiating from a single signature, swapping a carrier touches only a `doFetch` subclass, and every wire message is zod-validated, observable through the envelope tap, and reconcilable by rpcId. Ordinary unary calls remain bounded, while `host.pickDirectory` and `command.execute` may stay pending until the operation finishes or caller/connection cancellation arrives; this accepts that a non-cooperative user-paced operation can hang its request rather than treating valid operation duration as transport failure. The other accepted costs: two groups of packages need explicit tsconfig paths entries, and the reserved seams (fork/inject/task.list/listModels/hostInstanceId) stay dormant until a real consumer arrives.
## Alternatives considered
@@ -252,3 +252,4 @@ Every client shape consumes one contract: adding a unary method is a five-step m
| A DTO layer (a second wire-only structure set) | Core types reach the browser type-only at zero cost; a DTO is a permanent two-way synchronization tax |
| Cursor resumption (implementing mux since) | Reconnect = rebuild (opencode-style) covers all v1 needs; the signature keeps the seat, implementation waits for a real consumer |
| A createApiClient factory function (the original implementation) | Platform differences (transport/observation) are inheritance aspects, not parameters; the class family lets the fixture substitute at the protocol layer instead of wrapping a fake envelope |
+| Applying the 30-second transport deadline to `command.execute` | Command duration is operation work, not a transport-health budget; the deadline kills valid long-running handlers, while caller/connection cancellation already supplies the required stop path |
diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md
index 2add148054..89557182ca 100644
--- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md
+++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md
@@ -202,7 +202,7 @@ export type ResponseValue =
| `callUnary` | mint → tap → POST 全形 → `serverResponseSchema` parse → **rpcId 回显校验**(不符即 throw)→ tap → 吐窄形 |
| `readSse` | streaming fetch(非 EventSource)、`\n\n` 分帧、`data:` 拼接、ServerRequest 全形 parse、tap、吐窄形 `RpcRequest<帧>` |
| `respond` | client-response 透传(rpcId 是回填,此处不 mint);应答体 `rpcReceiptSchema` parse |
-| unary 超时 | `AbortSignal.timeout`(默认 30s,构造参数可调);流不设超时(长连接本性) |
+| unary 时限 | 普通 unary 调用使用 `AbortSignal.timeout`(默认 30s,构造参数可调);由用户掌控节奏的 `host.pickDirectory` 和 `command.execute` 不设该时限,但保留调用方/连接取消;流不设时限 |
| `resolveBase` | 浏览器=同源 origin;无 location 环境(Node)=`http://dsh.internal` 假 authority |
### 实例级 envelope 观测切面
@@ -232,7 +232,7 @@ export type ResponseValue =
## Consequences
-所有 client 形态消费同一契约:加一个 unary 方法是从单一签名辐射的五步机械改动,换载体只动一个 `doFetch` 子类,wire 上每条消息可 zod 校验、可经 envelope tap 观测、可按 rpcId 对账。接受的代价:两组包需要显式 tsconfig paths 条目;预留接缝(fork/inject/task.list/listModels/hostInstanceId)在真实消费者出现前保持休眠。
+所有 client 形态消费同一契约:加一个 unary 方法是从单一签名辐射的五步机械改动,换载体只动一个 `doFetch` 子类,wire 上每条消息可 zod 校验、可经 envelope tap 观测、可按 rpcId 对账。普通 unary 调用仍受时限约束,而 `host.pickDirectory` 与 `command.execute` 可保持挂起,直到操作完成或调用方/连接取消到来;若由用户掌控节奏的操作不自行结束,请求可能一直挂起,这是为避免把合理的操作时长视为传输失败而接受的代价。其余接受的代价:两组包需要显式 tsconfig paths 条目;预留接缝(fork/inject/task.list/listModels/hostInstanceId)在真实消费者出现前保持休眠。
## Alternatives considered
@@ -250,3 +250,4 @@ export type ResponseValue =
| DTO 层(wire 专用第二套结构) | core 类型 type-only 直达浏览器零成本;DTO 是永久的双向同步税 |
| cursor 续传(mux since 实装) | 重连=重建(opencode 同款)覆盖 v1 全部需求;签名留座,实装等真实消费者 |
| createApiClient 工厂函数(原实现) | 平台差异(传输/观测)是继承切面不是参数;类体系让 fixture 在协议层替换而不是包一层假信封 |
+| 对 `command.execute` 应用 30 秒传输时限 | 命令耗时属于操作本身,而非传输健康预算;该时限会终止本应继续运行的长时处理器,调用方/连接取消已提供所需的停止路径 |
diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml
index ac37214ebf..fedfc7e489 100644
--- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-web-config-plane.md
-2026-07-30-web-config-plane.md: 95ede6264026f7b32e95749d00fe841f57dbf867
-2026-07-30-web-config-plane.zh.md: 6e06b69218a405055621cbd40781f9fbda9f9e6b
+2026-07-30-web-config-plane.md: 6d1a8c242c1888ee4fca9e21ebc814f7a345d633
+2026-07-30-web-config-plane.zh.md: c3255cacfdd1f06d12f7bb2631f95273536b7ef9
diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md
index 95ede62640..6d1a8c242c 100644
--- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md
+++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md
@@ -20,7 +20,7 @@ PR1 made LLM adapter configuration restart-free at the seam, but the only writer
**A hand-written editor over a schema model layer.** `dsh-client-schema-form` rehydrates the wire's `toJSON()` envelope into live schemastery nodes for validation, path resolution, and immutable draft editing — but no generic rendering: the first cut shipped a full schema-driven form renderer, and the resulting page was an unstyled schema dump (every advanced field flattened onto the card, raw field names as labels, the `retryPolicy` unsupported-fallback in the main flow). The user chose the hand-written direction over adding a hint/grouping system, and a second round removed the reference input entirely: the card's primary field is one **API key** input, a whole-section provider without a configured key opens as its setup card, and the collapsed 自定义设置 fold carries the curated per-family extras (`baseURL` for both families, plus `reasoningEffort` for deepseek / `reasoning` for pi-ai), with every other field owned by `settings.yaml`. Validation still runs the rehydrated schema before writing, so a hand-coded field that drifts from its schema fails loud on save rather than silently.
-**The Models page is a three-domain join with seam-shaped apply semantics.** Rows are configured providers; the add card's select is the dormant directory remainder; badges come from route liveness. The key path stays reference-shaped without ever showing a reference: a typed key stores **write-only** through `credentials.set` under the profile's `apiKeyEnv`, deriving `_API_KEY` when none exists (the pi-ai profile records the derivation), so `settings.yaml` never carries a key value and the wholesale `settings.replace` a removal needs can never drop a sibling's secret. An edit without removals lands as a minimal `settings.update` merge patch; clearing a fold field back to inherited or deleting a row replaces the whole user section, because merge semantics cannot express removal.
+**The Models page is a three-domain join with seam-shaped apply semantics.** Rows are configured providers; the add card's select is the dormant directory remainder. Route liveness still gates readiness and invalidates the join, but the page does not render it as provider status because configuration presence and runtime availability are distinct. The key path stays reference-shaped without ever showing a reference: a typed key stores **write-only** through `credentials.set` under the profile's `apiKeyEnv`, deriving `_API_KEY` when none exists (the pi-ai profile records the derivation), so `settings.yaml` never carries a key value. Profile edits and removals land as minimal path-addressed `settings.mutate` operations against the redacted user section, which never names a secret the page did not receive. Removing a user-layer provider first opens a localized model-provider confirmation dialog; cancellation, its close button, and its mask leave the profile untouched, while the destructive confirmation submits the single unset and blocks duplicate submission until it settles.
## Alternatives considered
@@ -33,4 +33,4 @@ PR1 made LLM adapter configuration restart-free at the seam, but the only writer
## Consequences
-The whole loop is pinned keyless in the browser lane (`apps/web/tests/models-settings.e2e.ts`): the add card offers the dormant pi-ai catalog, adding `minimax-cn` with a typed key writes the reference-only profile into `settings.yaml`, stores the value into the harness home's `.env` under the derived `MINIMAX_CN_API_KEY`, registers the route live on the topology frame, and the customized fold merges `reasoning` beside the reference — zero model calls, ARIA goldens for the add-card and configured states, plus a scaffold `harnessHome` so tests never touch a real `~/.dsh` (the provider under test is one whose derived reference cannot collide with a developer's exported keys). The rename touched 239 files (fixtures, goldens, docs, python) in one commit with no compatibility alias. The renderer replacement cost one commit and no wire change: apply semantics, redaction, and the directory join were renderer-agnostic all along. Deferred: a per-row models preview (the picker already lists models), a page address for live routes that never declared configurability, and the documented reset edge — a `settings.replace` cannot re-supply a stored *literal* secret in the replaced subtree, which the reference-based default makes unreachable.
+The whole loop is pinned keyless in the browser lane (`apps/web/tests/models-settings.e2e.ts`): the add card offers the dormant pi-ai catalog, adding `minimax-cn` with a typed key writes the reference-only profile into `settings.yaml`, stores the value into the harness home's `.env` under the derived `MINIMAX_CN_API_KEY`, registers the route live on the topology frame, and the customized fold merges `reasoning` beside the reference — zero model calls, ARIA goldens for the add-card, configured, and delete-confirmation states, plus a scaffold `harnessHome` so tests never touch a real `~/.dsh` (the provider under test is one whose derived reference cannot collide with a developer's exported keys). The removal scenario proves cancellation leaves the profile intact, confirmation removes it, and the intentionally retained credential survives. The rename touched 239 files (fixtures, goldens, docs, python) in one commit with no compatibility alias. The renderer replacement cost one commit and no wire change: apply semantics, redaction, and the directory join were renderer-agnostic all along. Deferred: a per-row models preview (the picker already lists models), a page address for live routes that never declared configurability, and explicit removal of a provider's retained credential.
diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md
index 6e06b69218..c3255cacfd 100644
--- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md
+++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md
@@ -20,7 +20,7 @@ PR1 让 LLM(大语言模型)适配器配置在 seam 层面免重启,但唯
**架在 schema 模型层之上的手写编辑器。**`dsh-client-schema-form` 把 wire 的 `toJSON()` 信封还原(rehydrate)为活的 schemastery 节点,用于校验、路径解析与不可变草稿编辑——但不做通用渲染:第一版交付了完整的 schema 驱动表单渲染器,得到的却是一个未加样式、把 schema 原样倾倒出来的页面(每个进阶字段都平铺到卡片上、原始字段名直接充当标签、`retryPolicy` 的「不支持」回退落在主流程里)。用户没有再加一套提示/分组系统,而是选择了手写方向,第二轮又把引用输入框整个移除:卡片的主字段是一个 **API 密钥**输入框,未配置密钥的整分节提供方会以其设置卡片的形式打开,收起的「自定义设置」折叠区承载按家族精选的额外字段(两个家族都有 `baseURL`,另加 deepseek 的 `reasoningEffort`/pi-ai 的 `reasoning`),其余每个字段都归 `settings.yaml` 所有。校验仍会在写入前运行还原出的 schema,因此偏离其 schema 的手写字段会在保存时大声失败,而非静默失败。
-**Models 页是一次三领域联接,应用语义与 seam 同形。**每一行是一个已配置的提供方;「新增」卡片的选择框是可配置提供方目录中剩余的休眠条目;徽标来自路由存活状态。密钥通道保持引用形态,却从不展示任何引用:键入的密钥经 `credentials.set` **只写**存入 profile 的 `apiKeyEnv` 之下,引用不存在时便派生 `_API_KEY`(pi-ai profile 会记录该派生),因此 `settings.yaml` 从不携带密钥值,删除所需的整体 `settings.replace` 也绝不可能丢掉兄弟条目的机密。不含删除的编辑以一次最小的 `settings.update` 合并 patch 落地;把折叠区字段清回继承值或删除整行则经 `settings.replace` 替换整个用户分节,因为合并语义表达不了删除。
+**Models 页是一次三领域联接,应用语义与 seam 同形。**每一行是一个已配置的提供方;「新增」卡片的选择框是可配置提供方目录中剩余的休眠条目。路由存活状态仍用于就绪判定,并会使该联接失效,但页面不将其渲染为提供方状态,因为配置存在与运行时可用性是两个不同概念。密钥通道保持引用形态,却从不展示任何引用:键入的密钥经 `credentials.set` **只写**存入 profile 的 `apiKeyEnv` 之下,引用不存在时便派生 `_API_KEY`(pi-ai profile 会记录该派生),因此 `settings.yaml` 从不携带密钥值。profile 的编辑和删除会针对脱敏后的用户分节,以按路径寻址的最小 `settings.mutate` 操作落地,绝不会点名页面未收到的机密。删除用户层提供方时,会先打开本地化的模型提供方确认对话框;取消操作、关闭按钮和遮罩均不会改动 profile,而破坏性确认会提交唯一一条 unset,并在其完成前阻止重复提交。
## 曾考虑的替代方案
@@ -33,4 +33,4 @@ PR1 让 LLM(大语言模型)适配器配置在 seam 层面免重启,但唯
## 后果
-整条闭环以无密钥方式固定在浏览器测试通道(`apps/web/tests/models-settings.e2e.ts`):「新增」卡片提供休眠的 pi-ai catalog,携键入的密钥添加 `minimax-cn` 会把只含引用的 profile 写入 `settings.yaml`、把密钥值存入 harness 家目录 `.env` 中派生的 `MINIMAX_CN_API_KEY` 之下、路由随拓扑帧注册为存活,「自定义设置」折叠区则把 `reasoning` 合并到引用旁边——全程零模型调用,「新增」卡片态与已配置态各有 ARIA golden,另有脚手架式的 `harnessHome`,测试绝不触碰真实的 `~/.dsh`(受测提供方是派生引用不可能与开发者已导出密钥相撞的那一个)。这次重命名在一次提交中触及 239 个文件(fixture(测试前置数据)、golden、文档、python),未保留兼容别名。替换渲染器只花了一次提交,且没有任何 wire 变更:应用语义、脱敏与目录联接从一开始就与渲染器无关。延后事项:每行的模型预览(选择器已能列出模型)、为从未声明可配置性的存活路由提供页面地址,以及已记录在案的重置边界情形——`settings.replace` 无法在被替换的子树里重新补上已存储的*字面量*机密,而基于引用的默认形态让这种情况根本无从出现。
+整条闭环以无密钥方式固定在浏览器测试通道(`apps/web/tests/models-settings.e2e.ts`):「新增」卡片提供休眠的 pi-ai catalog,携键入的密钥添加 `minimax-cn` 会把只含引用的 profile 写入 `settings.yaml`、把密钥值存入 harness 家目录 `.env` 中派生的 `MINIMAX_CN_API_KEY` 之下、路由随拓扑帧注册为存活,「自定义设置」折叠区则把 `reasoning` 合并到引用旁边——全程零模型调用,「新增」卡片态、已配置态与删除确认态各有 ARIA golden,另有脚手架式的 `harnessHome`,测试绝不触碰真实的 `~/.dsh`(受测提供方是派生引用不可能与开发者已导出密钥相撞的那一个)。删除场景证明:取消后 profile 保持原样,确认后会将其删除,而刻意保留的凭据依然存在。这次重命名在一次提交中触及 239 个文件(fixture(测试前置数据)、golden、文档、python),未保留兼容别名。替换渲染器只花了一次提交,且没有任何 wire 变更:应用语义、脱敏与目录联接从一开始就与渲染器无关。延后事项:每行的模型预览(选择器已能列出模型)、为从未声明可配置性的存活路由提供页面地址,以及显式删除提供方所保留的凭据。
diff --git a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.i18n.yaml
new file mode 100644
index 0000000000..f9b2cfa908
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-web-diff-card.md
+2026-07-30-web-diff-card.md: 396bdbc2843c1bbed5c6a913be436d8b9e96a81c
+2026-07-30-web-diff-card.zh.md: afdeafa6e94b46b4f0fbd4a065afdac8a93ac57d
diff --git a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.md b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.md
new file mode 100644
index 0000000000..396bdbc284
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.md
@@ -0,0 +1,57 @@
+# Agent Note: Web diff card — the write/edit render intent reaches the browser
+
+Status: implemented
+
+English | [中文](2026-07-30-web-diff-card.zh.md)
+
+## Problem
+
+The `write` and `edit` tools declare `card: 'diff'` for both their call and their result ([render-intent union](../architecture/2026-07-02-tool-render-intent-union.md)): the call view carries the intended change derived from the arguments, and the result view carries the applied contextual hunks (`FileDiff[]`, computed by `packages/fs/tool-fs/src/diff.ts` and persisted in the result `meta` so replay reproduces it). That view already reaches the browser — host, connection, and runtime deliver it onto `ConversationSnapshot` as `callView`/`resultView` — and the TUI already renders it as per-file `+`/`-` blocks with a `+A -R · N file(s)` footer.
+
+The Web client ignored it. A write/edit call landed on `GenericToolCard`, whose row is derived from raw tool args, and the details panel flattened the result's content blocks into one `
`. The `diffs` payload — the whole point of the result — was discarded, so a file mutation read as a one-line confirmation with no visible change.
+
+This is the [terminal card](2026-07-28-web-terminal-card.md) done for the `diff` arm: that change made the Web client a consumer of the `terminal` render intent; this one makes it a consumer of the `diff` render intent, reusing the same four-layer shape.
+
+## Decision
+
+`DiffBlock` is a `ui-primitives` component that renders a file mutation as an inline diff surface, and both Web render sites for a write/edit call consume the diff render intent through it: the chat tool row's body and the details panel's Output section. `ui-conversation/src/client/contract/diff-card-model.ts` is the single place that turns the snapshot's `callView`/`resultView` pair into the component's props, so the two sites cannot disagree about a change. It returns null — the generic path — whenever neither side declares `card: 'diff'`, including a `card` value this client version does not know, and whenever a settled call's result view is generic, which is how write/edit keep their execution errors on the generic path. The result side is authoritative once the call settles: the applied hunks replace the call-time diff derived from the arguments alone. A paging window that drops the call head still renders, because the result view carries the whole change.
+
+The component's contract follows the TUI's `diffLines` (`packages/ui/tui/src/components/transcript.ts`) so a diff reads the same shape across front ends:
+
+- **One path header per file.** A new file opens a bold path header; a same-file second hunk (a scattered edit, or a `replace_all`) opens with a `⋯` gap instead of repeating the path. The `N file(s)` footer counts DISTINCT paths on both front ends — this PR moved the TUI footer off `diffs.length` onto the distinct-path count, so two hunks in one file read as `1 file` in both.
+- **The change in the diff's own colors.** A removed line is `- ` on the error token, an added line is `+ ` on the success token, drawn verbatim with `white-space: pre` inside a horizontally scrolling box — a source line is read by its indentation, so it scrolls rather than folds. A create (`oldText: null`) has no removed side.
+- **Height cap with an expand control.** A diff longer than `DEFAULT_DIFF_MAX_LINES` (16) shows `ceil(max/2)` head rows plus the remaining tail rows, with a button between reporting the hidden count. The split arithmetic matches `TerminalBlock` and the TUI's collapsed card, so a long diff's head and tail slices agree across front ends.
+- **Line terminator.** A side's content splits on `\n` under the terminator rule `TerminalBlock` uses: empty text is zero lines (a full deletion's `newText`, a create's absent `oldText` side), a single trailing newline terminates its last line rather than adding a phantom empty one, and an interior blank line survives. This PR applied the same rule to the TUI diff branch, so the `+A -R` footer counts agree on both front ends for the newline-terminated content real write/edit calls carry.
+- **Footer and copy.** A dim `└ +A -R · N file(s)` footer summarizes the change; `+A -R` are the added/removed line counts, the same per-side counts the TUI footer draws. The copy control copies the prefixed diff text (path headers, `- `/`+ ` lines, the `⋯` gap), so a multi-file copy stays attributable.
+
+Geometry, radius, and fonts mirror `CodeBlock`/`TerminalBlock` so a diff card, a terminal card, and a fenced block read as one family; `white-space: pre` plus horizontal scroll is the deliberate divergence. The copy control floats in the card's top-right corner rather than on a banner row of its own, because a banner carrying only a copy button drew an empty band above the first diff line — the TUI diff card has no banner either, only the footer.
+
+The chat row renders the diff resident under its path-link summary, capped at `CHAT_DIFF_MAX_LINES` (8) against the panel's 16 — the same inline-output decision and the same in-flow-vs-reading-surface split recorded for the [terminal card](2026-07-28-web-terminal-card.md#inline-output-in-the-chat-row-reverses-a-stated-convention). A write/edit row is single-file, so its summary stays an openable path link AND its diff card expands; the two coexist because the card is not the path's args body.
+
+## Alternatives considered
+
+**A side-by-side (two-column) diff.** Rejected for now by the owner: it is denser but does not fit the narrow chat row, and the goal was parity with the TUI's single-column unified form. A two-column mode in the details panel is a later props change, not a redesign.
+
+**Git-style line-number gutters.** The `FileDiff` contract carries only `{ path, oldText, newText }` — `structuredPatch`'s hunk start lines are dropped in `diff.ts`, so no line number reaches the client. Rendering a numbered gutter needs a backend contract change (carry `oldStart`/`newStart`) and a matching TUI upgrade to stay consistent; deferred so this PR stays a pure Web consumer of the existing contract.
+
+**Reuse `CodeBlock`.** Rejected for the same reason the terminal card was: `CodeBlock` soft-wraps and has no per-line `+`/`-` role, no path headers, and no footer. The two share geometry and font tokens, which is the only part where one implementation is correct for both.
+
+## Consequences
+
+`DiffBlock` reads only the diff view's fields, so it stays a pure function of what the render intent carries — replay-safe like the presenters that produce the view. A UI without the diff capability still gets the bridge's generic fallback; nothing about the tool's result shape changed. No new runtime dependency: unlike the terminal card's `anser`, a diff needs no parser.
+
+The multi-file arm of `DiffBlock` (one card, several path headers) has no producer today: `write`/`edit` each mutate one file per call, so a real card shows one file with one or more hunks. The arm is built and tested for a future multi-file mutation tool, not for a current consumer.
+
+## Testing
+
+`packages/client/ui-primitives/tests/diff-block.spec.tsx` pins the component: the create arm (added-only, no removed side), the edit arm (removed above added), the same-file `⋯` gap versus a new file's own header, the empty-diffs null render, the footer counts and their singular/plural, the head/tail cap with its `aria-expanded` toggle, and the copy control asserting the prefixed diff text on both the accepted and refused clipboard paths. Per-file 100%.
+
+`packages/client/ui-conversation/tests/diff-card.spec.tsx` pins the wiring at every render site: `diffCardModel`'s derivation and each of its null arms, the result hunks replacing the call-time diff, a window-truncated call still rendering from the result, the chat row's diff body, `FileMutationRow`'s resident card and its path link opening cwd-resolved through the host, its registration under both `write` and `edit`, and the panel's Output section.
+
+The fixture (`packages/client/connection/src/client/fixture.ts`) carries three diff turns so a `?fixture` server and the per-package wiring suite exercise all three arms at both render sites: a single-hunk edit (turn 62, keyed `FileMutationRow`), a create/write (turn 63), and a multi-hunk edit (turn 67, the `⋯` gap between two scattered hunks in one file). The built-boot snapshot (`apps/web/tests/built-boot.snapshot.ts`) is a boot-assembly smoke that asserts only that the graph mounts and reaches chat content (`data-sample="bash-global"`); by its own contract it carries no diff-behavior assertions, which the wiring suite owns.
+
+## Related
+
+- [Web terminal card](2026-07-28-web-terminal-card.md) — the same four-layer shape for the `terminal` arm; this note reuses its inline-output decision and its head/tail cap arithmetic.
+- [Tagged render-intent union for tool-call presentation](../architecture/2026-07-02-tool-render-intent-union.md) — the `card`-tagged vocabulary this consumes; the Web client is now a consumer of the `diff` arm too.
+- [Web client architecture](../architecture/2026-07-19-gui-web-client-architecture.md) — the slot and snapshot layering the two render sites sit in.
diff --git a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md
new file mode 100644
index 0000000000..afdeafa6e9
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md
@@ -0,0 +1,57 @@
+# Agent Note: Web diff 卡片 —— write/edit 渲染意图抵达浏览器
+
+Status: implemented
+
+[English](2026-07-30-web-diff-card.md) | 中文
+
+## Problem
+
+`write` 和 `edit` 工具为其 call 和 result 都声明了 `card: 'diff'`([render-intent union](../architecture/2026-07-02-tool-render-intent-union.md)):call view 携带从参数推导的预期改动,result view 携带已应用的上下文 hunk(`FileDiff[]`,由 `packages/fs/tool-fs/src/diff.ts` 计算,并持久化在 result `meta` 中以便回放重建)。该视图早已抵达浏览器 —— host、connection、runtime 将它作为 `callView`/`resultView` 投递到 `ConversationSnapshot` —— TUI 也已将其渲染为按文件分组的 `+`/`-` 块加 `+A -R · N file(s)` 页脚。
+
+Web 客户端忽略了它。write/edit 调用落到 `GenericToolCard`,其行从原始工具参数推导,详情面板把 result 的 content block 摊平进一个 `
`。`diffs` 载荷 —— result 的全部意义 —— 被丢弃,于是一次文件改动读起来只是一行确认、看不到任何改动。
+
+这是把 [terminal 卡片](2026-07-28-web-terminal-card.md) 对 `diff` 这一支重做一遍:那次改动让 Web 客户端成为 `terminal` 渲染意图的消费者;这次让它成为 `diff` 渲染意图的消费者,复用同一套四层结构。
+
+## Decision
+
+`DiffBlock` 是一个 `ui-primitives` 组件,把文件改动渲染为内联 diff 表面,write/edit 调用的两个 Web 渲染点都通过它消费 diff 渲染意图:chat 工具行的行体和详情面板的 Output 区。`ui-conversation/src/client/contract/diff-card-model.ts` 是唯一把快照的 `callView`/`resultView` 对转成组件 props 的地方,因此两个渲染点不会对一次改动产生分歧。当两侧都未声明 `card: 'diff'` 时它返回 null —— 走通用路径 —— 包括本客户端版本不认识的 `card` 值,以及已结算调用的 result view 是 generic 的情况(write/edit 的执行错误正是这样留在通用路径上的)。调用结算后 result 侧是权威:已应用的 hunk 替换仅从参数推导的 call 时 diff。分页窗口丢弃了 call 头也仍能渲染,因为 result view 携带完整改动。
+
+组件的契约遵循 TUI 的 `diffLines`(`packages/ui/tui/src/components/transcript.ts`),使 diff 在两个前端读起来是同一形态:
+
+- **每个文件一个路径头。** 新文件开启一个粗体路径头;同文件的第二个 hunk(分散编辑,或 `replace_all`)以一个 `⋯` gap 开启,而非重复路径。`N file(s)` 页脚在两个前端都统计**去重后的路径数** —— 本 PR 把 TUI 页脚从 `diffs.length` 改为去重路径计数,因此同文件两个 hunk 在两端都读作 `1 file`。
+- **改动用 diff 自身的颜色。** 删除行是 error token 上的 `- `,新增行是 success token 上的 `+ `,在横向滚动的盒子里以 `white-space: pre` 逐字绘制 —— 源码行靠缩进阅读,所以滚动而不折行。新建(`oldText: null`)没有删除侧。
+- **高度上限带展开控件。** 长于 `DEFAULT_DIFF_MAX_LINES`(16)的 diff 显示 `ceil(max/2)` 个头部行加剩余尾部行,中间一个按钮报告隐藏行数。分割算术与 `TerminalBlock` 和 TUI 的折叠卡片一致,因此长 diff 的头尾切片在两个前端一致。
+- **行终止符。** 每一侧的内容按 `TerminalBlock` 的终止符规则在 `\n` 上切分:空文本是零行(整文件删除的 `newText`、新建缺失的 `oldText` 侧),单个结尾换行终止其最后一行而非新增一条幻影空行,内部空行保留。本 PR 把同一规则应用到了 TUI diff 分支,因此对于真实 write/edit 调用携带的以换行结尾的内容,两个前端的 `+A -R` 页脚计数一致。
+- **页脚与复制。** 暗色 `└ +A -R · N file(s)` 页脚概括改动;`+A -R` 是新增/删除行数,与 TUI 页脚绘制的每侧计数相同。复制控件复制带前缀的 diff 文本(路径头、`- `/`+ ` 行、`⋯` gap),使多文件复制保持可归属。
+
+几何、圆角、字体镜像 `CodeBlock`/`TerminalBlock`,使 diff 卡片、terminal 卡片、代码块读起来是一家;`white-space: pre` 加横向滚动是刻意的分歧。复制控件浮在卡片右上角,而非占据自己的 banner 行,因为只放一个复制按钮的 banner 会在第一行 diff 上方画出一条空带 —— TUI 的 diff 卡片也没有 banner,只有页脚。
+
+chat 行把 diff 常驻渲染在路径链接摘要之下,上限 `CHAT_DIFF_MAX_LINES`(8),对应面板的 16 —— 与 [terminal 卡片](2026-07-28-web-terminal-card.md#inline-output-in-the-chat-row-reverses-a-stated-convention)记录的内联输出决策、以及流内表面对单调阅读表面的同一划分一致。write/edit 行是单文件的,所以它的摘要既是可打开的路径链接,其 diff 卡片又展开;两者共存,因为卡片不是路径的参数体。
+
+## Alternatives considered
+
+**并排(双栏)diff。** owner 目前拒绝:它更密但不适合狭窄的 chat 行,目标是与 TUI 单栏统一形式对齐。详情面板里的双栏模式是后续的 props 改动,不是重设计。
+
+**git 式行号槽。** `FileDiff` 契约只携带 `{ path, oldText, newText }` —— `structuredPatch` 的 hunk 起始行在 `diff.ts` 里被丢弃,所以没有行号抵达客户端。渲染行号槽需要后端契约改动(携带 `oldStart`/`newStart`)并同步升级 TUI 以保持一致;推迟,使本 PR 保持为对既有契约的纯 Web 消费。
+
+**复用 `CodeBlock`。** 因与 terminal 卡片相同的理由拒绝:`CodeBlock` 会折行,且没有每行 `+`/`-` 角色、没有路径头、没有页脚。两者共享几何与字体 token,那是唯一一处一个实现对两者都正确的部分。
+
+## Consequences
+
+`DiffBlock` 只读 diff view 的字段,因此它是渲染意图所携带内容的纯函数 —— 与产出该视图的 presenter 一样回放安全。没有 diff 能力的 UI 仍得到 bridge 的通用回退;工具的 result 形状没有任何改变。无新增运行时依赖:不同于 terminal 卡片的 `anser`,diff 不需要解析器。
+
+`DiffBlock` 的多文件支路(一张卡、多个路径头)今天没有生产者:`write`/`edit` 每次调用各改一个文件,所以真实卡片显示一个文件带一个或多个 hunk。该支路为将来的多文件改动工具而构建并测试,不是为当前消费者。
+
+## Testing
+
+`packages/client/ui-primitives/tests/diff-block.spec.tsx` 钉住组件:新建支路(只有新增、无删除侧)、编辑支路(删除在新增之上)、同文件 `⋯` gap 对比新文件自己的头、空 diffs 的 null 渲染、页脚计数及其单复数、头尾上限及其 `aria-expanded` 切换、以及复制控件在接受与拒绝两条剪贴板路径上断言带前缀的 diff 文本。Per-file 100%。
+
+`packages/client/ui-conversation/tests/diff-card.spec.tsx` 钉住每个渲染点的接线:`diffCardModel` 的派生及其每个 null 支路、result hunk 替换 call 时 diff、窗口截断的 call 仍从 result 渲染、chat 行的 diff 体、`FileMutationRow` 的常驻卡片及其路径链接经 host 以 cwd 解析打开、其在 `write` 与 `edit` 下的注册、以及面板的 Output 区。
+
+fixture(`packages/client/connection/src/client/fixture.ts`)携带三个 diff turn,使 `?fixture` 服务与 per-package 接线测试套件在两个渲染点演练全部三个支路:单 hunk 编辑(turn 62,keyed `FileMutationRow`)、新建/写入(turn 63)、多 hunk 编辑(turn 67,一个文件内两处分散 hunk 之间的 `⋯` gap)。built-boot snapshot(`apps/web/tests/built-boot.snapshot.ts`)是启动装配 smoke,只断言图挂载并抵达 chat 内容(`data-sample="bash-global"`);按其自身契约它不带 diff 行为断言,那由接线套件负责。
+
+## Related
+
+- [Web terminal 卡片](2026-07-28-web-terminal-card.md) —— `terminal` 支路的同一套四层结构;本 note 复用其内联输出决策与头尾上限算术。
+- [工具调用呈现的标签化 render-intent union](../architecture/2026-07-02-tool-render-intent-union.md) —— 本改动消费的 `card` 标签词汇;Web 客户端现在也是 `diff` 支路的消费者。
+- [Web 客户端架构](../architecture/2026-07-19-gui-web-client-architecture.md) —— 两个渲染点所处的 slot 与快照分层。
diff --git a/.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.i18n.yaml
new file mode 100644
index 0000000000..1c2cbd0216
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.md
+2026-07-31-gui-full-access-confirmation.md: 8208a20bee9b9ab8f1e73720790e3e5be4c27306
+2026-07-31-gui-full-access-confirmation.zh.md: 8ac115034f562fe96d53bdba010b05648d4d5947
diff --git a/.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.md b/.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.md
new file mode 100644
index 0000000000..8208a20bee
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.md
@@ -0,0 +1,30 @@
+# Agent Note: GUI Full access risk confirmation
+
+Status: implemented
+
+English | [中文](2026-07-31-gui-full-access-confirmation.zh.md)
+
+## Problem
+
+Switching the web client to `danger-full-access` was a single click on either permission surface (the composer's Access chip and the `/permission` popup picker), with the preset shown as the title-cased machine name `Danger Full Access`. Full access reduces confirmation steps and lets the agent run sensitive operations, modify files, or execute external commands, so an accidental pick armed the most dangerous preset with no deliberate acknowledgement step.
+
+## Decision
+
+**Both permission surfaces gate `danger-full-access` behind one shared in-page `RiskConfirmation` dialog whose enabling action stays disabled until an explicit acknowledgement checkbox is checked; the preset renders under the product label `Full access`; every dismissal path submits nothing.**
+
+- `RiskConfirmation` (ui-primitives) is a controlled Modal composition: title, description, acknowledgement checkbox, cancel, and a confirm button disabled until `acknowledged`. It stays an in-page dialog — the Modal portals to this document's body and never opens a native or separate browser window that could land on another display. `Modal` gains a `contentClassName` seat so the warning body scrolls inside constrained mobile/landscape viewports while the action row stays fixed.
+- The composer chip (`PermissionSelect`, ui-conversation) intercepts a Full-access pick before the `/permission` submit: `confirmation`/`acknowledged` component state opens the dialog, confirm submits `/permission danger-full-access` through the same injected `command` path as every other pick, and cancel/Escape/close/mask leave the current preset untouched with the checkbox reset. The confirmation revokes itself when the session locks (`locked`/value-absent effect) and resets across task switches (`key={sessionId}` remount). Copy rides the standard `conversation` locale seat as `access.confirm.*` keys.
+- The `/permission` popup (ui-permission over the ui-command shell) gates through data, not a second dialog implementation: `SelectOption` grows an optional `confirmation` payload, the popup controller owns the `confirming`/`acknowledged` state transitions, and `PopupSelectView` swaps the picker card for the same `RiskConfirmation` while a gated option is pending.
+- `Full access` intentionally overrides the kebab-to-title display transform on both surfaces (option rows, trigger label, settled command rows keep the machine name on the wire); the warning body remains locale-aware in Chinese and English.
+
+## Alternatives considered
+
+**A native/OS or separate-window confirmation.** Rejected: the dialog must stay inside the current WebUI window; a second window can appear on another display and detaches the decision from the page state it guards.
+
+**One shared locale namespace for both surfaces' safety copy.** Rejected: the ui-permission bundle and ui-conversation load independently, so each registers the same copy under its own namespace (`permission.access` beside the conversation dictionary); the duplication is fenced with an explanatory `jscpd:ignore` block rather than a cross-bundle import.
+
+**Gating in the host/permission backend.** Out of scope by design: the change is browser-client confirmation flow only; backend permission semantics, defaults, and the safer presets' one-click behavior are unchanged.
+
+## Consequences
+
+Every visible GUI path into Full access now requires a deliberate, informed acknowledgement, at the cost of one extra dialog step for users who genuinely want the preset. New pickers reuse the gate by attaching a `confirmation` payload (popup path) or the chip's state machine (composer path) instead of inventing bespoke dialogs. Acceptance: the composer flow's four gated cases in `input-bar.spec.tsx`, the popup gate in `popup-view.spec.tsx` and `popup.spec.ts`, the Modal/RiskConfirmation contract in `atoms.spec.tsx`, and the assembled `access-confirmation` web e2e whose golden pins the product-default Chinese dictionary copy.
diff --git a/.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.zh.md b/.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.zh.md
new file mode 100644
index 0000000000..8ac115034f
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.zh.md
@@ -0,0 +1,30 @@
+# Agent Note: GUI Full access 风险确认
+
+Status: implemented
+
+[English](2026-07-31-gui-full-access-confirmation.md) | 中文
+
+## Problem
+
+Web 客户端切换到 `danger-full-access` 在两个权限面(编辑器的 Access chip 与 `/permission` popup 选择器)上都只需一次点击,且预设以 Title Case 机器名 `Danger Full Access` 展示。Full access 会减少确认步骤,允许智能体执行敏感操作、修改文件或运行外部命令,误点即在毫无刻意确认环节的情况下启用了最危险的预设。
+
+## Decision
+
+**两个权限面都把 `danger-full-access` 关进同一个共享的页面内 `RiskConfirmation` 对话框:启用按钮在用户勾选明确的风险确认复选框前保持禁用;预设以产品标签 `Full access` 展示;所有取消路径都不提交任何命令。**
+
+- `RiskConfirmation`(ui-primitives)是受控的 Modal 组合:标题、说明、确认复选框、取消,以及 `acknowledged` 勾选前禁用的确认按钮。它始终是页面内对话框——Modal portal 到本文档 body,绝不打开可能落在另一块显示器上的原生或独立浏览器窗口。`Modal` 新增 `contentClassName` 座位,令警示正文在受限的移动端/横屏视口内滚动,动作行保持固定。
+- 编辑器 chip(ui-conversation 的 `PermissionSelect`)在 `/permission` 提交前拦截 Full-access 选择:`confirmation`/`acknowledged` 组件状态打开对话框,确认后经与其他选择完全相同的注入 `command` 通道提交 `/permission danger-full-access`;取消、Escape、关闭与遮罩点击均保持当前预设不变并重置复选框。会话锁定时确认自行撤销(`locked`/值缺席 effect),切换任务时随 `key={sessionId}` 重挂载而重置。文案经标准 `conversation` locale 座位以 `access.confirm.*` 键供给。
+- `/permission` popup(ui-permission 骑在 ui-command 外壳上)以数据而非第二套对话框实现完成把关:`SelectOption` 新增可选的 `confirmation` 载荷,popup 控制器拥有 `confirming`/`acknowledged` 状态迁移,`PopupSelectView` 在门控选项未决期间把选择卡换成同一个 `RiskConfirmation`。
+- `Full access` 在两个面上有意覆盖 kebab 转 Title Case 的显示变换(选项行、触发器标签;落定的命令行仍在 wire 上保留机器名);警示正文保持中英文 locale 感知。
+
+## Alternatives considered
+
+**原生/操作系统或独立窗口确认。** 已拒:对话框必须留在当前 WebUI 窗口内;第二个窗口可能出现在另一块显示器上,使决策脱离其守护的页面状态。
+
+**两个面共享一个安全文案 locale namespace。** 已拒:ui-permission bundle 与 ui-conversation 可独立加载,故各自在自己的 namespace 下注册同一份文案(`permission.access` 与 conversation 词典并立);这处重复以带说明的 `jscpd:ignore` 块圈护,而非跨 bundle import。
+
+**在 host/权限后端把关。** 设计上即出界:本变更只涉浏览器客户端确认流;后端权限语义、默认值与更安全预设的一键行为均不变。
+
+## Consequences
+
+进入 Full access 的每条可见 GUI 路径现在都要求刻意且知情的确认,代价是真想启用该预设的用户多一步对话框。新的选择器复用此门:popup 路径挂 `confirmation` 载荷、编辑器路径走 chip 的状态机,而不是各造对话框。验收:`input-bar.spec.tsx` 中编辑器流的四个门控用例、`popup-view.spec.tsx` 与 `popup.spec.ts` 的 popup 门、`atoms.spec.tsx` 的 Modal/RiskConfirmation 契约,以及组装态 `access-confirmation` web e2e——其 golden 钉住产品默认中文词典文案。
diff --git a/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.i18n.yaml
new file mode 100644
index 0000000000..70c5ce5a90
--- /dev/null
+++ b/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-22-product-first-root-readme.md
+2026-07-22-product-first-root-readme.md: 32542a45019d64ed1826d4eb21e68c67c3c3d52e
+2026-07-22-product-first-root-readme.zh.md: 1c4d5fa53854bfcade9742da1fb74d9636909f84
diff --git a/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.md b/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.md
new file mode 100644
index 0000000000..32542a4501
--- /dev/null
+++ b/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.md
@@ -0,0 +1,33 @@
+# Agent Note: Product-first root README
+
+Status: implemented
+
+English | [中文](2026-07-22-product-first-root-readme.zh.md)
+
+## Problem
+
+The root README is the repository's product front door. Its product-first structure and established voice remain useful, but concrete entry points and capability claims drift as the runtime grows. Rewriting sections whose facts remain correct increases the review surface and discards language that already works.
+
+## Decision
+
+The root README preserves its existing structure, order, and wording wherever the underlying fact remains correct. A refresh changes only stale claims and adds material needed to represent shipped surfaces; it does not use repository growth as a reason to reframe the whole page.
+
+A note before installation thanks internal testers, states that features and experience remain unfinished, and asks for direct reports of failures, confusion, and friction through the WeCom group. The existing development-stage statement identifies DeepSeek Harness as being in internal testing.
+
+The user-surface section adds the ACP automation server and Python/JSON-RPC SDK beside the existing Web, TUI, and headless entries. The installed TUI remains the single `dsh` command; the Web instructions build the active checkout before running `dsh web`, and custom or reused checkout paths stay explicit. These launch paths must remain executable through a real PTY and a production build/HTTP smoke, respectively. The capability paragraph keeps its compact inventory style while adding the shipped PTY, LSP, web, goal, planning, task, sandbox, approval, settings, credentials, session-query, and telemetry families and stating that compositions select subsets. One adjacent bullet records the authoritative-session-log rule because persistence, replay, queries, telemetry, and interfaces depend on it.
+
+Detailed package and service inventories remain at their owning documentation. The English and Chinese README sides share the same technical structure, while their community sections continue to point to the primary channel for each language audience. The documentation website keeps its separate user-guide landing page.
+
+## Alternatives considered
+
+**Rewrite the README around a new product narrative.** A complete rewrite can make every current surface prominent, but it replaces accurate, reviewed copy and creates unnecessary churn. Current facts fit the established product-first structure.
+
+**Present the repository as an SDK and package catalog.** This exposes implementation breadth immediately but makes a new reader reconstruct the product from package names. The package map and generated capability graph remain the authoritative inventories.
+
+**Use a long marketing page with screenshots, badges, and duplicated tutorials.** Rich media can demonstrate a stable product journey, but it ages separately from commands and source contracts. The root stays compact and links to runnable examples and owned guides.
+
+**Project the root README as the documentation website home page.** A single landing page avoids two narratives, but the website's user guide and the repository's product/developer front door have different navigation and maintenance needs.
+
+## Consequences
+
+Reviewers can distinguish factual refreshes from editorial rewrites, and future updates retain established wording unless its meaning becomes false or incomplete. The README must still change with affected commands, entry points, release-stage claims, or high-level capability families, while exhaustive detail remains linked rather than copied.
diff --git a/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.zh.md b/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.zh.md
new file mode 100644
index 0000000000..1c4d5fa538
--- /dev/null
+++ b/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.zh.md
@@ -0,0 +1,33 @@
+# Agent Note: 产品优先的根 README
+
+Status: implemented
+
+[English](2026-07-22-product-first-root-readme.md) | 中文
+
+## 问题
+
+根 README 是仓库的产品入口。其产品优先的结构和既有语气仍然有效,但随着运行时不断扩展,具体入口和能力声明会逐渐陈旧。重写事实仍然正确的章节,会扩大评审范围,也会丢弃已经行之有效的措辞。
+
+## 决策
+
+只要背后的事实仍然正确,根 README 就保留既有结构、顺序和措辞。刷新时只修正陈旧声明,并补充呈现已交付内容所需的信息;不会因为仓库规模增长就重构整篇叙事。
+
+安装说明之前的一则文字感谢内测用户,说明功能和体验仍待完善,并邀请大家通过企业微信群直接反馈失败、困惑和不顺手之处。既有的开发阶段声明明确说明 DeepSeek Harness 处于内测阶段。
+
+用户入口章节在已有的 Web、TUI 和 Headless 入口旁补充 ACP(Agent Client Protocol)自动化服务器和 Python/JSON-RPC SDK。安装后的 TUI 仍只需执行一条 `dsh` 命令;Web 说明要求先构建当前检出,再运行 `dsh web`,并明确处理自定义或复用的检出路径。这两条启动路径必须分别能在真实 PTY 与生产构建/HTTP 冒烟中原样执行。能力段落沿用简洁清单的写法,补充已经交付的 PTY、LSP、Web、目标、规划、任务、沙箱、审批、设置、凭据、会话查询和遥测等能力类别,并说明不同组合只选用其中一部分。相邻的一条列表项说明权威会话日志规则,因为持久化、回放、查询、遥测和各类接口都依赖它。
+
+包(package)与服务的完整清单仍由各自的归属文档维护。中英文 README 采用相同的技术结构,但社区章节仍分别指向各自语言受众的主要交流渠道。文档网站继续使用独立的用户指南首页。
+
+## 考虑过的替代方案
+
+**围绕新的产品叙事重写 README。** 完整重写能够突出所有现有入口和能力,但也会替换准确且已经过评审的文案,造成不必要的变动。现有事实能够纳入既有的产品优先结构。
+
+**将仓库呈现为 SDK 和包清单。** 这样能立即展现实现广度,却会迫使新读者从包名反推出产品。包索引与生成的能力图仍是权威清单。
+
+**使用包含截图、徽章和重复教程的长篇营销页面。** 富媒体能够展示稳定的产品使用路径,但其内容会独立于命令和源码契约而逐渐陈旧。根 README 保持紧凑,并链接到可运行示例和各自维护的指南。
+
+**将根 README 投影为文档网站首页。** 使用同一个首页可以避免两套叙事,但文档网站的用户指南与仓库面向产品和开发者的入口在导航和维护需求上并不相同。
+
+## 结果
+
+评审者可以区分事实更新与编辑性重写;今后的更新会保留既有措辞,除非其含义已经不再正确或完整。受影响的命令、入口、发布阶段声明或高层能力类别发生变化时,README 仍须同步更新;完整细节则继续以链接方式提供,而不是复制到正文。
diff --git a/README.md b/README.md
index 32a5deb57f..f6f199ecb8 100644
--- a/README.md
+++ b/README.md
@@ -6,6 +6,16 @@ DeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Ha
It uses an architecture where **everything is a plugin**.
+## Internal testing notice
+
+Thank you for making time to try DeepSeek Harness.
+
+This version is still in internal testing. Some features remain unfinished, and parts of the experience may feel rough.
+
+“As one cuts and files, as one carves and polishes.” Products grow through repeated encounters with real use and candid feedback. The problems you uncover in practice may lead us to re-examine, or even discard, existing designs.
+
+We especially want to hear about moments of failure, confusion, or friction. If DeepSeek Harness does not help—or instead makes your work harder—please leave a message in our WeCom group and tell us about your experience. Every report will help us refine it.
+
## Install
Install `dsh` with one command:
@@ -22,20 +32,14 @@ The installer keeps every checkout under `~/.dsh/source`: the master clone at `~
### Web UI
-For the recommended local interface, build the frontend after installation and after each update, then start the Web UI. Resolve the running checkout from the `dsh` launcher so the command holds regardless of which staging worktree is current (the launcher resolves through the stable `current` symlink):
+For the recommended local interface, build the active checkout after installation and after each update, then start the Web UI:
```sh
-dsh_bin=$(cd "$(dirname "$(command -v dsh)")" && pwd -P)/$(basename "$(command -v dsh)")
-while [ -L "$dsh_bin" ]; do
- link=$(readlink "$dsh_bin")
- case $link in /*) dsh_bin=$link ;; *) dsh_bin=$(cd "$(dirname "$dsh_bin")" && cd "$(dirname "$link")" && pwd -P)/$(basename "$link") ;; esac
-done
-dsh_dir=$(cd "$(dirname "$dsh_bin")/.." && pwd -P)
-pnpm --dir "$dsh_dir" run build && pnpm --dir "$dsh_dir" run build:web
+(cd ~/.dsh/source/current && pnpm run build)
dsh web
```
-The Web UI is served at `http://127.0.0.1:3080` by default.
+The full build produces the library and client bundles plus the frontend dist. The path above is the installer's default. If you set `DSH_SOURCE` or `DSH_CURRENT`, or reused an existing checkout, replace `~/.dsh/source/current` with that checkout path; see [`scripts/install.sh`](scripts/install.sh) for details. The Web UI is served at `http://127.0.0.1:3080` by default.
### TUI
@@ -53,11 +57,22 @@ Run one task, print the final answer, and exit:
dsh -p "summarize this workspace"
```
+### Automation and SDKs
+
+From a source checkout with `DEEPSEEK_API_KEY` in the environment or its root `.env`, start the ACP automation server:
+
+```sh
+pnpm run demo:acp
+```
+
+The [Python SDK](python/README.md) drives a bundled JSON-RPC runtime. The [examples](examples/README.md) cover the runnable headless, ACP, JSON-RPC, Code Mode, and self-referential compositions.
+
## Why DeepSeek Harness
-Built-in capabilities cover file reading, editing, and search; shell execution; reusable skills; task tracking; subagents and workflows; persistent sessions; and context compaction. The TUI also includes Plan Mode.
+Built-in capabilities cover file reading, editing, and search; shell and persistent PTY execution; reusable skills; task tracking, goals, plans, todos, and background tasks; subagents and workflows; sandboxing and approvals; settings and credentials; persistent, resumable, forkable, and queryable sessions; LSP and web access; context compaction; and telemetry. Each composition selects the subset appropriate to its surface. The TUI and Web UI both include Plan Mode.
- **Everything is a plugin.** Models, tools, policies, storage, context management, and interfaces are composable [Cordis plugins](docs/user/develop/basic/index.md), so deployments can extend or replace behavior without forking the agent loop. See the [architecture](docs/architecture.md) for the underlying design.
+- **Runs are reconstructable.** Anything visible to the model is logged in the authoritative session stream; persistence, resume/fork/query, replay, telemetry, and UIs derive from the same events. See the [session-log architecture](docs/architecture.md#session-log).
- **Code Mode (opt-in).** It exposes a `run_code` tool and a generated TypeScript SDK; only program output re-enters model context. See [Code Mode](packages/core/tools/README.md#code-mode).
- **Self-referential Cordis tools are opt-in.** They let the agent inspect its live runtime and mount or unmount plugins while it runs. See the [Cordis tools](packages/cordis/tool-cordis/README.md).
@@ -76,7 +91,7 @@ Start with the [development guide](docs/development.md) and read the [architectu
For agents, follow [AGENTS.md](AGENTS.md).
-DeepSeek Harness is currently pre-release.
+DeepSeek Harness is currently in internal testing.
## License
diff --git a/README.zh.md b/README.zh.md
index 7a5b875f38..282baf34d5 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -6,6 +6,16 @@ DeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源
它采用了**一切皆插件**的架构。
+## 内测声明
+
+感谢您愿意拨冗试用 DeepSeek Harness。
+
+目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗粝。
+
+“如切如磋,如琢如磨。”产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中暴露的问题,也可能促使我们重新审视,甚至推翻已有的设计。
+
+我们尤其希望听见那些失败、困惑与不顺手的时刻——如果它未能帮到您,甚至反而为工作平添了麻烦,请在企业微信群中留言,将使用感受告诉我们。每一条反馈,都会帮助我们把它打磨得更好。
+
## 安装
使用一条命令安装 `dsh`:
@@ -22,20 +32,14 @@ curl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/m
### Web UI
-推荐在本地使用 Web UI。安装完成后以及每次更新后,请先构建前端,再启动 Web UI。通过 `dsh` 启动器解析当前运行的检出,这样无论当前是哪个 staging worktree,命令都成立(启动器会经由稳定的 `current` 符号链接解析):
+推荐在本地使用 Web UI。安装完成后以及每次更新后,请先构建当前生效的检出,再启动 Web UI:
```sh
-dsh_bin=$(cd "$(dirname "$(command -v dsh)")" && pwd -P)/$(basename "$(command -v dsh)")
-while [ -L "$dsh_bin" ]; do
- link=$(readlink "$dsh_bin")
- case $link in /*) dsh_bin=$link ;; *) dsh_bin=$(cd "$(dirname "$dsh_bin")" && cd "$(dirname "$link")" && pwd -P)/$(basename "$link") ;; esac
-done
-dsh_dir=$(cd "$(dirname "$dsh_bin")/.." && pwd -P)
-pnpm --dir "$dsh_dir" run build && pnpm --dir "$dsh_dir" run build:web
+(cd ~/.dsh/source/current && pnpm run build)
dsh web
```
-Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。
+完整构建会生成库与客户端 bundle,以及前端 dist。上述路径是安装器的默认位置。如果你设置过 `DSH_SOURCE` 或 `DSH_CURRENT`,或者复用了已有检出,请把 `~/.dsh/source/current` 换成该检出路径;详情见 [`scripts/install.sh`](scripts/install.sh)。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。
### TUI
@@ -53,11 +57,22 @@ dsh
dsh -p "summarize this workspace"
```
+### 自动化与 SDK
+
+在源码检出中通过环境变量或根目录 `.env` 设置 `DEEPSEEK_API_KEY`,然后启动 ACP(Agent Client Protocol)自动化服务器:
+
+```sh
+pnpm run demo:acp
+```
+
+[Python SDK](python/README.md) 驱动随附的 JSON-RPC 运行时。[示例](examples/README.md)涵盖可运行的 headless、ACP、JSON-RPC、Code Mode 和自指组合。
+
## 为什么选择 DeepSeek Harness
-内置功能涵盖文件读取、编辑与搜索、shell 执行、可复用 skill(技能)、任务跟踪、subagent 与工作流、持久化会话,以及上下文压缩(context compaction)。TUI 还包含 Plan Mode。
+内置功能涵盖文件读取、编辑与搜索、shell 和持久 PTY 执行、可复用 skill(技能)、任务跟踪、目标、计划、待办事项与后台任务、subagent 与工作流、沙箱与审批、设置与凭据、可持久化、恢复、fork 与查询的会话、LSP 与 Web 访问、上下文压缩(context compaction),以及遥测。每个组合只选用适合其使用方式的能力子集。TUI 与 Web UI 均包含 Plan Mode。
- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均可组合为 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。
+- **运行可重建。** 凡是模型可见的内容,都会记录在权威会话流中;持久化、恢复/fork/查询、回放、遥测和 UI 均从同一组事件派生。参见[会话日志架构](docs/architecture.md#session-log)。
- **Code Mode(需显式启用)。** 它会提供 `run_code` 工具和生成的 TypeScript SDK,只有程序输出会重新进入模型上下文。参见 [Code Mode](packages/core/tools/README.md#code-mode)。
- **自指 Cordis 工具需显式启用。** 这些工具可让 agent 检查自身的实时运行时,并在运行中挂载或卸载插件。参见 [Cordis 工具](packages/cordis/tool-cordis/README.md)。
@@ -80,7 +95,7 @@ pnpm run test:coverage
面向 agent:遵循 [AGENTS.md](AGENTS.md)。
-DeepSeek Harness 目前处于预发布阶段。
+DeepSeek Harness 目前处于内测阶段。
## 许可证
diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml
index c3bf91e88f..b2fe93b91a 100644
--- a/apps/cli/README.i18n.yaml
+++ b/apps/cli/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write apps/cli/README.md
-README.md: e4b34c11d5deb722caed199d6350f7931092a636
-README.zh.md: 5701bc8b6d99f00e68db572a58a0b6d520d67f08
+README.md: c36a75fc61fd7118f48c9b68be3144177df19534
+README.zh.md: e926fa99c4e483351f52ca4e76b668e26b34d02f
diff --git a/apps/cli/README.md b/apps/cli/README.md
index e4b34c11d5..c36a75fc61 100644
--- a/apps/cli/README.md
+++ b/apps/cli/README.md
@@ -17,8 +17,7 @@ The TUI surface:
`dsh upgrade` is a guided fresh-session entry over the default TUI surface: it mints a fresh session in the invoking directory and seeds its first turn with the bundled `dsh-upgrade` skill, exactly as if the user typed `/skill:`. The launcher passes the skill name on the boot context ([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)), which the TUI auto-invokes once the chat is live. Both take no options — `--config`, `-p`, and `--resume` fail loud — and seed only on this first launch, so a later `dsh --resume ` of the session is an ordinary TUI session with no re-injection.
-
-The Web and headless surfaces boot `base.cordis.yml` plus `web.cordis.yml`, then apply `$DSH_HOME/config.yaml`; an explicit `--config ` replaces that personal overlay. Both surfaces otherwise share the same composition: both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root ` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, opt into first-message model titles, and mount a disposable in-memory SQLite content-index service. That service is ACTIVE at boot, while its `node:sqlite` module and database handle open only on the first content search. This keeps Node 22 startup output free of SQLite's experimental warning before search is used; the first actual search may still emit the runtime warning. Each service instance owns its database, so parallel invocations neither share unsupported SQLite state nor leave derived index files behind, and the first search lazily reconciles live and persisted logs. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`).
+The Web and headless surfaces boot `base.cordis.yml` plus `web.cordis.yml`, then apply `$DSH_HOME/config.yaml`; an explicit `--config ` replaces that personal overlay. Both surfaces otherwise share the same composition: both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root ` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, opt into first-message model titles, use the same bounded transient model-request retry policy as the TUI, and mount a disposable in-memory SQLite content-index service. That service is ACTIVE at boot, while its `node:sqlite` module and database handle open only on the first content search. This keeps Node 22 startup output free of SQLite's experimental warning before search is used; the first actual search may still emit the runtime warning. Each service instance owns its database, so parallel invocations neither share unsupported SQLite state nor leave derived index files behind, and the first search lazily reconciles live and persisted logs. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`).
The shipped TUI and Web compositions register the native DeepSeek adapter plus pi-ai OpenAI and Anthropic profiles. Credentials and endpoint overrides come from the provider-standard `DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`, `OPENAI_API_KEY` / `OPENAI_BASE_URL`, and `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL` pairs in the boot's layered environment.
diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md
index 5701bc8b6d..e926fa99c4 100644
--- a/apps/cli/README.zh.md
+++ b/apps/cli/README.zh.md
@@ -17,8 +17,7 @@ TUI 界面:
`dsh upgrade` 是默认 TUI 界面之上的引导式全新会话入口:它在调用目录中创建一个全新会话,并以内置 `dsh-upgrade` skill 播种其首轮,效果等同于用户手动键入 `/skill:`。启动器将 skill 名称提供到启动上下文([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)),TUI 在聊天就绪后自动调用它。两者都不接受任何选项——`--config`、`-p`、`--resume` 都会明确报错——且仅在首次启动时播种,因此之后 `dsh --resume ` 恢复该会话时是普通 TUI 会话,不会重复注入。
-
-Web 和无头界面启动 `base.cordis.yml` 与 `web.cordis.yml`,随后应用 `$DSH_HOME/config.yaml`;显式的 `--config ` 会替代该个人覆盖。除此之外,两者共享同一套组合:两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root ` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,并选用首条消息模型标题,且挂载一个可丢弃的内存 SQLite 内容索引服务。该服务在启动时处于 ACTIVE 状态,但其 `node:sqlite` 模块与数据库句柄分别要到首次内容搜索才会导入和打开。这样可使 Node 22 在尚未使用搜索时的启动输出不出现 SQLite 实验性警告;首次实际搜索仍可能发出运行时警告。每个服务实例独占自己的数据库,因此并行调用既不会共享不受支持的 SQLite 状态,也不会留下派生索引文件,首次搜索还会惰性对账实时日志与持久化日志。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。
+Web 和无头界面启动 `base.cordis.yml` 与 `web.cordis.yml`,随后应用 `$DSH_HOME/config.yaml`;显式的 `--config ` 会替代该个人覆盖。除此之外,两者共享同一套组合:两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root ` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,选用首条消息模型标题,采用与 TUI 相同的有界暂时性模型请求重试策略,并挂载一个可丢弃的内存 SQLite 内容索引服务。该服务在启动时处于 ACTIVE 状态,但其 `node:sqlite` 模块与数据库句柄分别要到首次内容搜索才会导入和打开。这样可使 Node 22 在尚未使用搜索时的启动输出不出现 SQLite 实验性警告;首次实际搜索仍可能发出运行时警告。每个服务实例独占自己的数据库,因此并行调用既不会共享不受支持的 SQLite 状态,也不会留下派生索引文件,首次搜索还会惰性对账实时日志与持久化日志。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。
已交付的 TUI 和 Web 组合会注册原生 DeepSeek 适配器,以及 pi-ai 的 OpenAI 和 Anthropic 提供方配置。凭据和端点覆盖来自启动分层环境中的提供方标准变量对:`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`、`OPENAI_API_KEY` / `OPENAI_BASE_URL` 和 `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL`。
diff --git a/apps/web/tests/access-confirmation.e2e.ts b/apps/web/tests/access-confirmation.e2e.ts
new file mode 100644
index 0000000000..89b383da85
--- /dev/null
+++ b/apps/web/tests/access-confirmation.e2e.ts
@@ -0,0 +1,104 @@
+// Web e2e scenario: every visible permission picker gates Full access behind
+// the same locale-aware, in-page risk confirmation. Zero model calls: the
+// scenario boots the shipped Web composition and exercises the real
+// permission projection, client command path, HTTP RPC, and pushed update.
+import { fileURLToPath } from 'node:url'
+import { join } from 'node:path'
+import type { Browser, Page } from 'playwright'
+import { chromium } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import {
+ assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
+ launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
+} from './scaffold.ts'
+import { saveFailureShot } from './support.ts'
+
+/**
+ * connectFreshWorkspace twin over the product default Chinese locale (the
+ * shared helper's anchors assume the English page every other scenario
+ * boots; this scenario deliberately keeps zh, so the localized picker
+ * copy is the anchor set).
+ */
+async function connectFreshWorkspaceZh(page: Page, name = 'workspace'): Promise {
+ await page.getByRole('button', { name: '选择工作区' }).click()
+ await page.getByRole('menuitem', { name: '新建工作区' }).click()
+ const dialog = page.getByRole('dialog', { name: '新建工作区' })
+ await dialog.waitFor({ timeout: 10_000 })
+ await dialog.getByLabel('新工作区名称').fill(name)
+ await dialog.getByRole('button', { name: '创建工作区' }).click()
+ await page.locator('textarea:enabled[placeholder="描述你想要构建的内容"]')
+ .waitFor({ timeout: 15_000 })
+}
+
+const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/access-confirmation', import.meta.url))
+const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
+const MODE = webSnapshotMode()
+
+describe('web e2e: Full access confirmation', () => {
+ let scaffold: WebScaffold
+ let browser: Browser
+ let page: Page
+ let tripwire: ReturnType
+
+ beforeAll(async () => {
+ scaffold = await launchWebScaffold({})
+ // CI uses Playwright's pinned browser. A developer may point this one
+ // scenario at an installed Chromium when the matching browser download
+ // is temporarily unavailable.
+ const executablePath = process.env.DSH_PLAYWRIGHT_EXECUTABLE_PATH
+ browser = await chromium.launch(executablePath === undefined ? {} : { executablePath })
+ // Keep the product default Chinese locale: the golden pins the actual
+ // registered dictionary rather than a test-local translation callback.
+ page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
+ tripwire = watchConsole(page)
+ await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+ await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+ await connectFreshWorkspaceZh(page)
+ }, 120_000)
+
+ afterAll(async () => {
+ await browser?.close()
+ await scaffold?.close()
+ })
+
+ it('requires acknowledgement before the composer picker can enable Full access', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-full-access-confirmation'))
+ const access = page.locator('button[aria-label^="访问模式"]').first()
+ await access.waitFor({ timeout: 10_000 })
+
+ // Normalize the starting preset through the real command path. The
+ // shipped web config may already start at Full access.
+ if ((await access.getAttribute('aria-label'))?.endsWith('Full access') === true) {
+ await access.click()
+ await page.getByRole('menuitem', { name: 'Workspace Write' }).click()
+ await expect.poll(() => access.getAttribute('aria-label'), { timeout: 10_000 })
+ .toBe('访问模式,当前:Workspace Write')
+ }
+
+ await access.click()
+ await page.getByRole('menuitem', { name: 'Full access' }).click()
+ const dialog = page.getByRole('dialog', { name: '确认启用 Full access?' })
+ await dialog.waitFor({ timeout: 10_000 })
+ const enable = dialog.getByRole('button', { name: '启用 Full access' })
+ expect(await enable.isDisabled()).toBe(true)
+
+ // The modal is in this page's body (not a native/new window) and escapes
+ // the sticky composer's stacking context.
+ expect(await dialog.evaluate(node => node.parentElement?.parentElement === document.body)).toBe(true)
+ const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
+ await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
+
+ await dialog.getByRole('checkbox', { name: '我已了解风险,并愿意继续' }).check()
+ expect(await enable.isEnabled()).toBe(true)
+ await enable.click()
+ await expect.poll(() => access.getAttribute('aria-label'), { timeout: 10_000 })
+ .toBe('访问模式,当前:Full access')
+ expect(await dialog.count()).toBe(0)
+ expect(tripwire.pageErrors).toEqual([])
+ }, 60_000)
+
+ it('keeps its snapshot inventory closed', async () => {
+ expect(tripwire.warnings).toEqual([])
+ await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
+ })
+})
diff --git a/apps/web/tests/built-boot.snapshot.ts b/apps/web/tests/built-boot.snapshot.ts
index 90b27e84cb..6fa5aec1ab 100644
--- a/apps/web/tests/built-boot.snapshot.ts
+++ b/apps/web/tests/built-boot.snapshot.ts
@@ -111,6 +111,17 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
expect(document.querySelector('[data-sample="bash-global"]')).not.toBeNull()
}, { timeout: 10_000 })
+ // The write/edit turns render a real diff card through the assembled graph
+ // (the keyed FileMutationRow + DiffBlock), not just the fixture's raw text.
+ // The write turn's `hello fixture\n` proves the terminator rule end to end: a
+ // trailing newline terminates its line, so the footer reads `+1` (not a
+ // phantom `+2`) and one distinct file. The `+ ` prefix is a CSS ::before, so
+ // it is absent from textContent — assert on the line body and the footer.
+ const diffCards = [...document.querySelectorAll('[data-diff]')]
+ expect(diffCards.length).toBeGreaterThan(0)
+ const footers = diffCards.map(card => card.textContent ?? '')
+ expect(footers.some(text => text.includes('hello fixture') && text.includes('+1 -0 · 1 file'))).toBe(true)
+
// The web render intent reaches the assembled boot graph: the fixture's
// web_search / web_fetch turns render their keyed WebRow cards, proving the
// registration, wire projection, and card rendering survive the real bundle
diff --git a/apps/web/tests/live-interactions.e2e.ts b/apps/web/tests/live-interactions.e2e.ts
index c563638e80..2a4107e128 100644
--- a/apps/web/tests/live-interactions.e2e.ts
+++ b/apps/web/tests/live-interactions.e2e.ts
@@ -221,8 +221,8 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
// only on change, so attempt count is invisible there).
expect(sessionEvents.filter(e => e.type === 'llm/retry').length).toBeGreaterThanOrEqual(1)
await expect.poll(() => page.getByText('event sourcing', { exact: false }).count(), { timeout: 10_000 }).toBeGreaterThan(0)
- // Golden of the recovered end-state: indistinguishable from a clean
- // completion — retries are deliberately invisible in the transcript.
+ // Golden of the recovered end-state: the discarded partial stays absent,
+ // while the settled retry row remains as durable recovery context.
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
await compareOrRefreshGolden(RETRY_EXPECTED, snapshot, MODE)
expect(tripwire.pageErrors).toEqual([])
diff --git a/apps/web/tests/models-settings.e2e.ts b/apps/web/tests/models-settings.e2e.ts
index 28c423b0a0..9c2215ed7f 100644
--- a/apps/web/tests/models-settings.e2e.ts
+++ b/apps/web/tests/models-settings.e2e.ts
@@ -1,14 +1,15 @@
// Web e2e scenario: the Models settings page end to end through the real
// wire — the add card offers the dormant pi-ai catalog, typing an API key
// stores it write-only under the derived reference (`MINIMAX_CN_API_KEY`)
-// while the settings document records only that reference, and the saved
-// route registers live (the row's 已启用 badge is the topology invalidation
-// landing). The customized-settings fold writes the curated reasoning field
-// as a merge patch. Zero model calls: configuration is pure
+// while the settings document records only that reference; the saved row
+// appears after the route topology invalidation without presenting liveness
+// as provider status. The customized-settings fold writes the curated
+// reasoning field as a merge patch. Zero model calls: configuration is pure
// settings/credentials/llm-domain traffic, so there is no fixture and a
// stray stream would fail loud on the open seam. The provider under test is
// minimax-cn so a developer's real ANTHROPIC/OPENAI environment keys can
-// never shadow the derived reference.
+// never shadow the derived reference. Removing that row is guarded by the
+// localized provider-confirmation dialog before the unset reaches the wire.
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
@@ -24,6 +25,7 @@ import { saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/models-settings', import.meta.url))
const EMPTY_EXPECTED = join(SNAPSHOT_DIR, 'empty.expected.md')
const CONFIGURED_EXPECTED = join(SNAPSHOT_DIR, 'configured.expected.md')
+const DELETE_EXPECTED = join(SNAPSHOT_DIR, 'delete.expected.md')
const MODE = webSnapshotMode()
describe('web e2e: Models settings page configures a dormant provider', () => {
@@ -82,7 +84,6 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
// registers, and the topology frame invalidates the page into the row.
const row = dialog.getByText('minimax-cn', { exact: true }).first()
await row.waitFor({ timeout: 10_000 })
- await dialog.getByText('已启用').waitFor({ timeout: 10_000 })
const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
expect(document).toContain('minimax-cn:')
expect(document).toContain('apiKeyEnv: MINIMAX_CN_API_KEY')
@@ -109,11 +110,42 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
expect(document).toContain('apiKeyEnv: MINIMAX_CN_API_KEY')
const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(CONFIGURED_EXPECTED, snapshot, MODE)
+ expect(tripwire.pageErrors).toEqual([])
+ }, 60_000)
+
+ it('confirms provider deletion before removing its settings profile', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-models-delete'))
+ const settingsDialog = page.getByRole('dialog', { name: '设置' })
+ await settingsDialog.getByRole('button', { name: '删除', exact: true }).click()
+ const deleteDialog = page.getByRole('dialog', { name: '删除模型提供方?' })
+ await deleteDialog.waitFor({ timeout: 10_000 })
+ const snapshot = await captureStableAria(
+ page,
+ '[role="dialog"][aria-label="删除模型提供方?"]',
+ scaffold.workspaceCwd,
+ )
+ await compareOrRefreshGolden(DELETE_EXPECTED, snapshot, MODE)
+
+ await deleteDialog.getByRole('button', { name: '取消', exact: true }).click()
+ expect(await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')).toContain('minimax-cn:')
+ await settingsDialog.getByRole('button', { name: '删除', exact: true }).click()
+ await page.getByRole('dialog', { name: '删除模型提供方?' })
+ .getByRole('button', { name: '删除提供方', exact: true }).click()
+ await expect.poll(
+ async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'),
+ { timeout: 10_000 },
+ ).not.toContain('minimax-cn:')
+ expect(await readFile(join(scaffold.harnessHome, '.env'), 'utf8'))
+ .toContain('MINIMAX_CN_API_KEY=sk-e2e-minimax')
+ await expect.poll(
+ async () => page.getByRole('dialog', { name: '删除模型提供方?' }).count(),
+ { timeout: 10_000 },
+ ).toBe(0)
await page.keyboard.press('Escape')
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
- await assertFixtureInventory(SNAPSHOT_DIR, ['configured.expected.md', 'empty.expected.md'])
+ await assertFixtureInventory(SNAPSHOT_DIR, ['configured.expected.md', 'delete.expected.md', 'empty.expected.md'])
})
})
diff --git a/apps/web/tests/queue-actions.e2e.ts b/apps/web/tests/queue-actions.e2e.ts
index 42e44c14ca..9993f18613 100644
--- a/apps/web/tests/queue-actions.e2e.ts
+++ b/apps/web/tests/queue-actions.e2e.ts
@@ -96,6 +96,26 @@ describe('web e2e: queue row actions', () => {
{ timeout: 10_000 },
).toBe(2)
+ await page.setViewportSize({ width: 640, height: 1000 })
+ const queueBox = await page.locator('[data-queue-dock]').boundingBox()
+ const composerBox = await page.locator('[data-composer-card]').boundingBox()
+ expect(queueBox).not.toBeNull()
+ expect(composerBox).not.toBeNull()
+ expect(queueBox!.x).toBeGreaterThanOrEqual(composerBox!.x)
+ expect(queueBox!.x + queueBox!.width)
+ .toBeLessThanOrEqual(composerBox!.x + composerBox!.width)
+ const queueLeftInset = queueBox!.x - composerBox!.x
+ const queueRightInset = composerBox!.x + composerBox!.width - queueBox!.x - queueBox!.width
+ const composerMetrics = await page.locator('[data-composer-card]').evaluate((element) => {
+ const style = getComputedStyle(element)
+ return {
+ dockInset: Number.parseFloat(style.getPropertyValue('--dsh-composer-dock-inset')),
+ }
+ })
+ expect(queueLeftInset).toBeCloseTo(composerMetrics.dockInset, 1)
+ expect(queueRightInset).toBeCloseTo(composerMetrics.dockInset, 1)
+ await page.setViewportSize({ width: 1680, height: 1000 })
+
const editRow = page.getByText(EDIT, { exact: true }).locator('..')
await editRow.getByRole('button', { name: 'Edit queued message' }).click()
const editor = page.getByRole('textbox', { name: 'Edit queued message' })
diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts
index 3437c41e18..472efc178f 100644
--- a/apps/web/tests/seeded-history.e2e.ts
+++ b/apps/web/tests/seeded-history.e2e.ts
@@ -238,7 +238,7 @@ describe('web e2e: seeded history renders through cold resume', () => {
// where neither half repeats the other (the dispatched `/` and its
// argument stay out of the title, and the settlement text never restates
// the command's own name).
- await page.getByRole('button', { name: 'Access mode, current: Danger Full Access' }).click()
+ await page.getByRole('button', { name: 'Access mode, current: Full access' }).click()
await page.getByRole('menuitem', { name: 'Workspace Write' }).click()
await page.getByRole('button', { name: 'Access mode, current: Workspace Write' }).waitFor({ timeout: 10_000 })
// Scoped to the row itself, so unrelated page text that happens to read
diff --git a/apps/web/tests/smoke-real.e2e.ts b/apps/web/tests/smoke-real.e2e.ts
index d2b2d32f21..8bcb8da23a 100644
--- a/apps/web/tests/smoke-real.e2e.ts
+++ b/apps/web/tests/smoke-real.e2e.ts
@@ -267,6 +267,98 @@ describe('dsh web keyless CLI smoke', () => {
}
})
+ it('retries a partial transport failure through the shipped Web composition', async () => {
+ requireDist()
+ const workspace = mkdtempSync(join(tmpdir(), 'dsh-web-retry-'))
+ const promptMarker = 'WEB_RETRY_REQUEST'
+ const recoveredMarker = 'WEB_RETRY_RECOVERED'
+ let mainAttempts = 0
+ const provider = createServer((request, response) => {
+ let body = ''
+ request.setEncoding('utf8')
+ request.on('data', (chunk: string) => { body += chunk })
+ request.on('end', () => {
+ const parsed = JSON.parse(body) as { max_tokens?: number; messages?: unknown[] }
+ const titleRequest = parsed.max_tokens === 64
+ const mainRequest = !titleRequest && body.includes(promptMarker)
+ response.writeHead(200, { 'content-type': 'text/event-stream' })
+ if (!mainRequest) {
+ response.end([
+ 'data: {"choices":[{"delta":{"content":"Web retry title"}}]}',
+ 'data: {"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}',
+ 'data: [DONE]',
+ '',
+ ].join('\n\n'))
+ return
+ }
+ mainAttempts++
+ if (mainAttempts === 1) {
+ response.write('data: {"choices":[{"delta":{"content":"WEB_RETRY_DISCARDED"}}]}\n\n')
+ setTimeout(() => { response.destroy() }, 20)
+ return
+ }
+ response.end([
+ `data: {"choices":[{"delta":{"content":"${recoveredMarker}"}}]}`,
+ 'data: {"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}',
+ 'data: [DONE]',
+ '',
+ ].join('\n\n'))
+ })
+ })
+ await new Promise(resolve => provider.listen(0, '127.0.0.1', resolve))
+ const address = provider.address()
+ if (address === null || typeof address === 'string') throw new Error('mock provider did not bind a TCP port')
+ const tsxLoader = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href
+ const child = spawn(
+ process.execPath,
+ ['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', '0'],
+ {
+ cwd: workspace,
+ env: {
+ ...process.env,
+ DEEPSEEK_API_KEY: 'keyless-web-retry',
+ DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`,
+ DSH_HOME: join(workspace, '.dsh'),
+ TSX_TSCONFIG_PATH: join(REPO_ROOT, 'tsconfig.json'),
+ },
+ stdio: ['ignore', 'pipe', 'pipe'],
+ },
+ )
+ try {
+ const baseUrl = await waitForReadyLine(child)
+ const created = await rpc<{ sessionId: string }>(baseUrl, 'session.create', {})
+ await rpc<{ accepted: true }>(baseUrl, 'session.prompt', {
+ sessionId: created.sessionId,
+ mode: 'queue',
+ content: [{ type: 'text', text: promptMarker }],
+ })
+ let page: HistoryPage | undefined
+ await expect.poll(async () => {
+ page = await history(baseUrl, created.sessionId)
+ return hasAssistantMarker(page, recoveredMarker)
+ }, { timeout: 20_000 }).toBe(true)
+ if (page === undefined) throw new Error('retry history was not observed')
+ const retry = page.events.find(({ event }) => event.type === 'llm/retry')?.event
+ expect(mainAttempts).toBe(2)
+ expect(retry?.data).toMatchObject({
+ turn: 1,
+ step: 1,
+ retry: 1,
+ maxRetries: 2,
+ failure: { code: 'TRANSPORT' },
+ })
+ expect(JSON.stringify(page.events)).toContain('WEB_RETRY_DISCARDED')
+ } finally {
+ const closed = child.exitCode === null
+ ? new Promise((resolveClose) => { child.once('close', () => { resolveClose() }) })
+ : Promise.resolve()
+ if (child.exitCode === null) child.kill('SIGTERM')
+ await closed
+ await new Promise(resolveClose => provider.close(() => { resolveClose() }))
+ rmSync(workspace, { recursive: true, force: true })
+ }
+ }, 30_000)
+
it('DSH_TOOLS_MODE=code collapses the provider wire tools to run_code with the SDK prompt section', async () => {
requireDist()
const workspace = mkdtempSync(join(tmpdir(), 'dsh-web-code-mode-'))
diff --git a/apps/web/tests/snapshots/access-confirmation/ui.expected.md b/apps/web/tests/snapshots/access-confirmation/ui.expected.md
new file mode 100644
index 0000000000..1287e6e565
--- /dev/null
+++ b/apps/web/tests/snapshots/access-confirmation/ui.expected.md
@@ -0,0 +1,10 @@
+- dialog "确认启用 Full access?":
+ - heading "确认启用 Full access?" [level=2]
+ - button "Close":
+ - img
+ - img
+ - paragraph: 启用 Full access 后,agent 将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。
+ - checkbox "我已了解风险,并愿意继续"
+ - text: 我已了解风险,并愿意继续
+ - button "取消"
+ - button "启用 Full access" [disabled]
diff --git a/apps/web/tests/snapshots/code-mode-round/ui.expected.md b/apps/web/tests/snapshots/code-mode-round/ui.expected.md
index 19845a6f77..5d1979eadf 100644
--- a/apps/web/tests/snapshots/code-mode-round/ui.expected.md
+++ b/apps/web/tests/snapshots/code-mode-round/ui.expected.md
@@ -37,7 +37,7 @@
- textbox "Message the agent"
- button "Commands":
- img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
+- 'button "Access mode, current: Full access"': Full access
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
diff --git a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md
index 0d0161867c..3ccbdfb332 100644
--- a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md
+++ b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md
@@ -52,7 +52,7 @@
- textbox "Message the agent"
- button "Commands":
- img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
+- 'button "Access mode, current: Full access"': Full access
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
diff --git a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md
index 7b181553b7..039ecc99ea 100644
--- a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md
+++ b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md
@@ -32,7 +32,7 @@
- textbox "Message the agent"
- button "Commands":
- img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
+- 'button "Access mode, current: Full access"': Full access
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
diff --git a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md
index b45b0bfc0e..7024719a3a 100644
--- a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md
+++ b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md
@@ -28,7 +28,7 @@
- textbox "Describe what you want to build"
- button "Commands":
- img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
+- 'button "Access mode, current: Full access"': Full access
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
diff --git a/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md
index 8be9ff8b86..15bee7afe4 100644
--- a/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md
+++ b/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md
@@ -28,7 +28,7 @@
- textbox "Describe what you want to build"
- button "Commands":
- img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
+- 'button "Access mode, current: Full access"': Full access
- button "Plan mode on, press to turn off": Plan
- button "Select model, current deepseek-v4-flash":
- text: deepseek-v4-flash
diff --git a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md
index 9e2499c974..113f81f9eb 100644
--- a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md
+++ b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md
@@ -24,7 +24,7 @@
- textbox "Message the agent"
- button "Commands":
- img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
+- 'button "Access mode, current: Full access"': Full access
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
diff --git a/apps/web/tests/snapshots/live-interactions/cancel.expected.md b/apps/web/tests/snapshots/live-interactions/cancel.expected.md
index c826bd6584..eb3742eb34 100644
--- a/apps/web/tests/snapshots/live-interactions/cancel.expected.md
+++ b/apps/web/tests/snapshots/live-interactions/cancel.expected.md
@@ -21,7 +21,7 @@
- textbox "Message the agent"
- button "Commands":
- img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
+- 'button "Access mode, current: Full access"': Full access
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
diff --git a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md
index 3de32d7e7d..9dcca575de 100644
--- a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md
+++ b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md
@@ -14,7 +14,7 @@
- textbox "Message the agent"
- button "Commands":
- img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
+- 'button "Access mode, current: Full access"': Full access
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
diff --git a/apps/web/tests/snapshots/live-interactions/retry.expected.md b/apps/web/tests/snapshots/live-interactions/retry.expected.md
index 4e7c3d4554..4ed6a10c6f 100644
--- a/apps/web/tests/snapshots/live-interactions/retry.expected.md
+++ b/apps/web/tests/snapshots/live-interactions/retry.expected.md
@@ -11,6 +11,8 @@
- img
- button "Edit":
- img
+- group:
+ - status: Retried model request (1/2) · {{duration}}
- button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.":
- img
- img
@@ -24,7 +26,7 @@
- textbox "Message the agent"
- button "Commands":
- img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
+- 'button "Access mode, current: Full access"': Full access
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
diff --git a/apps/web/tests/snapshots/message-actions/ui.expected.md b/apps/web/tests/snapshots/message-actions/ui.expected.md
index d423f6f53a..9aed20cfce 100644
--- a/apps/web/tests/snapshots/message-actions/ui.expected.md
+++ b/apps/web/tests/snapshots/message-actions/ui.expected.md
@@ -39,7 +39,7 @@
- textbox "Message the agent"
- button "Commands":
- img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
+- 'button "Access mode, current: Full access"': Full access
- button "Select model, current deepseek-v4-flash":
- text: deepseek-v4-flash
- img
diff --git a/apps/web/tests/snapshots/models-settings/configured.expected.md b/apps/web/tests/snapshots/models-settings/configured.expected.md
index 8b9c4ad6e1..251352ee00 100644
--- a/apps/web/tests/snapshots/models-settings/configured.expected.md
+++ b/apps/web/tests/snapshots/models-settings/configured.expected.md
@@ -14,7 +14,7 @@
- paragraph: 填入各提供方的 API 密钥即可使用其模型。
- list:
- listitem:
- - text: minimax-cn 已启用
+ - text: minimax-cn
- button "编辑"
- button "删除"
- button "+ 添加提供方"
diff --git a/apps/web/tests/snapshots/models-settings/delete.expected.md b/apps/web/tests/snapshots/models-settings/delete.expected.md
new file mode 100644
index 0000000000..afb0cb5fd2
--- /dev/null
+++ b/apps/web/tests/snapshots/models-settings/delete.expected.md
@@ -0,0 +1,7 @@
+- dialog "删除模型提供方?":
+ - heading "删除模型提供方?" [level=2]
+ - button "关闭":
+ - img
+ - paragraph: 删除此模型提供方会移除其配置。在重新添加前,你将无法继续使用其模型。
+ - button "取消"
+ - button "删除提供方"
diff --git a/apps/web/tests/snapshots/plan-review/approved.expected.md b/apps/web/tests/snapshots/plan-review/approved.expected.md
index 915e1b3e40..a81ed0ecd4 100644
--- a/apps/web/tests/snapshots/plan-review/approved.expected.md
+++ b/apps/web/tests/snapshots/plan-review/approved.expected.md
@@ -37,7 +37,7 @@
- textbox "Message the agent"
- button "Commands":
- img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
+- 'button "Access mode, current: Full access"': Full access
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
diff --git a/apps/web/tests/snapshots/question-composer/answered.expected.md b/apps/web/tests/snapshots/question-composer/answered.expected.md
index 49125fbcdd..03c5d84005 100644
--- a/apps/web/tests/snapshots/question-composer/answered.expected.md
+++ b/apps/web/tests/snapshots/question-composer/answered.expected.md
@@ -32,7 +32,7 @@
- textbox "Message the agent"
- button "Commands":
- img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
+- 'button "Access mode, current: Full access"': Full access
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
diff --git a/apps/web/tests/snapshots/queue-actions/collapsed.expected.md b/apps/web/tests/snapshots/queue-actions/collapsed.expected.md
index c96454ed8f..f09469c98c 100644
--- a/apps/web/tests/snapshots/queue-actions/collapsed.expected.md
+++ b/apps/web/tests/snapshots/queue-actions/collapsed.expected.md
@@ -16,7 +16,7 @@
- textbox "Message the agent"
- button "Commands":
- img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
+- 'button "Access mode, current: Full access"': Full access
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
diff --git a/apps/web/tests/snapshots/queue-actions/editing.expected.md b/apps/web/tests/snapshots/queue-actions/editing.expected.md
index 3e4864227d..cd211013c8 100644
--- a/apps/web/tests/snapshots/queue-actions/editing.expected.md
+++ b/apps/web/tests/snapshots/queue-actions/editing.expected.md
@@ -29,7 +29,7 @@
- textbox "Message the agent"
- button "Commands":
- img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
+- 'button "Access mode, current: Full access"': Full access
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
diff --git a/apps/web/tests/snapshots/queue-actions/ui.expected.md b/apps/web/tests/snapshots/queue-actions/ui.expected.md
index 9daac19be5..197e1cd622 100644
--- a/apps/web/tests/snapshots/queue-actions/ui.expected.md
+++ b/apps/web/tests/snapshots/queue-actions/ui.expected.md
@@ -22,7 +22,7 @@
- textbox "Message the agent"
- button "Commands":
- img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
+- 'button "Access mode, current: Full access"': Full access
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
diff --git a/apps/web/tests/snapshots/seeded-history/ui.expected.md b/apps/web/tests/snapshots/seeded-history/ui.expected.md
index 61d3996414..42455b1231 100644
--- a/apps/web/tests/snapshots/seeded-history/ui.expected.md
+++ b/apps/web/tests/snapshots/seeded-history/ui.expected.md
@@ -42,7 +42,7 @@
- textbox "Message the agent"
- button "Commands":
- img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
+- 'button "Access mode, current: Full access"': Full access
- button "Select model, current deepseek-v4-flash":
- text: deepseek-v4-flash
- img
diff --git a/apps/web/tests/snapshots/steering/settled.expected.md b/apps/web/tests/snapshots/steering/settled.expected.md
index d84645270c..ba2adad29f 100644
--- a/apps/web/tests/snapshots/steering/settled.expected.md
+++ b/apps/web/tests/snapshots/steering/settled.expected.md
@@ -33,7 +33,7 @@
- textbox "Message the agent"
- button "Commands":
- img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
+- 'button "Access mode, current: Full access"': Full access
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json
index f5774e1545..d7e48e4e6b 100644
--- a/apps/web/tsconfig.json
+++ b/apps/web/tsconfig.json
@@ -43,7 +43,8 @@
"tests/cordis-tool-round.e2e.ts",
"tests/message-actions.e2e.ts",
"tests/queue-actions.e2e.ts",
- "tests/skill-invocation-policy.e2e.ts"
+ "tests/skill-invocation-policy.e2e.ts",
+ "tests/access-confirmation.e2e.ts"
],
"references": [
{
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index 14446b91d8..3fb1e027ff 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -776,7 +776,7 @@ Requires: `agents`
export type Config = Readonly>
```
-Source: [`packages/llm/llm-retry/src/index.ts:45`](../packages/llm/llm-retry/src/index.ts)
+Source: [`packages/llm/llm-retry/src/index.ts:47`](../packages/llm/llm-retry/src/index.ts)
## `@deepseek-ai/dsh-lsp-local`
diff --git a/docs/module-graph.md b/docs/module-graph.md
index 225da0b51d..45eaddf9e9 100644
--- a/docs/module-graph.md
+++ b/docs/module-graph.md
@@ -822,6 +822,7 @@ flowchart TD
pkg_tool_ask_user --> pkg_invariants
pkg_tool_ask_user --> pkg_tools
pkg_tool_ask_user --> pkg_user_interaction
+ pkg_client_ui_permission --> pkg_client_locale
pkg_client_ui_permission --> pkg_client_runtime
pkg_client_ui_permission --> pkg_client_ui_command
pkg_client_ui_permission --> pkg_client_ui_slash
@@ -1174,7 +1175,7 @@ flowchart TD
| [`session-title-first-message-llm`](../packages/session-title/session-title-first-message-llm) | `session-title` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`session-title-llm`](../packages/session-title/session-title-llm) |
| [`agent-loop-testkit`](../packages/support/agent-loop-testkit) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |
-| [`client-ui-permission`](../packages/client/ui-permission) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-slash`](../packages/client/ui-slash), [`invariants`](../packages/support/invariants), [`permission`](../packages/ui/permission) |
+| [`client-ui-permission`](../packages/client/ui-permission) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-slash`](../packages/client/ui-slash), [`invariants`](../packages/support/invariants), [`permission`](../packages/ui/permission) |
| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) |
| [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) |
| [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) |
diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts
index 8a51f359f1..45445cc9cf 100644
--- a/packages/client/connection/src/client/fixture.ts
+++ b/packages/client/connection/src/client/fixture.ts
@@ -300,6 +300,13 @@ function buildAlphaLog(): SessionEvent[] {
toolTurn(61, 'fx-write', '{"path":"notes/demo.txt","content":"hello fixture\\n"}', 'wrote notes/demo.txt')
toolTurn(62, 'edit', '{"file_path":"notes/demo.txt","old_string":"hello","new_string":"hello fixture"}', '已编辑')
toolTurn(63, 'write', '{"file_path":"notes/new-demo.txt","content":"hello fixture\\n"}', '已写入')
+ // Turn 67: a multi-hunk edit — two scattered replacements in one file. Named
+ // `edit` so it lands on the keyed FileMutationRow (the resident diff card the
+ // single-hunk turn 62 also uses), and file_path `src/config.ts` is the marker
+ // the presenter reads to emit the two-hunk sample: the card draws one path
+ // header, the first hunk, a `⋯` gap, then the second (the same-file
+ // second-hunk arm turns 62/63 cannot reach).
+ toolTurn(67, 'edit', '{"file_path":"src/config.ts","old_string":"const timeout = 30","new_string":"const timeout = 60"}', '已编辑')
// Turn 64: one run_code turn with three logged sub-dispatches — the Code
// Mode acceptance surface (parent code row + nested native-identical rows,
// including an isError sub-call and a bash sub-call that must hit the same
@@ -413,9 +420,26 @@ function presentCall(name: string, argsRaw: string): ToolCallView | undefined {
diffs: [{ path: str(args.path), oldText: null, newText: str(args.content) }],
}
case 'edit':
- return { card: 'generic', title: `Edit ${str(args.file_path)}`, kind: 'edit', rawInput: args }
+ // The multi-hunk sample (turn 67) is keyed on its file_path, so the two
+ // scattered hunks share one path header and the card draws the `⋯` gap.
+ if (str(args.file_path) === 'src/config.ts') {
+ return {
+ card: 'diff', title: `Edit ${str(args.file_path)}`,
+ diffs: [
+ { path: str(args.file_path), oldText: 'const timeout = 30', newText: 'const timeout = 60' },
+ { path: str(args.file_path), oldText: 'retries: 1', newText: 'retries: 3' },
+ ],
+ }
+ }
+ return {
+ card: 'diff', title: `Edit ${str(args.file_path)}`,
+ diffs: [{ path: str(args.file_path), oldText: str(args.old_string), newText: str(args.new_string) }],
+ }
case 'write':
- return { card: 'generic', title: `Write ${str(args.file_path)}`, kind: 'edit', rawInput: args }
+ return {
+ card: 'diff', title: `Write ${str(args.file_path)}`,
+ diffs: [{ path: str(args.file_path), oldText: null, newText: str(args.content) }],
+ }
// The web tools keep a GENERIC pending card and add the `web` result card
// only at result time (the contract's result-only web shape); their pending
// kind matches the result kind so a call and its result read as one category.
@@ -1126,6 +1150,8 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
let failNextHistory = false
/** Force-enders for currently open stream generators (timing hook: simulated connection loss). */
const streamBreakers = new Set<() => void>()
+ /** Retry scenarios opened by timing hooks and completed in a later browser assertion phase. */
+ const retryScenarios = new Map()
// Timing-acceptance hooks (browser test backdoor): the in-memory fixture is ideally timed, which
// is exactly what masked the open-window and reconnect-gap bugs (audit S1/S3). These let
@@ -1149,6 +1175,89 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
const messageSeqs = log.filter(event => event.type === 'user/message').map(event => event.seq)
append(sid(id), { type: 'session/title', data: { title, messageSeqs, source: { kind: 'provider', provider: 'fixture' } } })
},
+ /** Open one failed model step whose partial remains visible until llm/retry arrives. */
+ beginModelRetry(id: string): void {
+ const sessionId = sid(id)
+ const turn = nextTurn.get(sessionId) ?? 0
+ nextTurn.set(sessionId, turn + 1)
+ retryScenarios.set(sessionId, { turn, stepStarted: true })
+ setRunning(sessionId, true)
+ append(sessionId, { type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
+ append(sessionId, { type: 'user/message', surfaceOp: 'append', data: { content: text('请重试这个请求'), source: { kind: 'user' } } })
+ append(sessionId, { type: 'step/start', data: { turn, step: 1 } })
+ append(sessionId, { type: 'assistant/chunk', data: { turn, step: 1, chunk: { type: 'block-start', index: 0, blockType: 'text' } } })
+ append(sessionId, { type: 'assistant/chunk', data: { turn, step: 1, chunk: { type: 'text-delta', index: 0, text: '应撤回的半截回复' } } })
+ append(sessionId, { type: 'step/end', data: { turn, step: 1 } })
+ },
+ /** Record one retry decision, then open the next retry turn. */
+ scheduleModelRetry(id: string, retry = 1, delayMs = 450): void {
+ const sessionId = sid(id)
+ const scenario = retryScenarios.get(sessionId)
+ if (scenario === undefined) throw new Error(`fixture: no model retry scenario for ${id}`)
+ if (!scenario.stepStarted) {
+ append(sessionId, { type: 'step/start', data: { turn: scenario.turn, step: 1 } })
+ append(sessionId, { type: 'assistant/chunk', data: { turn: scenario.turn, step: 1, chunk: { type: 'block-start', index: 0, blockType: 'text' } } })
+ append(sessionId, { type: 'assistant/chunk', data: { turn: scenario.turn, step: 1, chunk: { type: 'text-delta', index: 0, text: `第 ${String(retry)} 次应撤回的回复` } } })
+ append(sessionId, { type: 'step/end', data: { turn: scenario.turn, step: 1 } })
+ scenario.stepStarted = true
+ }
+ const failure = { code: 'TRANSPORT', message: '连接被重置' }
+ append(sessionId, {
+ type: 'llm/retry',
+ data: {
+ turn: scenario.turn, step: 1,
+ provider: 'fixture', mode: 'normal', policyKey: 'fixture-normal',
+ retry, maxRetries: 2, delayMs, failure,
+ },
+ })
+ append(sessionId, {
+ type: 'turn/end',
+ data: { turn: scenario.turn, reason: { kind: 'error', step: 1, failure } },
+ })
+ const next = nextTurn.get(sessionId) ?? scenario.turn + 1
+ nextTurn.set(sessionId, next + 1)
+ append(sessionId, { type: 'turn/start', data: { turn: next, trigger: { kind: 'retry' } } })
+ scenario.turn = next
+ scenario.stepStarted = false
+ },
+ /** Record one retry decision, then cancel its source turn before the retry starts. */
+ cancelModelRetryDuringBackoff(id: string, delayMs = 450): void {
+ const sessionId = sid(id)
+ const scenario = retryScenarios.get(sessionId)
+ if (scenario === undefined) throw new Error(`fixture: no model retry scenario for ${id}`)
+ const failure = { code: 'TRANSPORT', message: '连接被重置' }
+ append(sessionId, {
+ type: 'llm/retry',
+ data: {
+ turn: scenario.turn, step: 1,
+ provider: 'fixture', mode: 'normal', policyKey: 'fixture-normal',
+ retry: 1, maxRetries: 2, delayMs, failure,
+ },
+ })
+ append(sessionId, { type: 'turn/end', data: { turn: scenario.turn, reason: { kind: 'aborted' } } })
+ retryScenarios.delete(sessionId)
+ setRunning(sessionId, false)
+ },
+ /** Finish the timing-hook retry with a finalized response in the open retry turn. */
+ completeModelRetry(id: string): void {
+ const sessionId = sid(id)
+ const scenario = retryScenarios.get(sessionId)
+ if (scenario === undefined) throw new Error(`fixture: no model retry scenario for ${id}`)
+ retryScenarios.delete(sessionId)
+ append(sessionId, { type: 'step/start', data: { turn: scenario.turn, step: 1 } })
+ append(sessionId, {
+ type: 'assistant/message',
+ surfaceOp: 'append',
+ data: {
+ turn: scenario.turn,
+ step: 1,
+ message: assistantMessage(text('重试后的完整回复')),
+ },
+ })
+ append(sessionId, { type: 'step/end', data: { turn: scenario.turn, step: 1 } })
+ append(sessionId, { type: 'turn/end', data: { turn: scenario.turn, reason: { kind: 'completed' } } })
+ setRunning(sessionId, false)
+ },
/** Log append WITHOUT the mux emit: a frame lost in transit — history still serves it, the client must repull. */
appendSilent(id: string, msg: string): void {
const log = logOf(sid(id))
diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts
index 2f09bfc151..f70c4eb421 100644
--- a/packages/client/connection/tests/fixture.spec.ts
+++ b/packages/client/connection/tests/fixture.spec.ts
@@ -19,6 +19,10 @@ interface TimingHooks {
failNextHistory(): void
appendUser(id: string, msg: string): void
appendTitle(id: string, title: string): void
+ beginModelRetry(id: string): void
+ scheduleModelRetry(id: string, retry?: number, delayMs?: number): void
+ cancelModelRetryDuringBackoff(id: string, delayMs?: number): void
+ completeModelRetry(id: string): void
appendSilent(id: string, msg: string): void
breakStreams(): void
}
@@ -814,8 +818,18 @@ describe('createFixtureApi', () => {
hooks.appendSilent('fx-alpha', '静默丢帧')
hooks.appendUser('fx-alpha', '正常直播')
hooks.appendTitle('fx-alpha', 'Fixture 修订标题')
+ hooks.beginModelRetry('fx-alpha')
+ hooks.scheduleModelRetry('fx-alpha')
+ hooks.completeModelRetry('fx-alpha')
+ hooks.beginModelRetry('fx-alpha')
+ hooks.cancelModelRetryDuringBackoff('fx-alpha')
await vi.waitFor(() => {
expect(seen.some(f => f.type === 'session/event' && JSON.stringify(f.event.data).includes('正常直播'))).toBe(true)
+ expect(seen.some(f => f.type === 'session/event' && (f.event as { type: string }).type === 'llm/retry')).toBe(true)
+ expect(seen.some(f => f.type === 'session/event' && JSON.stringify(f.event.data).includes('重试后的完整回复'))).toBe(true)
+ expect(seen.some(f => f.type === 'session/event'
+ && f.event.type === 'turn/end'
+ && f.event.data.reason.kind === 'aborted')).toBe(true)
expect(seen.some(f => f.type === 'session/projection' && f.key === 'title' && f.value === 'Fixture 修订标题')).toBe(true)
})
expect(seen.some(f => f.type === 'session/event' && JSON.stringify(f.event.data).includes('静默丢帧'))).toBe(false)
diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml
index a8d4140893..f42e4f09d0 100644
--- a/packages/client/runtime/README.i18n.yaml
+++ b/packages/client/runtime/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/runtime/README.md
-README.md: 12023868c577ebcae6898d13358a2456295496c2
-README.zh.md: 7ef4c93d36b3f0b32c0bfcf8a38892260240c74f
+README.md: 9f2b165f1a98dcecfa3ab82386da9b094cfd2f54
+README.zh.md: 3ed047e65d3bddc14c3b6b84f327bbeebf805d4b
diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md
index 12023868c5..9f2b165f1a 100644
--- a/packages/client/runtime/README.md
+++ b/packages/client/runtime/README.md
@@ -30,6 +30,10 @@ SlotsService gives the renderer separate bare observables for `useSessions` and
`SessionManager` retains the latest validated `session/title` control snapshot independently of list and session-instance arrival. Newer event seqs replace older snapshots, title timestamps contribute to list recency, and a subscription baseline discards any retained title beyond its `lastSeq` before the optional folded title arrives. Explicit session removal also clears the retained title. The client-facing `SessionSummary.title` is therefore only the actual durable title; `displayTitle` is always present and falls back through the cwd basename and session id. A cold persisted session keeps that fallback until opening or resuming it causes the host to fold and project its log-backed title. `ISession.rename` settles the `title` projection cell directly from the unary response's `{title, seq}` under the same higher-seq-wins rule — the list row and every `useProjection('title')` reader update ahead of the push frame, whose later replay of the same seq is a no-op.
+## Model retry projection
+
+The Session object validates plugin-owned, provider-routed `llm/retry` payloads at the event wire boundary against the producer's complete field contract, including timer, integer, status, provider-delay, and non-empty diagnostic bounds. A valid event removes the matching failed step's streaming partial and inserts a durable retry notice at the event's sequence position. The notice is `scheduled` until a following retry turn starts; an aborted or disposed source turn marks it `cancelled`, while the retry turn marks it `started`. Normal-mode notices carry their finite maximum; always-mode notices remain explicitly unbounded. Window rebuild and history replay apply the same projection, so logged chunks from the discarded attempt never reappear as an interrupted reply after refresh. A terminal turn without `llm/retry` retains the existing behavior: visible unfinalized output is frozen as an interrupted assistant node.
+
## Session forking
`ISessions.fork({sessionId, atSeq?, increaseTitle?})` resolves only after the child summary is locally addressable, carrying source lineage and cwd with `blank: false`; callers choose whether to open it. With `increaseTitle: true`, the client renames the child from the source session's persisted title: a trailing `(N)` or `(N)` is incremented without changing bracket style, while any other title gets ` (1)` appended; the rename is skipped when the source has no persisted title, and a rename failure rejects the promise but leaves the created child in place. This option is not sent in the Host fork request. A `workspace-attach-failed` response still identifies a child already published by the Host, so `SessionManager` reconciles that partial success before `SessionForkError` reaches the caller instead of making a retry create a duplicate child.
diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md
index 7ef4c93d36..3ed047e65d 100644
--- a/packages/client/runtime/README.zh.md
+++ b/packages/client/runtime/README.zh.md
@@ -30,6 +30,10 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
`SessionManager` 独立于列表和 Session 实例到达情况,保留最近一次通过验证的 `session/title` 控制快照。seq 更高的事件会替换旧快照,标题时间戳计入列表新近程度;订阅基线会先丢弃 seq 超过其 `lastSeq` 的任何已保留标题,再接收可选的折叠标题。显式移除 Session 也会清除已保留标题。因此,面向客户端的 `SessionSummary.title` 只包含实际的持久化标题;`displayTitle` 始终存在,并依次回退到 cwd basename 和 Session id。冷态持久化会话会保持该回退值,直到打开或恢复会话,促使主机折叠并投影由日志支撑的标题。`ISession.rename` 用 unary 响应中的 `{title, seq}` 直接结算 `title` 投影格,遵循同一 seq 高者胜规则——列表行和所有 `useProjection('title')` 读者在推送帧到达前即更新;推送帧随后重放同一 seq 时为无操作。
+## 模型重试投影
+
+Session 对象会在事件 wire 边界依据生产方的完整字段契约,验证由插件负责、按提供方路由的 `llm/retry` 载荷,包括计时器、整数、状态、提供方延迟和非空诊断字段的边界。有效事件会移除对应失败步骤的流式输出片段,并在该事件的序列位置插入一条持久的重试提示。该提示在后续重试轮次开始前为 `scheduled`;源轮次中止或释放会将其标记为 `cancelled`,重试轮次则会将其标记为 `started`。normal mode 提示携带其有限上限;always mode 提示则保持显式无界。窗口重建与历史回放应用相同的投影,因此刷新后,来自已丢弃尝试的日志分片绝不会重新显示为中断回复。没有 `llm/retry` 的终止轮次保留现有行为:可见但尚未定稿的输出会冻结为中断的 assistant 节点。
+
## 会话 fork
`ISessions.fork({sessionId, atSeq?, increaseTitle?})` 只在子会话摘要已能在本地寻址后才完成;该摘要携带源会话的谱系和 cwd,且 `blank: false`,由调用方决定是否打开。`increaseTitle: true` 会在 client 端把源会话的持久化标题改名到子会话:尾部 `(N)` 或 `(N)` 递增并保留括号样式,其余标题追加 ` (1)`;源会话没有持久化标题时跳过改名,改名失败时拒绝 promise 但保留已创建的子会话。该选项不会进入 Host fork 请求。即使响应为 `workspace-attach-failed`,其中仍会标识 Host 已发布的子会话,因此 `SessionManager` 会先将这一部分成功对账,再让 `SessionForkError` 到达调用方,避免重试创建重复的子会话。
diff --git a/packages/client/runtime/package.json b/packages/client/runtime/package.json
index 60e2eddf09..a3897e9523 100644
--- a/packages/client/runtime/package.json
+++ b/packages/client/runtime/package.json
@@ -36,6 +36,7 @@
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
+ "@deepseek-ai/dsh-llm-retry": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-projection": "workspace:^",
"@deepseek-ai/dsh-session-title": "workspace:^",
@@ -49,6 +50,7 @@
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
+ "@deepseek-ai/dsh-timeout": "workspace:^",
"@types/react": "~18.3.1",
"cordis": "^4.0.0-rc.7"
},
diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts
index 6c557dfd4e..68d1a3931f 100644
--- a/packages/client/runtime/src/client/index.ts
+++ b/packages/client/runtime/src/client/index.ts
@@ -45,7 +45,7 @@ export type {
export type {
AssistantBlock, AssistantMessageNode, AssistantProvenanceView, AssistantRequestConfig,
AssistantTiming, CodeSubCall, CommandNode, ComposerPhase, ContextMessageNode, ConversationNode,
- ConversationSnapshot, QueuedMessage, RunningToolCall,
+ ConversationSnapshot, ModelRetryNode, QueuedMessage, RunningToolCall,
SteeringMessageNode, TodoItem, ToolResultNode, UnknownSurfaceNode, UserMessageNode,
} from './sessions/conversation.ts'
export type {
diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts
index f68a271c63..ff027d0b99 100644
--- a/packages/client/runtime/src/client/sessions/conversation.ts
+++ b/packages/client/runtime/src/client/sessions/conversation.ts
@@ -5,6 +5,7 @@
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
+import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types'
import type { TodoItem } from '@deepseek-ai/dsh-session/types'
import type {
InboxItemId, RpcError, SessionId, ToolCallView, ToolResultView,
@@ -121,6 +122,19 @@ export interface ContextMessageNode {
source: unknown
}
+/** Durable notice that a closed failed step is waiting for a model-request retry. */
+export type ModelRetryNode = LlmRetryEventData & {
+ kind: 'model-retry'
+ seq: number
+ /** Unix epoch ms from the llm/retry session event. */
+ time: number
+ /**
+ * Client-derived lifecycle: scheduled until a retry turn starts, started
+ * once it does, or cancelled when the failed turn aborts first.
+ */
+ retryState: 'scheduled' | 'started' | 'cancelled'
+}
+
/** A tool result paired (when in-window) with its call head. */
export interface ToolResultNode {
kind: 'tool-result'
@@ -183,6 +197,7 @@ export type ConversationNode =
| AssistantMessageNode
| SteeringMessageNode
| ContextMessageNode
+ | ModelRetryNode
| ToolResultNode
| CommandNode
| UnknownSurfaceNode
@@ -265,7 +280,7 @@ export interface PromptError {
/** The immutable snapshot contract Session hands to uSES (see the web client architecture RFC). */
export interface ConversationSnapshot {
sessionId: SessionId
- /** Surface fold product (finalized conversation nodes in surface order). */
+ /** Finalized surface events and durable operational notices in event order. */
nodes: readonly ConversationNode[]
/** Fold degradation flag (cross-window replace defense): when true, nodes come from the lenient linear scan. */
foldDegraded: boolean
diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts
index 6850e3e4c9..2b63600a59 100644
--- a/packages/client/runtime/src/client/sessions/session.ts
+++ b/packages/client/runtime/src/client/sessions/session.ts
@@ -2,6 +2,7 @@
import type { Context } from 'cordis'
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
+import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type {
HistoryEntry, IApiClient, InboxItemId, MuxFrame, QueueAction, RpcError,
@@ -12,8 +13,8 @@ import type {
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { SessionFace } from '../contract/session.ts'
import type {
- CodeSubCall, ComposerPhase, ConversationNode, ConversationSnapshot, OpenState,
- PromptError, QueuedMessage, RunningToolCall,
+ CodeSubCall, ComposerPhase, ConversationNode, ConversationSnapshot, ModelRetryNode,
+ OpenState, PromptError, QueuedMessage, RunningToolCall,
} from './conversation.ts'
import type { PendingInteraction } from './pending.ts'
import { PendingWait } from './pending.ts'
@@ -26,6 +27,10 @@ import type { ProjectionsBaseline } from './projection-store.ts'
/** Messages requested per history page. */
export const PAGE_MESSAGES = 50
+// Browser bundles cannot value-import the host timeout library. This protocol
+// bound is pinned to @deepseek-ai/dsh-timeout's MAX_TIMER_DELAY_MS in tests.
+const MAX_RETRY_DELAY_MS = 2_147_483_647
+
/** Manager-owned observers of a Session object's local state edges. */
export interface SessionOptions {
/**
@@ -88,9 +93,9 @@ export class Session implements SessionFace {
private readonly foldAdapter = new FoldAdapter()
private partial: PartialAccumulator | null = null
private openCalls = new Map()
- /** Interrupted-turn terminal nodes (frozen partial text / aborted tool cards), merged into the flow by seq.
- * Derived from window events (turn/end sweep) — rebuilt by rebuildDerivedFromWindow like partial/openCalls. */
- private frozenNodes: ConversationNode[] = []
+ /** Operational notices and interrupted-turn terminal nodes merged into the flow by seq.
+ * Derived from window events — rebuilt by rebuildDerivedFromWindow like partial/openCalls. */
+ private derivedNodes: ConversationNode[] = []
private pending = new Map()
// Revision counters preserve array identity when derived content is unchanged, so
// React.memo children survive unrelated snapshot swaps (chunk storms must not re-render every
@@ -100,12 +105,12 @@ export class Session implements SessionFace {
private callsCache: { rev: number; value: RunningToolCall[] } | null = null
private pendingRev = 0
private pendingCache: { rev: number; value: PendingInteraction[] } | null = null
+ private derivedRev = 0
+ private nodesCache: { folded: readonly ConversationNode[]; derivedRev: number; value: readonly ConversationNode[] } | null = null
/** 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
- private nodesCache: { folded: readonly ConversationNode[]; frozenRev: number; value: readonly ConversationNode[] } | null = null
/** `run_code` sub-dispatches by parent callId (window-derived, like openCalls). Appends
* copy-on-write the per-parent array so published snapshot references never mutate. */
private codeDispatches = new Map()
@@ -625,8 +630,28 @@ export class Session implements SessionFace {
}
/** Per-event side effects (right column of the §A.9 dispatch table):
- * chunk accumulation / partial clear on finalize / openCalls add-remove. */
+ * chunk/retry projection and openCalls add-remove. */
private applyEventSideEffects(event: SessionEvent, view?: ToolEventView): void {
+ const eventType = event.type as string
+ if (eventType === 'llm/retry') {
+ const data = parseRetryEventData(event.data)
+ if (data === null) {
+ console.error(`[web-runtime] ignored malformed llm/retry event at seq ${event.seq}`)
+ return
+ }
+ if (this.partial !== null && this.partial.turn === data.turn && this.partial.step === data.step) {
+ this.partial = null
+ }
+ this.derivedNodes.push({
+ kind: 'model-retry',
+ seq: event.seq,
+ time: event.time,
+ retryState: 'scheduled',
+ ...data,
+ })
+ this.derivedRev++
+ return
+ }
// The `tool/code-dispatch-start`/`tool/code-dispatch` pair is declared by
// the host-side dsh-tools plugin whose types cannot enter the client
// program (its host Context merges collide with the client's), so this
@@ -687,6 +712,10 @@ export class Session implements SessionFace {
return
}
switch (event.type) {
+ case 'turn/start': {
+ if (event.data.trigger.kind === 'retry') this.settleScheduledRetry('started')
+ return
+ }
case 'assistant/chunk': {
const { turn, step, chunk } = event.data
if (this.partial === null || this.partial.turn !== turn || this.partial.step !== step) {
@@ -715,6 +744,9 @@ export class Session implements SessionFace {
return
}
case 'turn/end': {
+ if (event.data.reason.kind === 'aborted' || event.data.reason.kind === 'disposed') {
+ this.settleScheduledRetry('cancelled', event.data.turn)
+ }
// Aborted turns never finalize. The accumulated partial is VALUE, not residue: freeze it
// into an interrupted terminal node (pulse stops, text survives) instead of deleting it.
// Shared by live and window-replay paths, so a refresh reconstructs the same frozen node
@@ -724,12 +756,12 @@ export class Session implements SessionFace {
const visible = blocks.some(b => (b.kind === 'text' || b.kind === 'reasoning' ? b.text !== '' : true))
if (visible) {
// Fractional seq: strictly after every event of this turn (all < turn/end seq), before the next turn.
- this.frozenNodes.push({
+ this.derivedNodes.push({
kind: 'assistant', seq: event.seq - 0.9, time: event.time,
turn: this.partial.turn, step: this.partial.step,
blocks, interrupted: true,
})
- this.frozenRev++
+ this.derivedRev++
}
this.partial = null
}
@@ -739,7 +771,7 @@ export class Session implements SessionFace {
this.openCalls.delete(callId)
this.callsRev++
// The spinner card becomes an interrupted terminal card (never vanishes mid-flow).
- this.frozenNodes.push({
+ this.derivedNodes.push({
kind: 'tool-result', seq: event.seq - 0.8 + callOffset++ * 0.01, time: event.time,
callId,
call: { name: call.name, argsRaw: call.argsRaw },
@@ -747,7 +779,7 @@ export class Session implements SessionFace {
content: [], isError: true, error: { name: 'Interrupted', code: 'interrupted' },
callView: call.callView, resultView: null,
})
- this.frozenRev++
+ this.derivedRev++
}
return
}
@@ -756,15 +788,36 @@ export class Session implements SessionFace {
}
}
- /** Re-derive state (partial/openCalls/frozenNodes) from raw window events after a rebuild — keeps
- * paging/stitching consistent, and makes the live freeze and the history replay converge on the
- * same interrupted nodes (chunks are logged, so the replayed sweep re-freezes identical text). */
+ /**
+ * Settle the newest scheduled retry, optionally restricted to its failed turn.
+ * @param retryState - next client projection state to publish.
+ * @param turn - failed turn required for cancellation; omitted for the next retry turn start.
+ */
+ private settleScheduledRetry(
+ retryState: Exclude,
+ turn?: number,
+ ): void {
+ const index = this.derivedNodes.findLastIndex(node =>
+ node.kind === 'model-retry'
+ && node.retryState === 'scheduled'
+ && (turn === undefined || node.turn === turn))
+ if (index < 0) return
+ const node = this.derivedNodes[index]
+ /* v8 ignore next -- findLastIndex's predicate narrows the indexed node only at runtime. */
+ if (node?.kind !== 'model-retry') return
+ this.derivedNodes[index] = { ...node, retryState }
+ this.derivedRev++
+ }
+
+ /** Re-derive state (partial/openCalls/derivedNodes) from raw window events after a rebuild — keeps
+ * paging/stitching consistent, and makes live handling and history replay converge on the same
+ * retry notices and interrupted nodes. */
private rebuildDerivedFromWindow(): void {
this.partial = null
this.openCalls.clear()
this.callsRev++
- this.frozenNodes = []
- this.frozenRev++
+ this.derivedNodes = []
+ this.derivedRev++
this.codeDispatches = new Map()
this.dispatchesRev++
for (let i = 0; i < this.events.length; i++) {
@@ -781,17 +834,17 @@ export class Session implements SessionFace {
private buildSnapshot(): ConversationSnapshot {
const { nodes: folded, degraded } = this.foldAdapter.nodes()
- // Frozen interrupted nodes ride fractional seqs: a stable merge keeps them in flow order.
- // The merged array is cached on (folded reference, frozenRev) so an unchanged flow keeps its
+ // Derived nodes use their event seq or a nearby fractional seq: a stable merge keeps flow order.
+ // The merged array is cached on (folded reference, derivedRev) so an unchanged flow keeps its
// reference across snapshot swaps (§A.9.4).
let nodes: readonly ConversationNode[]
- if (this.nodesCache !== null && this.nodesCache.folded === folded && this.nodesCache.frozenRev === this.frozenRev) {
+ if (this.nodesCache !== null && this.nodesCache.folded === folded && this.nodesCache.derivedRev === this.derivedRev) {
nodes = this.nodesCache.value
} else {
- nodes = this.frozenNodes.length === 0
+ nodes = this.derivedNodes.length === 0
? folded
- : [...folded, ...this.frozenNodes].sort((a, b) => a.seq - b.seq)
- this.nodesCache = { folded, frozenRev: this.frozenRev, value: nodes }
+ : [...folded, ...this.derivedNodes].sort((a, b) => a.seq - b.seq)
+ this.nodesCache = { folded, derivedRev: this.derivedRev, value: nodes }
}
if (this.callsCache === null || this.callsCache.rev !== this.callsRev) {
this.callsCache = { rev: this.callsRev, value: [...this.openCalls.values()] }
@@ -835,6 +888,58 @@ export class Session implements SessionFace {
}
}
+/** Validate the plugin-owned payload at the session-event wire boundary. */
+function parseRetryEventData(value: unknown): LlmRetryEventData | null {
+ if (value === null || typeof value !== 'object') return null
+ const data = value as Record
+ const failure = data.failure
+ if (failure === null || typeof failure !== 'object') return null
+ const failureData = failure as Record
+ if (!nonNegativeSafeInteger(data.turn)
+ || !nonNegativeSafeInteger(data.step)
+ || typeof data.provider !== 'string'
+ || data.provider.length === 0
+ || typeof data.policyKey !== 'string'
+ || data.policyKey.length === 0
+ || !positiveSafeInteger(data.retry)
+ || typeof data.delayMs !== 'number'
+ || !Number.isFinite(data.delayMs)
+ || data.delayMs < 0
+ || data.delayMs > MAX_RETRY_DELAY_MS
+ || typeof failureData.message !== 'string'
+ || failureData.message.length === 0
+ || typeof failureData.code !== 'string'
+ || failureData.code.length === 0) return null
+ if (data.mode === 'normal') {
+ if (!positiveSafeInteger(data.maxRetries) || data.retry > data.maxRetries) return null
+ } else if (data.mode === 'always') {
+ if ('maxRetries' in data) return null
+ } else {
+ return null
+ }
+ if (failureData.status !== undefined
+ && (typeof failureData.status !== 'number'
+ || !Number.isInteger(failureData.status)
+ || failureData.status < 100
+ || failureData.status > 599)) return null
+ if (failureData.providerRetryAfterMs !== undefined
+ && (typeof failureData.providerRetryAfterMs !== 'number'
+ || !Number.isFinite(failureData.providerRetryAfterMs)
+ || failureData.providerRetryAfterMs <= 0)) return null
+ if (failureData.requestId !== undefined
+ && (typeof failureData.requestId !== 'string'
+ || failureData.requestId.length === 0)) return null
+ return data as unknown as LlmRetryEventData
+}
+
+function nonNegativeSafeInteger(value: unknown): value is number {
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0
+}
+
+function positiveSafeInteger(value: unknown): value is number {
+ return nonNegativeSafeInteger(value) && value > 0
+}
+
/**
* The composerPhase judgment — the single site that knows the predicate
* (consumers switch on the result, never re-derive). Monotone per session
diff --git a/packages/client/runtime/tests/event-script.ts b/packages/client/runtime/tests/event-script.ts
index 53f80e0e69..b96fffaae9 100644
--- a/packages/client/runtime/tests/event-script.ts
+++ b/packages/client/runtime/tests/event-script.ts
@@ -63,7 +63,25 @@ export const ev = {
}),
stepEnd: (seq: number, turn: number, step = 0): SessionEvent =>
at(seq, { type: 'step/end', data: { turn, step } }),
- turnEnd: (seq: number, turn: number, reason: 'completed' | 'cancelled' = 'completed'): SessionEvent =>
+ retry: (
+ seq: number,
+ turn: number,
+ step = 0,
+ retry = 1,
+ maxRetries = 2,
+ delayMs = 500,
+ message = 'temporary transport failure',
+ ): SessionEvent =>
+ at(seq, {
+ type: 'llm/retry',
+ data: {
+ turn, step,
+ provider: 'fake', mode: 'normal', policyKey: 'fake-normal',
+ retry, maxRetries, delayMs,
+ failure: { code: 'TRANSPORT', message },
+ },
+ }),
+ turnEnd: (seq: number, turn: number, reason: 'completed' | 'aborted' | 'disposed' = 'completed'): SessionEvent =>
at(seq, { type: 'turn/end', data: { turn, reason: { kind: reason } } }),
commandRun: (seq: number, commandId: string, name: string, args = ''): SessionEvent =>
at(seq, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } }),
diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts
index e62e14bd48..a8b1fbd5dc 100644
--- a/packages/client/runtime/tests/session.spec.ts
+++ b/packages/client/runtime/tests/session.spec.ts
@@ -8,6 +8,7 @@
import { describe, expect, it, vi } from 'vitest'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
+import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import { Session } from '../src/client/sessions/session.ts'
import { FakeApiClient, deferred, err, ok } from './fake-api.ts'
@@ -161,6 +162,214 @@ describe('live event path', () => {
expect((last as { interrupted?: true }).interrupted).toBeUndefined()
})
+ it('retracts the failed step partial on retry and keeps a replayable notice before the recovered response', async () => {
+ const { session } = await opened()
+ const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
+ const retryTurn = [
+ ev.turnStart(6, 1),
+ ev.user(7, '请重试'),
+ ev.stepStart(8, 1),
+ ev.chunkStart(9, 1),
+ ev.chunkText(10, 1, '不完整回复'),
+ ev.stepEnd(11, 1),
+ ev.retry(12, 1, 0, 1, 2, 450, '连接被重置'),
+ at(13, {
+ type: 'turn/end',
+ data: {
+ turn: 1,
+ reason: {
+ kind: 'error', step: 0,
+ failure: { code: 'TRANSPORT', message: '连接被重置' },
+ },
+ },
+ }),
+ at(14, { type: 'turn/start', data: { turn: 2, trigger: { kind: 'retry' } } }),
+ ev.stepStart(15, 2),
+ ev.assistant(16, 2, '完整回复'),
+ ev.stepEnd(17, 2),
+ ev.turnEnd(18, 2),
+ ]
+ for (const event of retryTurn.slice(0, 7)) feed(event)
+
+ let snapshot = session.getSnapshot()
+ expect(snapshot.partial).toBeNull()
+ expect(snapshot.nodes.at(-1)).toMatchObject({
+ kind: 'model-retry',
+ retryState: 'scheduled',
+ turn: 1,
+ step: 0,
+ provider: 'fake',
+ mode: 'normal',
+ policyKey: 'fake-normal',
+ retry: 1,
+ maxRetries: 2,
+ delayMs: 450,
+ failure: { code: 'TRANSPORT', message: '连接被重置' },
+ })
+ expect(JSON.stringify(snapshot.nodes)).not.toContain('不完整回复')
+
+ for (const event of retryTurn.slice(7)) feed(event)
+ snapshot = session.getSnapshot()
+ expect(snapshot.nodes.slice(-2).map(node => node.kind)).toEqual(['model-retry', 'assistant'])
+ expect(snapshot.nodes.at(-2)).toMatchObject({ kind: 'model-retry', retryState: 'started' })
+ expect(snapshot.nodes.at(-1)).toMatchObject({ kind: 'assistant', blocks: [{ kind: 'text', text: '完整回复' }] })
+
+ const replay = makeSession()
+ replay.api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), ...retryTurn])
+ await replay.session.open()
+ expect(replay.session.getSnapshot().nodes).toEqual(snapshot.nodes)
+ expect(replay.session.getSnapshot().partial).toBeNull()
+ })
+
+ it('rejects retry payloads outside the producer contract without retracting the current partial', async () => {
+ const { session } = await opened()
+ const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
+ feed(ev.turnStart(6, 1))
+ feed(ev.chunkStart(7, 1))
+ feed(ev.chunkText(8, 1, '仍在生成'))
+ const valid = {
+ turn: 1, step: 0,
+ provider: 'fake', mode: 'normal', policyKey: 'fake-normal',
+ retry: 1, maxRetries: 2, delayMs: 500,
+ failure: { code: 'TRANSPORT', message: 'temporary failure' },
+ }
+ const invalid = [
+ { ...valid, turn: Number.MAX_SAFE_INTEGER + 1 },
+ { ...valid, step: Number.MAX_SAFE_INTEGER + 1 },
+ { ...valid, provider: '' },
+ { ...valid, policyKey: '' },
+ { ...valid, retry: Number.MAX_SAFE_INTEGER + 1 },
+ { ...valid, maxRetries: Number.MAX_SAFE_INTEGER + 1 },
+ { ...valid, delayMs: -1 },
+ { ...valid, delayMs: Number.POSITIVE_INFINITY },
+ { ...valid, delayMs: MAX_TIMER_DELAY_MS + 1 },
+ { ...valid, failure: { ...valid.failure, message: '' } },
+ { ...valid, failure: { ...valid.failure, code: '' } },
+ { ...valid, failure: { ...valid.failure, status: '429' } },
+ { ...valid, failure: { ...valid.failure, status: 99 } },
+ { ...valid, failure: { ...valid.failure, status: 429.5 } },
+ { ...valid, failure: { ...valid.failure, status: 600 } },
+ { ...valid, failure: { ...valid.failure, providerRetryAfterMs: 0 } },
+ { ...valid, failure: { ...valid.failure, providerRetryAfterMs: Number.POSITIVE_INFINITY } },
+ { ...valid, failure: { ...valid.failure, requestId: 1 } },
+ { ...valid, failure: { ...valid.failure, requestId: '' } },
+ ]
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
+ try {
+ for (const [index, data] of invalid.entries()) {
+ feed(at(9 + index, { type: 'llm/retry', data }))
+ }
+ expect(session.getSnapshot().partial?.blocks).toEqual([{ kind: 'text', text: '仍在生成' }])
+ expect(session.getSnapshot().nodes.filter(node => node.kind === 'model-retry')).toEqual([])
+ expect(errorSpy).toHaveBeenCalledTimes(invalid.length)
+ expect(errorSpy).toHaveBeenCalledWith('[web-runtime] ignored malformed llm/retry event at seq 9')
+ } finally {
+ errorSpy.mockRestore()
+ }
+ })
+
+ it('accepts complete retry payloads at the producer field boundaries', async () => {
+ const { session } = await opened()
+ session.handleMuxEnvelope('r' as never, {
+ type: 'session/event',
+ sessionId: SID,
+ event: at(6, {
+ type: 'llm/retry',
+ data: {
+ turn: Number.MAX_SAFE_INTEGER,
+ step: Number.MAX_SAFE_INTEGER,
+ provider: 'fake',
+ mode: 'normal',
+ policyKey: 'fake-normal',
+ retry: Number.MAX_SAFE_INTEGER,
+ maxRetries: Number.MAX_SAFE_INTEGER,
+ delayMs: MAX_TIMER_DELAY_MS,
+ failure: {
+ code: 'RATE_LIMIT',
+ message: 'provider busy',
+ status: 599,
+ providerRetryAfterMs: Number.MIN_VALUE,
+ requestId: 'req-1',
+ },
+ },
+ }),
+ })
+ expect(session.getSnapshot().nodes.at(-1)).toMatchObject({
+ kind: 'model-retry',
+ retryState: 'scheduled',
+ retry: Number.MAX_SAFE_INTEGER,
+ delayMs: MAX_TIMER_DELAY_MS,
+ failure: { status: 599, providerRetryAfterMs: Number.MIN_VALUE, requestId: 'req-1' },
+ })
+ })
+
+ it('projects always-mode retries and rejects mode-specific maximums or unknown modes', async () => {
+ const { session } = await opened()
+ const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
+ try {
+ feed(at(6, {
+ type: 'llm/retry',
+ data: {
+ turn: 1, step: 0,
+ provider: 'fake', mode: 'always', policyKey: 'fake-always',
+ retry: 3, delayMs: 500,
+ failure: { code: 'TRANSPORT', message: 'retry forever' },
+ },
+ }))
+ expect(session.getSnapshot().nodes.at(-1)).toMatchObject({
+ kind: 'model-retry',
+ retryState: 'scheduled',
+ mode: 'always',
+ retry: 3,
+ })
+
+ feed(at(7, {
+ type: 'llm/retry',
+ data: {
+ turn: 2, step: 0,
+ provider: 'fake', mode: 'always', policyKey: 'fake-always',
+ retry: 4, maxRetries: 4, delayMs: 500,
+ failure: { code: 'TRANSPORT', message: 'unexpected maximum' },
+ },
+ }))
+ feed(at(8, {
+ type: 'llm/retry',
+ data: {
+ turn: 2, step: 0,
+ provider: 'fake', mode: 'sometimes', policyKey: 'fake-unknown',
+ retry: 4, delayMs: 500,
+ failure: { code: 'TRANSPORT', message: 'unknown mode' },
+ },
+ }))
+ expect(session.getSnapshot().nodes.filter(node => node.kind === 'model-retry')).toHaveLength(1)
+ expect(errorSpy).toHaveBeenCalledTimes(2)
+ } finally {
+ errorSpy.mockRestore()
+ }
+ })
+
+ it.each(['aborted', 'disposed'] as const)(
+ 'marks a scheduled retry as cancelled when its failed turn ends %s',
+ async (reason) => {
+ const { session } = await opened()
+ const feed = (event: SessionEvent) => {
+ session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event })
+ }
+ feed(ev.turnStart(6, 1))
+ feed(ev.retry(7, 1))
+ expect(session.getSnapshot().nodes.at(-1)).toMatchObject({
+ kind: 'model-retry',
+ retryState: 'scheduled',
+ })
+ feed(ev.turnEnd(8, 1, reason))
+ expect(session.getSnapshot().nodes.at(-1)).toMatchObject({
+ kind: 'model-retry',
+ retryState: 'cancelled',
+ })
+ },
+ )
+
it('freezes an unfinalized partial into an interrupted node on turn/end (cancel path)', async () => {
const { session } = await opened()
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
@@ -168,7 +377,7 @@ describe('live event path', () => {
feed(ev.user(7, '要被打断的'))
feed(ev.chunkStart(8, 1))
feed(ev.chunkText(9, 1, '说到一半'))
- feed(ev.turnEnd(10, 1, 'cancelled')) // no assistant/message ever arrives
+ feed(ev.turnEnd(10, 1, 'aborted')) // no assistant/message ever arrives
const snapshot = session.getSnapshot()
expect(snapshot.partial).toBeNull()
const frozen = snapshot.nodes.at(-1)
@@ -187,7 +396,7 @@ describe('live event path', () => {
expect(session.getSnapshot().runningCalls).toEqual([])
// Second call never resolves: turn/end freezes it as an error card.
feed(ev.toolCall(9, 1, 'c2', 'slow_tool', '{}'))
- feed(ev.turnEnd(10, 1, 'cancelled'))
+ feed(ev.turnEnd(10, 1, 'aborted'))
const snapshot = session.getSnapshot()
expect(snapshot.runningCalls).toEqual([])
expect(snapshot.nodes.at(-1)).toMatchObject({
@@ -529,7 +738,7 @@ describe('remaining branches', () => {
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
feed(ev.turnStart(6, 1))
feed(ev.chunkStart(7, 1)) // empty text block only, no delta
- feed(ev.turnEnd(8, 1, 'cancelled'))
+ feed(ev.turnEnd(8, 1, 'aborted'))
const snapshot = session.getSnapshot()
expect(snapshot.partial).toBeNull()
expect(snapshot.nodes.filter(n => n.kind === 'assistant' && (n as { interrupted?: true }).interrupted)).toEqual([])
@@ -543,7 +752,7 @@ describe('remaining branches', () => {
feed(ev.turnStart(6, 1))
feed(ev.toolCall(7, 1, 'turn1-call', 'echo', '{}'))
feed(ev.toolCall(8, 2, 'turn2-call', 'echo', '{}')) // stray call attributed to a later turn
- feed(ev.turnEnd(9, 1, 'cancelled'))
+ feed(ev.turnEnd(9, 1, 'aborted'))
const snapshot = session.getSnapshot()
expect(snapshot.runningCalls.map(c => c.callId)).toEqual(['turn2-call'])
expect(snapshot.nodes.at(-1)).toMatchObject({ kind: 'tool-result', callId: 'turn1-call', isError: true })
@@ -637,7 +846,7 @@ describe('remaining branches', () => {
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
feed(ev.turnStart(6, 1))
feed(at(7, { type: 'assistant/chunk', data: { turn: 1, step: 0, chunk: { type: 'tool-call-delta', index: 0, id: 'c1', name: 'echo', argumentsDelta: '{' } } }))
- feed(ev.turnEnd(8, 1, 'cancelled'))
+ feed(ev.turnEnd(8, 1, 'aborted'))
const frozen = session.getSnapshot().nodes.at(-1)
expect(frozen).toMatchObject({ kind: 'assistant', interrupted: true, blocks: [{ kind: 'tool-call', callId: 'c1' }] })
})
diff --git a/packages/client/runtime/tsconfig.json b/packages/client/runtime/tsconfig.json
index 7d3f05e6c7..1abbb0414e 100644
--- a/packages/client/runtime/tsconfig.json
+++ b/packages/client/runtime/tsconfig.json
@@ -35,6 +35,9 @@
{
"path": "../../llm/llm"
},
+ {
+ "path": "../../llm/llm-retry"
+ },
{
"path": "../../support/invariants"
}
diff --git a/packages/client/ui-command/src/client/PopupSelectView.tsx b/packages/client/ui-command/src/client/PopupSelectView.tsx
index 7fe5edcb86..456cdd4200 100644
--- a/packages/client/ui-command/src/client/PopupSelectView.tsx
+++ b/packages/client/ui-command/src/client/PopupSelectView.tsx
@@ -12,7 +12,7 @@
import { useEffect, useRef } from 'react'
import { useSyncExternalStore } from 'react'
import clsx from 'clsx'
-import { IconCheckOutline16, useAnchoredMaxHeight } from '@deepseek-ai/dsh-client-ui-primitives'
+import { IconCheckOutline16, RiskConfirmation, useAnchoredMaxHeight } from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import { filterOptions } from './popup.ts'
import type { PopupSelectController } from './popup.ts'
@@ -60,23 +60,24 @@ export function PopupSelectView({ popup, t }: PopupSelectViewProps) {
// closes the shell before its own handlers run; that click's target then
// takes focus naturally, so no focusComposer here.
useEffect(() => {
- if (!state.open) return
+ if (!state.open || state.confirming !== null) return
const onPointerDown = (ev: PointerEvent): void => {
if (cardRef.current !== null && ev.target instanceof Node && cardRef.current.contains(ev.target)) return
popup.dismiss()
}
document.addEventListener('pointerdown', onPointerDown, true)
return () => { document.removeEventListener('pointerdown', onPointerDown, true) }
- }, [state.open, popup])
+ }, [state.open, state.confirming, popup])
// Focus the search input after it mounts (separate effect so the ref is populated).
useEffect(() => {
- if (state.open) searchRef.current?.focus()
- }, [state.open])
+ if (state.open && state.confirming === null) searchRef.current?.focus()
+ }, [state.open, state.confirming])
if (!state.open) return null
const rows = filterOptions(state.options, state.search)
+ const confirmation = state.confirming?.confirmation
const onKeyDown = (ev: React.KeyboardEvent): void => {
// ArrowLeft/ArrowRight fall through on purpose: the search input keeps
@@ -103,55 +104,73 @@ export function PopupSelectView({ popup, t }: PopupSelectViewProps) {
}
return (
-
+ >
)
}
diff --git a/packages/client/ui-command/src/client/contract.ts b/packages/client/ui-command/src/client/contract.ts
index 61ab4de2e2..8498b5def8 100644
--- a/packages/client/ui-command/src/client/contract.ts
+++ b/packages/client/ui-command/src/client/contract.ts
@@ -6,12 +6,23 @@
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import type { ClientSessionContext } from '@deepseek-ai/dsh-client-ui-slash/client'
+/** Copy for an option that must be acknowledged before onSelect can run. */
+export interface SelectConfirmation {
+ readonly title: string
+ readonly description: string
+ readonly acknowledgeLabel: string
+ readonly cancelLabel: string
+ readonly confirmLabel: string
+}
+
/** One option row of a popupSelect shell. */
export interface SelectOption {
readonly id: string
readonly label: string
readonly detail?: string
readonly active?: boolean
+ /** Optional in-page risk gate owned by the shared popup shell. */
+ readonly confirmation?: SelectConfirmation
}
/**
diff --git a/packages/client/ui-command/src/client/index.ts b/packages/client/ui-command/src/client/index.ts
index e010c95227..76f3d8c551 100644
--- a/packages/client/ui-command/src/client/index.ts
+++ b/packages/client/ui-command/src/client/index.ts
@@ -24,7 +24,7 @@ export { filterOptions, PopupSelectController } from './popup.ts'
export type { PopupSelectDeps, PopupSpec, PopupState, TokenSegment } from './popup.ts'
export type { PopupSelectInjected, PopupSelectViewProps } from './PopupSelectView.tsx'
export type {
- CommandContribution, CommandDecoration, CommandServiceContract, CommandUiSpec, SelectOption,
+ CommandContribution, CommandDecoration, CommandServiceContract, CommandUiSpec, SelectConfirmation, SelectOption,
} from './contract.ts'
export type { CommandKey } from './locales.ts'
diff --git a/packages/client/ui-command/src/client/popup.ts b/packages/client/ui-command/src/client/popup.ts
index c2d30f3213..5e20911820 100644
--- a/packages/client/ui-command/src/client/popup.ts
+++ b/packages/client/ui-command/src/client/popup.ts
@@ -67,12 +67,17 @@ export interface PopupState {
readonly active: number
/** A select() settlement is in flight: further select/search/highlight no-op until it settles. */
readonly submitting: boolean
+ /** Option waiting for explicit risk acknowledgement; null during normal selection. */
+ readonly confirming: SelectOption | null
+ /** Caller-controlled checkbox state for the pending confirmation. */
+ readonly acknowledged: boolean
/** Surfaced settlement failure (options load or onSelect); null when none. */
readonly error: string | null
}
const CLOSED: PopupState = {
- open: false, command: null, status: 'pending', options: [], search: '', active: 0, submitting: false, error: null,
+ open: false, command: null, status: 'pending', options: [], search: '', active: 0,
+ submitting: false, confirming: null, acknowledged: false, error: null,
}
/**
@@ -166,7 +171,7 @@ export class PopupSelectController {
*/
setSearch(search: string): void {
const s = this.state.getSnapshot()
- if (!s.open || s.submitting || search === s.search) return
+ if (!s.open || s.submitting || s.confirming !== null || search === s.search) return
this.state.set({ ...s, search, active: 0 })
}
@@ -177,7 +182,7 @@ export class PopupSelectController {
*/
move(dir: 1 | -1): void {
const s = this.state.getSnapshot()
- if (!s.open || s.status !== 'ready' || s.submitting) return
+ if (!s.open || s.status !== 'ready' || s.submitting || s.confirming !== null) return
const rows = filterOptions(s.options, s.search)
if (rows.length === 0) return
const active = (s.active + dir + rows.length) % rows.length
@@ -191,7 +196,7 @@ export class PopupSelectController {
*/
highlight(index: number): void {
const s = this.state.getSnapshot()
- if (!s.open || s.status !== 'ready' || s.submitting) return
+ if (!s.open || s.status !== 'ready' || s.submitting || s.confirming !== null) return
if (index < 0 || index >= filterOptions(s.options, s.search).length || index === s.active) return
this.state.set({ ...s, active: index })
}
@@ -209,10 +214,46 @@ export class PopupSelectController {
async select(index: number): Promise {
const binding = this.binding
const s = this.state.getSnapshot()
- if (binding === null || !s.open || s.status !== 'ready' || s.submitting) return
+ if (binding === null || !s.open || s.status !== 'ready' || s.submitting || s.confirming !== null) return
const option = filterOptions(s.options, s.search)[index]
if (option === undefined) return
- this.state.set({ ...s, submitting: true, error: null })
+ if (option.confirmation !== undefined) {
+ this.state.set({ ...s, confirming: option, acknowledged: false, error: null })
+ return
+ }
+ await this.settle(binding, option)
+ }
+
+ /**
+ * Update the explicit checkbox for the currently pending risk gate.
+ * @param acknowledged - whether the user has acknowledged the displayed risk.
+ */
+ acknowledge(acknowledged: boolean): void {
+ const s = this.state.getSnapshot()
+ if (!s.open || s.submitting || s.confirming === null || s.acknowledged === acknowledged) return
+ this.state.set({ ...s, acknowledged })
+ }
+
+ /** Cancel only the risk gate and return to the still-open option picker. */
+ cancelConfirmation(): void {
+ const s = this.state.getSnapshot()
+ if (!s.open || s.submitting || s.confirming === null) return
+ this.state.set({ ...s, confirming: null, acknowledged: false })
+ }
+
+ /** Settle the gated option only after the checkbox is acknowledged. */
+ async confirm(): Promise {
+ const binding = this.binding
+ const s = this.state.getSnapshot()
+ if (binding === null || !s.open || s.submitting || s.confirming === null || !s.acknowledged) return
+ await this.settle(binding, s.confirming)
+ }
+
+ /** Run the business settlement for an already admitted option. */
+ private async settle(binding: OpenBinding, option: SelectOption): Promise {
+ const s = this.state.getSnapshot()
+ if (this.binding !== binding || !s.open || s.submitting) return
+ this.state.set({ ...s, submitting: true, confirming: null, acknowledged: false, error: null })
try {
await binding.spec.onSelect(option, binding.context)
} catch (error) {
diff --git a/packages/client/ui-command/tests/popup-view.spec.tsx b/packages/client/ui-command/tests/popup-view.spec.tsx
index d8fadaae7a..0bbe01b4a9 100644
--- a/packages/client/ui-command/tests/popup-view.spec.tsx
+++ b/packages/client/ui-command/tests/popup-view.spec.tsx
@@ -38,6 +38,17 @@ const OPTIONS: SelectOption[] = [
{ id: 'light', label: 'Light', active: true },
{ id: 'sepia', label: 'Sepia', detail: 'warm' },
]
+const GATED: SelectOption = {
+ id: 'full',
+ label: 'Full access',
+ confirmation: {
+ title: 'Enable Full access?',
+ description: 'Sensitive operations.',
+ acknowledgeLabel: 'I understand the risks',
+ cancelLabel: 'Cancel',
+ confirmLabel: 'Enable Full access',
+ },
+}
const SEGMENT: TokenSegment = { via: 'enter', token: '/theme' }
@@ -149,6 +160,37 @@ describe('PopupSelectView', () => {
expect(view.container.childElementCount).toBe(0)
})
+ it('renders a gated option as an in-page modal and requires the checkbox before onSelect', async () => {
+ const onSelect = vi.fn()
+ const { popup, consume } = await mountOpen({
+ options: () => Promise.resolve([GATED]),
+ onSelect,
+ })
+ await act(async () => { fireEvent.click(screen.getByRole('option', { name: 'Full access' })) })
+ expect(screen.queryByLabelText('/theme 选项')).toBeNull()
+ expect(screen.getByRole('dialog', { name: 'Enable Full access?' })).toBeTruthy()
+ const enable = screen.getByRole('button', { name: 'Enable Full access' }) as HTMLButtonElement
+ expect(enable.disabled).toBe(true)
+ expect(onSelect).not.toHaveBeenCalled()
+
+ fireEvent.click(screen.getByRole('checkbox', { name: 'I understand the risks' }))
+ expect(enable.disabled).toBe(false)
+ await act(async () => { fireEvent.click(enable) })
+ expect(onSelect).toHaveBeenCalledExactlyOnceWith(GATED, 'ctx-A')
+ expect(consume).toHaveBeenCalledExactlyOnceWith(SEGMENT)
+ expect(popup.state.getSnapshot().open).toBe(false)
+ })
+
+ it('canceling a gated option returns to the picker with acknowledgement reset', async () => {
+ await mountOpen({ options: () => Promise.resolve([GATED]) })
+ await act(async () => { fireEvent.click(screen.getByRole('option', { name: 'Full access' })) })
+ fireEvent.click(screen.getByRole('checkbox'))
+ fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
+ expect(screen.getByLabelText('/theme 选项')).toBeTruthy()
+ await act(async () => { fireEvent.click(screen.getByRole('option', { name: 'Full access' })) })
+ expect(screen.getByRole('checkbox').checked).toBe(false)
+ })
+
it('submitting shows pending, locks the search input, and further Enter/click no-op', async () => {
let release!: () => void
const onSelect = vi.fn(() => new Promise((resolve) => { release = resolve }))
diff --git a/packages/client/ui-command/tests/popup.spec.ts b/packages/client/ui-command/tests/popup.spec.ts
index 87a1070a40..68e8084b80 100644
--- a/packages/client/ui-command/tests/popup.spec.ts
+++ b/packages/client/ui-command/tests/popup.spec.ts
@@ -19,6 +19,17 @@ const OPTIONS: SelectOption[] = [
{ id: 'light', label: 'Light', active: true },
{ id: 'sepia', label: 'Sepia', detail: 'warm' },
]
+const GATED: SelectOption = {
+ id: 'full',
+ label: 'Full access',
+ confirmation: {
+ title: 'Enable Full access?',
+ description: 'Sensitive operations.',
+ acknowledgeLabel: 'I understand',
+ cancelLabel: 'Cancel',
+ confirmLabel: 'Enable Full access',
+ },
+}
const SEGMENT: TokenSegment = { via: 'enter', token: '/theme' }
@@ -200,6 +211,38 @@ describe('search / move / highlight over the filtered list', () => {
})
describe('select', () => {
+ it('gates a confirmed option until acknowledgement, then settles through the original binding', async () => {
+ const onSelect = vi.fn()
+ const deps = makeDeps()
+ const { popup } = await readyPopup({ options: () => Promise.resolve([GATED]), onSelect }, deps)
+ await popup.select(0)
+ expect(popup.state.getSnapshot()).toMatchObject({
+ open: true, confirming: GATED, acknowledged: false, submitting: false,
+ })
+ expect(onSelect).not.toHaveBeenCalled()
+ await popup.confirm()
+ expect(onSelect).not.toHaveBeenCalled()
+ popup.acknowledge(true)
+ await popup.confirm()
+ expect(onSelect).toHaveBeenCalledExactlyOnceWith(GATED, CTX_A)
+ expect(deps.consume).toHaveBeenCalledExactlyOnceWith(SEGMENT)
+ expect(popup.state.getSnapshot().open).toBe(false)
+ })
+
+ it('cancels a confirmation back to the picker without selecting or consuming', async () => {
+ const onSelect = vi.fn()
+ const deps = makeDeps()
+ const { popup } = await readyPopup({ options: () => Promise.resolve([GATED]), onSelect }, deps)
+ await popup.select(0)
+ popup.acknowledge(true)
+ popup.cancelConfirmation()
+ expect(popup.state.getSnapshot()).toMatchObject({
+ open: true, confirming: null, acknowledged: false, submitting: false,
+ })
+ expect(onSelect).not.toHaveBeenCalled()
+ expect(deps.consume).not.toHaveBeenCalled()
+ })
+
it('runs onSelect with the filtered option and the open-time context, consumes, closes, refocuses', async () => {
const seen: Array<{ option: SelectOption; context: Ctx }> = []
const deps = makeDeps()
diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml
index cfbff2117e..28068bdc5f 100644
--- a/packages/client/ui-conversation/README.i18n.yaml
+++ b/packages/client/ui-conversation/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
-README.md: 64440a3d69d78871bdc4777f88bb65c71b02fb69
-README.zh.md: dde2d38d9fae102dbfd49e9c5cacf16385ef3ef8
+README.md: 7c6e36409efd5f2f9224e85a9cbd3a5e515833c1
+README.zh.md: 7661826153bc44ff47a660fa49a4ffd902d93bdd
diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md
index 64440a3d69..7c6e36409e 100644
--- a/packages/client/ui-conversation/README.md
+++ b/packages/client/ui-conversation/README.md
@@ -8,7 +8,7 @@ The resident conversation shell survives no-session and session transitions. Wit
The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: `), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves.
-Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The sidebar mirrors the blocked state through the manager-tracked `waitingApproval` list bit (lit for uninstantiated sessions too), which outranks the running ring until the question resolves. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels (the `/permission` popup's display transform twin), and a pick submits the `/permission ` command line through the bar's injected `command` callback.
+Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The sidebar mirrors the blocked state through the manager-tracked `waitingApproval` list bit (lit for uninstantiated sessions too), which outranks the running ring until the question resolves. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels. Safe preset picks submit `/permission ` immediately through the bar's injected `command` callback, while `danger-full-access` is presented as `Full access` and first opens an in-page Modal risk confirmation. The enabling action stays disabled until the user checks the acknowledgement; cancel, Escape, close, and mask click submit nothing.
Logged non-user messages render as a default-collapsed `上下文注入` disclosure. It shares the Tool calls header geometry and interaction with `ToolRow` through the package-internal `DisclosureRow`, while retaining context semantics: the expanded 141px scrollport shows bounded inline JSON for both `content` and `source`, and no tool state, summary, or keyed toolview dispatch is synthesized ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md)).
@@ -18,6 +18,10 @@ A tool call declaring the `terminal` render intent renders its command output in
A tool call declaring the `web` render intent renders its web retrieval inline, at both conversation render sites, through ui-primitives' `WebBlock`. `contract/web-card-model.ts` is the single derivation from the snapshot's `resultView`, mirroring the terminal card, so the sites cannot disagree about what a web call shows; it yields null — the generic path — for a running call, a non-web result view, a generic result view, a `card` tag this client version does not know, or a web card whose `kind` this client version does not know (a newer host's value, which the wire cannot be trusted to be `search` or `fetch`). The keyed `WebRow` registers one component under both `web_search` and `web_fetch`, discriminating on the tool name only for its icon and title; a web-declaring tool without a keyed row lands on the `GenericToolCard` fallback, which grows the same resident card, and the details panel renders it at the primitive's full source allowance and, below the card, the flattened model-visible result content — a fetch body is readable only there, since its card carries only the URL and status. Rows cap at `CHAT_WEB_MAX_SOURCES` (8) against the panel's 16, the same summary-versus-reading split the terminal card draws ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md)).
+A tool call declaring the `diff` render intent (the `write`/`edit` tools) renders its applied change inline through ui-primitives' `DiffBlock`, the same four-layer shape. `contract/diff-card-model.ts` is the single derivation from the `callView`/`resultView` pair; the settled result's hunks replace the call-time diff, and it yields null — the generic path — for any other card tag or a generic result view (write/edit's execution errors). The keyed `FileMutationRow` (registered under both `write` and `edit`) carries the card resident below its summary, whose path link still opens the file through the host; the render-site fallback and the details panel are diff-aware too. Rows cap at `CHAT_DIFF_MAX_LINES` (8) against the panel's 16 ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md)).
+
+The chat flow projects consecutive model-retry nodes across retry turns into one stable, muted status row updated to the latest attempt; every retry event remains in the runtime snapshot and session log. Its frontend countdown anchors the scheduled delay to client receipt, avoiding host/browser clock skew, rounds remaining time up to seconds, and has a one-second floor. The latest unresolved retry uses a left-to-right text shimmer. Subsequent turn facts distinguish an attempt that started from one cancelled during backoff, while the Host running bit only controls the live animation; the row then shows a static completed or cancelled label. Normal policy rows show the finite retry maximum; always policy rows show `∞`. Activating the row reveals the latest exact retry delay and failure message. The client runtime removes each failed step's streaming tail before its retry node arrives, while the status remains visible after a later attempt succeeds.
+
Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openFile`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders).
The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`/ 已完成 · ` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: -1` — above the queue rows — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and starts collapsed as a header of title plus `"/ tasks · in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the standing list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included.
diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md
index dde2d38d9f..7661826153 100644
--- a/packages/client/ui-conversation/README.zh.md
+++ b/packages/client/ui-conversation/README.zh.md
@@ -16,9 +16,13 @@
声明 `web` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `WebBlock` 内联渲染其 web 检索。`contract/web-card-model.ts` 是从快照的 `resultView` 推导的唯一位置,镜像终端卡片,因此两个渲染点不可能对一次 web 调用的显示产生分歧;对运行中的调用、非 web 的 result view、generic result view、本客户端版本不认识的 `card` 标签,或本客户端版本不认识 `kind` 的 web 卡片(更新的 host 发来的值,wire 上不可信其为 `search` 或 `fetch`),它返回 null,落回通用路径。键控的 `WebRow` 把一个组件注册在 `web_search` 与 `web_fetch` 两个键下,仅根据工具名判别以选取图标与标题;没有自己键控行的 web 声明工具落到 `GenericToolCard` 兜底,它长出同一张常驻卡片,详情面板则以原语的完整 source 额度渲染它,并在卡片下方渲染摊平的模型可见结果内容——fetch 正文只在此处可读,因为其卡片只携带 URL 和状态。行的上限是 `CHAT_WEB_MAX_SOURCES`(8),面板为 16,与终端卡片所画的摘要面对阅读面的同一划分([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md))。
+声明 `diff` 渲染意图的工具调用(`write`/`edit` 工具),通过 ui-primitives 的 `DiffBlock` 内联渲染其已应用的改动,采用同一套四层结构。`contract/diff-card-model.ts` 是从 `callView`/`resultView` 对推导的唯一位置;已结算 result 的 hunk 替换 call 时 diff,对任何其他 card 标签或 generic result view(write/edit 的执行错误)它返回 null,落回通用路径。键控的 `FileMutationRow`(在 `write` 与 `edit` 下都注册)把卡片常驻在摘要之下,其路径链接仍经 host 打开文件;渲染点兜底行与详情面板同样感知 diff。行的上限是 `CHAT_DIFF_MAX_LINES`(8),面板为 16([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md))。
+
+聊天流会将跨重试轮次连续出现的模型重试节点投影为一个稳定的弱化状态行,并用最新一次尝试更新该行;每个重试事件仍保留在运行时快照与会话日志中。前端倒计时以客户端收到事件的时刻为计划延迟的起点,避免 Host 与浏览器的时钟偏差;剩余时间向上取整到秒,且下限为 1 秒。最近一次尚未完成的重试会显示从左到右的文字渐变动画。后续轮次事实用于区分已开始的尝试与在退避期间取消的尝试,Host 的 running 位只控制实时动画;随后该行会显示静态的已完成或已取消标签。normal 策略行显示有限重试上限;always 策略行显示 `∞`。激活该行会显示最近一次重试的精确延迟和失败消息。客户端运行时会在相应重试节点到达前移除每个失败步骤的流式输出尾部;后续某次尝试成功后,该状态仍保持可见。
+
工具行同样是 slot:独立工具环(`ToolViewRegistry`/`ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openFile`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);Session 区分在组件内部完成(`useSessions` 读取 `parentId`,bash 示例是第三方姿态的范例)。Trajectory/waterfall 工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。
-审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。侧边栏通过 manager 跟踪的 `waitingApproval` 列表位(未实例化会话同样点亮)镜像该阻塞状态,其优先级高于运行中圆环,直至问题解决。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,kebab-case 预设名渲染为 Title Case 标签(与 `/permission` popup 的显示变换孪生),选中会经由输入栏注入的 `command` 回调提交 `/permission ` 命令行。
+审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。侧边栏通过 manager 跟踪的 `waitingApproval` 列表位(未实例化会话同样点亮)镜像该阻塞状态,其优先级高于运行中圆环,直至问题解决。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,普通安全预设会立即经输入栏注入的 `command` 回调提交 `/permission `,而 `danger-full-access` 在界面中显示为 `Full access`,选择后先打开页面内的 Modal 风险确认。用户勾选确认项前启用按钮始终不可用;取消、Escape、关闭按钮与点击遮罩都不会提交命令。
todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: -1` 占用 `'conversation.input.dock'` 列表 slot(位于队列行之上),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加 `"<已完成>/<总数> tasks · in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。
diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts
index 24c26ab432..0be0f4f534 100644
--- a/packages/client/ui-conversation/src/client/apply.ts
+++ b/packages/client/ui-conversation/src/client/apply.ts
@@ -20,6 +20,7 @@ import { InputBar } from './skeleton/InputBar.tsx'
import { ChatView } from './chat/ChatView.tsx'
import { StatsLine } from './chat/StatsLine.tsx'
import { bashToolviewSample } from './toolviews/bash-sample.tsx'
+import { fileMutationToolview } from './toolviews/file-mutation-row.tsx'
import { webToolview } from './toolviews/web-row.tsx'
import { ApprovalPanel } from './skeleton/ApprovalPanel.tsx'
import { todoToolview } from './toolviews/todo-row.tsx'
@@ -319,6 +320,10 @@ export function apply(ctx: Context): void {
// (ToolRow-matching Bash · {description} chrome; scoped badge in child sessions).
ctx.plugin(bashToolviewSample)
+ // The write/edit rows ride the same seam: a file-mutation call declares the
+ // diff render intent, so these rows stack the applied diff card under their
+ // path-link summary (the terminal card's posture, applied to diffs).
+ ctx.plugin(fileMutationToolview)
// The web rows ride the same seam: one WebRow registered under both
// web_search and web_fetch, rendering the completed retrieval's web card
// resident under the summary (a product registration, not a sample).
diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx
index 9bc089520c..d509a2e521 100644
--- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx
+++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx
@@ -55,6 +55,17 @@ type RenderToolRow = ChatViewSlotProps['renderSlot']
* chat view narrows once to the runtime snapshot the binding actually feeds. */
type UseConversation = SnapshotSelectorHook
+function activeRetrySeq(nodes: readonly ConversationNode[], running: boolean): number | null {
+ if (!running) return null
+ for (let index = nodes.length - 1; index >= 0; index -= 1) {
+ const node = nodes[index]
+ if (node === undefined) continue
+ if (node.kind === 'model-retry') return node.retryState === 'cancelled' ? null : node.seq
+ if (node.kind === 'assistant' || node.kind === 'user') return null
+ }
+ return null
+}
+
/** One `run_code` sub-dispatch row: the identical keyed-slot dispatch as a
* top-level call (same registrations, same fallback), nested by the parent.
* A started-but-unsettled sub-call arrives as the RunningToolCall shape and
@@ -262,6 +273,7 @@ export function ChatView({
const selectedCallId = useStore(s => s.selection?.callId)
const items = useMemo(() => deriveChatFlow(nodes), [nodes])
+ const activeRetry = useMemo(() => activeRetrySeq(nodes, running), [nodes, running])
// Only the last content assistant of each turn owns IconActions; mid-turn
// text (before tools) omits `time` so AssistantMarkdown stays chrome-free.
const actionSeqs = useMemo(() => assistantActionsSeqs(nodes), [nodes])
@@ -424,7 +436,15 @@ export function ChatView({
}
/* v8 ignore next -- tool-result never reaches here: deriveChatFlow folds them into groups. */
if (node.kind === 'tool-result') return null
- return
+ return (
+
+ )
}
return (
diff --git a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx
index d35e7c0721..faadcf090b 100644
--- a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx
+++ b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx
@@ -10,6 +10,7 @@ import {
IconThinkOutline14, WebBlock,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps, ToolRowOwnerProps } from '../contract/slots.ts'
+import { diffCardModel } from '../contract/diff-card-model.ts'
import { terminalCardModel, terminalFailed } from '../contract/terminal-card-model.ts'
import { CHAT_WEB_MAX_SOURCES, webCardModel } from '../contract/web-card-model.ts'
import { toolRowModel, type ToolRowVariant } from '../contract/tool-call-model.ts'
@@ -36,6 +37,7 @@ export interface GenericToolCardProps extends ToolRowOwnerProps {
export function GenericToolCard({ toolName, block, cwd, openFile, inspect, t }: GenericToolCardProps) {
const model = toolRowModel(toolName, block, cwd)
const terminal = terminalCardModel(block, cwd)
+ const diff = diffCardModel(block)
const web = webCardModel(block)
// A failing exit status is the terminal card's own error signal (the call
// itself settles isError:false), surfaced as the row's red state dot.
@@ -53,10 +55,14 @@ export function GenericToolCard({ toolName, block, cwd, openFile, inspect, t }:
// A terminal presenter's description is the contract's above-card text, so
// it outranks the args-derived summary here exactly as it does in BashRow.
summary={terminal?.description ?? model.summary}
- body={model.body}
+ // Single-file tools never expose an args body — the path link is the only
+ // args interaction. A diff card is not an args body: a write/edit row is
+ // single-file AND carries a diff, so the card expands under the path link.
+ body={singleFile ? null : model.body}
output={model.output}
errorSummary={model.errorSummary}
terminal={terminal}
+ diff={diff}
state={state}
filePath={model.filePath}
onOpenFile={singleFile ? openFile : undefined}
diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css
index 2667024bcd..3ecc77a23d 100644
--- a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css
+++ b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css
@@ -34,6 +34,106 @@
padding: 2px 0;
}
+.retryRow {
+ color: var(--dsw-alias-label-tertiary);
+ font-size: 13px;
+ line-height: 20px;
+}
+
+.retrySummary {
+ display: inline-flex;
+ align-items: center;
+ width: fit-content;
+ padding: 2px 0;
+ gap: 7px;
+ border-radius: 3px;
+ color: inherit;
+ cursor: pointer;
+ list-style: none;
+ user-select: none;
+}
+
+.retrySummary::-webkit-details-marker {
+ display: none;
+}
+
+.retrySummary::after {
+ width: 6px;
+ height: 6px;
+ border-right: 1.5px solid currentcolor;
+ border-bottom: 1.5px solid currentcolor;
+ content: '';
+ opacity: 0.8;
+ transform: rotate(-45deg);
+ transition: transform 120ms ease;
+}
+
+.retrySummary:hover {
+ color: var(--dsw-alias-label-secondary);
+}
+
+.retrySummary:focus-visible {
+ outline: 1.5px solid var(--dsw-alias-button-info-fill);
+ outline-offset: 2px;
+}
+
+.retryText {
+ color: inherit;
+}
+
+.retryRow[data-active] .retryText {
+ background:
+ linear-gradient(
+ 90deg,
+ var(--dsw-alias-label-tertiary) 0%,
+ var(--dsw-alias-label-tertiary) 40%,
+ var(--dsw-alias-label-secondary) 50%,
+ var(--dsw-alias-label-tertiary) 60%,
+ var(--dsw-alias-label-tertiary) 100%
+ );
+ background-position: 100% 50%;
+ background-size: 200% 100%;
+ background-clip: text;
+ color: transparent;
+ animation: retry-shimmer 1.6s ease-in-out infinite;
+}
+
+.retryRow[open] .retrySummary::after {
+ transform: rotate(45deg);
+}
+
+.retryDetails {
+ display: grid;
+ gap: 2px;
+ margin-top: 3px;
+ padding-left: 14px;
+ overflow-wrap: anywhere;
+ font-size: 12px;
+ line-height: 18px;
+}
+
+.retryDetailLabel {
+ color: var(--dsw-alias-label-secondary);
+}
+
+@keyframes retry-shimmer {
+ from {
+ background-position: 100% 50%;
+ }
+
+ to {
+ background-position: 0 50%;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .retryRow[data-active] .retryText {
+ background: none;
+ color: inherit;
+ animation: none;
+ }
+}
+
/* Reference chip projection inside a user bubble (`name` model
spans render as chips; free geometry — no textarea pairing here). */
.refChip {
diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx
index bb6429470f..d271db9989 100644
--- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx
+++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx
@@ -1,13 +1,11 @@
-// MessageItem: the four simple node kinds — user bubble (right-aligned, with
+// MessageItem: simple chat nodes — user bubble (right-aligned, with
// clock + copy / branch / edit IconActions), steering (badged bubble), context
-// injection and unknown-surface JSON rows. Props are frozen node slices off
-// the snapshot cache; memo holds across streaming because unchanged nodes
-// keep their references.
+// injection, retry disclosure, and unknown-surface JSON rows.
-import { memo } from 'react'
+import { memo, useEffect, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
import type {
- ContextMessageNode, SteeringMessageNode, UnknownSurfaceNode, UserMessageNode,
+ ContextMessageNode, ModelRetryNode, SteeringMessageNode, UnknownSurfaceNode, UserMessageNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import { JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps } from '../contract/slots.ts'
@@ -16,7 +14,8 @@ import { MessageIconActions } from './MessageIconActions.tsx'
import css from './MessageItem.module.css'
export interface MessageItemProps {
- node: UserMessageNode | SteeringMessageNode | ContextMessageNode | UnknownSurfaceNode
+ node: UserMessageNode | SteeringMessageNode | ContextMessageNode | ModelRetryNode | UnknownSurfaceNode
+ retryActive?: boolean
/** Fork the session through the turn containing this message (user-bubble branch action). */
onFork?: (seq: number) => void
/** The owning view's locale seat, passed down as a plain prop. */
@@ -34,6 +33,80 @@ function contentText(content: readonly unknown[]): { text: string; rest: unknown
return { text: texts.join(''), rest }
}
+function retrySeconds(milliseconds: number): number {
+ return Math.max(1, Math.ceil(milliseconds / 1_000))
+}
+
+interface RetryCountdown {
+ deadline: number
+ seconds: number
+}
+
+function ModelRetryItem({ node, active, t }: {
+ node: ModelRetryNode
+ active: boolean
+ t: ChatViewSlotProps['t']
+}) {
+ // Anchor the host-scheduled delay to this browser's first render of the
+ // retry node. Host event time and Date.now() may belong to different clocks.
+ const deadline = useMemo(() => Date.now() + node.delayMs, [node.delayMs, node.seq])
+ const scheduledSeconds = retrySeconds(node.delayMs)
+ const maximum = node.mode === 'normal' ? node.maxRetries : '∞'
+ const [countdown, setCountdown] = useState(() => ({
+ deadline,
+ seconds: retrySeconds(deadline - Date.now()),
+ }))
+ const remainingSeconds = countdown.deadline === deadline
+ ? countdown.seconds
+ : retrySeconds(deadline - Date.now())
+
+ useEffect(() => {
+ if (!active) return
+ const updateCountdown = (): number => {
+ const next = retrySeconds(deadline - Date.now())
+ setCountdown(current => (
+ current.deadline === deadline && current.seconds === next
+ ? current
+ : { deadline, seconds: next }
+ ))
+ return next
+ }
+ if (updateCountdown() === 1) return
+ const timer = window.setInterval(() => {
+ if (updateCountdown() === 1) window.clearInterval(timer)
+ }, 250)
+ return () => { window.clearInterval(timer) }
+ }, [active, deadline])
+
+ const label = active
+ ? t('message.retry.active')
+ : node.retryState === 'cancelled'
+ ? t('message.retry.cancelled')
+ : node.retryState === 'started'
+ ? t('message.retry.started')
+ : t('message.retry.scheduled')
+ const seconds = active ? remainingSeconds : scheduledSeconds
+
+ return (
+
+
+
+ {t('message.retry.status', { label, retry: node.retry, maximum, seconds })}
+
+
+
+
+ )
+}
/**
* Display projection of reference forms in a user bubble (free geometry — no
* textarea alignment constraint here); everything else stays plain text. The
@@ -66,7 +139,9 @@ function projectUserText(text: string): ReactNode {
return <>{parts}>
}
-export const MessageItem = memo(function MessageItem({ node, onFork, t }: MessageItemProps) {
+export const MessageItem = memo(function MessageItem({
+ node, retryActive = false, onFork, t,
+}: MessageItemProps) {
const truncated = (total: number): string => t('json.truncated', { total })
switch (node.kind) {
case 'user': {
@@ -105,6 +180,8 @@ export const MessageItem = memo(function MessageItem({ node, onFork, t }: Messag
return (
)
+ case 'model-retry':
+ return
default:
return (
diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css
index 43cb37462a..030a6f0d80 100644
--- a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css
+++ b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css
@@ -257,6 +257,12 @@
margin: 4px 0 4px 4px;
}
+/* A write/edit diff renders through DiffBlock; like the terminal card it draws
+ its own surface, so only the row indentation is this file's concern. */
+.diffBody {
+ margin: 4px 0 4px 4px;
+}
+
/* In-row code renders at the smaller code size (12/18) via each primitive's
rebindable content-font seam; standalone markdown code blocks keep 13/22. */
.codeBody {
diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx
index 9413e1809c..40a5824ce3 100644
--- a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx
+++ b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx
@@ -18,8 +18,9 @@
import { useState, type MouseEvent, type ReactNode } from 'react'
import clsx from 'clsx'
-import { CodeBlock, StateDot, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives'
+import { CodeBlock, DiffBlock, StateDot, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives'
import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
+import { CHAT_DIFF_MAX_LINES, type DiffCardModel } from '../contract/diff-card-model.ts'
import { terminalBlockLabels, type TerminalCardModel } from '../contract/terminal-card-model.ts'
import type { ToolRowState, ToolRowVariant } from '../contract/tool-call-model.ts'
import { DisclosureRow } from './DisclosureRow.tsx'
@@ -48,6 +49,13 @@ export interface ToolRowProps {
* expandable.
*/
terminal?: TerminalCardModel | null | undefined
+ /**
+ * Diff-card material for a call whose render intent is a diff card (derived by
+ * `diffCardModel`); it replaces the text body when present, the same way
+ * `terminal` does. A call carries at most one card intent, so the two are
+ * never both set.
+ */
+ diff?: DiffCardModel | null | undefined
state: ToolRowState
/**
* Filesystem path from tool args; when set with onOpenFile, the summary
@@ -95,6 +103,7 @@ export function ToolRow({
output,
errorSummary,
terminal,
+ diff,
state,
filePath,
onOpenFile,
@@ -102,8 +111,9 @@ export function ToolRow({
}: ToolRowProps) {
const [expanded, setExpanded] = useState(false)
const terminalBody = terminal ?? null
+ const diffBody = diff ?? null
const outputText = output ?? null
- const expandable = body !== null || outputText !== null || terminalBody !== null
+ const expandable = body !== null || outputText !== null || terminalBody !== null || diffBody !== null
const open = expanded && expandable
// An error row's collapsed summary IS the failure: the first error line in
// the error color outranks both the args summary and a terminal description.
@@ -175,38 +185,40 @@ export function ToolRow({
className={css.terminalBody}
/>
)
- : isThink
- ?