瀏覽代碼

fix(permission): revoke stale Auto choices and simplify review paths

pku-xht 1 周之前
父節點
當前提交
41fbde8034
共有 26 個文件被更改,包括 277 次插入127 次删除
  1. 2 2
      .agents/notes/implemented/feature/2026-08-28-auto-review.i18n.yaml
  2. 3 3
      .agents/notes/implemented/feature/2026-08-28-auto-review.md
  3. 3 3
      .agents/notes/implemented/feature/2026-08-28-auto-review.zh.md
  4. 13 1
      apps/web/tests/access-confirmation.e2e.ts
  5. 2 2
      packages/client/ui-commands/README.i18n.yaml
  6. 1 1
      packages/client/ui-commands/README.md
  7. 1 1
      packages/client/ui-commands/README.zh.md
  8. 3 1
      packages/client/ui-commands/src/client/contract.ts
  9. 24 13
      packages/client/ui-commands/src/client/service.ts
  10. 122 4
      packages/client/ui-commands/tests/service.client.spec.ts
  11. 2 2
      packages/client/ui-permission-presets/README.i18n.yaml
  12. 1 1
      packages/client/ui-permission-presets/README.md
  13. 1 1
      packages/client/ui-permission-presets/README.zh.md
  14. 1 3
      packages/client/ui-permission-presets/package.json
  15. 4 0
      packages/client/ui-permission-presets/src/client/index.ts
  16. 14 1
      packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts
  17. 19 27
      packages/client/ui-tool/src/client/tool/components/ToolRow.tsx
  18. 7 4
      packages/client/ui-tool/src/client/tool/toolviews/GenericToolCard.tsx
  19. 15 30
      packages/client/ui-tool/tests/tool-row.client.spec.tsx
  20. 5 15
      packages/experimental/auto-review/src/index.ts
  21. 22 4
      packages/experimental/auto-review/tests/auto-review.spec.ts
  22. 3 4
      pnpm-lock.yaml
  23. 3 2
      snapshots/sdk/subagent-fork-in-process/session.1.v3.jsonl
  24. 3 2
      snapshots/sdk/subagent-mixed/session.2.v3.jsonl
  25. 0 0
      snapshots/session/cordis-inspect-jsdoc/session.v3.jsonl
  26. 3 0
      snapshots/web/auto-review-denial/snapshot.yml

+ 2 - 2
.agents/notes/implemented/feature/2026-08-28-auto-review.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-28-auto-review.md
-2026-08-28-auto-review.md: d5eed2132d1ca3b91473b511e55c03a640ce389e
-2026-08-28-auto-review.zh.md: 4dc91f3d0565c219b513360bb1b19e369b440f85
+2026-08-28-auto-review.md: b8308048a0f1b7e435fe1b7d1610d37c5624820f
+2026-08-28-auto-review.zh.md: 1950be4302902820e2428214d16e4e020a1a4fbb

+ 3 - 3
.agents/notes/implemented/feature/2026-08-28-auto-review.md

@@ -40,13 +40,13 @@ The integration uses only the latest `request/header.config` provider/model and
 | `FILTERED_HISTORY` | Current compaction surface's sourced human/direct-parent messages, checkpoints, image/attachment facts, and historical call names with logged arguments |
 | `PENDING_ACTION` | Tool name, description, parameter schema, and parsed arguments |
 
-The main agent's V3 `system/message` nodes, assistant text/reasoning, and tool results are excluded. The current call appears only in `PENDING_ACTION`; an unstarted sibling has no historical call fact. Native schema comes from the latest request header. PTC captures a frozen schema at binding construction and passes it through the scheduler into `ToolExecution`; descriptions and parameter schemas never enter start/settle events or the Session/SDK wire. Missing, inconsistent, or ambiguous action facts reject the call without consulting the live registry. An oversized request fails closed without summarization, truncation, another compaction pass, or a small output-token budget.
+The main agent's V3 `system/message` nodes, assistant text/reasoning, and tool results are excluded. The current call must belong to the open step recorded by `step/start`; missing step ownership fails closed. It appears only in `PENDING_ACTION`; an unstarted sibling has no historical call fact. Native schema comes from the latest request header. PTC captures a frozen schema at binding construction and passes it through the scheduler into `ToolExecution`; descriptions and parameter schemas never enter start/settle events or the Session/SDK wire. Missing, inconsistent, or ambiguous action facts reject the call without consulting the live registry. An oversized request fails closed without summarization, truncation, another compaction pass, or a small output-token budget.
 
 ### Result and cancellation
 
 The reviewer may emit reasoning blocks followed by exactly one JSON text block and terminal `stop`. The closed object admits only `low + allow`, `medium + allow/deny`, and `high + deny`; only deny may carry a string `reason`. Extra fields, duplicate members, invalid combinations, other blocks or termination, and provider failures share the ordinary Auto denial outcome. Risk and reviewer traces are not durable state.
 
-Native results and PTC settle events carry the same structured `AutoReviewDeniedError` / `AUTO_REVIEW_DENIED` and optional raw reason. The main agent receives only `Auto review rejected tool "<name>"; its body was not executed` through ordinary failure rendering. PTC retains the existing program exception/catch behavior; catching a denial does not elevate it to an outer failure. The Web card identifies the denial when collapsed and shows one not-executed line when expanded. Only that display trims and collapses line separators or supplies the localized empty-reason fallback; persistence and both SDKs preserve the complete raw reason, without a new length or redaction rule.
+Native results and PTC settle events carry the same structured `AutoReviewDeniedError` / `AUTO_REVIEW_DENIED` and optional raw reason. The main agent receives only `Auto review rejected tool "<name>"; its body was not executed` through ordinary failure rendering. PTC retains the existing program exception/catch behavior; catching a denial does not elevate it to an outer failure. The generic Web tool card supplies the denial identity for the collapsed row and one not-executed output line for the expanded row, with no input body. Only that display trims and collapses line separators or supplies the localized empty-reason fallback; persistence and both SDKs preserve the complete raw reason, without a new length or redaction rule.
 
 Admission and active-review enrollment occur synchronously before the first await. The integration owns one lifecycle controller and one set of active operations. Unload closes new selection/review admission, changes live Auto Sessions to Full access through the existing preset writer without changing knobs or closing terminals, then aborts and drains reviews before removing listener and contribution. After provider settlement, a lifecycle abort always produces canonical pre-dispatch cancellation, including late allow, deny, or failure. Caller cancellation retains ToolRuntime's priority: late allow cancels before dispatch; late deny or failure retains that outcome. No cancelled review starts a tool body.
 
@@ -54,7 +54,7 @@ A persisted Auto Session cannot publish when the complete integration is absent
 
 ### Process catalog and children
 
-The permission owner publishes one complete process catalog through generated `permissionPresets` Remote methods; the BFF explicitly mounts it and forwards a payload-free invalidation event. One browser directory subscribes before reading and serves both pickers. Epoch and connection-generation checks publish only the winning complete result. A winning failure or connection reset clears the previous snapshot; only a later existing read, notification, or reset retries. Disposed or stale settlements cannot publish. Session projection carries current selection only, so catalog installation or removal writes no Session event or sequence.
+The permission owner publishes one complete process catalog through generated `permissionPresets` Remote methods; the BFF explicitly mounts it and forwards a payload-free invalidation event. One browser directory subscribes before reading and serves both pickers. Epoch and connection-generation checks publish only the winning complete result. A winning failure or connection reset clears the previous snapshot; only a later existing read, notification, or reset retries. Disposed or stale settlements cannot publish. Each shared snapshot publication dismisses the slash picker and its pending confirmation through the command owner while preserving the draft; reopening loads the current catalog. Session projection carries current selection only, so catalog installation or removal writes no Session event or sequence.
 
 Auto appears with a superscript `EXP` badge. Both visible current-session pickers require the experimental confirmation; explicit `/permission auto` is already consent. The composer uses the generic Menu's existing portal placement to stay within the viewport while keeping its 218–360px bounds. The slash popup keeps `min(220px, 100%)` with `max-width: 100%`, including when a narrow composer collapses its trigger.
 

+ 3 - 3
.agents/notes/implemented/feature/2026-08-28-auto-review.zh.md

@@ -40,13 +40,13 @@ Integration 只使用最新 `request/header.config` 的 provider/model 与 shi
 | `FILTERED_HISTORY` | 当前 compaction surface 中带来源的 human/直接父级消息、checkpoint、图片/附件事实,以及历史调用名称与日志参数 |
 | `PENDING_ACTION` | 工具名称、描述、参数 schema 与解析后的 arguments |
 
