Merge master into codex/sandbox-policy-context

# Conflicts:
#	apps/web/tests/snapshots/live-interactions/retry.expected.md
#	apps/web/tsconfig.json
#	tsconfig.host.json
This commit is contained in:
Tianyi Cui
2026-07-31 13:04:21 +08:00
165 changed files with 4180 additions and 648 deletions
@@ -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
@@ -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.
@@ -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(命令行界面)ACPAgent Client Protocol)示例组合使用同一套按提供方路由的策略。库消费方仍需显式组合插件:省略该插件时,请求失败保持终态。
agent-spine 演示组合包加载该插件,因此共享的 stdio/TUI、一次性 CLI(命令行界面)ACPAgent 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()` 的直接调用方仍只尝试一次,并收到相同的结构化失败事实。
@@ -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-25-web-input-machine-and-slash-pipeline.md
2026-07-25-web-input-machine-and-slash-pipeline.md: 92bb91c3e892d928cedf18ec57c725a116b6ffc8
2026-07-25-web-input-machine-and-slash-pipeline.zh.md: 5bee6df52f16d935aa4f4ccff8627a2d43d44c8c
2026-07-25-web-input-machine-and-slash-pipeline.md: c3deadb34d3a633525dde701c92bcc98c05e5d6e
2026-07-25-web-input-machine-and-slash-pipeline.zh.md: 7a6988423dcdffebb0a28735146439c8ade0a862
@@ -63,7 +63,7 @@ Calls that stay un-evented (registry registration → explicit call → await):
A trigger/menu/pick pipeline with zero knowledge of "commands":
- The service holds only the source registry (`SlashSource{trigger: '/'|'@', name, order?, candidates, onPick, matchSpace?, matchEnter?}`; (trigger,name) unique; the optional `order` sorts the roster — lower first, default 0, ties keep registration order — and that sorted roster is both group order and polling order) and `sessionOf(sctx)`. Implementing a match hook IS the declaration of participation in space/enter adjudication; the pipeline polls in roster order, the first non-undefined answer wins, and no claimant means the default sink. matchSpace is synchronous (space fires mid-keystroke; hot cache only); matchEnter is asynchronous (it may await the source's own warmup, and a warmup failure rejects).
- The controller holds the single authoritative hit (span included; retained for Space after the menu closes), the per-session menu store, the candidate-fetch generation, keyboard arbitration (combobox mode: focus stays in the textarea, ↑↓/Enter/Escape are intercepted and all pass the IME composition guard, with the single exception Shift+Enter unconditionally going first), and pick orchestration (outcome → self-dispatched bail events); a `dismiss()` verb backs MenuView's injected `onDismiss` (a pointer down outside both the menu and the surrounding composer card closes the menu; MenuView also localizes group titles through the `slash.menu` locale namespace and clamps its height to the viewport space above the composer via ui-primitives' `useAnchoredMaxHeight`); at each session scope's birth it runs `warm(projection)` once over the source roster — within that scope the projection holds only the stable sessionId, with no published/capability transitions; the scope disposer tears down the controller.
- The controller holds the single authoritative hit (span included; retained for Space after the menu closes), the per-session menu store, the candidate-fetch generation, keyboard arbitration (combobox mode: focus stays in the textarea, ↑↓/Enter/Escape are intercepted and all pass the IME composition guard, with the single exception Shift+Enter unconditionally going first), and pick orchestration (outcome → self-dispatched bail events). `toggleSource(name, syntheticHit)` is the chrome-launch path: it seeds only that registered source over the caller's textarea selection and publishes `launcher = name` until close; ordinary typed tracking clears the launcher and restores the full trigger roster. Both paths render the same MenuView and execute the same `onPick` chain. A `dismiss()` verb backs MenuView's injected `onDismiss` (a pointer down outside both the menu and the surrounding composer card closes the menu; MenuView also localizes group titles through the `slash.menu` locale namespace and clamps its height to the viewport space above the composer via ui-primitives' `useAnchoredMaxHeight`); at each session scope's birth it runs `warm(projection)` once over the source roster — within that scope the projection holds only the stable sessionId, with no published/capability transitions; the scope disposer tears down the controller.
- Trigger-detection word boundaries (`user@host` and URL `/` never trigger) and the guard tiers (plain: `/` everywhere + `@` inline / claimed: `/` suppressed, `@` live / frozen: none) are the frozen pure core.
### hub / facade: the resident shell and the strict-session input body
@@ -101,7 +101,7 @@ skill/@subagent references skip the placeholder + occurrence identity chain —
- `conversation.input.dock` — the stacked strip above the input (QueueDock's read-only queue list lands here), ordered by `order`.
- `conversation.composer.dock` — the stats band on the composer's top edge.
- `conversation.input.left` / `conversation.input.right` — the tool-row left and right regions.
- `conversation.input.plan` / `conversation.input.model` (single) — the tool row's two named control seats; the bar passes only `locked` (owner props), each stays empty until its owning plugin registers, no placeholder fallback.
- `conversation.input.plan` / `conversation.input.model` (single) — the tool row's two named control seats; the bar passes only `locked` (owner props), each stays empty until its owning plugin registers, no placeholder fallback. The plan seat stays empty while inactive because the shared Command source owns entry; an effective plan target renders the warn-state `Plan ×` status button, whose only action is `/plan off`.
- `conversation.hero.workspace` (root scope) — the Workspace picker shared by the no-session and blank Hero; a pick reuses or creates the target blank session through `connectWorkspace`, moving the draft where necessary before switching current.
### Testing discipline
@@ -122,6 +122,8 @@ The state machine's entire behavior is covered by pure-JS unit tests (event sequ
| Space adjudication also claiming execute-kind commands | The misfire defense: after a space the whole line is an ordinary prompt; irreversible side effects keep explicit entry points only |
| A generic tokenPattern decoration mechanism | Structured occurrence records replace pattern scanning |
| A placeholder select resident in the tool row | Named seats stay empty until registration; a placeholder clashing with the real implementation is two sources of truth |
| An always-visible Plan on/off toggle | The shared Command source already owns entry; a second entry point turns a status seat into redundant mode chrome |
| A second plus-menu component/controller, or an Add/File group above Command | It would duplicate async candidates, keyboard highlight, focus retention, and pick state; the plus control is only a source-filtered launcher for the existing MenuView, and this scope has no file capability |
| All references through U+FFFC chips (the pre-Decision-21 line) | Plain text + derived decoration carries zero identity state; the literal text IS the model projection, sparing undo/clipboard any special cases; the chip chain is kept for scenarios needing indivisible atomicity |
## Consequences
@@ -63,7 +63,7 @@ occurrence 表与 chip 三投影:
对"命令"零知识的触发/菜单/pick 管线:
- service 只有 source 注册表(`SlashSource{trigger: '/'|'@', name, order?, candidates, onPick, matchSpace?, matchEnter?}`(trigger,name) 唯一;可选 `order` 对 roster 排序——越小越靠前、默认 0、同值保持注册序——排序后的 roster 同时是组序与轮询序)与 `sessionOf(sctx)`。实现 match 钩子即参与空格/回车裁决的声明;管线按 roster 序轮询,首个非 undefined 应答胜出,无人认领落 default sink。matchSpace 同步(空格在击键中触发,只许热缓存);matchEnter 异步(可 await 源自身预热,预热失败即 reject)。
- controller 持有唯一权威 hit(含 span;菜单关闭后为 Space 保留)、per-session menu store、候选 fetch generation、键盘仲裁(combobox 模式:焦点始终在 textarea,↑↓/Enter/Escape 拦截且全程过 IME composition 守卫,唯一例外 Shift+Enter 无条件先行)pick 编排(outcome → 自派 bail 事件)`dismiss()` 动词支撑 MenuView 注入的 `onDismiss`(指针落在菜单与所在 composer 卡片之外即关闭菜单;MenuView 还经 `slash.menu` locale 命名空间本地化组标题,并经 ui-primitives 的 `useAnchoredMaxHeight` 把高度收敛到 composer 上方的视口空间);每个 session scope 出生时对 source roster 做一次 `warm(projection)`projection 在该 scope 内只有稳定的 sessionId,无 published/能力跃迁;scope disposer 拆除 controller。
- controller 持有唯一权威 hit(含 span;菜单关闭后为 Space 保留)、per-session menu store、候选 fetch generation、键盘仲裁(combobox 模式:焦点始终在 textarea,↑↓/Enter/Escape 拦截且全程过 IME composition 守卫,唯一例外 Shift+Enter 无条件先行),以及 pick 编排(outcome → 自派 bail 事件)`toggleSource(name, syntheticHit)` 是 chrome launcher 路径:它基于调用方的 textarea selection,只 seed 对应的已注册 source,并发布 `launcher = name` 直至关闭;普通的键入式 tracking 会清除 launcher 并恢复完整的 trigger roster。两条路径渲染同一个 MenuView,并执行同一条 `onPick` 链。`dismiss()` 动词支撑 MenuView 注入的 `onDismiss`(指针落在菜单与所在 composer 卡片之外即关闭菜单;MenuView 还经 `slash.menu` locale 命名空间本地化组标题,并经 ui-primitives 的 `useAnchoredMaxHeight` 把高度收敛到 composer 上方的视口空间);每个 session scope 出生时对 source roster 做一次 `warm(projection)`projection 在该 scope 内只有稳定的 sessionId,无 published/能力跃迁;scope disposer 拆除 controller。
- 触发检测词边界(`user@host`、URL `/` 永不触发)、守卫分档(plain`/` 到处 + `@` 行内 / claimed`/` 抑制、`@` 活 / frozen:全无)为冻结纯核。
### hub / facade:常驻外壳与严格 session 输入体
@@ -101,7 +101,7 @@ skill/@subagent 引用不走占位符 + occurrence 身份链——pick 直接把
- `conversation.input.dock`——输入上方堆叠条(QueueDock 的队列只读列表落此),order 定序。
- `conversation.composer.dock`——composer 上沿统计带。
- `conversation.input.left` / `conversation.input.right`——工具行左右区。
- `conversation.input.plan` / `conversation.input.model`(single)——工具行两具名控制位;bar 只传 `locked`owner props),空到 owning 插件注册为止,无占位 fallback。
- `conversation.input.plan` / `conversation.input.model`(single)——工具行两具名控制位;bar 只传 `locked`owner props),空到 owning 插件注册为止,无占位 fallback。plan seat 未激活时保持为空,因为入口归共享 Command source 所有;有效 plan 目标会渲染 warn 状态的 `Plan ×` 状态按钮,其唯一动作是 `/plan off`
- `conversation.hero.workspace`root scope)——无 session / blank Hero 共用的 Workspace pickerpick 经 `connectWorkspace` 复用或创建目标 blank session,必要时搬运 draft 后切 current。
### 测试纪律
@@ -122,6 +122,8 @@ skill/@subagent 引用不走占位符 + occurrence 身份链——pick 直接把
| 空格裁决也认领即执行型命令 | 误触发防线:空格后整行是普通 prompt;不可逆副作用只留显式入口 |
| 通用 tokenPattern 装饰机制 | 结构化 occurrence 记录取代模式扫描 |
| 占位 select 常驻工具行 | 具名坑位空到注册为止;占位件与真实现冲突时是双真相源 |
| 始终可见的 Plan 开/关切换 | 入口已归共享 Command source 所有;第二个入口会把状态 seat 变成冗余的 mode chrome |
| 第二套加号菜单组件/controller,或在 Command 上方增加 Add/File 分组 | 这会重复异步候选、键盘高亮、焦点保留与 pick 状态;加号控件只是既有 MenuView 按 source 过滤的 launcher,且此 scope 没有文件能力 |
| 引用一律走 U+FFFC chip(决策 21 前旧线) | 纯文本 + 派生装饰零身份状态;原文即模型投影,undo/剪贴板免特判;chip 链保留给需要不可分原子性的场景 |
## 后果
@@ -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-settings-write-path-integrity.md
2026-07-30-settings-write-path-integrity.md: 07bd095162879c8e7866846cf562f6a13307e5fc
2026-07-30-settings-write-path-integrity.zh.md: 5d02177073d482b61750d7bdfbbd0866bc227a6a
2026-07-30-settings-write-path-integrity.md: 7bd50adc8a812759c3ae3f80a50978d75baa712a
2026-07-30-settings-write-path-integrity.zh.md: da07745ef1de1b694fc3fd1ee3d04322cdadc992
@@ -14,7 +14,7 @@ Review found the provider's write path could destroy state it never observed, an
**One operation chain, and every write is a read-modify-write.** Watcher refreshes and persists from every namespace queue share a single settled chain, and `persistSection` begins by reconciling the on-disk text into the seam — publishing any unobserved difference first — before rendering against that fresh text. A write can no longer resurrect a stale document, and an on-disk document that turned invalid fails the write loud rather than being overwritten (the reload path keeps its warn-and-keep-last-good policy; the shared `reconcileFromDisk` throws and each caller picks its policy). The watcher's `ready` signal queues one extra reconcile, closing the startup gap between the initial load and the watcher becoming active.
**Writes hold a `wx`-created `<file>.lock` sibling.** The read-render-rename cycle runs under a cross-process writer lock with exponential backoff, a 2 s acquisition deadline, and stale takeover after 5 s (a crashed holder, broken with a warning). Readers never lock — the rename commit is atomic — so contention is writer-only and resolves in milliseconds. The lock constants are protocol invariants, not config: a holder rewrites one small document, so the deadline and stale age derive from that bound, not from deployment taste.
**Writes hold a `wx`-created `<file>.lock` sibling.** The read-render-rename cycle runs under a cross-process writer lock with exponential backoff and a 2 s acquisition deadline. A contender times out without removing the existing lock because age cannot distinguish a crashed owner from a paused live writer; orphan recovery is an operator action. Readers never lock — the rename commit is atomic — so contention is writer-only. The retry and deadline constants are protocol invariants, not deployment config.
**Observer disposal is quiescent.** Watchers carry an `active` flag checked when a queued invocation would start, so a disposer that ran while the invocation waited prevents the start entirely; started invocations register in a service-level `pendingTails` set that the dispose drain awaits beside the write queues. The `settings/updated` fan-out contains a returned thenable's rejection through the same listener diagnostic as a sync throw, and the event contract now states that the `INVARIANT` rethrow serves synchronous listeners only — invariant companions must stay sync, which the shipped companion already is.
@@ -24,7 +24,7 @@ Review found the provider's write path could destroy state it never observed, an
## Alternatives considered
- **`proper-lockfile` instead of a hand-rolled lock** — the dependency-over-hand-rolling policy was weighed: the library is barely maintained, its stale/retry policy is broader than this one-file protocol needs, and the shipped lock is ~40 lines with deterministic tests (including injected `EEXIST`/`stat` races). The policy favors dependencies that delete owned code; this one would replace 40 explained lines with an opaque peer.
- **`proper-lockfile` instead of a hand-rolled lock** — the dependency-over-hand-rolling policy was weighed: the library is barely maintained, its ownership and retry policy is broader than this one-file protocol needs, and the shipped lock is a small exclusive-create loop with deterministic contention tests. The policy favors dependencies that delete owned code; this one would replace a narrow protocol with an opaque peer.
- **Revision/CAS instead of a lock** — rename cannot express compare-and-swap, so a CAS needs a version sidecar or content re-hash and a retry loop in every writer; the lock achieves the same serialization with one primitive and keeps readers free.
- **Merging external edits into the in-flight write's own section** — the seam merges patches over the state visible at call time, so a same-namespace external edit racing a write still resolves last-write-wins; folding it in would need three-way merge semantics no consumer has asked for. The write publishes the external state first, so the loser is at least observed before being superseded.
- **Declaring async `settings/updated` listeners unsupported** — the typed signature is `void` and lint flags misused promises, but an unlinted JS plugin can still register an async listener; a contract note cannot un-throw an unhandled rejection, so containment is the only defense that holds at runtime.
@@ -32,4 +32,4 @@ Review found the provider's write path could destroy state it never observed, an
## Consequences
`update()` gained a documented failure mode (lock deadline, invalid on-disk document) and the rejection messages carry `$`-rooted paths. Remaining, documented in the provider README: same-namespace concurrent edits stay last-write-wins (no per-value merge or revision check), a watcher event the OS never delivers leaves the cache stale until the next signal or write, and comments inside replaced arrays or attached inline to changed scalar values go with the value they described. The [user-settings seam note](2026-07-28-user-settings-seam.md)'s deferred-lockfile alternative is superseded by this note. The same defect classes exist in `dsh-credentials-local` (two chains over one `.env`, cached whole-file write-back, post-persist emit) and in the `llm/adapters-updated` fan-out on the stacked branches; those fixes belong to the PRs that introduce the packages and follow this template on merge-up.
`update()` has documented failure modes for the lock deadline and an invalid on-disk document, and rejection messages carry `$`-rooted paths. A crashed holder can leave a lock that requires verified operator removal; automatic age-based takeover would permit overlapping writers. Remaining, documented in the provider README: same-namespace concurrent edits stay last-write-wins (no per-value merge or revision check), a watcher event the OS never delivers leaves the cache stale until the next signal or write, and comments inside replaced arrays or attached inline to changed scalar values go with the value they described. The [user-settings seam note](2026-07-28-user-settings-seam.md)'s deferred-lockfile alternative is superseded by this note. The same defect classes exist in `dsh-credentials-local` (two chains over one `.env`, cached whole-file write-back, post-persist emit) and in the `llm/adapters-updated` fan-out on the stacked branches; those fixes belong to the PRs that introduce the packages and follow this template on merge-up.
@@ -18,7 +18,7 @@ YAML 写入则整体替换 namespace 节点,把分节内的每条注释都删
**单一操作链,且每次写入都是读-改-写。**watcher 的刷新与来自各 namespace 队列的持久化共享同一条结算链;`persistSection` 会先把磁盘上的文本对账进 seam——任何未被观察到的差异都先发布出去——然后才对照这份新鲜文本渲染。写入不再可能复活一份陈旧文档;磁盘上已变非法的文档会让写入响亮失败,而不是被覆盖(重载路径保持其“告警并保留最后可用值”策略;共享的 `reconcileFromDisk` 抛错,各调用方自选策略)。watcher 的 `ready` 信号会额外排入一次对账,弥合初始加载与 watcher 生效之间的启动缺口。
**写入持有以 `wx` 创建的同目录 `<file>.lock`。**读-渲染-rename 循环在一把跨进程写锁下运行指数退避2 s 获取截止时间、5 s 后陈旧接管(持有者已崩溃;打破旧锁时给出告警)。读方从不加锁——rename 提交是原子的——因此竞争只发生在写方之间,毫秒级即可化解。锁的各项常量是协议不变式,不是配置:持有者只是重写一份小文档,截止时间与陈旧时限都从这一上界推得,而非出自部署偏好
**写入持有以 `wx` 创建的同目录 `<file>.lock`。**读-渲染-rename 循环在一把跨进程写锁下运行,采用指数退避2 s 获取期限。竞争者会超时,但不会移除现有锁,因为锁龄无法区分已经崩溃的所有者与被暂停但仍存活的写入方;遗留锁恢复须由操作者执行。读方从不加锁——rename 提交是原子的——因此竞争只发生在写方之间。重试与期限常量是协议不变式,而非部署配置
**观察者 dispose 达到完全停稳。**watcher 携带一个 `active` 标志,排队的调用即将启动时先检查它,因此在调用等待期间已经运行过的释放器能让这次启动彻底不发生;已启动的调用会登记进服务级的 `pendingTails` 集合,dispose 排空除了等待各写队列,还会等待该集合。`settings/updated` 扇出会把监听器返回的 thenable 的 rejection 收容进与同步抛错相同的监听器诊断;事件契约现已写明 `INVARIANT` 重抛只服务同步监听器——不变式配套插件必须保持同步,而已交付的那个配套插件本就是同步的。
@@ -28,7 +28,7 @@ YAML 写入则整体替换 namespace 节点,把分节内的每条注释都删
## 曾考虑的替代方案
- **用 `proper-lockfile` 取代手写锁**——按“依赖优先于手写”政策做过权衡:该库几乎无人维护,其陈旧/重试策略比这个单文件协议所需的更宽泛,而已交付的锁约 40 行并带确定性测试(含注入的 `EEXIST`/`stat` 竞态)。该政策偏向能删除自有代码的依赖;这个依赖只会把 40 行带解释的代码换成一个不透明的等价物。
- **用 `proper-lockfile` 取代手写锁**——按“依赖优先于手写”政策做过权衡:该库几乎无人维护,其所有权与重试策略比这个单文件协议所需的更宽泛,而已交付的锁只是一个小型独占创建循环,带确定性的竞争测试。该政策偏向能删除自有代码的依赖;这个依赖只会把一个窄协议换成不透明的等价物。
- **用修订号/CAS 取代锁**——rename 表达不了 compare-and-swap,因此 CAS 需要一个版本伴随文件或内容重哈希,外加每个写方里的一个重试循环;锁用一个原语实现同样的串行化,还让读方完全免锁。
- **把外部编辑合并进正在进行的写入自身的分节**——seam 是在调用时刻可见的状态之上合并 patch 的,因此与写入竞态的同 namespace 外部编辑仍按后写胜出解决;要把外部编辑并进来,需要三方合并语义,而没有任何消费方提出过这种需求。写入会先发布外部状态,落败一方至少在被取代之前被观察到。
- **宣布不支持异步 `settings/updated` 监听器**——类型签名是 `void`lint 也会标记误用的 promise,但未经 lint 的 JS 插件仍能注册异步监听器;契约里的一句说明无法收回已经抛出的 unhandled rejection,收容是唯一在运行时守得住的防线。
@@ -36,6 +36,6 @@ YAML 写入则整体替换 namespace 节点,把分节内的每条注释都删
## 后果
`update()` 有了成文的失败模式(锁截止时间到期、磁盘文档非法)rejection 消息携带以 `$` 为根的路径。仍然存在、且已记录在提供方 README 中的有:同 namespace 并发编辑仍是后写胜出(没有逐值合并,也没有修订号检查);OS 从未投递的 watcher 事件会让缓存保持陈旧,直到下一个信号或下一次写入;被替换数组内部的注释、以及行内附着在被改标量值上的注释,会随其描述的值一起消失。
`update()` 对锁获取期限与磁盘文档非法都有成文的失败模式rejection 消息携带以 `$` 为根的路径。持有者崩溃后可能留下锁,需要操作者核实后移除;若按锁龄自动接管,则会允许多个写入方重叠。仍然存在、且已记录在提供方 README 中的有:同 namespace 并发编辑仍是后写胜出(没有逐值合并,也没有修订号检查);OS 从未投递的 watcher 事件会让缓存保持陈旧,直到下一个信号或下一次写入;被替换数组内部的注释、以及行内附着在被改标量值上的注释,会随其描述的值一起消失。
[用户设置 seam note](2026-07-28-user-settings-seam.md)里“延后锁文件”那条替代方案已被本 note 取代。同类缺陷还存在于 `dsh-credentials-local`(两条链共用一个 `.env`、按缓存整文件写回、持久化之后才发事件)与堆叠分支上的 `llm/adapters-updated` 扇出;这些修复归引入相应包(package)的那些 PRPull Request)所有,向上合并时按本模板处理。
@@ -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-result-card-frontend.md
2026-07-30-web-result-card-frontend.md: d6f4785e83335ca2dd5295516baf47c845ebf5bd
2026-07-30-web-result-card-frontend.zh.md: ed95cbe39f4f0bf77ba5da64d664705a0841863f
@@ -0,0 +1,49 @@
# Agent Note: Web result card frontend — rendering the web render intent in the browser
Status: implemented
English | [中文](2026-07-30-web-result-card-frontend.zh.md)
## Problem
The `web_search` and `web_fetch` tools declare a `card: 'web'` result view ([web result card](2026-07-30-web-result-card.md)): a `kind`-tagged union carrying either the structured cited sources plus an optional provider answer (`kind: 'search'`) or the fetched URL and its HTTP status (`kind: 'fetch'`). That view already reaches the browser — host, connection, and runtime deliver it onto `ConversationSnapshot` as `resultView` — but the Web client ignored it: a completed web call rendered only as its flattened model-facing text, the same lossy render the contract note explains the structured view exists to replace. A `web_search` reached the reader as one free-text markdown line per source rather than a citation list of clickable sources, and a `web_fetch` as its markdown body with no retrieval summary.
## Decision
`WebBlock` is a `ui-primitives` component that renders a completed web retrieval, and every Web render site for a web call consumes the `web` render intent through it: the keyed chat tool rows (`web_search`/`web_fetch`), the `GenericToolCard` render-site fallback, and the details panel's Output section. `ui-conversation/src/client/contract/web-card-model.ts` is the single place that turns the snapshot's `resultView` into the component's props, mirroring `terminal-card-model.ts`, so no two sites disagree about what a web call shows. It returns null — the generic path — for a running call (the web card is result-only, since the tools keep a generic pending view), for a settled call whose result view is not a web card including a `card` value this client version does not know (which arrives over the wire and so cannot be trusted to be a compiled variant), for a generic result view (a web tool's error path returns the generic card, whose text the generic path preserves), and for a web card whose `kind` this client version does not know (a newer host's value off the wire, which reading as a fetch would draw as an empty URL and `HTTP undefined`).
One component draws both kinds, discriminated by `kind`. A `search` shows the answer as markdown above a citation list; each source is a safe external link labelled by its title, or its hostname when the provider gave none, with the snippet and publication date below it, and a `来源列表已截断` indicator when the tool capped the list. A `fetch` shows a compact summary: the linked final URL, its HTTP status, and a `内容已截断` indicator. One component rather than two because both are web retrieval rendered as one card family, which is exactly the reason the contract carries them under one `card` tag with a `kind` discriminant.
**Links are safe by the http(s) subset of the allowlist MarkdownText applies to untrusted assistant-authored links** — MarkdownText also permits `mailto:`, deliberately excluded here since a retrieval URL is never a mail address. A source or fetch URL becomes a navigable anchor only when its protocol is `http:` or `https:`, with `target="_blank"` and `rel="noopener noreferrer"`; a `javascript:`/`data:`/`file:`/`mailto:` URL or an unparseable string renders as plain text with no href. The result content a web tool returns is model-authored and reaches this component unverified, so it is treated as untrusted exactly as assistant markdown is. The label falls back from title to hostname to the raw URL, so a source always reads as something even when both the title is absent and the URL does not parse.
**Geometry mirrors CodeBlock/TerminalBlock** (12px radius, code-block surface, 16px vertical margin) so a web card reads as one family with them. A long source list caps at `maxSources` with a head/tail collapse using TerminalBlock's exact split arithmetic (`ceil(max/2)` head lines plus the remaining tail), so a long body's slices agree between the two cards. A source list is prose rather than column-aligned output, so it wraps normally instead of scrolling horizontally the way a terminal card's output does — that is the one deliberate divergence from TerminalBlock.
The card is **resident** under the summary row in the chat rows, capped at `CHAT_WEB_MAX_SOURCES` (8) — half the primitive's own default of 16, which the details panel keeps — the same summary-surface-versus-reading-surface split `CHAT_TERMINAL_MAX_LINES` draws for the terminal card, and the same resident posture `BashRow` uses. The keyed rows register one `WebRow` component under both `web_search` and `web_fetch`; the row discriminates on the tool name only to pick its icon (search vs. browse) and its title (`Search`/`Fetch`). A web-declaring tool without its own keyed row lands on `GenericToolCard`, which grows the same resident card. The details panel renders the card at the primitive's full source allowance and, below it, the flattened model-visible result content: a `web_fetch` card carries only the URL and status, so its fetched body is readable only here.
## Consequences
`WebBlock` reads only the web view's fields, so it stays a pure function of what the render intent carries — no session lookups, replay-safe like the presenters that produce the view, and unlike the terminal card it needs no cwd resolution because a web view carries no path. A UI without the `web` capability (the TUI) still gets the contract's fallback `content`; nothing about the tools' result shape changed. `MarkdownText` is reused for the answer, so the answer's own untrusted-link handling and GFM rendering come for free.
A separate later PR unifies the whole-row collapse/expand interaction and will flip every resident card (terminal, diff, web) to expand-gated at once; this card follows the current resident convention rather than pre-empting that change.
## Alternatives considered
**Two components, one per kind.** Rejected: the two shapes share their card chrome, their safe-link handling, and their truncation indicator, and the contract already expresses their difference as a `kind` discriminant under one `card` tag; two components would duplicate the shared surface and split the safe-link logic.
**Reparse the model-facing render text instead of consuming the structured view.** Rejected for the same reason the contract note gives: `web_search`'s render collapses each source's fields into one free-text line labelled by title OR hostname, so reparsing cannot recover `{url, title?, snippet?, publishedAt?}`. The structured `resultView` is the only faithful source, which is why the backend PR added it.
**Render plain anchors without the protocol allowlist.** Rejected: the URL is model-authored and unverified at this seam, so an unfiltered href would let a `javascript:` URL execute on click. The allowlist is the http(s) subset of MarkdownText's (which also permits `mailto:`), so untrusted retrieval links behave identically wherever they render.
## Testing
`packages/client/ui-primitives/tests/web-block.spec.tsx` pins the component per-file to the 100% gate: both kinds; the title-or-hostname-or-raw-URL label fallback; the safe-link attributes on both kinds (an http(s) URL becoming an external anchor with `target`/`rel`, a `javascript:`/`file:`/unparseable URL rendering as a plain span with no href); the snippet and date shown or omitted on present/empty/absent; the truncation indicator gated on the flag; and the source-list height cap with its head/tail slice and expand/collapse control including the default cap.
`packages/client/ui-conversation/tests/web-card.spec.tsx` mirrors `terminal-card.spec.tsx` at every wiring seam: `webCardModel`'s derivation projecting every source field, its truncation and absent-answer arms, the fetch derivation, and each null arm (running, null result view, generic result view, unknown card tag, unknown web `kind`); the keyed `WebRow`'s resident card for both kinds capped tighter than the panel, its summary-row-alone running and failed arms; the `GenericToolCard` fallback growing the resident card for a web-declaring tool and keeping the plain row for a non-web call; the details panel's Output section for both kinds — including a `web_fetch`'s body flattened below its URL/status card — and its flattened fallback for a non-web result; and the keyed registration under both `web_search` and `web_fetch` with one component. That file sits on the coverage `exclude` list (`ui-conversation/src/*`), so a coverage run measures none of it.
The fixture (`packages/client/connection/src/client/fixture.ts`) adds turns 66 (`web_search`) and 67 (`web_fetch`), authored inline because the client-side fixture cannot import the web tool: turn 66's result view carries an answer and three sources exercising the citation list (a titled source with a snippet and date, a source with no title so its hostname labels the link, and a source with a date but no snippet) with the capped indicator on; turn 67's carries the fetched URL and a 200 status. Both keep a generic pending call view and add the `web` card only at result time, matching the contract's result-only web shape, and are named after the real tools so they hit the keyed `WebRow`. They are ordered before the todo turn (renumbered to 68) for the same reason the terminal turn is: the standing plan retires at the next `turn/start`, so a turn appended after it would empty the dock's plan strip. This drives the built-boot snapshot and a live `?fixture` server.
## Related
- [Web result card](2026-07-30-web-result-card.md) — the backend PR that added the `card: 'web'` result arm and made the two tools emit it; this is its deferred frontend consumer.
- [Web terminal card](2026-07-28-web-terminal-card.md) — the precedent this mirrors: a `ui-primitives` block, a single card-model derivation, keyed and fallback chat rows, and a details-panel arm, for the `terminal` render intent.
- [Tagged render-intent union for tool-call presentation](../architecture/2026-07-02-tool-render-intent-union.md) — the `card`-tagged vocabulary; the Web client is now a full consumer of the `web` arm.
@@ -0,0 +1,49 @@
# Agent Note: Web result 卡片前端 —— 在浏览器渲染 web 渲染意图
Status: implemented
[English](2026-07-30-web-result-card-frontend.md) | 中文
## Problem
`web_search``web_fetch` 工具声明了 `card: 'web'` result view[web result card](2026-07-30-web-result-card.md)):一个 `kind` 标签联合,携带结构化的被引用 sources 加可选的 provider answer`kind: 'search'`),或抓取的 URL 及其 HTTP 状态(`kind: 'fetch'`)。该视图早已抵达浏览器 —— host、connection、runtime 将它作为 `resultView` 投递到 `ConversationSnapshot` —— 但 Web 客户端忽略了它:一次已完成的 web 调用只渲染为摊平的模型可见文本,正是契约笔记所解释的、结构化视图要替代的那种有损渲染。`web_search` 到达读者时是每个 source 一行自由文本 markdown,而非可点击 source 的引用列表;`web_fetch` 是它的 markdown 正文,没有检索摘要。
## Decision
`WebBlock` 是一个 `ui-primitives` 组件,渲染一次已完成的 web 检索,web 调用的每个 Web 渲染点都通过它消费 `web` 渲染意图:键控的 chat 工具行(`web_search`/`web_fetch`)、`GenericToolCard` 渲染点兜底,以及详情面板的 Output 区。`ui-conversation/src/client/contract/web-card-model.ts` 是唯一把快照的 `resultView` 转成组件 props 的地方,镜像 `terminal-card-model.ts`,因此没有两个渲染点会对一次 web 调用的显示产生分歧。它返回 null —— 走通用路径 —— 对运行中的调用(web 卡片是 result-only 的,因为工具保留 generic pending 视图)、对 result view 不是 web 卡片的已结算调用(包括本客户端版本不认识的 `card` 值,它经 wire 抵达因而不能被信任为已编译的变体)、对 generic result viewweb 工具的错误路径返回 generic 卡片,其文本由通用路径保留)、以及对本客户端版本不认识 `kind` 的 web 卡片(更新的 host 经 wire 发来的值,读作 fetch 会画出空 URL 和 `HTTP undefined`)。
一个组件绘制两种 kind,由 `kind` 判别。`search` 把 answer 作为 markdown 显示在引用列表上方;每个 source 是一个安全外链,以其标题为标签,provider 未给标题时以其主机名为标签,下方是 snippet 与发布日期,工具截断列表时显示 `来源列表已截断` 提示。`fetch` 显示一个紧凑摘要:带链接的最终 URL、其 HTTP 状态、以及 `内容已截断` 提示。用一个组件而非两个,因为两者都是渲染为同一卡片族的 web 检索 —— 这正是契约把它们放在一个 `card` 标签下、用 `kind` 判别的原因。
**链接的安全性沿用 MarkdownText 对不受信任的 assistant 链接所用 allowlist 的 http(s) 子集。** MarkdownText 还允许 `mailto:`,此处刻意排除,因为检索 URL 绝不会是邮件地址。一个 source 或 fetch URL 仅当其协议为 `http:``https:` 时才成为可导航锚点,带 `target="_blank"``rel="noopener noreferrer"``javascript:`/`data:`/`file:`/`mailto:` URL 或无法解析的字符串渲染为纯文本、无 href。web 工具返回的 result content 是模型创作的,未经验证抵达本组件,因此像 assistant markdown 一样被当作不受信任处理。标签从标题回退到主机名再回退到原始 URL,因此即便标题缺失且 URL 无法解析,source 也总能读作某个东西。
**几何镜像 CodeBlock/TerminalBlock**12px 圆角、code-block 表面、16px 垂直外边距),使 web 卡片与它们读作一家。长 source 列表在 `maxSources` 处折叠,用 TerminalBlock 完全相同的分割算术做头/尾折叠(`ceil(max/2)` 头部行加剩余尾部),使长正文的切片在两张卡之间一致。source 列表是散文而非按列对齐的输出,所以它正常换行,而不像终端卡片的输出那样横向滚动 —— 这是与 TerminalBlock 唯一刻意的分歧。
卡片在 chat 行中**常驻**于摘要行之下,上限 `CHAT_WEB_MAX_SOURCES`(8)—— 原语自身默认 16 的一半,面板保留 16 —— 与 `CHAT_TERMINAL_MAX_LINES` 为终端卡片所画的摘要面对阅读面的同一划分,以及 `BashRow` 所用的同一常驻姿态。键控行把一个 `WebRow` 组件注册在 `web_search``web_fetch` 两个键下;行仅根据工具名判别以选取其图标(search 对 browse)与标题(`Search`/`Fetch`)。没有自己键控行的 web 声明工具落到 `GenericToolCard`,它长出同一张常驻卡片。详情面板以原语的完整 source 额度渲染卡片,并在其下方渲染摊平的模型可见结果内容:`web_fetch` 卡片只携带 URL 与状态,因此其抓取正文只在此处可读。
## Consequences
`WebBlock` 只读 web view 的字段,因此它是渲染意图所携带内容的纯函数 —— 无会话查找,与产出该视图的 presenter 一样回放安全,且不同于终端卡片它不需要 cwd 解析,因为 web view 不携带路径。没有 `web` 能力的 UI(TUI)仍得到契约的回退 `content`;工具的 result 形状没有任何改变。answer 复用 `MarkdownText`,因此 answer 自身的不受信任链接处理与 GFM 渲染免费获得。
一条独立的后续 PR 会统一整行折叠/展开交互,并把每张常驻卡片(terminal、diff、web)一次性翻成 expand-gated;本卡片遵循当前的常驻约定,而非抢先做那次改动。
## Alternatives considered
**两个组件,每种 kind 一个。** 拒绝:两种形状共享卡片外框、安全链接处理、截断提示,而契约已经把它们的差异表达为一个 `card` 标签下的 `kind` 判别;两个组件会重复共享表面并拆分安全链接逻辑。
**重解析模型可见的渲染文本,而非消费结构化视图。** 因契约笔记给出的同一理由拒绝:`web_search` 的渲染把每个 source 的字段压缩成一行自由文本、以标题或主机名为标签,所以重解析无法恢复 `{url, title?, snippet?, publishedAt?}`。结构化的 `resultView` 是唯一忠实来源,这正是后端 PR 添加它的原因。
**不加协议 allowlist 直接渲染裸锚点。** 拒绝:URL 在此接缝处是模型创作、未经验证的,所以未过滤的 href 会让 `javascript:` URL 在点击时执行。该 allowlist 是 MarkdownText allowlist(它还允许 `mailto:`)的 http(s) 子集,因此不受信任的检索链接无论在何处渲染都行为相同。
## Testing
`packages/client/ui-primitives/tests/web-block.spec.tsx` 把组件钉到 per-file 100% 门槛:两种 kind;标题-或-主机名-或-原始 URL 的标签回退;两种 kind 上的安全链接属性(http(s) URL 成为带 `target`/`rel` 的外链,`javascript:`/`file:`/无法解析的 URL 渲染为无 href 的纯 span;snippet 与日期在存在/为空/缺失时的显示或省略;由标志位控制的截断提示;以及 source 列表高度上限及其头/尾切片与展开/收起控件,含默认上限。
`packages/client/ui-conversation/tests/web-card.spec.tsx` 在每个接线接缝镜像 `terminal-card.spec.tsx`:`webCardModel` 的派生投影每个 source 字段、其截断与缺失 answer 的支路、fetch 派生、以及每个 null 支路(运行中、null result view、generic result view、未知 card 标签、未知 web `kind`;键控 `WebRow` 对两种 kind 的常驻卡片、比面板收得更紧、其仅摘要行的运行中与失败支路;`GenericToolCard` 兜底为 web 声明工具长出常驻卡片、并为非 web 调用保持纯行;详情面板 Output 区对两种 kind —— 含 `web_fetch` 正文摊平在其 URL/状态卡片下方 —— 及其对非 web 结果的摊平回退;以及在 `web_search``web_fetch` 两键下用一个组件的键控注册。该文件位于覆盖率 `exclude` 列表(`ui-conversation/src/*`,因此覆盖率运行不度量它。
fixture`packages/client/connection/src/client/fixture.ts`)添加 turn 66`web_search`)与 67`web_fetch`),内联撰写,因为客户端 fixture 无法 import web 工具:turn 66 的 result view 携带一个 answer 与三个 source,演练引用列表(一个带 snippet 与日期的有标题 source、一个无标题因而以主机名标注链接的 source、一个有日期无 snippet 的 source)并开启截断提示;turn 67 携带抓取的 URL 与一个 200 状态。两者都保留 generic pending call view,仅在 result 时添加 `web` 卡片,匹配契约的 result-only web 形状,且以真实工具命名,使其命中键控 `WebRow`。它们被排在 todo turn(重编号为 68)之前,理由与终端 turn 相同:待定计划在下一个 `turn/start` 退休,所以排在其后的 turn 会清空 dock 的 plan strip。这驱动 built-boot snapshot 与一个实时 `?fixture` 服务。
## Related
- [Web result card](2026-07-30-web-result-card.md) —— 添加 `card: 'web'` result 支路并让两个工具发出它的后端 PR;本条是它推迟的前端消费者。
- [Web terminal card](2026-07-28-web-terminal-card.md) —— 本条所镜像的先例:一个 `ui-primitives` block、一处 card-model 派生、键控与兜底 chat 行、以及一个详情面板支路,用于 `terminal` 渲染意图。
- [工具调用呈现的标签化 render-intent union](../architecture/2026-07-02-tool-render-intent-union.md) —— `card` 标签词汇;Web 客户端现在是 `web` 支路的完整消费者。
@@ -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
@@ -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.
@@ -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` 座位,令警示正文在受限的移动端/横屏视口内滚动,动作行保持固定。
- 编辑器 chipui-conversation 的 `PermissionSelect`)在 `/permission` 提交前拦截 Full-access 选择:`confirmation`/`acknowledged` 组件状态打开对话框,确认后经与其他选择完全相同的注入 `command` 通道提交 `/permission danger-full-access`;取消、Escape、关闭与遮罩点击均保持当前预设不变并重置复选框。会话锁定时确认自行撤销(`locked`/值缺席 effect),切换任务时随 `key={sessionId}` 重挂载而重置。文案经标准 `conversation` locale 座位以 `access.confirm.*` 键供给。
- `/permission` popupui-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 钉住产品默认中文词典文案。
@@ -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
@@ -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.
@@ -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 入口旁补充 ACPAgent Client Protocol)自动化服务器和 Python/JSON-RPC SDK。安装后的 TUI 仍只需执行一条 `dsh` 命令;Web 说明要求先构建当前检出,再运行 `dsh web`,并明确处理自定义或复用的检出路径。这两条启动路径必须分别能在真实 PTY 与生产构建/HTTP 冒烟中原样执行。能力段落沿用简洁清单的写法,补充已经交付的 PTY、LSP、Web、目标、规划、任务、沙箱、审批、设置、凭据、会话查询和遥测等能力类别,并说明不同组合只选用其中一部分。相邻的一条列表项说明权威会话日志规则,因为持久化、回放、查询、遥测和各类接口都依赖它。
包(package)与服务的完整清单仍由各自的归属文档维护。中英文 README 采用相同的技术结构,但社区章节仍分别指向各自语言受众的主要交流渠道。文档网站继续使用独立的用户指南首页。
## 考虑过的替代方案
**围绕新的产品叙事重写 README。** 完整重写能够突出所有现有入口和能力,但也会替换准确且已经过评审的文案,造成不必要的变动。现有事实能够纳入既有的产品优先结构。
**将仓库呈现为 SDK 和包清单。** 这样能立即展现实现广度,却会迫使新读者从包名反推出产品。包索引与生成的能力图仍是权威清单。
**使用包含截图、徽章和重复教程的长篇营销页面。** 富媒体能够展示稳定的产品使用路径,但其内容会独立于命令和源码契约而逐渐陈旧。根 README 保持紧凑,并链接到可运行示例和各自维护的指南。
**将根 README 投影为文档网站首页。** 使用同一个首页可以避免两套叙事,但文档网站的用户指南与仓库面向产品和开发者的入口在导航和维护需求上并不相同。
## 结果
评审者可以区分事实更新与编辑性重写;今后的更新会保留既有措辞,除非其含义已经不再正确或完整。受影响的命令、入口、发布阶段声明或高层能力类别发生变化时,README 仍须同步更新;完整细节则继续以链接方式提供,而不是复制到正文。
+2 -2
View File
@@ -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 README.md
README.md: f9f7294b42e29132d5cd46c0ab6a5f5265a1d8f3
README.zh.md: 88cbf8522d8f1a183a48dc7e80858d1a0ced8f0f
README.md: baf5d79b157ae845cc837261452853afd48dbe46
README.zh.md: 57d7bcf44cda36b37ae233754dbfba4ead2204fd
+26 -11
View File
@@ -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 <a href="https://wj.qq.com/s2/27234598/03eb/">WeCom group</a> 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
+26 -11
View File
@@ -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`,然后启动 ACPAgent 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 目前处于内测阶段。
## 许可证
+2 -2
View File
@@ -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
+1 -2
View File
@@ -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:<name>`. 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 <id>` 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 <path>` 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 <path>` 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 <path>` 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 <path>` 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.
+1 -2
View File
@@ -17,8 +17,7 @@ TUI 界面:
`dsh upgrade` 是默认 TUI 界面之上的引导式全新会话入口:它在调用目录中创建一个全新会话,并以内置 `dsh-upgrade` skill 播种其首轮,效果等同于用户手动键入 `/skill:<name>`。启动器将 skill 名称提供到启动上下文([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)),TUI 在聊天就绪后自动调用它。两者都不接受任何选项——`--config``-p``--resume` 都会明确报错——且仅在首次启动时播种,因此之后 `dsh --resume <id>` 恢复该会话时是普通 TUI 会话,不会重复注入。
Web 和无头界面启动 `base.cordis.yml``web.cordis.yml`,随后应用 `$DSH_HOME/config.yaml`;显式的 `--config <path>` 会替代该个人覆盖。除此之外,两者共享同一套组合:两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root <path>` 覆盖,否则会在该根目录下创建具名 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 <path>` 会替代该个人覆盖。除此之外,两者共享同一套组合:两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root <path>` 覆盖,否则会在该根目录下创建具名 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`
+104
View File
@@ -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<void> {
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<typeof watchConsole>
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'])
})
})
+12
View File
@@ -111,6 +111,18 @@ 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 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
// path (not just the per-package src benches). The selector pins the KEYED
// WebRow (its own `data-variant="web"` wrapper), not the `[data-web]` attribute
// WebBlock draws — the generic fallback renders the same WebBlock, so a silent
// keyed-registration failure would still satisfy a bare `[data-web]` check.
await waitFor(() => {
expect(document.querySelector('[data-variant="web"][data-tool="web_search"]')).not.toBeNull()
expect(document.querySelector('[data-variant="web"][data-tool="web_fetch"]')).not.toBeNull()
}, { timeout: 10_000 })
// Every bundle injected its plugin-owned style tag (the loader's CSS path).
const styleOwners = [...document.head.querySelectorAll('style[data-plugin]')]
.map(style => style.getAttribute('data-plugin'))
+87 -1
View File
@@ -25,6 +25,8 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/lifecycle-chrome', import.meta.url))
const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
const HERO_EXPECTED = join(SNAPSHOT_DIR, 'hero.expected.md')
const COMMAND_MENU_EXPECTED = join(SNAPSHOT_DIR, 'command-menu.expected.md')
const PLAN_ACTIVE_EXPECTED = join(SNAPSHOT_DIR, 'plan-active.expected.md')
// Post-reload golden: the same settled conversation rebuilt purely from
// persistence + history — byte-equal rendering is exactly the recovery claim.
const RELOADED_EXPECTED = join(SNAPSHOT_DIR, 'reloaded.expected.md')
@@ -56,6 +58,88 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
await scaffold?.close()
})
it.skipIf(MODE === 'record')('opens the shared slash menu from plus with only Command candidates', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-command-menu-launcher'))
const launcher = page.getByRole('button', { name: 'Commands' })
await launcher.click()
const menu = page.getByRole('listbox', { name: 'Trigger suggestions' })
await menu.waitFor({ timeout: 10_000 })
const snapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(COMMAND_MENU_EXPECTED, snapshot, MODE)
expect(snapshot).toContain('text: Commands')
expect(snapshot).not.toContain('text: Skills')
expect(snapshot).not.toContain('text: Subagents')
const launchedBox = await menu.boundingBox()
await page.locator('textarea').first().press('Escape')
await expect.poll(() => menu.count()).toBe(0)
const input = page.locator('textarea').first()
await input.fill('/')
await menu.waitFor({ timeout: 10_000 })
const typedBox = await menu.boundingBox()
expect(launchedBox).not.toBeNull()
expect(typedBox).not.toBeNull()
expect(Math.abs(launchedBox!.x - typedBox!.x)).toBeLessThan(1)
expect(Math.abs(
launchedBox!.y + launchedBox!.height - typedBox!.y - typedBox!.height,
)).toBeLessThan(1)
await input.fill('')
await expect.poll(() => menu.count()).toBe(0)
})
it.skipIf(MODE === 'record')('shows active Plan as the warn-state status action', async () => {
const activeScaffold = await launchWebScaffold()
const activePage = await newEnglishPage(browser)
const activeTripwire = watchConsole(activePage)
try {
await activePage.goto(activeScaffold.baseUrl, { waitUntil: 'load' })
await activePage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await connectFreshWorkspace(activePage)
const input = activePage.locator('textarea').first()
await activePage.getByRole('button', { name: 'Commands' }).click()
const menu = activePage.getByRole('listbox', { name: 'Trigger suggestions' })
await menu.waitFor({ timeout: 10_000 })
await menu.getByRole('option', { name: 'plan Enter or leave plan mode' }).click()
await expect.poll(() => input.inputValue()).toBe('/plan ')
await input.press('Enter')
const planButton = activePage.getByRole('button', { name: 'Plan mode on, press to turn off' })
await planButton.waitFor({ timeout: 10_000 })
const planSnapshot = await captureStableAria(activePage, '[class*="frame"]', activeScaffold.workspaceCwd)
await compareOrRefreshGolden(PLAN_ACTIVE_EXPECTED, planSnapshot, MODE)
const planStyle = await planButton.evaluate((element) => {
const probe = document.createElement('span')
probe.style.color = 'var(--dsw-alias-state-warn-label)'
probe.style.backgroundColor = 'var(--dsw-alias-state-warn-tertiary)'
document.body.append(probe)
const actual = getComputedStyle(element)
const reference = getComputedStyle(probe)
const result = {
color: actual.color,
backgroundColor: actual.backgroundColor,
borderRadius: actual.borderRadius,
fontSize: actual.fontSize,
referenceColor: reference.color,
referenceBackgroundColor: reference.backgroundColor,
}
probe.remove()
return result
})
expect(planStyle.color).toBe(planStyle.referenceColor)
expect(planStyle.backgroundColor).toBe(planStyle.referenceBackgroundColor)
expect(planStyle.borderRadius).toBe('999px')
expect(planStyle.fontSize).toBe('13px')
await planButton.click()
await expect.poll(() => planButton.count()).toBe(0)
expect(activeTripwire.pageErrors).toEqual([])
expect(activeTripwire.warnings).toEqual([])
} catch (error) {
await saveFailureShot(activePage, 'web-e2e-plan-active').catch(() => undefined)
throw error
} finally {
await activePage.close()
await activeScaffold.close()
}
})
it('sends the first prompt from the empty-state hero (all modes)', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-lifecycle-send'))
if (MODE !== 'record') {
@@ -152,6 +236,8 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'hero.expected.md', 'reloaded.expected.md'])
await assertFixtureInventory(SNAPSHOT_DIR, [
'session.jsonl', 'command-menu.expected.md', 'hero.expected.md', 'plan-active.expected.md', 'reloaded.expected.md',
])
})
})
+2 -2
View File
@@ -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([])
+20
View File
@@ -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' })
+1 -1
View File
@@ -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
+92
View File
@@ -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<void>(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<void>((resolveClose) => { child.once('close', () => { resolveClose() }) })
: Promise.resolve()
if (child.exitCode === null) child.kill('SIGTERM')
await closed
await new Promise<void>(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-'))
@@ -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]
@@ -39,10 +39,9 @@
- img
- text: {{clock}}
- textbox "Message the agent"
- button "Add attachment":
- button "Commands":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- button "Plan mode off, press to turn on": Plan off
- 'button "Access mode, current: Full access"': Full access
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
@@ -54,10 +54,9 @@
- img
- text: {{clock}}
- textbox "Message the agent"
- button "Add attachment":
- button "Commands":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- button "Plan mode off, press to turn on": Plan off
- 'button "Access mode, current: Full access"': Full access
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
@@ -34,10 +34,9 @@
- img
- text: {{clock}}
- textbox "Message the agent"
- button "Add attachment":
- button "Commands":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- button "Plan mode off, press to turn on": Plan off
- 'button "Access mode, current: Full access"': Full access
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
@@ -0,0 +1,6 @@
- listbox "Trigger suggestions":
- text: Commands
- option "goal set or view the goal for a long-running task" [selected]
- option "permission Switch the permission preset (sandbox mode + approval policy)"
- option "plan Enter or leave plan mode"
- option "model Select the model for this conversation"
@@ -26,10 +26,9 @@
- text: workspace
- img
- textbox "Describe what you want to build"
- button "Add attachment":
- button "Commands":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- button "Plan mode off, press to turn on": Plan off
- 'button "Access mode, current: Full access"': Full access
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
@@ -0,0 +1,39 @@
- button "New session"
- button "Collapse sidebar":
- img
- button "New session":
- img
- text: New Session
- text: Workspaces
- button "Group by":
- img
- button "Create workspace":
- img
- button "Search sessions":
- img
- textbox "Search name, keywords..."
- tree "Sessions":
- treeitem "workspace 1 session" [expanded]:
- img
- text: workspace 1 session
- treeitem "New Session now" [selected]
- button "Settings":
- img
- text: Settings
- text: Let's start building
- button "Choose workspace":
- img
- text: workspace
- img
- textbox "Describe what you want to build"
- button "Commands":
- img
- '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
- img
- button "Send message" [disabled]
- text: Details
- button "Close details"
- text: Click a tool row in the message flow to view its details
@@ -26,10 +26,9 @@
- img
- text: {{clock}}
- textbox "Message the agent"
- button "Add attachment":
- button "Commands":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- button "Plan mode off, press to turn on": Plan off
- 'button "Access mode, current: Full access"': Full access
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
@@ -23,10 +23,9 @@
- img
- text: {{clock}}
- textbox "Message the agent"
- button "Add attachment":
- button "Commands":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- button "Plan mode off, press to turn on": Plan off
- 'button "Access mode, current: Full access"': Full access
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
@@ -16,10 +16,9 @@
- img
- text: Context injection
- textbox "Message the agent"
- button "Add attachment":
- button "Commands":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- button "Plan mode off, press to turn on": Plan off
- 'button "Access mode, current: Full access"': Full access
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
@@ -15,6 +15,8 @@
- img
- img
- text: Context injection
- 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
@@ -26,10 +28,9 @@
- img
- text: {{clock}}
- textbox "Message the agent"
- button "Add attachment":
- button "Commands":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- button "Plan mode off, press to turn on": Plan off
- 'button "Access mode, current: Full access"': Full access
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
@@ -37,10 +37,9 @@
- img
- text: 7/25 {{clock}}
- textbox "Message the agent"
- button "Add attachment":
- button "Commands":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- button "Plan mode off, press to turn on": Plan off
- 'button "Access mode, current: Full access"': Full access
- button "Select model, current deepseek-v4-flash":
- text: deepseek-v4-flash
- img
@@ -39,10 +39,9 @@
- img
- text: {{clock}}
- textbox "Message the agent"
- button "Add attachment":
- button "Commands":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- button "Plan mode off, press to turn on": Plan off
- 'button "Access mode, current: Full access"': Full access
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
@@ -34,10 +34,9 @@
- img
- text: {{clock}}
- textbox "Message the agent"
- button "Add attachment":
- button "Commands":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- button "Plan mode off, press to turn on": Plan off
- 'button "Access mode, current: Full access"': Full access
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
@@ -18,10 +18,9 @@
- paragraph: partial
- button "2 queued messages"
- textbox "Message the agent"
- button "Add attachment":
- button "Commands":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- button "Plan mode off, press to turn on": Plan off
- 'button "Access mode, current: Full access"': Full access
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
@@ -31,10 +31,9 @@
- button "Cancel editing":
- img
- textbox "Message the agent"
- button "Add attachment":
- button "Commands":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- button "Plan mode off, press to turn on": Plan off
- 'button "Access mode, current: Full access"': Full access
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
@@ -24,10 +24,9 @@
- button "Remove queued message":
- img
- textbox "Message the agent"
- button "Add attachment":
- button "Commands":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- button "Plan mode off, press to turn on": Plan off
- 'button "Access mode, current: Full access"': Full access
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
@@ -42,10 +42,9 @@
- img
- text: permission preset workspace-write
- textbox "Message the agent"
- button "Add attachment":
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Plan mode off, press to turn on": Plan off
- button "Select model, current deepseek-v4-flash":
- text: deepseek-v4-flash
- img
@@ -40,10 +40,9 @@
- img
- text: Context injection
- textbox "Message the agent"
- button "Add attachment":
- button "Commands":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- button "Plan mode off, press to turn on": Plan off
- 'button "Access mode, current: Full access"': Full access
- button "Select model, current deepseek-v4-flash":
- text: deepseek-v4-flash
- img
@@ -35,10 +35,9 @@
- img
- text: {{clock}}
- textbox "Message the agent"
- button "Add attachment":
- button "Commands":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- button "Plan mode off, press to turn on": Plan off
- 'button "Access mode, current: Full access"': Full access
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
+2 -1
View File
@@ -45,7 +45,8 @@
"tests/queue-actions.e2e.ts",
"tests/skill-invocation-policy.e2e.ts",
"tests/permission-policy-context.e2e.ts",
"tests/sandbox-policy-wording.experiment.e2e.ts"
"tests/sandbox-policy-wording.experiment.e2e.ts",
"tests/access-confirmation.e2e.ts"
],
"references": [
{
+1 -1
View File
@@ -776,7 +776,7 @@ Requires: `agents`
export type Config = Readonly<Record<string, never>>
```
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`
+4 -2
View File
@@ -824,6 +824,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
@@ -934,6 +935,7 @@ flowchart TD
pkg_client_ui_plan --> pkg_client_locale
pkg_client_ui_plan --> pkg_client_runtime
pkg_client_ui_plan --> pkg_client_ui_conversation
pkg_client_ui_plan --> pkg_client_ui_primitives
pkg_client_ui_plan --> pkg_client_ui_slots
pkg_client_ui_plan --> pkg_invariants
pkg_client_ui_plan --> pkg_plan_mode
@@ -1175,7 +1177,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) |
@@ -1190,7 +1192,7 @@ flowchart TD
| [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) |
| [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
| [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`commands`](../packages/ui/commands), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |
| [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) |
| [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) |
| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks-local`](../packages/tasks/tasks-local), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) |
| [`sdk-protocol`](../packages/sdk/sdk-protocol) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
| [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
@@ -137,6 +137,44 @@ const TERMINAL_EXIT_STATUS: Record<string, { exitCode: number } | { signal: stri
[TERMINAL_OUTPUT_FIXTURE]: { exitCode: 1 },
}
/**
* The structured `web_search` result view for fixture turn 66, authored inline
* because this client-side fixture cannot import the web tool that projects it.
* The sources exercise the citation list's features: a titled source with a
* snippet and a date, a source with no title (its hostname labels the link) and
* a snippet but no date, and a source with a title and a date but no snippet.
* `truncated` marks the capped indicator. The shape is the contract's own
* search view minus its wire discriminants.
*/
const WEB_SEARCH_RESULT: Omit<Extract<ToolResultView, { card: 'web'; kind: 'search' }>, 'card' | 'kind'> = {
answer: 'DeepSeek Harness is a plugin-based agent harness on vendored Cordis where **every capability is a plugin**.',
sources: [
{
url: 'https://github.com/deepseek-ai/deepseek-harness',
title: 'DeepSeek Harness — plugin-based agent harness',
snippet: 'Everything is a plugin: session, tools, agent-loop, and LLM adapters all mount on the same Cordis context.',
publishedAt: '2026-07-01',
},
{
url: 'https://www.deepseek.com/blog/harness-architecture',
snippet: 'The capability-seam pattern splits each capability into interface, implementation, and consumer packages.',
},
{
url: 'https://docs.deepseek.com/harness/plugins',
title: 'Writing a harness plugin',
publishedAt: '2026-06-15',
},
],
truncated: true,
}
/** The `web_fetch` result view for fixture turn 67, authored inline for the same reason. */
const WEB_FETCH_RESULT: Omit<Extract<ToolResultView, { card: 'web'; kind: 'fetch' }>, 'card' | 'kind'> = {
url: 'https://www.deepseek.com/blog/harness-architecture',
statusCode: 200,
truncated: false,
}
const DEEPSEEK_REASONING = {
efforts: [
{ id: 'off', name: 'Off' },
@@ -326,8 +364,20 @@ function buildAlphaLog(): SessionEvent[] {
// strip empty and take the todo surfaces' own coverage with it.
toolTurn(65, 'bash', '{"command":"pnpm run check","cwd":"/tmp/fixture/deep/nested"}', TERMINAL_OUTPUT_FIXTURE)
// Turns 66-67: the web render intent — a web_search whose result view carries
// structured sources plus an answer (the citation list, one source lacking a
// title so its hostname labels the link, the capped indicator on), and a
// web_fetch whose result view carries the fetched URL and its HTTP status.
// Both keep a generic pending call view and add the `web` card only at
// result time, which is the contract's result-only web shape. Named after
// the real tools so they hit the keyed WebRow registration. Ordered BEFORE
// the todo turn for the same reason turn 65 is: the standing plan retires at
// the next turn/start, so a turn after it would empty the dock's plan strip.
toolTurn(66, 'web_search', '{"query":"deepseek harness architecture"}', 'Search results for deepseek harness architecture.')
toolTurn(67, 'web_fetch', '{"url":"https://www.deepseek.com/blog/harness-architecture"}', '# Harness architecture\n\nEverything is a plugin.')
const todoArgs = JSON.stringify({ todos: fixtureTodos })
toolTurn(66, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 1 in progress, 1 completed.')
toolTurn(68, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 1 in progress, 1 completed.')
// The real tool appends the snapshot mid-execution — between tool/call and
// tool/result — so the fixture reproduces that exact ordering (the last
// toolTurn events run ... tool/call, tool/result, step/end, turn/end).
@@ -366,6 +416,13 @@ function presentCall(name: string, argsRaw: string): ToolCallView | undefined {
return { card: 'generic', title: `Edit ${str(args.file_path)}`, kind: 'edit', rawInput: args }
case 'write':
return { card: 'generic', title: `Write ${str(args.file_path)}`, kind: 'edit', rawInput: args }
// 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.
case 'web_search':
return { card: 'generic', title: `Search ${str(args.query)}`, kind: 'search', rawInput: args }
case 'web_fetch':
return { card: 'generic', title: `Fetch ${str(args.url)}`, kind: 'fetch', rawInput: args }
default:
return undefined // echo et al: the documented no-view fallback path
}
@@ -374,6 +431,17 @@ function presentCall(name: string, argsRaw: string): ToolCallView | undefined {
function presentResult(name: string, argsRaw: string, resultText: string): ToolResultView | undefined {
const call = presentCall(name, argsRaw)
if (call === undefined) return undefined
// The web tools keep a generic pending card, so their result card is chosen
// by tool name rather than by the pending card tag: the structured `web` card
// the frontend consumes. The view carries no `content` copy (per the contract
// and the web-result-card note); a capability-less UI falls back to the raw
// `tool/result` content, which this fixture emits from `resultText`.
if (name === 'web_search') {
return { card: 'web', kind: 'search', ...WEB_SEARCH_RESULT }
}
if (name === 'web_fetch') {
return { card: 'web', kind: 'fetch', ...WEB_FETCH_RESULT }
}
switch (call.card) {
case 'terminal':
// The sample's own exit status, authored beside it: re-parsing the
@@ -1058,6 +1126,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<SessionId, { turn: number; stepStarted: boolean }>()
// 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
@@ -1081,6 +1151,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))
@@ -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)
+2 -2
View File
@@ -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
+4
View File
@@ -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.
+4
View File
@@ -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` 到达调用方,避免重试创建重复的子会话。
+2
View File
@@ -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"
},
+1 -1
View File
@@ -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 {
@@ -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
@@ -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<string, RunningToolCall>()
/** 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<string, PendingInteraction>()
// 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<string, readonly CodeSubCall[]>()
@@ -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<ModelRetryNode['retryState'], 'scheduled'>,
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<string, unknown>
const failure = data.failure
if (failure === null || typeof failure !== 'object') return null
const failureData = failure as Record<string, unknown>
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
+19 -1
View File
@@ -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' } } }),
+214 -5
View File
@@ -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' }] })
})
+3
View File
@@ -35,6 +35,9 @@
{
"path": "../../llm/llm"
},
{
"path": "../../llm/llm-retry"
},
{
"path": "../../support/invariants"
}
@@ -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<HTMLDivElement>): void => {
// ArrowLeft/ArrowRight fall through on purpose: the search input keeps
@@ -103,55 +104,73 @@ export function PopupSelectView({ popup, t }: PopupSelectViewProps) {
}
return (
<div
ref={cardRef}
className={css.card}
style={{ maxHeight }}
aria-label={t('overlay.aria', { command: String(state.command) })}
onKeyDown={onKeyDown}
>
<input
ref={searchRef}
className={css.search}
type="text"
placeholder={t('search.placeholder')}
aria-label={t('search.aria')}
value={state.search}
readOnly={state.submitting}
onChange={(ev) => { popup.setSearch(ev.currentTarget.value) }}
/>
{state.error !== null && (
<div className={css.error} role="alert">
<span className={css.errorText}>{state.error}</span>
{state.status === 'failed' && (
<button type="button" className={css.retry} onClick={() => { popup.retry() }}>{t('retry')}</button>
<>
{state.confirming === null && (
<div
ref={cardRef}
className={css.card}
style={{ maxHeight }}
aria-label={t('overlay.aria', { command: String(state.command) })}
onKeyDown={onKeyDown}
>
<input
ref={searchRef}
className={css.search}
type="text"
placeholder={t('search.placeholder')}
aria-label={t('search.aria')}
value={state.search}
readOnly={state.submitting}
onChange={(ev) => { popup.setSearch(ev.currentTarget.value) }}
/>
{state.error !== null && (
<div className={css.error} role="alert">
<span className={css.errorText}>{state.error}</span>
{state.status === 'failed' && (
<button type="button" className={css.retry} onClick={() => { popup.retry() }}>{t('retry')}</button>
)}
</div>
)}
{state.status === 'pending' && <div className={css.status}>{t('status.loading')}</div>}
{state.submitting && <div className={css.status}>{t('status.applying')}</div>}
{state.status === 'ready' && rows.length === 0 && <div className={css.status}>{t('status.empty')}</div>}
{state.status === 'ready' && (
<div role="listbox" aria-label={t('listbox.aria', { command: String(state.command) })} className={css.viewport}>
{rows.map((option, index) => (
<div
key={option.id}
role="option"
aria-selected={index === state.active}
className={clsx(css.row, index === state.active && css.rowActive)}
// mousedown would race the document capture listener; the shell
// owns focus anyway, so a plain click (inside the card → no
// dismiss) works.
onClick={() => { void popup.select(index) }}
onMouseEnter={() => { popup.highlight(index) }}
>
<span className={css.label}>{option.label}</span>
{option.detail !== undefined && <span className={css.detail}>{option.detail}</span>}
{option.active === true && <span className={css.check}><IconCheckOutline16 /></span>}
</div>
))}
</div>
)}
</div>
)}
{state.status === 'pending' && <div className={css.status}>{t('status.loading')}</div>}
{state.submitting && <div className={css.status}>{t('status.applying')}</div>}
{state.status === 'ready' && rows.length === 0 && <div className={css.status}>{t('status.empty')}</div>}
{state.status === 'ready' && (
<div role="listbox" aria-label={t('listbox.aria', { command: String(state.command) })} className={css.viewport}>
{rows.map((option, index) => (
<div
key={option.id}
role="option"
aria-selected={index === state.active}
className={clsx(css.row, index === state.active && css.rowActive)}
// mousedown would race the document capture listener; the shell
// owns focus anyway, so a plain click (inside the card → no
// dismiss) works.
onClick={() => { void popup.select(index) }}
onMouseEnter={() => { popup.highlight(index) }}
>
<span className={css.label}>{option.label}</span>
{option.detail !== undefined && <span className={css.detail}>{option.detail}</span>}
{option.active === true && <span className={css.check}><IconCheckOutline16 /></span>}
</div>
))}
</div>
{confirmation !== undefined && (
<RiskConfirmation
open
title={confirmation.title}
description={confirmation.description}
acknowledgeLabel={confirmation.acknowledgeLabel}
cancelLabel={confirmation.cancelLabel}
confirmLabel={confirmation.confirmLabel}
acknowledged={state.acknowledged}
onAcknowledgedChange={(value) => { popup.acknowledge(value) }}
onCancel={() => { popup.cancelConfirmation() }}
onConfirm={() => { void popup.confirm() }}
/>
)}
</div>
</>
)
}
@@ -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
}
/**
@@ -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'
+47 -6
View File
@@ -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<TCtx = unknown> {
*/
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<TCtx = unknown> {
*/
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<TCtx = unknown> {
*/
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<TCtx = unknown> {
async select(index: number): Promise<void> {
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<void> {
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<TCtx>, option: SelectOption): Promise<void> {
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) {
@@ -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<HTMLInputElement>('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<void>((resolve) => { release = resolve }))
@@ -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()
@@ -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: b4b1e5653705c76bac3e0227e6df77143a11cbbe
README.zh.md: 74e0f3dc0ebaf74e2e065c6b88f3a30fce94b391
README.md: 3e0bca6610e5503e4c2c1f9fe5ad7a07bbcbdd2a
README.zh.md: 1606461fc1060825133bb0c3f3f6381bda3926ad
+7 -3
View File
@@ -8,13 +8,17 @@ 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: <active id>`), 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 <preset>` 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 <preset>` 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)).
Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and a path summary; that path is a hover-underline link that opens the file with the host OS default application (`host.openPath`, relative paths resolve against the session cwd). Tool rows are not whole-row click targets and do not open the details panel. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged). Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering.
A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed `BashRow` carries the card resident below its summary row; since tool rows are no longer details-panel click targets, the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which is what keeps a summary surface bounded — the panel stays the single-call reading surface. Inline output is licensed for this intent alone; a generic tool's content remains panel-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)).
A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed `BashRow` carries the card resident below its summary row; since tool rows are no longer details-panel click targets, the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which is what keeps a summary surface bounded — the panel stays the single-call reading surface. Inline output is licensed per render intent — the terminal and web cards, each with its own bound; a generic tool's content remains panel-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)).
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)).
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: '<tool>', 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).
@@ -24,7 +28,7 @@ The todo surfaces are two registrations over that shape, both plain registrant p
Per-session UI state for selection and the active view lives in the declared chat store (`stores.ts` `createChatStore`); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies `useSession`/`sessionId`, global `useSessions`/`useWorkspaces`, and the input machine's `useInput`/`inputActions`; store faces and inject factories supply the remaining state and callbacks.
The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop button), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording, localized through the `conversation` locale namespace this package registers (the `placeholder.plan` / `hint.plan` keys) and shared verbatim with the claimed `/plan` command hint (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. The composer-bar slot itself is `session-maybe`: with no current session the same bar renders inert (machine faces absent, `disabled` owner prop) instead of swapping in a parallel disabled tree, so the textarea DOM survives the workspace pick; the strict-session control seats simply stay empty until a session exists.
The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop button), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. The leading plus button is a Command launcher, not an attachment surface: it asks the session's `SlashController` to open only the `/` trigger's `command` source over the current textarea selection, while ui-slash's existing `MenuView` remains the sole floating menu and pick path. No file row, file input, upload protocol, or second menu component is introduced. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording, localized through the `conversation` locale namespace this package registers (the `placeholder.plan` / `hint.plan` keys) and shared verbatim with the claimed `/plan` command hint (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. The composer-bar slot itself is `session-maybe`: with no current session the same bar renders inert (machine faces absent, `disabled` owner prop) instead of swapping in a parallel disabled tree, so the textarea DOM survives the workspace pick; the strict-session control seats simply stay empty until a session exists.
`src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations + composed slot props including the tool-row contract, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` (sample registrants) domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components (skeleton, chat rows) and the store factory stay internal and reach the page exclusively through apply's slot registrations (tests take them via the `./src/*` subpath).
+7 -3
View File
@@ -12,11 +12,15 @@
通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和路径摘要;该路径是悬停下划线链接,点击后通过宿主操作系统的默认应用打开文件(`host.openPath`,相对路径相对会话 cwd 解析)。工具行不再是整行点击目标,也不会打开 details 面板。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行)。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect``Mount temporary Plugin``Unmount temporary Plugin`mount 行保留 code 变体的可展开源码渲染。
声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView``resultView` 对推导的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null,落回通用路径。因此两个渲染点也都显示卡片的运行状态点,它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。多行命令的每一行各占一个提示行,状态点只在第一行为整次调用标记一次——退出状态属于整次调用,因此每行一枚就会声称一个 bash 并不报告的逐行结果。键控的 `BashRow` 把卡片常驻在摘要行下方;由于工具行已不再是详情面板的点击目标,卡片的复制与展开控件就是该行唯一的交互。渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`(8),面板为 16,正是这一点让摘要面保持有界——面板仍是单次调用的阅读面。内联输出只对该意图开放;通用工具的内容仍然只在面板中呈现([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。
声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView``resultView` 对推导的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null,落回通用路径。因此两个渲染点也都显示卡片的运行状态点,它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。多行命令的每一行各占一个提示行,状态点只在第一行为整次调用标记一次——退出状态属于整次调用,因此每行一枚就会声称一个 bash 并不报告的逐行结果。键控的 `BashRow` 把卡片常驻在摘要行下方;由于工具行已不再是详情面板的点击目标,卡片的复制与展开控件就是该行唯一的交互。渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`(8),面板为 16,正是这一点让摘要面保持有界——面板仍是单次调用的阅读面。内联输出按渲染意图开放——终端卡片与 web 卡片,各有自己的上限;通用工具的内容仍然只在面板中呈现([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。
声明 `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))。
聊天流会将跨重试轮次连续出现的模型重试节点投影为一个稳定的弱化状态行,并用最新一次尝试更新该行;每个重试事件仍保留在运行时快照与会话日志中。前端倒计时以客户端收到事件的时刻为计划延迟的起点,避免 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: '<tool>', 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 <preset>` 命令
审批经由本包声明的链接管编辑器:`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 <preset>`,而 `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 · <n> in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。
@@ -24,7 +28,7 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插
逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store(`stores.ts` `createChatStore`)中;InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession``sessionId`、全局 `useSessions``useWorkspaces`,以及输入状态机的 `useInput``inputActions`store 表层与 inject factory 提供其余状态和回调。
输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止按钮之前)声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。当 `plan` 投影的有效目标为 plan mode 时,InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `conversation` locale 命名空间(`placeholder.plan` / `hint.plan` 键)本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值;owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent(智能体)仍能收到回答;没有待处理交互时,活跃会话的 composer 归 Chat 所有。composer bar slot 本身为 `session-maybe`:没有当前会话时,同一个 bar 以不可交互状态渲染(machine face 均缺席、`disabled` owner prop),而不是换入一棵平行的 disabled 树,因此选择 workspace 时 textarea DOM 不会被销毁;严格会话作用域的控件 seat 在会话存在之前保持为空。
输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止按钮之前)声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。前置加号按钮是 Command launcher,而非附件入口:它要求当前会话的 `SlashController` 基于 textarea 当前 selection,只打开 `/` trigger 的 `command` source,同时 ui-slash 既有的 `MenuView` 仍是唯一的浮层菜单与 pick 路径。不引入 File 行、file input、上传协议或第二套菜单组件。`plan` 投影的有效目标为 plan mode 时,InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `conversation` locale 命名空间(`placeholder.plan` / `hint.plan` 键)本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值;owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent(智能体)仍能收到回答;没有待处理交互时,活跃会话的 composer 归 Chat 所有。composer bar slot 本身为 `session-maybe`:没有当前会话时,同一个 bar 以不可交互状态渲染(machine face 均缺席、`disabled` owner prop),而不是换入一棵平行的 disabled 树,因此选择 workspace 时 textarea DOM 不会被销毁;严格会话作用域的控件 seat 在会话存在之前保持为空。
`src/client/` 按未来的包拆分组织:`contract/` 是唯一的跨领域共享表层(`slots.ts` slot 声明 + 组合后的 slot props,包括工具行契约、`views.ts` 共享原语、`tool-call-model.ts`);`skeleton/``chat/``toolviews/`(示例注册方)领域目录只导入 contract 文件,彼此绝不导入;`apply.ts` 是唯一允许导入全部三个领域的组装点。`/client` 导出表层只包含契约:`apply``inject`、两个服务类和 `contract/` 类型家族;实现组件(骨架、聊天行)与 store factory 保持内部状态,只能通过 apply 的 slot 注册到达页面(测试通过 `./src/*` 子路径获取它们)。
@@ -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 { webToolview } from './toolviews/web-row.tsx'
import { ApprovalPanel } from './skeleton/ApprovalPanel.tsx'
import { todoToolview } from './toolviews/todo-row.tsx'
import { askQuestionToolview } from './toolviews/ask-question-row.tsx'
@@ -52,6 +53,10 @@ const ABSENT_LEXICON = {
getSnapshot: () => EMPTY_LEXICON,
subscribe: () => () => {},
}
const ABSENT_MENU_LAUNCHER = {
getSnapshot: (): string | null => null,
subscribe: () => () => {},
}
/** Resolve the session-scoped conversation face (scope-addressed send/cancel), failing loud. */
function scopedConversation(sessions: ISessions, id: SessionId): IConversation {
@@ -192,14 +197,28 @@ export function apply(ctx: Context): void {
if (sessionId === undefined) {
return {
keyboard: undefined,
toggleCommandMenu: undefined,
stop: undefined,
command: undefined,
hooks: { notices: ABSENT_NOTICES, lexicon: ABSENT_LEXICON },
hooks: { notices: ABSENT_NOTICES, lexicon: ABSENT_LEXICON, menuLauncher: ABSENT_MENU_LAUNCHER },
}
}
const shell = inputHub.shell(sessionId)
const slash = inputHub.slash(sessionId)
return {
keyboard: shell,
toggleCommandMenu: slash === undefined
? undefined
: (selection) => {
shell.dismissPopup()
const snapshot = shell.snapshot
slash.toggleSource('command', {
trigger: '/',
query: '',
position: snapshot.draft.slice(0, selection.start).trim() === '' ? 'leading' : 'inline',
span: { ...selection, draftRev: snapshot.draftRev },
})
},
stop: () => {
scopedConversation(sessions, sessionId).cancel().catch(() => {
// Stop failure surfaces via snapshot.promptError; nothing to restore.
@@ -211,7 +230,11 @@ export function apply(ctx: Context): void {
const result = await session.command(line)
return result.ok && result.value.matched
},
hooks: { notices: shell.notices, lexicon: shell.lexicon },
hooks: {
notices: shell.notices,
lexicon: shell.lexicon,
menuLauncher: slash?.launcher ?? ABSENT_MENU_LAUNCHER,
},
}
},
}, InputBar)
@@ -296,6 +319,11 @@ export function apply(ctx: Context): void {
// (ToolRow-matching Bash · {description} chrome; scoped badge in child sessions).
ctx.plugin(bashToolviewSample)
// 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).
ctx.plugin(webToolview)
// The todo_write row rides the same seam (a product registration, not a sample).
ctx.plugin(todoToolview)
@@ -55,6 +55,17 @@ type RenderToolRow = ChatViewSlotProps['renderSlot']
* chat view narrows once to the runtime snapshot the binding actually feeds. */
type UseConversation = SnapshotSelectorHook<ConversationSnapshot>
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 <MessageItem key={item.key} node={node} onFork={forkAt} t={t} />
return (
<MessageItem
key={item.key}
node={node}
retryActive={node.kind === 'model-retry' && node.seq === activeRetry}
onFork={forkAt}
t={t}
/>
)
}
return (
@@ -0,0 +1,15 @@
/* The generic card grows a resident web card under its summary row when the
tool declares the `web` render intent but has no keyed row of its own (the
web_search/web_fetch rows register their own WebRow). A column around the
ToolRow keeps the row's own 24px height. */
.card {
display: flex;
flex-direction: column;
}
/* Row indentation matches ToolRow's expanded bodies (16px leading + 6px gap),
and replaces the primitive's standalone vertical margin with the flow's. */
.web {
margin: 4px 0 4px 22px;
}
@@ -7,12 +7,14 @@
import type { ReactNode } from 'react'
import {
IconApiOutline14, IconBrowseOutline16, IconCodeOutline16, IconEditOutline16, IconSearchOutline16, IconSparkle16,
IconThinkOutline14,
IconThinkOutline14, WebBlock,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps, ToolRowOwnerProps } from '../contract/slots.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'
import { ToolRow } from './ToolRow.tsx'
import css from './GenericToolCard.module.css'
/** Variant leading icons (figma table); all glyphs render at 14 inside the 16px leading box. */
const VARIANT_ICONS: Record<ToolRowVariant, ReactNode> = {
@@ -34,13 +36,14 @@ 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 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.
const state = model.state === 'ok' && terminal !== null && terminalFailed(terminal)
? 'error'
: model.state
const singleFile = model.filePath !== undefined
return (
const row = (
<ToolRow
t={t}
variant={model.variant}
@@ -60,4 +63,13 @@ export function GenericToolCard({ toolName, block, cwd, openFile, inspect, t }:
inspect={inspect}
/>
)
// A web-declaring tool without its own keyed row lands here; its card is
// resident under the summary, mirroring WebRow (and BashRow's terminal card).
if (web === null) return row
return (
<div className={css.card}>
{row}
<WebBlock {...web} maxSources={CHAT_WEB_MAX_SOURCES} className={css.web} />
</div>
)
}
@@ -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 (`<skill>name</skill>` model
spans render as chips; free geometry — no textarea pairing here). */
.refChip {
@@ -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<RetryCountdown>(() => ({
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 (
<details className={css.retryRow} data-active={active || undefined}>
<summary className={css.retrySummary}>
<span className={css.retryText} role="status">
{t('message.retry.status', { label, retry: node.retry, maximum, seconds })}
</span>
</summary>
<div className={css.retryDetails}>
<div>
<span className={css.retryDetailLabel}>{t('message.retry.delay')}</span>
{Math.round(node.delayMs)}ms
</div>
<div>
<span className={css.retryDetailLabel}>{t('message.retry.failure')}</span>
{node.failure.message}
</div>
</div>
</details>
)
}
/**
* 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 (
<ContextInjectionRow content={node.content} source={node.source} t={t} />
)
case 'model-retry':
return <ModelRetryItem node={node} active={retryActive} t={t} />
default:
return (
<div className={css.contextRow}>
@@ -1,7 +1,8 @@
/**
* Chat flow derivation: ConversationSnapshot nodes -> render items. Tool
* results group into consecutive-run tool groups (figma step-summary flow,
* VERTICAL gap10) alternating with narration; everything else passes through.
* VERTICAL gap10) alternating with narration. Consecutive retry notices
* reuse the first notice's row while projecting the latest retry turn.
* Item identity keys are stable across snapshots so the list parent can
* subscribe to keys only while rows subscribe to content. IconActions ownership
* (last content assistant per turn) is derived here too so ChatView and the
@@ -49,7 +50,7 @@ export function assistantActionsSeqs(nodes: readonly ConversationNode[]): Readon
/**
* Group finalized nodes into the step-summary flow.
* @param nodes - snapshot nodes (surface order).
* @returns flow items; consecutive tool-results merged into one group keyed by the first seq.
* @returns flow items; consecutive tool results and retry notices reuse their first key.
*/
export function deriveChatFlow(nodes: readonly ConversationNode[]): ChatFlowItem[] {
const items: ChatFlowItem[] = []
@@ -63,6 +64,17 @@ export function deriveChatFlow(nodes: readonly ConversationNode[]): ChatFlowItem
} else {
group.push(node)
}
} else if (node.kind === 'model-retry') {
group = null
const previous = items[items.length - 1]
if (
previous?.kind === 'node'
&& previous.node.kind === 'model-retry'
) {
items[items.length - 1] = { ...previous, node }
} else {
items.push({ kind: 'node', key: `n${node.seq}`, node })
}
} else {
group = null
items.push({ kind: 'node', key: `n${node.seq}`, node })
@@ -5,7 +5,7 @@ import type {
} from '@deepseek-ai/dsh-client-ui-slots'
import type { CommandNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
import type { ComposerKeyboard, InputActions, InputNotice, InputState } from '../input/contract.ts'
import type { ComposerKeyboard, EditSelection, InputActions, InputNotice, InputState } from '../input/contract.ts'
import type { createChatStore } from '../stores.ts'
import type { CallId, SelectionTarget, ViewTab } from './views.ts'
@@ -274,14 +274,14 @@ export interface ComposerBarOwnerProps {
rightItems?: ReactNode
/** composer.dock entries (stats line), rendered under the card inside the bar's width column. */
footer?: ReactNode
onAdd?: () => void
addLabel?: string
}
/** Injected share of the composer-bar entry (package-internal faces). */
export interface ComposerBarInjected {
/** The InputBar-exclusive keyboard/DOM command face (decision 20 private plane); absent with the session. */
keyboard: ComposerKeyboard | undefined
/** Toggle the shared slash menu with only its command source; absent without ui-slash or a session. */
toggleCommandMenu: ((selection: EditSelection) => void) | undefined
/** Cancel the in-flight turn; absent with the session. */
stop: (() => void) | undefined
/**
@@ -301,6 +301,8 @@ export interface ComposerBarInjected {
notices: ObservableSnapshot<InputNotice | null>
/** Hot plain-text reference lexicon for the decoration scan (decision 21). */
lexicon: ObservableSnapshot<ReadonlyMap<'/' | '@', readonly string[]>>
/** Source name opened by the programmatic menu launcher, or null. */
menuLauncher: ObservableSnapshot<string | null>
}
}
@@ -0,0 +1,84 @@
/**
* Pure derivation of the web-card props from a frozen call slice: the
* `card:'web'` render intent the `web_search`/`web_fetch` tools declare at
* result time arrives on the snapshot as `resultView`, and this is the one
* place that turns it into what {@link WebBlock} draws. Both conversation
* render sites (the chat tool row's resident/expanded body and the details
* panel's Output section) call this, so the sources and fetch summary they
* show are derived once.
*
* The web card is result-only by contract: those tools keep a generic pending
* call view, so there is nothing to derive while the call is still running and
* a running call always takes the generic path.
* @module
*/
import type { WebBlockProps } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ToolCallBlock } from './tool-call-model.ts'
/**
* Sources the chat row's web body shows before collapsing the middle — half
* the primitive's own default, which the details panel keeps. A chat row is a
* summary surface inside the message flow: the flow must stay scannable across
* many calls, while the details panel is the single-call reading surface. A
* design constant of this UI's row geometry, not a deployment choice, so it is
* fixed here rather than a plugin Config field.
*/
export const CHAT_WEB_MAX_SOURCES = 8
/**
* Derive the web-card props for a tool call, or null when this call is not a
* web card and belongs on the generic path.
*
* The result side supplies the whole card: the sources and answer for a
* `search`, the URL and status for a `fetch`. Cases producing null, all of
* them the documented generic-card default:
*
* - A running call (no `resultView` yet): the web tools keep a generic pending
* card, so nothing web-shaped exists until the call settles.
* - A settled call whose result view is not a web card — including a `card`
* value this UI version does not know, which arrives over the wire and so
* cannot be trusted to be one of the compiled variants, and a generic result
* view (a web tool's error path returns the generic card, whose text the
* generic path preserves).
* - A web card whose `kind` this UI version does not know (a newer host's
* value): the wire cannot be trusted to be `search` or `fetch`, so it takes
* the generic path rather than rendering as a malformed fetch.
* @param block - RunningToolCall or ToolResultNode off the snapshot caches.
* @returns the web-card props, or null for the generic path.
*/
export function webCardModel(block: ToolCallBlock): WebBlockProps | null {
// Running calls have no result view; the web card is result-only.
if (!('kind' in block)) return null
const result = block.resultView
if (result?.card !== 'web') return null
if (result.kind === 'search') {
return {
kind: 'search',
answer: result.answer,
sources: result.sources.map(source => ({
url: source.url,
title: source.title,
snippet: source.snippet,
publishedAt: source.publishedAt,
})),
truncated: result.truncated,
}
}
// Discriminate `fetch` explicitly rather than treating it as the else of
// `search`: a `kind` this UI version does not know arrives over the wire from
// a newer host, and reading it as a fetch would draw an empty URL and
// `HTTP undefined`. It takes the generic path, the same wire-boundary default
// an unknown `card` tag takes above. The static union narrows `kind` to
// `'fetch'` here, but the runtime value is off the wire, so the guard and its
// null fallthrough are load-bearing despite the type.
// oxlint-disable-next-line typescript/no-unnecessary-condition
if (result.kind === 'fetch') {
return {
kind: 'fetch',
url: result.url,
statusCode: result.statusCode,
truncated: result.truncated,
}
}
return null
}
@@ -106,6 +106,17 @@ export class InputHub implements InputService {
return this.shell(id)
}
/**
* Resolve the optional slash controller for composer chrome that launches
* the shared candidate menu without typing a trigger.
* @param id - session id.
* @returns the resident controller, or undefined when ui-slash is absent.
*/
slash(id: SessionId): SlashController | undefined {
const actx = this.sessions().scope(id)
return actx === undefined ? undefined : this.controller(actx)
}
/**
* Default sink: optimistic clear + prompt. The session is always a real
* host entity (materialized when its workspace was picked), so there is
@@ -19,10 +19,15 @@ export const zh = {
'placeholder.unavailable': '会话不可用',
'placeholder.hero': '描述你想要构建的内容',
'placeholder.workspace': '选择一个工作区开始',
'input.addAttachment': '添加附件',
'input.commands': '命令',
'input.stop': '停止生成',
'input.send': '发送消息',
'input.accessMode': '访问模式,当前:{name}',
'access.confirm.title': '确认启用 Full access',
'access.confirm.description': '启用 Full access 后,agent 将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。',
'access.confirm.acknowledge': '我已了解风险,并愿意继续',
'access.confirm.cancel': '取消',
'access.confirm.enable': '启用 Full access',
'hero.headline': '开始构建吧',
'hero.chooseWorkspace': '选择工作区',
'session.hierarchy': '会话层级',
@@ -48,6 +53,13 @@ export const zh = {
'message.unknownBlock': '未知内容块',
'message.stopped': '已停止',
'message.branch': '在新对话中分支',
'message.retry.active': '正在重试模型请求',
'message.retry.cancelled': '模型请求重试已取消',
'message.retry.started': '已重试模型请求',
'message.retry.scheduled': '等待重试模型请求',
'message.retry.status': '{label}{retry}/{maximum} · {seconds}s',
'message.retry.delay': '重试延迟:',
'message.retry.failure': '失败原因:',
'command.running': '执行中…',
'command.failed': '命令失败',
'command.done': '已完成',
@@ -101,10 +113,15 @@ export const en = {
'placeholder.unavailable': 'Session unavailable',
'placeholder.hero': 'Describe what you want to build',
'placeholder.workspace': 'Choose a workspace to start',
'input.addAttachment': 'Add attachment',
'input.commands': 'Commands',
'input.stop': 'Stop generating',
'input.send': 'Send message',
'input.accessMode': 'Access mode, current: {name}',
'access.confirm.title': 'Enable Full access?',
'access.confirm.description': 'Full access reduces confirmation steps and lets the agent perform more actions directly, including sensitive operations, file changes, or external commands. Only use it when you trust the current task.',
'access.confirm.acknowledge': 'I understand the risks and want to continue',
'access.confirm.cancel': 'Cancel',
'access.confirm.enable': 'Enable Full access',
'hero.headline': 'Let\'s start building',
'hero.chooseWorkspace': 'Choose workspace',
'session.hierarchy': 'Session hierarchy',
@@ -130,6 +147,13 @@ export const en = {
'message.unknownBlock': 'Unknown content block',
'message.stopped': 'Stopped',
'message.branch': 'Branch into a new conversation',
'message.retry.active': 'Retrying model request',
'message.retry.cancelled': 'Model request retry cancelled',
'message.retry.started': 'Retried model request',
'message.retry.scheduled': 'Waiting to retry model request',
'message.retry.status': '{label} ({retry}/{maximum}) · {seconds}s',
'message.retry.delay': 'Retry delay: ',
'message.retry.failure': 'Failure reason: ',
'command.running': 'Running…',
'command.failed': 'Command failed',
'command.done': 'Completed',
@@ -1,10 +1,21 @@
/* Figma .FileContainerText 1:791: 776px wrapper around the inset 752px panel. */
/* Figma .FileContainerText 1:791: the wrapper uses the shared dock inset
inside the composer card around the inset panel. */
.dock {
box-sizing: border-box;
flex: none;
width: 100%;
max-width: 776px;
width: calc(
100% -
var(--dsh-composer-side-clearance) -
var(--dsh-composer-side-clearance) -
var(--dsh-composer-dock-inset) -
var(--dsh-composer-dock-inset)
);
max-width: calc(
var(--dsh-composer-card-max-width) -
var(--dsh-composer-dock-inset) -
var(--dsh-composer-dock-inset)
);
/* Flex gap still applies after this item; subtract it together with the
design's overlap so the later composer paints over the queue edge. */
margin: 0 auto calc(
@@ -73,7 +73,7 @@ export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps
}
return (
<div className={css.dock}>
<div className={css.dock} data-queue-dock="">
<div className={css.panel}>
{queue.length > 1 && (
<button
@@ -133,6 +133,12 @@
--dsh-composer-stack-gap: 6px;
--dsh-queue-composer-overlap: 5px;
/* InputBar and dock registrants derive their horizontal geometry from the
same card width, outer clearance, and dock inset. */
--dsh-composer-card-max-width: 800px;
--dsh-composer-side-clearance: 32px;
--dsh-composer-dock-inset: 12px;
display: flex;
flex-direction: column;
gap: var(--dsh-composer-stack-gap);
@@ -106,3 +106,9 @@
.terminal {
margin: 0;
}
/* Same rule for the web card: it sits under the section label, so the section
owns the spacing rather than the primitive's own vertical margin. */
.web {
margin: 0;
}
@@ -7,11 +7,12 @@
// share the store seat exists for) and derives the call material from the
// session snapshot — no data of its own.
import { CodeBlock, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives'
import { CodeBlock, TerminalBlock, WebBlock } from '@deepseek-ai/dsh-client-ui-primitives'
import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import type { DetailsSlotProps } from '../contract/slots.ts'
import { terminalBlockLabels, terminalCardModel } from '../contract/terminal-card-model.ts'
import { webCardModel } from '../contract/web-card-model.ts'
import { resultText, type ToolCallBlock } from '../contract/tool-call-model.ts'
import css from './DetailsPanel.module.css'
@@ -127,8 +128,10 @@ export function DetailsPanel({ useSession, useSessions, sessionId, useStore, clo
* The Output section's body for the selected call. A terminal-card call — a
* shell command's call/result views — renders through the shared TerminalBlock
* at the primitive's own full height allowance, so column-aligned output keeps
* its alignment and scrolls sideways instead of folding. Every other call, and
* a running call with no terminal card yet, keeps the flattened text form.
* its alignment and scrolls sideways instead of folding. A web-card call — a
* `web_search`/`web_fetch` result — renders through WebBlock at its own full
* source-list allowance. Every other call, and a running call with no card
* yet, keeps the flattened text form.
* @param props.material - the selected call's material from {@link materialFor}.
* @param props.cwd - the session workspace root, resolving the terminal view's cwd.
* @param props.t - the panel's locale seat, passed down as a plain prop.
@@ -148,6 +151,24 @@ function OutputBody({ material, cwd, t }: { material: CallMaterial; cwd: string
</>
)
}
const web = webCardModel(material.block)
// Full source-list allowance here (the panel is the single-call reading
// surface); the chat rows cap it at CHAT_WEB_MAX_SOURCES. Below the card the
// panel also renders the flattened result content — the model-visible text
// the card does not carry verbatim (a web_fetch card shows only the URL and
// status, so its fetched body lives only here; a search card's answer and
// sources are structured, so the flattened form repeats them as the raw text
// the model saw).
if (web !== null) {
const settled = 'kind' in material.block ? material.block : null
const body = settled === null ? '' : resultText(settled)
return (
<>
<WebBlock {...web} className={css.web} />
{body !== '' && <pre className={css.code}>{body}</pre>}
</>
)
}
// A settled call always carries the result node the flattened form needs;
// the running shape has no result to flatten.
if (!('kind' in material.block)) return <div className={css.empty}>{t('details.running')}</div>
@@ -11,7 +11,7 @@
padding: 0 24px;
}
/* Cap matches InputBar card width (800). Glow may paint past the sides. */
/* Cap matches the InputBar card. Glow may paint past the sides. */
.stack {
display: flex;
flex-direction: column;
@@ -19,7 +19,7 @@
/* figma 75:8208: 12 between title block / workspace / card. */
gap: 12px;
width: 100%;
max-width: 800px;
max-width: var(--dsh-composer-card-max-width);
overflow: visible;
}
@@ -23,7 +23,7 @@
/* figma Input_Bottom: pad L32/R32/B8; the bottom gradient mask is owned by
the chat scroller. No top pad: the composer stack's gap owns the space
above; error/status strips still carry their own margin. */
padding: 0 32px 8px;
padding: 0 var(--dsh-composer-side-clearance) 8px;
}
.hero {
@@ -33,7 +33,7 @@
.error,
.status {
width: 100%;
max-width: 800px;
max-width: var(--dsh-composer-card-max-width);
margin-bottom: 6px;
padding: 4px 8px;
border-radius: 8px;
@@ -48,7 +48,7 @@
.notice {
width: 100%;
max-width: 800px;
max-width: var(--dsh-composer-card-max-width);
margin-bottom: 6px;
padding: 4px 8px;
border-radius: 8px;
@@ -69,6 +69,7 @@
}
.card {
box-sizing: border-box;
position: relative; /* overlay anchor positioning context */
display: flex;
flex-direction: column;
@@ -76,7 +77,7 @@
top pad on the card before .InputText. */
gap: 12px;
width: 100%;
max-width: 800px;
max-width: var(--dsh-composer-card-max-width);
padding-top: 10px;
/* Input stroke: black/0.10 light, white/0.06 dark (figma darkmode note says
the input border is one notch weaker than buttons) — exactly the
@@ -34,13 +34,14 @@ export interface InputBarError {
export type InputBarProps = ComposerBarProps
export function InputBar({
useSession, useInput, inputActions, keyboard, stop, command, t, renderSlot, useNotices, useLexicon,
useSession, useInput, inputActions, keyboard, toggleCommandMenu, stop, command, t,
renderSlot, useNotices, useLexicon, useMenuLauncher,
useProjection, sessionId, variant, disabled: inert = false, placeholder, accessory, overlay, leftItems, rightItems, footer,
onAdd, addLabel,
}: InputBarProps) {
const input = useInput(s => s)
const notice = useNotices(s => s)
const lexicon = useLexicon(s => s)
const commandMenuOpen = useMenuLauncher(source => source === 'command')
const promptError = useSession(s => s.promptError) ?? null
const running = useSession(s => s.running) ?? false
const removed = useSession(s => s.removed) ?? false
@@ -257,7 +258,11 @@ export function InputBar({
inputRef.current?.focus()
}
const addText = addLabel ?? t('input.addAttachment')
const onToggleCommandMenu = (): void => {
const el = inputRef.current
if (el !== null) toggleCommandMenu?.(selectionOf(el))
}
const primaryLabel = running ? t('input.stop') : t('input.send')
const onPrimary = (): void => {
if (inputActions === undefined || stop === undefined) return // absent machine: the button is disabled
@@ -274,7 +279,7 @@ export function InputBar({
// or while the command face is absent with the session).
const accessSelect: ReactNode = command === undefined
? null
: <PermissionSelect value={permissions} locked={locked} command={command} t={t} />
: <PermissionSelect key={sessionId} value={permissions} locked={locked} command={command} t={t} />
// Mirror-layer decorations: a visible backdrop with transparent text. The
// claim token highlights through behind the textarea glyphs; each U+FFFC
@@ -399,11 +404,13 @@ export function InputBar({
<button
type="button"
className={css.add}
aria-label={addText}
title={addText}
disabled={locked}
aria-label={t('input.commands')}
title={t('input.commands')}
aria-haspopup="listbox"
aria-expanded={commandMenuOpen}
disabled={locked || toggleCommandMenu === undefined}
onMouseDown={keepFocus}
onClick={onAdd}
onClick={onToggleCommandMenu}
>
<IconPlusOutline16 size={14} />
</button>
@@ -1,21 +1,28 @@
import { useState } from 'react'
import { useEffect, useState } from 'react'
import type { PermissionSelect as PermissionSelectValue } from '@deepseek-ai/dsh-permission/client'
import { Menu } from '@deepseek-ai/dsh-client-ui-primitives'
import { Menu, RiskConfirmation } from '@deepseek-ai/dsh-client-ui-primitives'
import type { MenuEntry } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ComposerBarProps } from '../contract/slots.ts'
import css from './PermissionSelect.module.css'
const FULL_ACCESS = 'danger-full-access'
/**
* Display transform: kebab-case machine names render as title-case labels
* (`workspace-write` → `Workspace Write`); non-kebab host-configured names
* pass through. Twin of the /permission popup's (client ui-permission) — the
* two permission surfaces must show the same text.
* pass through. Full access intentionally overrides the machine-name
* transform so both permission surfaces use the product label `Full access`;
* the warning body remains locale-aware.
*/
function displayName(name: string): string {
if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name)) return name
return name.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
}
function optionLabel(option: PermissionSelectValue['options'][number]): string {
return option.value === FULL_ACCESS ? 'Full access' : displayName(option.name)
}
export interface PermissionSelectProps {
value: PermissionSelectValue | undefined
locked: boolean
@@ -27,49 +34,94 @@ export interface PermissionSelectProps {
export function PermissionSelect({ value, locked, command, t }: PermissionSelectProps) {
const [pick, setPick] = useState<string | null>(null)
const [open, setOpen] = useState(false)
const [confirmation, setConfirmation] = useState<string | null>(null)
const [acknowledged, setAcknowledged] = useState(false)
useEffect(() => {
if (!locked && value !== undefined) return
setOpen(false)
setAcknowledged(false)
setConfirmation(null)
}, [locked, value])
if (value === undefined) return null
const currentValue = pick ?? value.currentValue
const current = value.options.find(option => option.value === currentValue)
const busy = pick !== null
const busy = pick !== null || confirmation !== null
const items: MenuEntry[] = value.options
.filter(o => o.value !== 'custom')
.map(option => ({ id: option.value, label: displayName(option.name) }))
.map(option => ({ id: option.value, label: optionLabel(option) }))
const choose = (id: string): void => {
setOpen(false)
if (id === value.currentValue) return
const submit = (id: string): void => {
setPick(id)
void command(`/permission ${id}`)
.catch(() => false)
.then(() => { setPick(null) })
}
const choose = (id: string): void => {
setOpen(false)
if (id === value.currentValue) return
if (id === FULL_ACCESS) {
setAcknowledged(false)
setConfirmation(id)
return
}
submit(id)
}
const closeConfirmation = (): void => {
setAcknowledged(false)
setConfirmation(null)
}
const confirmFullAccess = (): void => {
if (locked || !acknowledged || confirmation === null) return
const id = confirmation
closeConfirmation()
submit(id)
}
return (
<Menu
open={open}
items={items}
selectedId={currentValue}
onSelect={choose}
onClose={() => { setOpen(false) }}
side="top"
anchor={
<button
type="button"
className={css.trigger}
aria-label={t('input.accessMode', { name: displayName(current?.name ?? currentValue) })}
title={current?.description}
disabled={locked || busy}
onClick={() => { setOpen(!open) }}
>
<span className={css.triggerLabel}>{displayName(current?.name ?? currentValue)}</span>
<svg className={css.chevron} viewBox="0 0 12 12" width="12" height="12" aria-hidden>
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none" />
</svg>
</button>
}
/>
<>
<Menu
open={open}
items={items}
selectedId={currentValue}
onSelect={choose}
onClose={() => { setOpen(false) }}
side="top"
anchor={
<button
type="button"
className={css.trigger}
aria-label={t('input.accessMode', { name: current === undefined ? displayName(currentValue) : optionLabel(current) })}
title={current?.description}
disabled={locked || busy}
onClick={() => { setOpen(!open) }}
>
<span className={css.triggerLabel}>{current === undefined ? displayName(currentValue) : optionLabel(current)}</span>
<svg className={css.chevron} viewBox="0 0 12 12" width="12" height="12" aria-hidden>
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" fill="none" />
</svg>
</button>
}
/>
<RiskConfirmation
open={confirmation !== null}
title={t('access.confirm.title')}
description={t('access.confirm.description')}
acknowledgeLabel={t('access.confirm.acknowledge')}
cancelLabel={t('access.confirm.cancel')}
confirmLabel={t('access.confirm.enable')}
acknowledged={acknowledged}
disabled={locked}
onAcknowledgedChange={setAcknowledged}
onCancel={closeConfirmation}
onConfirm={confirmFullAccess}
/>
</>
)
}
@@ -1,13 +1,24 @@
/* Todo strip in the composer context stack (Figma 9:959): tip surface,
14px radius, status icons + secondary item labels. */
14px radius, status icons + secondary item labels. It shares the composer
card geometry and adds the dock inset on both sides. */
.root {
box-sizing: border-box;
flex: none;
overflow: hidden;
margin: 0 auto;
width: calc(100% - 88px);
max-width: 752px;
width: calc(
100% -
var(--dsh-composer-side-clearance) -
var(--dsh-composer-side-clearance) -
var(--dsh-composer-dock-inset) -
var(--dsh-composer-dock-inset)
);
max-width: calc(
var(--dsh-composer-card-max-width) -
var(--dsh-composer-dock-inset) -
var(--dsh-composer-dock-inset)
);
border: 1px solid var(--dsw-alias-border-l1);
border-radius: 14px;
background: var(--dsw-specific-tip);
@@ -0,0 +1,95 @@
/* Web toolview: same geometry/tokens as ToolRow (figma icon · summary), plus
the web card the row stacks under its summary line, mirroring the bash row's
resident terminal card. */
/* Summary line over the web card; the summary row keeps its own 24px height,
so the card is a column around it rather than a change to it. */
.card {
display: flex;
flex-direction: column;
}
/* Row indentation matches ToolRow's expanded bodies (16px leading + 6px gap),
and replaces the primitive's standalone vertical margin with the flow's. */
.web {
margin: 4px 0 4px 22px;
}
.root {
position: relative; /* sweep-glare overlay anchor */
overflow: hidden;
display: flex;
align-items: center;
height: 24px;
min-width: 0;
}
/* Running sweep glare — same deepsuite ShimmerText pattern as ToolRow. */
.root[data-state='running']::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 300px;
background: linear-gradient(
90deg,
transparent 0%,
color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%,
transparent 100%
);
animation: dsh-web-row-sweep 2.6s ease-out infinite;
pointer-events: none;
}
@keyframes dsh-web-row-sweep {
0% { left: -300px; }
90%, 100% { left: 100%; }
}
.leading {
flex: none;
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
margin-right: 6px;
color: var(--dsw-alias-label-tertiary);
}
.title {
flex: none;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-secondary);
}
.sep {
flex: none;
width: 2px;
height: 2px;
border-radius: 1px;
margin: 0 8px;
background: var(--dsw-alias-label-caption);
}
.summary {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-tertiary);
}
.visuallyHidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@@ -0,0 +1,92 @@
// Web toolview registrant: third-party posture over the keyed toolview hole
// (ctx.slots.register + ToolRowProps only — never imports the chat domain).
// Registered under BOTH web_search and web_fetch, since both declare the one
// `web` render intent and render through the one WebBlock family; the row
// discriminates on the toolName only to pick its icon and title.
//
// A web tool declares the `web` render intent at result time, so this row
// renders the completed retrieval through WebBlock resident below its summary,
// the same posture BashRow uses for the terminal card: no expand control on the
// row itself, not a details-panel target, and the block's own expander keeps a
// long source list from taking over the message flow (CHAT_WEB_MAX_SOURCES is
// passed as maxSources — the chat flow's tighter cap over the block's default
// of 16). Until the call settles there is no web card (the tools keep a generic
// pending view), so a running row is the summary line alone.
import type { Context } from 'cordis'
import { IconBrowseOutline16, IconSearchOutline16, StateDot, WebBlock } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ToolRowProps } from '../contract/slots.ts'
import { CHAT_WEB_MAX_SOURCES, webCardModel } from '../contract/web-card-model.ts'
import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts'
import css from './web-row.module.css'
/** web_fetch reads one URL; web_search queries. Titles are figma literals. */
const WEB_TITLES: Record<string, string> = {
web_search: 'Search',
web_fetch: 'Fetch',
}
/** Leading icon per tool, yielding to the state semantic while failed/stopped. */
function leadingFor(toolName: string, state: ToolRowState) {
switch (state) {
case 'error': return <StateDot state="error" />
case 'stopped': return <StateDot state="warning" />
// Running keeps the icon — the row sweep carries the in-flight signal.
default: return toolName === 'web_fetch' ? <IconBrowseOutline16 size={14} /> : <IconSearchOutline16 size={14} />
}
}
/** Visually hidden status — StateDot is aria-hidden; AT needs a text label. */
function stateStatus(state: ToolRowState): string | null {
switch (state) {
case 'running': return '运行中'
case 'error': return '失败'
case 'stopped': return '已停止'
default: return null
}
}
/**
* Web row: icon + Search/Fetch · {summary} in the shared ToolRow chrome, with
* the completed retrieval's web card resident below it. The summary row is not
* a details-panel control (tool rows stopped being one), so the card's own
* links and expander are the row's only interactions.
*/
export function WebRow({ toolName, block }: ToolRowProps) {
const model = toolRowModel(toolName, block)
const web = webCardModel(block)
const status = stateStatus(model.state)
return (
<div className={css.card}>
<div className={css.root} data-variant="web" data-tool={toolName} data-state={model.state}>
<span className={css.leading}>{leadingFor(toolName, model.state)}</span>
{status !== null && <span className={css.visuallyHidden}>{status}</span>}
<span className={css.title}>{WEB_TITLES[toolName] ?? model.title}</span>
<span className={css.sep} aria-hidden />
<span className={css.summary}>{model.summary}</span>
</div>
{web !== null && (
<WebBlock {...web} maxSources={CHAT_WEB_MAX_SOURCES} className={css.web} />
)}
</div>
)
}
/**
* The web rows as a plain registrant plugin, riding the same load-order seam as
* the bash sample: `inject: ['conversation']` guarantees the chat entry (and
* with it the 'conversation.chat.toolview' declaration) is on the ledger. One
* WebRow component registers under both web tool names.
*/
export const webToolview = {
name: 'web-toolview',
inject: ['slots', 'conversation'],
/**
* Register the web row under both web tool names' keyed toolview holes.
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
*/
apply(ctx: Context): void {
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'web_search' }, WebRow)
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'web_fetch' }, WebRow)
},
}
@@ -188,9 +188,11 @@ describe('conversation slot inject surface', () => {
// hooks compartment still present so the render side's hook order holds.
const absent = injectFn(undefined)
expect(absent.keyboard).toBeUndefined()
expect(absent.toggleCommandMenu).toBeUndefined()
expect(absent.stop).toBeUndefined()
expect(absent.hooks.notices.getSnapshot()).toBeNull()
expect(absent.hooks.lexicon.getSnapshot().size).toBe(0)
expect(absent.hooks.menuLauncher.getSnapshot()).toBeNull()
// A scope whose service tree lost 'conversation' (the feature fiber
// unloaded while a retained inject closure re-runs): fails loud too.
const stop = injectFn(ROOT).stop!
@@ -84,12 +84,13 @@ describe('apply wiring', () => {
await b.runtime.dispose()
})
it('mounts the bash sample and the product rows as keyed entries through the load-order seam', async () => {
it('mounts the bash sample, the web rows, and the product rows as keyed entries through the load-order seam', async () => {
const b = await bench()
// Every registrant plugin's inject: ['slots', 'conversation'] resolved — the
// service being present implies the chat entry declared the hole first.
// service being present implies the chat entry declared the hole first. The
// web rows register one component under both web tool names.
const entries = b.slots.entries('conversation.chat.toolview')
expect(entries.map(e => e.options.key)).toEqual(['bash', 'todo_write', 'ask_user_question'])
expect(entries.map(e => e.options.key)).toEqual(['bash', 'web_search', 'web_fetch', 'todo_write', 'ask_user_question'])
// Stats stick with the composer (not inside ChatView).
expect(b.slots.entries('conversation.composer.dock').map(e => e.options.id)).toEqual(['stats'])
await b.runtime.dispose()

Some files were not shown because too many files have changed in this diff Show More