-主 agent 的 V3 `system/message` 节点、assistant 正文/reasoning 与 tool results 全部排除。当前调用只在 `PENDING_ACTION` 出现;尚未开始的 sibling 没有历史调用事实。原生 schema 来自最新 request header。PTC 在 binding 构造时捕获冻结 schema,经由调度器传入 `ToolExecution`;描述与参数 schema 不进入开始/结算事件或 Session/SDK wire。动作事实缺失、不一致或有歧义时拒绝调用,不查询 live registry。超窗请求直接拒绝,不做摘要、截断、额外 compaction 或设置小型输出 token 预算。
+主 agent 的 V3 `system/message` 节点、assistant 正文/reasoning 与 tool results 全部排除。当前调用必须属于 `step/start` 记录的开放 step;缺少 step 归属时拒绝执行。该调用只在 `PENDING_ACTION` 出现;尚未开始的 sibling 没有历史调用事实。原生 schema 来自最新 request header。PTC 在 binding 构造时捕获冻结 schema,经由调度器传入 `ToolExecution`;描述与参数 schema 不进入开始/结算事件或 Session/SDK wire。动作事实缺失、不一致或有歧义时拒绝调用,不查询 live registry。超窗请求直接拒绝,不做摘要、截断、额外 compaction 或设置小型输出 token 预算。
 
 ### 结果与取消
 
 Reviewer 可以输出 reasoning blocks,随后恰好一个 JSON text block 和终态 `stop`。封闭对象只允许 `low + allow`、`medium + allow/deny` 与 `high + deny`;只有 deny 可携带字符串 `reason`。额外字段、重复成员、非法组合、其他 block 或终态以及 provider 失败均使用普通 Auto 拒绝结果。风险与 reviewer trace 不成为持久状态。
 
-原生结果与 PTC 结算事件携带同形结构化 `AutoReviewDeniedError`/`AUTO_REVIEW_DENIED` 及可选原始理由。主 agent 通过普通失败渲染只收到 `Auto review rejected tool "<name>"; its body was not executed`。PTC 保留既有程序异常/catch 行为;捕获拒绝不会将其提升为外层失败。Web 卡片在折叠时标识拒绝,展开时显示一行未执行说明。只有该显示过程会 trim、折叠行分隔符,或提供本地化空理由 fallback;持久化与两套 SDK 保留完整原始理由,不增加长度或脱敏规则。
+原生结果与 PTC 结算事件携带同形结构化 `AutoReviewDeniedError`/`AUTO_REVIEW_DENIED` 及可选原始理由。主 agent 通过普通失败渲染只收到 `Auto review rejected tool "<name>"; its body was not executed`。PTC 保留既有程序异常/catch 行为;捕获拒绝不会将其提升为外层失败。通用 Web 工具卡片为折叠行提供拒绝身份,为展开行提供一行未执行输出,不提供输入正文。只有该显示过程会 trim、折叠行分隔符,或提供本地化空理由 fallback;持久化与两套 SDK 保留完整原始理由,不增加长度或脱敏规则。
 
 准入与在途 review 登记在首次 await 前同步完成。Integration 拥有一个生命周期 controller 和一个在途操作集合。卸载先关闭新选择/review admission,经由既有 preset writer 把存活 Auto Session 切为 Full access,不改变旋钮、不关闭终端,然后中止并等待 review 结清,最后移除 listener 与 contribution。Provider 结算后,lifecycle abort 始终形成规范的 dispatch 前取消,包括晚到 allow、deny 或 failure。Caller 取消保留 ToolRuntime 优先级:晚到 allow 在 dispatch 前取消;晚到 deny 或 failure 保留原结果。被取消的 review 不启动工具 body。
 
@@ -54,7 +54,7 @@ Reviewer 可以输出 reasoning blocks,随后恰好一个 JSON text block 和
 
 ### 进程目录与 child
 
-Permission owner 通过生成的 `permissionPresets` Remote 方法发布一份完整进程目录;BFF 显式挂载它,并转发无 payload 的失效事件。一个浏览器目录在读取前订阅,为两个选择器提供数据。Epoch 与 connection-generation 检查只发布胜出的完整结果。胜出读取失败或 connection reset 会清空旧快照;只有后续既有读取、通知或 reset 才重试。已 dispose 或陈旧的结算不能发布。Session 投影只携带当前选择,因此目录安装或移除不写 Session 事件或序号。
+Permission owner 通过生成的 `permissionPresets` Remote 方法发布一份完整进程目录;BFF 显式挂载它,并转发无 payload 的失效事件。一个浏览器目录在读取前订阅,为两个选择器提供数据。Epoch 与 connection-generation 检查只发布胜出的完整结果。胜出读取失败或 connection reset 会清空旧快照;只有后续既有读取、通知或 reset 才重试。已 dispose 或陈旧的结算不能发布。每次共享快照发布都通过命令 owner 关闭 slash 选择器与待确认对话框,同时保留草稿;重新打开时加载当前目录。Session 投影只携带当前选择,因此目录安装或移除不写 Session 事件或序号。
 
 Auto 带右上标 `EXP`。两个可见当前会话选择器都要求实验确认;显式 `/permission auto` 已构成同意。Composer 使用通用 Menu 既有 portal 定位保持在视口内,同时保留 218–360px 边界。Slash popup 保留 `min(220px, 100%)` 与 `max-width: 100%`,窄 composer 将 trigger 折叠时也一样。
 

+ 13 - 1
apps/web/tests/access-confirmation.e2e.ts

@@ -220,11 +220,24 @@ describe('web e2e: experimental Auto and Full access confirmation', () => {
     await input.press('Enter')
     await expect.poll(() => access.getAttribute('aria-label')).toBe('访问模式,当前:Auto review EXP')
     expect(await page.getByRole('dialog').count()).toBe(0)
+    await writeComposerDraft(page, input, '/permission')
+    await input.press('Escape')
+    await input.press('Enter')
+    const slash = page.locator('[aria-label="/permission 选项"]')
+    await slash.waitFor()
+    expect(await slash.getByRole('option').count()).toBe(4)
     const entry = [...scaffold.ctx.loader.entries()].find(row => row.options.id === 'auto-review')
     if (entry === undefined) throw new Error('experimental Auto integration is absent')
     await entry.update({ disabled: true })
     await scaffold.ctx.loader.await()
     await expect.poll(() => access.getAttribute('aria-label')).toBe('访问模式,当前:完全权限')
+    await expect.poll(() => slash.count()).toBe(0)
+    expect(await input.textContent()).toBe('/permission')
+    await input.press('Enter')
+    await slash.waitFor()
+    expect(await slash.getByRole('option').count()).toBe(3)
+    expect(await slash.getByText('Auto review', { exact: true }).count()).toBe(0)
+    await page.keyboard.press('Escape')
     await access.click()
     await expect.poll(() => page.getByRole('menuitem').allTextContents())
       .toEqual(['仅可查看', '工作区内修改', '完全权限'])
@@ -241,7 +254,6 @@ describe('web e2e: experimental Auto and Full access confirmation', () => {
     await writeComposerDraft(page, input, '/permission')
     await input.press('Escape')
     await input.press('Enter')
-    const slash = page.locator('[aria-label="/permission 选项"]')
     await slash.waitFor()
     expect(await slash.getByRole('option').count()).toBe(4)
     await page.keyboard.press('Escape')

+ 2 - 2
packages/client/ui-commands/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-commands/README.md
-README.md: e1264d10bd8ff807d8f348339e0c055607579407
-README.zh.md: c5323149aea1e38ff09262db9bd2aacad7b284ea
+README.md: 67f3fbab5a9d44af4f88fbe33df6079f03459187
+README.zh.md: 6845ccba457ef6e9601f2f03374d71cb569ba2d3

+ 1 - 1
packages/client/ui-commands/README.md

@@ -43,7 +43,7 @@ When the composer submits with images or generic files, only a host command decl
 <details>
 <summary>Implementation internals — click to expand</summary>
 
-`src/client/contract.ts` is the fixed business contract: `CommandUiContract.register(name, spec)` and `decorate(name, spec)` are everything a business package consumes. `CommandDirectory` is the one wire-derived cache, keyed by session: ordinary sessions fetch through `command.list({sessionId})`, entries are soft-invalidated by the forwarded `commands/change` owner event and hard-invalidated by `connection/reset`, and epoch-guarded so a superseded pull can never overwrite a newer one. `matchSpace` answers synchronously from this cache only; `matchEnter` strong-waits it on the SubmitAttempt signal and rejects on warmup failure. After `command.execute` returns a matched result, the browser emits a local `command/executed` acknowledgment; other clients receive the durable command nodes through the Host event stream but never this acknowledgment. `PopupSelectController` is the headless shell state; `PopupSelectView` self-registers into `conversation.input.overlay` with per-session resolution.
+`src/client/contract.ts` defines the business API: `register(contribution)` adds a client command, `decorate(decoration)` gives a host command a picker, and `dismiss(name)` closes that command's open popups and confirmations. Dismissal aborts pending option loads, prevents their late results from reopening the popup, and preserves composer drafts. `CommandDirectory` is the one wire-derived cache, keyed by session: ordinary sessions fetch through `command.list({sessionId})`, entries are soft-invalidated by the forwarded `commands/change` owner event and hard-invalidated by `connection/reset`, and epoch-guarded so a superseded pull can never overwrite a newer one. `matchSpace` answers synchronously from this cache only; `matchEnter` strong-waits it on the SubmitAttempt signal and rejects on warmup failure. After `command.execute` returns a matched result, the browser emits a local `command/executed` acknowledgment; other clients receive the durable command nodes through the Host event stream but never this acknowledgment. `PopupSelectController` is the headless shell state; `PopupSelectView` self-registers into `conversation.input.overlay` with per-session resolution.
 
 </details>
 

+ 1 - 1
packages/client/ui-commands/README.zh.md

@@ -43,7 +43,7 @@ composer 携带图片或通用文件提交时,只有声明了 `input.attachmen
 <details>
 <summary>实现细节——点击展开</summary>
 
-`src/client/contract.ts` 是固定的业务约定:`CommandUiContract.register(name, spec)` 与 `decorate(name, spec)` 是业务包消费的全部内容。`CommandDirectory` 是唯一的 wire 派生缓存,以会话为 key:普通会话经 `command.list({sessionId})` 拉取;条目由转发的 `commands/change` owner 事件软失效、由 `connection/reset` 硬失效,并以 epoch 把关,被取代的旧拉取永远无法覆盖更新的结果。`matchSpace` 只凭该缓存同步应答;`matchEnter` 在 SubmitAttempt 信号上强等缓存,预热失败即拒绝。`command.execute` 返回匹配结果后,浏览器发布本地 `command/executed` 确认;其他客户端经宿主事件流收到持久命令节点,但收不到这条确认。`PopupSelectController` 是不含界面的外壳状态;`PopupSelectView` 自注册进 `conversation.input.overlay`,按会话解析。
+`src/client/contract.ts` 定义业务 API:`register(contribution)` 添加客户端命令,`decorate(decoration)` 为宿主命令提供选择器,`dismiss(name)` 关闭该命令已打开的弹窗与确认对话框。关闭会中止待完成的选项加载,阻止晚到结果重新打开弹窗,并保留 composer 草稿。`CommandDirectory` 是唯一的 wire 派生缓存,以会话为 key:普通会话经 `command.list({sessionId})` 拉取;条目由转发的 `commands/change` owner 事件软失效、由 `connection/reset` 硬失效,并以 epoch 把关,被取代的旧拉取永远无法覆盖更新的结果。`matchSpace` 只凭该缓存同步应答;`matchEnter` 在 SubmitAttempt 信号上强等缓存,预热失败即拒绝。`command.execute` 返回匹配结果后,浏览器发布本地 `command/executed` 确认;其他客户端经宿主事件流收到持久命令节点,但收不到这条确认。`PopupSelectController` 是不含界面的外壳状态;`PopupSelectView` 自注册进 `conversation.input.overlay`,按会话解析。
 
 </details>
 

+ 3 - 1
packages/client/ui-commands/src/client/contract.ts

@@ -1,7 +1,7 @@
 /**
  * Frozen contract of the client command surface. Types only. The
  * CommandUiRuntime (`ctx.commandUi`) implements this face; business packages
- * consume `register` alone.
+ * consume its registration and dismissal operations.
  */
 import type { Context as ClientContext } from '@deepseek-ai/cordis'
 import type { ClientSessionContext } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
@@ -86,6 +86,8 @@ export interface CommandUiContract {
    * Duplicate names throw at registration.
    */
   decorate(decoration: CommandDecoration): () => void
+  /** Close this command's open popups and confirmations without consuming composer drafts. */
+  dismiss(name: string): void
   /** Resolve the per-session popup controller for one session scope (wiring/overlay layer). */
   popupFor(actx: ClientContext): unknown
 }

+ 24 - 13
packages/client/ui-commands/src/client/service.ts

@@ -21,7 +21,7 @@ import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client'
 import { rankByName } from '@deepseek-ai/dsh-client-ui-primitives'
 import type {
   CandidateRequest, ClientSessionContext, CommandClaim, PickOutcome, InputTriggerCandidate, InputTriggerPick,
-  SubmitAttachment, SubmitEnvelope, SubmitOutcome,
+  SubmitAttachment, SubmitEnvelope,
 } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
 import type { CommandContribution, CommandDecoration, CommandUiContract } from './contract.ts'
 import type { CommandDescriptor } from './directory.ts'
@@ -148,6 +148,17 @@ export class CommandUiRuntime extends Service implements CommandUiContract {
     return () => { void dispose() }
   }
 
+  /**
+   * Close every open popup for a command whose options have become stale.
+   * Pending loads and confirmations lose their binding; drafts stay intact.
+   * @param name - command name without the leading slash.
+   */
+  dismiss(name: string): void {
+    for (const popup of this.live.popups.values()) {
+      if (popup.state.getSnapshot().command === name) popup.dismiss()
+    }
+  }
+
   /**
    * Resolve the per-session popup controller (lazy; dies with the session
    * scope). The controller's consume callback dispatches the scoped
@@ -246,11 +257,11 @@ export class CommandUiRuntime extends Service implements CommandUiContract {
       this.openPopup(name, decoration.ui, pick.session, { via: 'menu', span: pick.span })
       return 'handled'
     }
-    if (desc.input !== undefined) return { claim: this.leadingClaim(desc, pick.session) }
+    if (desc.input !== undefined) return { claim: this.leadingClaim(desc.name, desc.input, pick.session) }
     // Menu-pick execute consumes the trigger span before the detached run
     // (scoped event; the input owns the CAS guard).
     this.consumeVia(pick.session.sessionId, { via: 'menu', span: pick.span })
-    this.runDetached(desc, pick.session, `/${name}`)
+    this.runDetached(pick.session, `/${name}`)
     return 'handled'
   }
 
@@ -261,7 +272,7 @@ export class CommandUiRuntime extends Service implements CommandUiContract {
     if (this.live.contributions.has(name)) return undefined // popup kinds never claim on space
     const desc = this.directory.resolve(session.sessionId, name)
     if (desc === undefined || desc.input === undefined) return undefined
-    return { claim: this.leadingClaim(desc, session) }
+    return { claim: this.leadingClaim(desc.name, desc.input, session) }
   }
 
   /**
@@ -314,12 +325,12 @@ export class CommandUiRuntime extends Service implements CommandUiContract {
     }
     if (desc.input !== undefined) {
       if (envelope.attachments > 0 && desc.input.attachments !== true) refuseAttachments()
-      return { claim: this.leadingClaim(desc, session) }
+      return { claim: this.leadingClaim(desc.name, desc.input, session) }
     }
     if (!bare) return undefined
     if (envelope.attachments > 0) refuseAttachments()
     this.consumeVia(session.sessionId, { via: 'enter', token })
-    this.runDetached(desc, session, trimmed)
+    this.runDetached(session, trimmed)
     return 'handled'
   }
 
@@ -336,12 +347,12 @@ export class CommandUiRuntime extends Service implements CommandUiContract {
   }
 
   /** Build the leadingInput claim: token `/name ` + the command.execute submit transaction. */
-  private leadingClaim(desc: CommandDescriptor, session: ClientSessionContext): CommandClaim {
-    const token = `/${desc.name} `
+  private leadingClaim(name: string, input: NonNullable<CommandDescriptor['input']>, session: ClientSessionContext): CommandClaim {
+    const token = `/${name} `
     return {
       token,
-      ...(desc.input !== undefined ? { hint: desc.input.hint } : {}),
-      ...(desc.input?.attachments === true ? { attachments: true } : {}),
+      hint: input.hint,
+      ...(input.attachments === true ? { attachments: true } : {}),
       submit: (args, _actx, attachments) => this.execute(session, token + args, attachments),
     }
   }
@@ -361,7 +372,7 @@ export class CommandUiRuntime extends Service implements CommandUiContract {
     session: ClientSessionContext,
     line: string,
     attachments: readonly SubmitAttachment[] = [],
-  ): Promise<SubmitOutcome> {
+  ): Promise<{ kind: 'success' } | { kind: 'error'; text: string }> {
     const result = await this.ctx.remote.commands.execute(session.sessionId, line, attachments)
     if (!result.ok) throw new Error(`command.execute failed: ${result.error.code}: ${result.error.message}`)
     if (result.value === undefined) return { kind: 'error', text: `unknown or malformed command: ${line}` }
@@ -405,11 +416,11 @@ export class CommandUiRuntime extends Service implements CommandUiContract {
    * entered a handler and therefore never logged — falls back to the composer
    * notice as immediate feedback.
    */
-  private runDetached(desc: CommandDescriptor, session: ClientSessionContext, line: string): void {
+  private runDetached(session: ClientSessionContext, line: string): void {
     void this.execute(session, line).then(
       (outcome) => {
         // matched:false maps to an error outcome with no logged lifecycle.
-        if (outcome.kind === 'error') this.noticeFor(session.sessionId, 'error', outcome.text ?? `/${desc.name} failed`)
+        if (outcome.kind === 'error') this.noticeFor(session.sessionId, 'error', outcome.text)
       },
       (error: unknown) => {
         this.noticeFor(session.sessionId, 'error', error instanceof Error ? error.message : String(error))

+ 122 - 4
packages/client/ui-commands/tests/service.client.spec.ts

@@ -104,7 +104,7 @@ async function bench(opts: BenchOptions = {}) {
   })
   // Real scope tags behind a fake sessions face.
   const scopes = new Map<SessionId, { ctx: Context; fiber: { dispose(): Promise<void> } }>()
-  ctx.provide('sessions', {
+  const removeSessions = ctx.provide('sessions', {
     scope: (id: SessionId) => scopes.get(id)?.ctx,
     scopeOf: (c: Context) => scopeOf(c),
     subagentAddress: (id: SessionId) => id === opts.addressed
@@ -119,7 +119,7 @@ async function bench(opts: BenchOptions = {}) {
   })
   /** Notices the fake conversation face collected (runDetached routing). */
   const notices: Array<{ scope: SessionId | undefined; level: 'info' | 'error'; text: string }> = []
-  ctx.provide('conversation', {
+  const removeConversation = ctx.provide('conversation', {
     input: {
       for: (actx: Context) => ({
         notify: (level: 'info' | 'error', text: string) => {
@@ -142,7 +142,10 @@ async function bench(opts: BenchOptions = {}) {
   const warm = async (session: ClientSessionContext) => {
     await source.candidates(session, { query: '', position: 'leading', drilled: false, signal: new AbortController().signal })
   }
-  return { ctx, fiber, command, source, mint, warm, listCalls, executeCalls, executions, registered, notices, remote }
+  return {
+    ctx, fiber, command, source, mint, warm, listCalls, executeCalls, executions, registered, notices, remote,
+    removeSessions, removeConversation,
+  }
 }
 
 function menuPick(source: InputTriggerSource, name: string, session: ClientSessionContext, end?: number) {
@@ -176,6 +179,14 @@ const req = (query: string, position: 'leading' | 'inline' = 'leading') =>
   ({ query, position, drilled: false, signal: new AbortController().signal })
 
 describe('registration', () => {
+  it('reports missing initialization dependencies', () => {
+    const missingLocale = new Context()
+    expect(() => new CommandUiRuntime(missingLocale)).toThrow('locale service unavailable')
+    const missingTriggers = new Context()
+    missingTriggers.provide('locale', { bind: () => (key: string) => key })
+    expect(() => new CommandUiRuntime(missingTriggers)).toThrow('slash service unavailable')
+  })
+
   it('registers the "/" source with matchSpace/matchEnter/warm hooks and removes it on fiber disposal', async () => {
     const { registered, source, fiber } = await bench()
     expect(typeof source.matchSpace).toBe('function')
@@ -315,6 +326,9 @@ describe('decorations (bare-invocation UI on host commands)', () => {
     const argued = await source.matchEnter!(proj('s1'), '/goal ship it', new AbortController().signal, { attachments: 0 })
     if (argued === undefined || argued === 'handled' || !('claim' in argued)) throw new Error('expected the host claim')
     expect(argued.claim.token).toBe('/goal ')
+    command.dismiss('goal')
+    expect(menuPick(source, 'goal', proj('s1'))).toBe('handled')
+    expect(command.popupFor(scope.ctx).state.getSnapshot()).toMatchObject({ open: true, command: 'goal' })
   })
 
   it('space never consults the decoration (host claim)', async () => {
@@ -342,12 +356,16 @@ describe('decorations (bare-invocation UI on host commands)', () => {
     await warm(proj('s1'))
     expect(await source.matchEnter!(proj('s1'), '/plan', new AbortController().signal, { attachments: 0 })).toBe('handled')
     expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/plan', images: [] }])
+    expect(menuPick(source, 'plan', proj('s1'))).toBe('handled')
+    expect(executeCalls).toHaveLength(2)
   })
 
   it('duplicate decoration names fail loud', async () => {
     const { command } = await bench()
-    command.decorate(goalDecoration())
+    const dispose = command.decorate(goalDecoration())
     expect(() => { command.decorate(goalDecoration()) }).toThrow('duplicate decoration for /goal')
+    dispose()
+    expect(() => { command.decorate(goalDecoration())() }).not.toThrow()
   })
 })
 
@@ -691,6 +709,21 @@ describe('detached admission notices', () => {
     await flush()
     expect(notices).toEqual([])
   })
+
+  it('contains a carrier rejection and drops notices when the conversation consumer unloads', async () => {
+    const b = await bench()
+    b.mint('s1')
+    await b.warm(proj('s1'))
+    vi.spyOn(b.ctx.remote.commands, 'execute').mockRejectedValue('carrier stopped')
+    menuPick(b.source, 'plan', proj('s1'))
+    await flush()
+    expect(b.notices).toEqual([{ scope: sid('s1'), level: 'error', text: 'carrier stopped' }])
+    b.notices.length = 0
+    b.removeConversation()
+    menuPick(b.source, 'plan', proj('s1'))
+    await flush()
+    expect(b.notices).toEqual([])
+  })
 })
 
 describe('register (contribution face)', () => {
@@ -704,6 +737,69 @@ describe('register (contribution face)', () => {
 })
 
 describe('popupFor', () => {
+  it('does not open a popup for an absent scope or reuse a removed sessions service', async () => {
+    const b = await bench()
+    b.command.register(themeContribution())
+    expect(menuPick(b.source, 'theme', proj('closed'))).toBe('handled')
+    b.removeSessions()
+    expect(() => b.command.popupFor(b.ctx)).toThrow('sessions service unavailable')
+  })
+
+  it('dismisses matching popups and confirmations while preserving other commands and drafts', async () => {
+    const { command, source, mint } = await bench()
+    const pending = Promise.withResolvers<readonly SelectOption[]>()
+    let pendingSignal: AbortSignal | undefined
+    const onSelect = vi.fn()
+    command.register(themeContribution({ ui: themeUi({
+      options: (session, signal) => {
+        if (session.sessionId === sid('s2')) {
+          pendingSignal = signal
+          return pending.promise
+        }
+        return Promise.resolve([{
+          id: 'dark',
+          label: 'Dark',
+          confirmation: {
+            title: 'Confirm theme', description: 'Change theme', acknowledgeLabel: 'Acknowledge',
+            cancelLabel: 'Cancel', confirmLabel: 'Confirm',
+          },
+        }])
+      },
+      onSelect,
+    }) }))
+    command.register(themeContribution({ name: 'other' }))
+    const scope = mint('s1')
+    const first = command.popupFor(scope.ctx)
+    const second = command.popupFor(mint('s2').ctx)
+    const other = command.popupFor(mint('other').ctx)
+    const consume = vi.fn(() => true as const)
+    scope.ctx.on('slash/input-consume-token', consume)
+    const focus = vi.fn()
+    command.bindComposerFocus(sid('s1'), focus)
+    menuPick(source, 'theme', proj('s1'))
+    menuPick(source, 'theme', proj('s2'))
+    menuPick(source, 'other', proj('other'))
+    await Promise.resolve()
+    await first.select(0)
+    first.acknowledge(true)
+    expect(first.state.getSnapshot().confirming?.id).toBe('dark')
+    expect(second.state.getSnapshot().status).toBe('pending')
+
+    command.dismiss('theme')
+
+    expect(first.state.getSnapshot()).toMatchObject({ open: false, options: [], confirming: null })
+    expect(second.state.getSnapshot()).toMatchObject({ open: false, options: [] })
+    expect(pendingSignal?.aborted).toBe(true)
+    expect(other.state.getSnapshot().open).toBe(true)
+    pending.resolve([{ id: 'late', label: 'Late' }])
+    await Promise.resolve()
+    await first.confirm()
+    expect(second.state.getSnapshot()).toMatchObject({ open: false, options: [] })
+    expect(onSelect).not.toHaveBeenCalled()
+    expect(consume).not.toHaveBeenCalled()
+    expect(focus).not.toHaveBeenCalled()
+  })
+
   it('resolves lazily per session; a foreign session gets its own controller; unscoped ctx throws', async () => {
     const { ctx, command, mint } = await bench()
     const a = mint('s1')
@@ -735,6 +831,28 @@ describe('popupFor', () => {
     expect(focus).toHaveBeenCalledTimes(1)
   })
 
+  it('unbinding an old composer preserves its replacement and unbinding the current one removes focus', async () => {
+    const { command, source, mint } = await bench()
+    command.register(themeContribution())
+    const scope = mint('s1')
+    scope.ctx.on('slash/input-consume-token', () => true)
+    const oldFocus = vi.fn()
+    const newFocus = vi.fn()
+    const unbindOld = command.bindComposerFocus(sid('s1'), oldFocus)
+    const unbindNew = command.bindComposerFocus(sid('s1'), newFocus)
+    unbindOld()
+    menuPick(source, 'theme', proj('s1'))
+    await Promise.resolve()
+    await command.popupFor(scope.ctx).select(0)
+    expect(oldFocus).not.toHaveBeenCalled()
+    expect(newFocus).toHaveBeenCalledOnce()
+    unbindNew()
+    menuPick(source, 'theme', proj('s1'))
+    await Promise.resolve()
+    await command.popupFor(scope.ctx).select(0)
+    expect(newFocus).toHaveBeenCalledOnce()
+  })
+
   it('the enter path opens with the bare-token guard', async () => {
     const { command, source, mint } = await bench()
     command.register(themeContribution())

+ 2 - 2
packages/client/ui-permission-presets/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-permission-presets/README.md
-README.md: e5d067dc163a3f5f5a43ec94dbbf23349eee471a
-README.zh.md: fd470e258eb9c16e551700aa731b9e8c062ee97e
+README.md: b8ac750ba63dfbe6ff8c6dbe830611ae02b091de
+README.zh.md: 7ece3bc60b6ae8c2b1396a8aedd89d000f6cea10

+ 1 - 1
packages/client/ui-permission-presets/README.md

@@ -31,7 +31,7 @@ Mount this plugin alongside the settings, commands, and conversation packages. G
 
 A pick submits the `/permission <preset>` command line. The argued path (`/permission <preset>` typed directly) still switches directly; the decoration replaces only the bare invocation. The built-in labels are `Read Only`, `Workspace Write`, `Full access`, and `Auto review` in English and `仅可查看`, `工作区内修改`, `完全权限`, and `Auto review` in Chinese. Explicit host labels remain unchanged, unknown kebab-case names render in title case, and `auto` carries an `EXP` badge plus an experimental-risk confirmation. `custom` is display state, never a target.
 
-When the live catalog withdraws a preset, the composer closes its pending confirmation and shows the Session's current value instead of an unavailable optimistic pick. A submitted command remains busy until its response settles.
+When the live catalog withdraws a preset, the composer closes its pending confirmation and shows the Session's current value instead of an unavailable optimistic pick. Every shared catalog publication also closes an open slash picker or its confirmation without consuming the draft; reopening reads the current catalog. A submitted command remains busy until its response settles.
 
 ### The Settings row
 

+ 1 - 1
packages/client/ui-permission-presets/README.zh.md

@@ -31,7 +31,7 @@ kind: "package-reference"
 
 选中即提交 `/permission <preset>` 命令行。带参路径(直接键入 `/permission <preset>`)仍直接切换;装饰只替换裸调用。内置标签在英文界面中是 `Read Only`、`Workspace Write`、`Full access` 和 `Auto review`,在中文界面中是「仅可查看」「工作区内修改」「完全权限」和 `Auto review`。显式 host 标签保持原样,未知 kebab-case 名称渲染为 Title Case;`auto` 带有 `EXP` badge,并在可见选择时要求实验风险确认。`custom` 只是显示状态,绝非目标。
 
-实时目录撤销某个预设时,composer 关闭对应的待确认对话框,并用 Session 的当前值替代已不可用的乐观选择。已经提交的命令在响应结束前继续保持忙碌状态。
+实时目录撤销某个预设时,composer 关闭对应的待确认对话框,并用 Session 的当前值替代已不可用的乐观选择。每次共享目录发布还会关闭已打开的 slash 选择器或其确认对话框,不消费草稿;重新打开时读取当前目录。已经提交的命令在响应结束前继续保持忙碌状态。
 
 ### 设置行
 

+ 1 - 3
packages/client/ui-permission-presets/package.json

@@ -44,9 +44,6 @@
     "watch": "tsdown --watch"
   },
   "license": "MIT",
-  "dependencies": {
-    "clsx": "^2.0.0"
-  },
   "peerDependencies": {
     "@deepseek-ai/cordis": "workspace:^"
   },
@@ -66,6 +63,7 @@
     "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
     "@deepseek-ai/dsh-permission-presets": "workspace:^",
     "@types/react": "~18.3.1",
+    "clsx": "^2.0.0",
     "react": "^18.2.0",
     "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
     "@deepseek-ai/dsh-client-ui-session": "workspace:^"

+ 4 - 0
packages/client/ui-permission-presets/src/client/index.ts

@@ -133,6 +133,10 @@ export function apply(ctx: ClientContext): void {
 
   const catalog = new PermissionCatalogDirectory(ctx)
   ctx.effect(() => () => { catalog.dispose() }, 'ui-permission: process catalog directory')
+  ctx.effect(
+    () => catalog.store.subscribe(() => { command.dismiss('permission') }),
+    'ui-permission: dismiss stale slash choices',
+  )
 
   ctx.effect(() => ctx.locale.register('settings.permission', { zh, en }), 'ui-permission: settings row dictionaries')
 

+ 14 - 1
packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts

@@ -72,11 +72,13 @@ async function bench() {
   } as never, () => null)
   await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await()
   let decoration: CommandDecoration | undefined
+  const dismissed: string[] = []
   ctx.provide('commandUi', {
     decorate(c: CommandDecoration) {
       decoration = c
       return () => { decoration = undefined }
     },
+    dismiss(name: string) { dismissed.push(name) },
   })
   const values = new Map<SessionId, PermissionSelection>()
   const commands: string[] = []
@@ -102,7 +104,7 @@ async function bench() {
   await fiber.await()
   await vi.waitFor(() => { expect(catalogCalls).toBe(1) })
   return {
-    ctx, fiber, locale, values, commands, remote,
+    ctx, fiber, locale, values, commands, remote, dismissed,
     catalogCalls: () => catalogCalls,
     setCatalog: (value: PermissionCatalog) => {
       catalog = value
@@ -118,6 +120,17 @@ async function bench() {
 }
 
 describe('ui-permission browser plugin', () => {
+  it('dismisses stale slash choices when the catalog publishes and removes that subscription on disposal', async () => {
+    const b = await bench()
+    const initial = b.dismissed.length
+    b.setCatalog({ options: CATALOG.options.filter(option => option.value !== 'auto') })
+    await vi.waitFor(() => { expect(b.dismissed.slice(initial)).toEqual(['permission']) })
+    await b.fiber.dispose()
+    b.setCatalog(CATALOG)
+    await Promise.resolve()
+    expect(b.dismissed.slice(initial)).toEqual(['permission'])
+  })
+
   it('hangs the /permission popup decoration on the host command', async () => {
     const b = await bench()
     const c = b.decoration()!

+ 19 - 27
packages/client/ui-tool/src/client/tool/components/ToolRow.tsx

@@ -18,9 +18,8 @@ import {
   diffBlockLabels, readBlockLabels, searchBlockLabels, webBlockLabels,
 } from '../models/primitive-labels.ts'
 import type { AskQuestionCardModel } from '../models/ask-question-card-model.ts'
-import { localizeAutoReviewDenial } from '../models/auto-review-denial.ts'
 import {
-  formatToolBody, type AutoReviewDenial, type ToolRowState, type ToolRowVariant,
+  formatToolBody, type ToolRowState, type ToolRowVariant,
 } from '../models/tool-call-model.ts'
 import type { WebCardModelProps } from '../models/web-card-model.ts'
 import { AskQuestionCard } from './AskQuestionCard.tsx'
@@ -50,8 +49,6 @@ export interface ToolRowProps {
   askQuestion?: AskQuestionCardModel | null | undefined
   /** Error first line shown as the collapsed summary on an error row; null/absent = keep `summary`. */
   errorSummary?: string | null | undefined
-  /** Structured Auto-review denial; replaces all ordinary input/card/output presentation. */
-  autoReviewDenial?: AutoReviewDenial | null | undefined
   /** Terminal card; card fields are mutually exclusive and replace text sections. */
   terminal?: TerminalCardModel | null | undefined
   diff?: DiffCardModel | null | undefined
@@ -123,7 +120,6 @@ export function ToolRow({
   output,
   askQuestion,
   errorSummary,
-  autoReviewDenial,
   terminal,
   diff,
   read,
@@ -139,28 +135,24 @@ export function ToolRow({
   inspect,
 }: ToolRowProps) {
   const [expanded, setExpanded] = useState(false)
-  const autoReview = autoReviewDenial === undefined || autoReviewDenial === null
-    ? null
-    : localizeAutoReviewDenial(autoReviewDenial, t)
   const terminalLabels = useMemo(() => terminalBlockLabels(t), [t])
   const diffLabels = useMemo(() => diffBlockLabels(t), [t])
   const readLabels = useMemo(() => readBlockLabels(t), [t])
   const searchLabels = useMemo(() => searchBlockLabels(t), [t])
   const webLabels = useMemo(() => webBlockLabels(t), [t])
-  const terminalBody = autoReview !== null || terminal === undefined || terminal === null
+  const terminalBody = terminal === undefined || terminal === null
     ? null
     : localizeTerminalCardModel(terminal, t)
-  const diffBody = autoReview === null ? diff ?? null : null
-  const readBody = autoReview === null ? read ?? null : null
-  const imageBody = autoReview === null
-    && image !== undefined && image !== null && renderSlot !== undefined && loadImage !== undefined
+  const diffBody = diff ?? null
+  const readBody = read ?? null
+  const imageBody = image !== undefined && image !== null && renderSlot !== undefined && loadImage !== undefined
     ? image
     : null
-  const searchBody = autoReview === null ? search ?? null : null
-  const webBody = autoReview === null ? web ?? null : null
-  const askQuestionBody = autoReview === null ? askQuestion ?? null : null
-  const inputRaw = autoReview === null ? bodyRaw ?? null : null
-  const outputText = autoReview?.output ?? output ?? null
+  const searchBody = search ?? null
+  const webBody = web ?? null
+  const askQuestionBody = askQuestion ?? null
+  const inputRaw = bodyRaw ?? null
+  const outputText = output ?? null
   const card = askQuestionBody ?? terminalBody ?? diffBody ?? readBody ?? imageBody ?? searchBody ?? webBody
   const expandable = inputRaw !== null || outputText !== null || card !== null
   const open = expanded && expandable
@@ -170,7 +162,7 @@ export function ToolRow({
   )
   const status = stateStatus(state, t)
   // A failure must replace, not supplement, the normal summary.
-  const failureLine = autoReview?.summary ?? (state === 'error' ? errorSummary ?? null : null)
+  const failureLine = state === 'error' ? errorSummary ?? null : null
   const summaryText = failureLine ?? terminalBody?.description ?? summary
   // A diff row's collapsed line carries the card's +/- totals (the same
   // numbers the expanded footer prints) so the change size reads without
@@ -181,16 +173,16 @@ export function ToolRow({
     return `+${added} -${removed}`
   }, [diffBody])
   const suffix = failureLine === null ? summarySuffix ?? diffStat : null
-  const fileLink = filePath !== undefined && onOpenFile !== undefined && failureLine === null
   const toggleExpand = () => {
     setExpanded(v => !v)
   }
-  const openFile = (event: MouseEvent<HTMLButtonElement>) => {
-    event.stopPropagation()
-    if (filePath === undefined || onOpenFile === undefined) return
-    if (filePathLine === undefined) onOpenFile(filePath)
-    else onOpenFile(filePath, { line: filePathLine })
-  }
+  const openFile = filePath !== undefined && onOpenFile !== undefined && failureLine === null
+    ? (event: MouseEvent<HTMLButtonElement>) => {
+      event.stopPropagation()
+      if (filePathLine === undefined) onOpenFile(filePath)
+      else onOpenFile(filePath, { line: filePathLine })
+    }
+    : undefined
   // Keep Enter/Space on the focused path link from bubbling to the row's
   // keydown handler, which would preventDefault() the key and toggle expand
   // instead of activating the link — the keyboard analogue of openFile's
@@ -221,7 +213,7 @@ export function ToolRow({
              its title shows no trailing dot). */
           <>
             <span className={css.sep} aria-hidden />
-            {fileLink ? (
+            {openFile !== undefined ? (
               <button
                 type="button"
                 className={css.fileLink}

+ 7 - 4
packages/client/ui-tool/src/client/tool/toolviews/GenericToolCard.tsx

@@ -9,6 +9,7 @@ import { searchCardModel } from '../models/search-card-model.ts'
 import { terminalCardModel, terminalFailed } from '../models/terminal-card-model.ts'
 import { webCardModel } from '../models/web-card-model.ts'
 import { toolRowModel, type ToolRowVariant } from '../models/tool-call-model.ts'
+import { localizeAutoReviewDenial } from '../models/auto-review-denial.ts'
 import { ToolRow } from '../components/ToolRow.tsx'
 
 /** Variant leading icons (figma table); all glyphs render at 14 inside the 16px leading box. */
@@ -29,6 +30,9 @@ export interface GenericToolCardProps extends ToolCallOwnerProps {
 
 export function GenericToolCard({ toolName, block, cwd, home, openFile, inspect, t }: GenericToolCardProps) {
   const model = toolRowModel(toolName, block, cwd, home)
+  const autoReview = model.autoReviewDenial === null
+    ? null
+    : localizeAutoReviewDenial(model.autoReviewDenial, t)
   const terminal = terminalCardModel(block, cwd)
   const read = readCardModel(block, cwd, home)
   const diff = diffCardModel(block)
@@ -51,10 +55,9 @@ export function GenericToolCard({ toolName, block, cwd, home, openFile, inspect,
       // Single-file tools never expose an args body — the path link is the only
       // args interaction. A card is not an args body: a read/write/edit row is
       // single-file AND carries a card, so the card expands under the path link.
-      bodyRaw={singleFile ? null : model.bodyRaw}
-      output={model.output}
-      errorSummary={model.errorSummary}
-      autoReviewDenial={model.autoReviewDenial}
+      bodyRaw={singleFile || autoReview !== null ? null : model.bodyRaw}
+      output={autoReview?.output ?? model.output}
+      errorSummary={autoReview?.summary ?? model.errorSummary}
       terminal={terminal}
       diff={diff}
       read={read}

+ 15 - 30
packages/client/ui-tool/tests/tool-row.client.spec.tsx

@@ -352,8 +352,13 @@ describe('ToolRow', () => {
       <ToolRow {...rowProps} variant="read" title="Read" summary="src/a.ts" filePath="src/a.ts" onOpenFile={open} />,
     )
     const row = view.getByRole('button', { name: /Read/ })
+    const path = view.getByText('src/a.ts')
+    for (const key of ['Enter', ' ', 'Tab']) {
+      fireEvent.keyDown(path, { key })
+      expect(row.getAttribute('aria-expanded')).toBe('false')
+    }
     // Path click opens the file and leaves the row collapsed.
-    fireEvent.click(view.getByText('src/a.ts'))
+    fireEvent.click(path)
     expect(open).toHaveBeenCalledWith('src/a.ts')
     expect(row.getAttribute('aria-expanded')).toBe('false')
     // Row click (outside the link) expands the args body.
@@ -396,32 +401,6 @@ describe('ToolRow', () => {
     expect(view.getByText('List files')).toBeTruthy()
   })
 
-  it('an Auto denial replaces the collapsed failure and expands to one localized OUT line', () => {
-    const stringify = vi.spyOn(JSON, 'stringify')
-    const view = render(
-      <ToolRow
-        {...rowProps}
-        state="error"
-        output="Tool execution rejected by user"
-        errorSummary="Tool execution rejected by user"
-        autoReviewDenial={{ reason: '  scope\r\nwas not authorized  ' }}
-      />,
-    )
-    expect(view.getByText('Auto review 已拒绝')).toBeTruthy()
-    expect(view.queryByText('scope\r\nwas not authorized')).toBeNull()
-    expect(view.queryByText('Tool execution rejected by user')).toBeNull()
-
-    fireEvent.click(view.getByRole('button'))
-
-    expect(view.queryByText('输入')).toBeNull()
-    expect(view.getAllByText('输出')).toHaveLength(1)
-    expect(view.getByText('工具未执行。原因:scope was not authorized')).toBeTruthy()
-    expect(view.queryByText('Tool execution rejected by user')).toBeNull()
-    expect(stringify.mock.calls.some(([value]) => (
-      typeof value === 'object' && value !== null && 'a' in value
-    ))).toBe(false)
-  })
-
   it('renders summarySuffix outside the ellipsized summary span, and drops it on a failure line', () => {
     const view = render(<ToolRow {...rowProps} summarySuffix="+2" />)
     const summary = view.getByText('List files')
@@ -558,18 +537,24 @@ describe('GenericToolCard', () => {
     expect(bash.openFile).not.toHaveBeenCalled()
   })
 
-  it('renders a nested Auto denial through the generic fallback without exposing raw failure content', () => {
+  it('renders a nested Auto denial as one localized OUT line without formatting its input', () => {
+    const stringify = vi.spyOn(JSON, 'stringify')
     const denied = result({
       parentCallId: 'outer',
       call: { name: 'mystery', argsRaw: '{"path":"secret"}' },
       content: [{ type: 'text', text: 'Tool execution rejected by user' }],
       isError: true,
-      error: { name: 'AutoReviewDeniedError', code: 'AUTO_REVIEW_DENIED', reason: 'not authorized' },
+      error: { name: 'AutoReviewDeniedError', code: 'AUTO_REVIEW_DENIED', reason: '  scope\r\nwas not authorized  ' },
     })
     const view = render(<GenericToolCard {...props('mystery', denied)} />)
     expect(view.getByText('Auto review 已拒绝')).toBeTruthy()
     fireEvent.click(view.getByRole('button'))
-    expect(view.getByText('工具未执行。原因:not authorized')).toBeTruthy()
+    expect(view.getByText('工具未执行。原因:scope was not authorized')).toBeTruthy()
+    expect(view.queryByText('输入')).toBeNull()
+    expect(view.getAllByText('输出')).toHaveLength(1)
+    expect(stringify.mock.calls.some(([value]) => (
+      typeof value === 'object' && value !== null && 'path' in value
+    ))).toBe(false)
     expect(view.queryByText('Tool execution rejected by user')).toBeNull()
     expect(view.queryByText(/"path"/)).toBeNull()
   })

+ 5 - 15
packages/experimental/auto-review/src/index.ts

@@ -268,12 +268,11 @@ function scopedCallKey(step: StepIdentity, callId: ToolCallId): string {
   return `${step.turn}\0${step.step}\0${callId}`
 }
 
-/** Assign each PTC start to its open step or its latest logged root's step. */
+/** Assign each PTC start to the step open when it was logged. */
 function scopePtcStarts(events: readonly SessionEvent[]): {
   readonly starts: readonly ScopedPtcStart[]
   readonly openStep: StepIdentity | undefined
 } {
-  const latestRoots = new Map<ToolCallId, NativeCallEvent>()
   const starts: ScopedPtcStart[] = []
   let openStep: StepIdentity | undefined
   for (const event of events) {
@@ -289,17 +288,11 @@ function scopePtcStarts(events: readonly SessionEvent[]): {
       openStep = undefined
       continue
     }
-    if (event.type === 'tool/call') {
-      latestRoots.set(event.data.callId, event)
-      continue
-    }
     if (event.type !== 'tool/ptc-dispatch-start') continue
-    const root = latestRoots.get(event.data.rootCallId)
-    const step = openStep ?? (root === undefined ? undefined : stepIdentity(root.data))
-    if (step === undefined) {
+    if (openStep === undefined) {
       throw new Error('auto-review: a PTC call has no owning step in the session log')
     }
-    starts.push({ event, step })
+    starts.push({ event, step: openStep })
   }
   return { starts, openStep }
 }
@@ -377,7 +370,7 @@ function snapshotAutoReview(agent: Agent, exec: ToolExecution): ReviewSnapshot {
   }
 
   const nativeCalls = events.filter((event): event is NativeCallEvent => event.type === 'tool/call')
-  const { starts, openStep } = scopePtcStarts(events)
+  const { starts, openStep: currentStep } = scopePtcStarts(events)
   const initialPromptSeq = directParentInitialPromptSeq(agent, events)
   const nativeByScopedId = new Map<string, NativeCallEvent[]>()
   for (const event of nativeCalls) {
@@ -400,11 +393,8 @@ function snapshotAutoReview(agent: Agent, exec: ToolExecution): ReviewSnapshot {
     else bucket.push(start)
   }
 
-  const latestRootCall = nativeCalls.findLast(event => event.data.callId === exec.rootCallId)
-  const currentStep = openStep
-    ?? (latestRootCall === undefined ? undefined : stepIdentity(latestRootCall.data))
   if (currentStep === undefined) {
-    throw new Error('auto-review: the pending root call is missing from the session log')
+    throw new Error('auto-review: the pending call has no open step in the session log')
   }
   const currentRootCalls = nativeByScopedId.get(scopedCallKey(currentStep, exec.rootCallId)) ?? []
   const currentRootCall = currentRootCalls[0]

+ 22 - 4
packages/experimental/auto-review/tests/auto-review.spec.ts

@@ -189,6 +189,7 @@ function appendAssistant(
   turn = 1,
   step = 1,
 ): void {
+  session.append('step/start', { turn, step })
   session.append('assistant/message', {
     turn,
     step,
@@ -485,6 +486,7 @@ describe('native review request', () => {
       { type: 'tool-call', id: ToolCallId('old-unstarted'), name: 'probe', arguments: '{}' },
     ], 1, 1)
     appendNativeCall(session, callId, 'probe', '{"path":"old"}', 1, 1)
+    session.append('step/end', { turn: 1, step: 1 })
     appendAssistant(session, [
       { type: 'tool-call', id: callId, name: 'probe', arguments: '{"path":"current"}' },
     ], 1, 2)
@@ -727,6 +729,7 @@ describe('native review request', () => {
     })
 
     const invalidId = ToolCallId('invalid-json-arguments')
+    session.append('step/end', { turn: 1, step: 1 })
     appendAssistant(session, [{ type: 'tool-call', id: invalidId, name: 'probe', arguments: 'not-json' }], 1, 2)
     appendNativeCall(session, invalidId, 'probe', 'not-json', 1, 2)
     await ctx.tools.execute({
@@ -1000,7 +1003,6 @@ describe('PTC and bypass semantics', () => {
     const subCallId = ToolCallId('reused-outer:code:0')
     session.append('turn/start', { turn: 1 })
     appendHeader(session)
-    session.append('step/start', { turn: 1, step: 1 })
     appendAssistant(session, [{
       type: 'tool-call', id: outerCallId, name: RUN_CODE_NAME, arguments: '{"code":"old"}',
     }], 1, 1)
@@ -1031,7 +1033,6 @@ describe('PTC and bypass semantics', () => {
       }),
     }, { surfaceOp: 'append' })
     session.append('step/end', { turn: 1, step: 1 })
-    session.append('step/start', { turn: 1, step: 2 })
     appendAssistant(session, [{
       type: 'tool-call', id: outerCallId, name: RUN_CODE_NAME, arguments: '{"code":"current"}',
     }], 1, 2)
@@ -1185,6 +1186,9 @@ describe('out-of-process delegation boundary', () => {
       kind: 'user', rpcId: 'remote-delegation-allow',
     } as never)
     const allowedId = ToolCallId('remote-delegation-allowed')
+    session.append('step/end', { turn: 1, step: 1 })
+    session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+    session.append('turn/start', { turn: 2 })
     appendAssistant(session, [{
       type: 'tool-call', id: allowedId, name: 'delegate_remote', arguments: rawArgs,
     }], 2, 1)
@@ -1469,13 +1473,19 @@ describe('logged-fact failures', () => {
         appendAssistant(session, [{ type: 'tool-call', id: callId, name: 'probe', arguments: '{}' }])
         appendNativeCall(session, callId, 'probe', '{}')
       } },
+      { id: 'closed-current-step', prepare: (session, callId) => {
+        appendHeader(session, [{ name: 'probe', description: 'probe', parameters: { type: 'object' } }])
+        appendAssistant(session, [{ type: 'tool-call', id: callId, name: 'probe', arguments: '{}' }])
+        appendNativeCall(session, callId, 'probe', '{}')
+        session.append('step/end', { turn: 1, step: 1 })
+      } },
       { id: 'missing-current-surface', prepare: (session, callId) => {
         appendHeader(session, [{ name: 'probe', description: 'probe', parameters: { type: 'object' } }])
+        session.append('step/start', { turn: 1, step: 1 })
         appendNativeCall(session, callId, 'probe', '{}')
       } },
       { id: 'missing-current-log', prepare: (session, callId) => {
         appendHeader(session, [{ name: 'probe', description: 'probe', parameters: { type: 'object' } }])
-        session.append('step/start', { turn: 1, step: 1 })
         appendAssistant(session, [{ type: 'tool-call', id: callId, name: 'probe', arguments: '{}' }])
       } },
       { id: 'duplicate-current-log', prepare: (session, callId) => {
@@ -1553,7 +1563,6 @@ describe('logged-fact failures', () => {
       } },
       { id: 'unstarted-call-with-ptc-log', prepare: (session, callId) => {
         appendHeader(session, [{ name: 'probe', description: 'probe', parameters: { type: 'object' } }])
-        session.append('step/start', { turn: 1, step: 1 })
         const later = ToolCallId('unstarted-with-ptc')
         appendAssistant(session, [
           { type: 'tool-call', id: callId, name: 'probe', arguments: '{}' },
@@ -1630,6 +1639,13 @@ describe('logged-fact failures', () => {
       readonly logParent?: boolean
     }> = [
       { id: 'missing-start', starts: () => {} },
+      { id: 'start-without-open-step', starts: (session, outer, inner) => {
+        session.append('step/end', { turn: 1, step: 1 })
+        session.append('tool/ptc-dispatch-start', {
+          rootCallId: outer, parentCallId: outer, subCallId: inner,
+          name: 'probe', arguments: {},
+        })
+      } },
       { id: 'duplicate-start', starts: (session, outer, inner) => {
         for (let index = 0; index < 2; index += 1) {
           session.append('tool/ptc-dispatch-start', {
@@ -1703,6 +1719,8 @@ describe('logged-fact failures', () => {
         if (item.logParent !== false) {
           appendNativeCall(session, outer, RUN_CODE_NAME, '{"code":"probe()"}')
         }
+      } else {
+        session.append('step/start', { turn: 1, step: 1 })
       }
       item.starts(session, outer, inner)
       const result = await ctx.tools.execute({

+ 3 - 4
pnpm-lock.yaml

@@ -3184,10 +3184,6 @@ importers:
         version: 18.3.1
 
   packages/client/ui-permission-presets:
-    dependencies:
-      clsx:
-        specifier: ^2.0.0
-        version: 2.1.1
     devDependencies:
       '@deepseek-ai/cordis':
         specifier: workspace:^
@@ -3240,6 +3236,9 @@ importers:
       '@types/react':
         specifier: ~18.3.1
         version: 18.3.31
+      clsx:
+        specifier: ^2.0.0
+        version: 2.1.1
       react:
         specifier: ^18.2.0
         version: 18.3.1

+ 3 - 2
snapshots/sdk/subagent-fork-in-process/session.1.v3.jsonl

@@ -12,12 +12,13 @@
 {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}}
 {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
 {"type":"session/title","data":{"title":"Remember this fact for later:","messageSeqs":[8],"source":{"kind":"fallback"}}}
-{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to remember the codeword \"MARMALADE\" and reply with just \"OK\"."},{"type":"text","text":"OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:4}}"},"usage":{"inputTokens":2885,"outputTokens":25,"cacheReadTokens":0,"reasoningTokens":23},"stream":[{"type":"chunk","time":1788699463198,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1788699463198,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," remember"," the"," cod","ew","ord"," \"","M","ARM","AL","ADE","\""," and"," reply"," with"," just"," \"","OK","\"."]},{"type":"chunk","time":1788699463198,"chunk":{"type":"block-start","index":1,"blockType":"text"}},{"type":"text-chunks","time0":1788699463198,"index":1,"dt":[],"texts":["OK"]},{"type":"chunk","time":1788699463198,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to remember the codeword \"MARMALADE\" and reply with just \"OK\"."}}},{"type":"chunk","time":1788699463198,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"OK"}}},{"type":"chunk","time":1788699463198,"chunk":{"type":"usage","usage":{"inputTokens":2885,"outputTokens":25,"cacheReadTokens":0,"reasoningTokens":23}}},{"type":"chunk","time":1788699463198,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}
+{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to remember the codeword \"MARMALADE\" and reply with just \"OK\"."},{"type":"text","text":"OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:4}}"},"usage":{"inputTokens":2885,"outputTokens":25,"cacheReadTokens":0,"reasoningTokens":23},"stream":[{"type":"chunk","time":1788911730255,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1788911730255,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," remember"," the"," cod","ew","ord"," \"","M","ARM","AL","ADE","\""," and"," reply"," with"," just"," \"","OK","\"."]},{"type":"chunk","time":1788911730255,"chunk":{"type":"block-start","index":1,"blockType":"text"}},{"type":"text-chunks","time0":1788911730255,"index":1,"dt":[],"texts":["OK"]},{"type":"chunk","time":1788911730255,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to remember the codeword \"MARMALADE\" and reply with just \"OK\"."}}},{"type":"chunk","time":1788911730255,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"OK"}}},{"type":"chunk","time":1788911730255,"chunk":{"type":"usage","usage":{"inputTokens":2885,"outputTokens":25,"cacheReadTokens":0,"reasoningTokens":23}}},{"type":"chunk","time":1788911730255,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}
 {"type":"step/end","data":{"turn":1,"step":1}}
 {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}
 {"type":"session/end-seed","data":{"inherited":true}}
 {"type":"sandbox/mode","data":{"mode":"danger-full-access","source":"delegation"}}
 {"type":"approval/policy","data":{"policy":"never","source":"delegation"}}
+{"type":"permission/preset","data":{"preset":"danger-full-access"}}
 {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{message:9}}"}]}}
 {"type":"turn/start","data":{"turn":2}}
 {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
@@ -26,6 +27,6 @@
 {"type":"user/message","data":{"content":[{"type":"text","text":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{message:9}}"},"surfaceOp":"append"}
 {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{message:10}}"},"surfaceOp":"append"}
 {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"resume"}}
-{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user asked me to remember the project codeword \"MARMALADE\" and now they're asking what it is. I should just reply with that word."},{"type":"text","text":"MARMALADE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:11}}"},"usage":{"inputTokens":97,"outputTokens":39,"cacheReadTokens":2816,"reasoningTokens":34},"stream":[{"type":"chunk","time":1788699463263,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1788699463263,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," asked"," me"," to"," remember"," the"," project"," cod","ew","ord"," \"","M","ARM","AL","ADE","\""," and"," now"," they","'re"," asking"," what"," it"," is","."," I"," should"," just"," reply"," with"," that"," word","."]},{"type":"chunk","time":1788699463263,"chunk":{"type":"block-start","index":1,"blockType":"text"}},{"type":"text-chunks","time0":1788699463263,"index":1,"dt":[0,0,0],"texts":["M","ARM","AL","ADE"]},{"type":"chunk","time":1788699463263,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user asked me to remember the project codeword \"MARMALADE\" and now they're asking what it is. I should just reply with that word."}}},{"type":"chunk","time":1788699463263,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"MARMALADE"}}},{"type":"chunk","time":1788699463263,"chunk":{"type":"usage","usage":{"inputTokens":97,"outputTokens":39,"cacheReadTokens":2816,"reasoningTokens":34}}},{"type":"chunk","time":1788699463263,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}
+{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user asked me to remember the project codeword \"MARMALADE\" and now they're asking what it is. I should just reply with that word."},{"type":"text","text":"MARMALADE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:11}}"},"usage":{"inputTokens":97,"outputTokens":39,"cacheReadTokens":2816,"reasoningTokens":34},"stream":[{"type":"chunk","time":1788911730317,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1788911730317,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," asked"," me"," to"," remember"," the"," project"," cod","ew","ord"," \"","M","ARM","AL","ADE","\""," and"," now"," they","'re"," asking"," what"," it"," is","."," I"," should"," just"," reply"," with"," that"," word","."]},{"type":"chunk","time":1788911730317,"chunk":{"type":"block-start","index":1,"blockType":"text"}},{"type":"text-chunks","time0":1788911730317,"index":1,"dt":[0,0,0],"texts":["M","ARM","AL","ADE"]},{"type":"chunk","time":1788911730317,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user asked me to remember the project codeword \"MARMALADE\" and now they're asking what it is. I should just reply with that word."}}},{"type":"chunk","time":1788911730317,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"MARMALADE"}}},{"type":"chunk","time":1788911730317,"chunk":{"type":"usage","usage":{"inputTokens":97,"outputTokens":39,"cacheReadTokens":2816,"reasoningTokens":34}}},{"type":"chunk","time":1788911730317,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}
 {"type":"step/end","data":{"turn":2,"step":1}}
 {"type":"turn/end","data":{"turn":2,"reason":{"kind":"completed"}}}

+ 3 - 2
snapshots/sdk/subagent-mixed/session.2.v3.jsonl

@@ -12,12 +12,13 @@
 {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}}
 {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
 {"type":"session/title","data":{"title":"Remember this fact for later:","messageSeqs":[8],"source":{"kind":"fallback"}}}
-{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to remember a codeword and just reply with \"OK\"."},{"type":"text","text":"OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:4}}"},"usage":{"inputTokens":2883,"outputTokens":19,"cacheReadTokens":0,"reasoningTokens":17},"stream":[{"type":"chunk","time":1788699464642,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1788699464642,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," remember"," a"," cod","ew","ord"," and"," just"," reply"," with"," \"","OK","\"."]},{"type":"chunk","time":1788699464642,"chunk":{"type":"block-start","index":1,"blockType":"text"}},{"type":"text-chunks","time0":1788699464642,"index":1,"dt":[],"texts":["OK"]},{"type":"chunk","time":1788699464642,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to remember a codeword and just reply with \"OK\"."}}},{"type":"chunk","time":1788699464642,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"OK"}}},{"type":"chunk","time":1788699464642,"chunk":{"type":"usage","usage":{"inputTokens":2883,"outputTokens":19,"cacheReadTokens":0,"reasoningTokens":17}}},{"type":"chunk","time":1788699464642,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}
+{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to remember a codeword and just reply with \"OK\"."},{"type":"text","text":"OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:4}}"},"usage":{"inputTokens":2883,"outputTokens":19,"cacheReadTokens":0,"reasoningTokens":17},"stream":[{"type":"chunk","time":1788911730902,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1788911730902,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," remember"," a"," cod","ew","ord"," and"," just"," reply"," with"," \"","OK","\"."]},{"type":"chunk","time":1788911730902,"chunk":{"type":"block-start","index":1,"blockType":"text"}},{"type":"text-chunks","time0":1788911730902,"index":1,"dt":[],"texts":["OK"]},{"type":"chunk","time":1788911730902,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to remember a codeword and just reply with \"OK\"."}}},{"type":"chunk","time":1788911730902,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"OK"}}},{"type":"chunk","time":1788911730902,"chunk":{"type":"usage","usage":{"inputTokens":2883,"outputTokens":19,"cacheReadTokens":0,"reasoningTokens":17}}},{"type":"chunk","time":1788911730902,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}
 {"type":"step/end","data":{"turn":1,"step":1}}
 {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}
 {"type":"session/end-seed","data":{"inherited":true}}
 {"type":"sandbox/mode","data":{"mode":"danger-full-access","source":"delegation"}}
 {"type":"approval/policy","data":{"policy":"never","source":"delegation"}}
+{"type":"permission/preset","data":{"preset":"danger-full-access"}}
 {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{message:15}}"}]}}
 {"type":"turn/start","data":{"turn":2}}
 {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
@@ -26,6 +27,6 @@
 {"type":"user/message","data":{"content":[{"type":"text","text":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{message:15}}"},"surfaceOp":"append"}
 {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{message:16}}"},"surfaceOp":"append"}
 {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"resume"}}
-{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user is asking me to recall the project codeword that was mentioned earlier in the conversation. I was told to remember it: SAFFRON."},{"type":"text","text":"SAFFRON"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:17}}"},"usage":{"inputTokens":95,"outputTokens":35,"cacheReadTokens":2816,"reasoningTokens":31},"stream":[{"type":"chunk","time":1788699464779,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1788699464779,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," is"," asking"," me"," to"," recall"," the"," project"," cod","ew","ord"," that"," was"," mentioned"," earlier"," in"," the"," conversation","."," I"," was"," told"," to"," remember"," it",":"," SA","FF","RON","."]},{"type":"chunk","time":1788699464779,"chunk":{"type":"block-start","index":1,"blockType":"text"}},{"type":"text-chunks","time0":1788699464779,"index":1,"dt":[0,0],"texts":["SA","FF","RON"]},{"type":"chunk","time":1788699464779,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user is asking me to recall the project codeword that was mentioned earlier in the conversation. I was told to remember it: SAFFRON."}}},{"type":"chunk","time":1788699464779,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"SAFFRON"}}},{"type":"chunk","time":1788699464779,"chunk":{"type":"usage","usage":{"inputTokens":95,"outputTokens":35,"cacheReadTokens":2816,"reasoningTokens":31}}},{"type":"chunk","time":1788699464779,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}
+{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user is asking me to recall the project codeword that was mentioned earlier in the conversation. I was told to remember it: SAFFRON."},{"type":"text","text":"SAFFRON"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:17}}"},"usage":{"inputTokens":95,"outputTokens":35,"cacheReadTokens":2816,"reasoningTokens":31},"stream":[{"type":"chunk","time":1788911731013,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1788911731013,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," is"," asking"," me"," to"," recall"," the"," project"," cod","ew","ord"," that"," was"," mentioned"," earlier"," in"," the"," conversation","."," I"," was"," told"," to"," remember"," it",":"," SA","FF","RON","."]},{"type":"chunk","time":1788911731013,"chunk":{"type":"block-start","index":1,"blockType":"text"}},{"type":"text-chunks","time0":1788911731013,"index":1,"dt":[0,0],"texts":["SA","FF","RON"]},{"type":"chunk","time":1788911731013,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user is asking me to recall the project codeword that was mentioned earlier in the conversation. I was told to remember it: SAFFRON."}}},{"type":"chunk","time":1788911731013,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"SAFFRON"}}},{"type":"chunk","time":1788911731013,"chunk":{"type":"usage","usage":{"inputTokens":95,"outputTokens":35,"cacheReadTokens":2816,"reasoningTokens":31}}},{"type":"chunk","time":1788911731013,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}
 {"type":"step/end","data":{"turn":2,"step":1}}
 {"type":"turn/end","data":{"turn":2,"reason":{"kind":"completed"}}}

File diff suppressed because it is too large
+ 0 - 0
snapshots/session/cordis-inspect-jsdoc/session.v3.jsonl


+ 3 - 0
snapshots/web/auto-review-denial/snapshot.yml

@@ -5,3 +5,6 @@ composition: web-auto-review
 recording: authored
 header:
   class: web-default
+  pin: true
+  systemPromptSource: fresh-round-trip
+  toolSchemasSource: fresh-round-trip

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