From 41fbde80348c3a22eac6c940b3c8a459a79cd960 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Wed, 9 Sep 2026 08:08:10 +0800 Subject: [PATCH] fix(permission): revoke stale Auto choices and simplify review paths --- .../feature/2026-08-28-auto-review.i18n.yaml | 4 +- .../feature/2026-08-28-auto-review.md | 6 +- .../feature/2026-08-28-auto-review.zh.md | 6 +- apps/web/tests/access-confirmation.e2e.ts | 14 +- packages/client/ui-commands/README.i18n.yaml | 4 +- packages/client/ui-commands/README.md | 2 +- packages/client/ui-commands/README.zh.md | 2 +- .../client/ui-commands/src/client/contract.ts | 4 +- .../client/ui-commands/src/client/service.ts | 37 +++-- .../ui-commands/tests/service.client.spec.ts | 126 +++++++++++++++++- .../ui-permission-presets/README.i18n.yaml | 4 +- .../client/ui-permission-presets/README.md | 2 +- .../client/ui-permission-presets/README.zh.md | 2 +- .../client/ui-permission-presets/package.json | 4 +- .../ui-permission-presets/src/client/index.ts | 4 + .../tests/browser-plugin.client.spec.ts | 15 ++- .../src/client/tool/components/ToolRow.tsx | 46 +++---- .../client/tool/toolviews/GenericToolCard.tsx | 11 +- .../ui-tool/tests/tool-row.client.spec.tsx | 45 +++---- .../experimental/auto-review/src/index.ts | 20 +-- .../auto-review/tests/auto-review.spec.ts | 26 +++- pnpm-lock.yaml | 7 +- .../session.1.v3.jsonl | 5 +- .../sdk/subagent-mixed/session.2.v3.jsonl | 5 +- .../cordis-inspect-jsdoc/session.v3.jsonl | 2 +- snapshots/web/auto-review-denial/snapshot.yml | 3 + 26 files changed, 278 insertions(+), 128 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-28-auto-review.i18n.yaml b/.agents/notes/implemented/feature/2026-08-28-auto-review.i18n.yaml index 3829ce4f33..257f53defc 100644 --- a/.agents/notes/implemented/feature/2026-08-28-auto-review.i18n.yaml +++ b/.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 diff --git a/.agents/notes/implemented/feature/2026-08-28-auto-review.md b/.agents/notes/implemented/feature/2026-08-28-auto-review.md index d5eed2132d..b8308048a0 100644 --- a/.agents/notes/implemented/feature/2026-08-28-auto-review.md +++ b/.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 ""; 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 ""; 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. diff --git a/.agents/notes/implemented/feature/2026-08-28-auto-review.zh.md b/.agents/notes/implemented/feature/2026-08-28-auto-review.zh.md index 4dc91f3d05..1950be4302 100644 --- a/.agents/notes/implemented/feature/2026-08-28-auto-review.zh.md +++ b/.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 ""; its body was not executed`。PTC 保留既有程序异常/catch 行为;捕获拒绝不会将其提升为外层失败。Web 卡片在折叠时标识拒绝,展开时显示一行未执行说明。只有该显示过程会 trim、折叠行分隔符,或提供本地化空理由 fallback;持久化与两套 SDK 保留完整原始理由,不增加长度或脱敏规则。 +原生结果与 PTC 结算事件携带同形结构化 `AutoReviewDeniedError`/`AUTO_REVIEW_DENIED` 及可选原始理由。主 agent 通过普通失败渲染只收到 `Auto review rejected tool ""; 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 折叠时也一样。 diff --git a/apps/web/tests/access-confirmation.e2e.ts b/apps/web/tests/access-confirmation.e2e.ts index 98ddef8f27..2b2ba0e135 100644 --- a/apps/web/tests/access-confirmation.e2e.ts +++ b/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') diff --git a/packages/client/ui-commands/README.i18n.yaml b/packages/client/ui-commands/README.i18n.yaml index dc82321424..9049e8a36f 100644 --- a/packages/client/ui-commands/README.i18n.yaml +++ b/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 diff --git a/packages/client/ui-commands/README.md b/packages/client/ui-commands/README.md index e1264d10bd..67f3fbab5a 100644 --- a/packages/client/ui-commands/README.md +++ b/packages/client/ui-commands/README.md @@ -43,7 +43,7 @@ When the composer submits with images or generic files, only a host command decl
Implementation internals — click to expand -`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.
diff --git a/packages/client/ui-commands/README.zh.md b/packages/client/ui-commands/README.zh.md index c5323149ae..6845ccba45 100644 --- a/packages/client/ui-commands/README.zh.md +++ b/packages/client/ui-commands/README.zh.md @@ -43,7 +43,7 @@ composer 携带图片或通用文件提交时,只有声明了 `input.attachmen
实现细节——点击展开 -`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`,按会话解析。
diff --git a/packages/client/ui-commands/src/client/contract.ts b/packages/client/ui-commands/src/client/contract.ts index b8cdd64b52..726cb5615e 100644 --- a/packages/client/ui-commands/src/client/contract.ts +++ b/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 } diff --git a/packages/client/ui-commands/src/client/service.ts b/packages/client/ui-commands/src/client/service.ts index f14f13f0be..920a0faa67 100644 --- a/packages/client/ui-commands/src/client/service.ts +++ b/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, 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 { + ): 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)) diff --git a/packages/client/ui-commands/tests/service.client.spec.ts b/packages/client/ui-commands/tests/service.client.spec.ts index f387ebffc1..7c60afd032 100644 --- a/packages/client/ui-commands/tests/service.client.spec.ts +++ b/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 } }>() - 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() + 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()) diff --git a/packages/client/ui-permission-presets/README.i18n.yaml b/packages/client/ui-permission-presets/README.i18n.yaml index 0bcd6639a0..401d4cbbfc 100644 --- a/packages/client/ui-permission-presets/README.i18n.yaml +++ b/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 diff --git a/packages/client/ui-permission-presets/README.md b/packages/client/ui-permission-presets/README.md index e5d067dc16..b8ac750ba6 100644 --- a/packages/client/ui-permission-presets/README.md +++ b/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 ` command line. The argued path (`/permission ` 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 diff --git a/packages/client/ui-permission-presets/README.zh.md b/packages/client/ui-permission-presets/README.zh.md index fd470e258e..7ece3bc60b 100644 --- a/packages/client/ui-permission-presets/README.zh.md +++ b/packages/client/ui-permission-presets/README.zh.md @@ -31,7 +31,7 @@ kind: "package-reference" 选中即提交 `/permission ` 命令行。带参路径(直接键入 `/permission `)仍直接切换;装饰只替换裸调用。内置标签在英文界面中是 `Read Only`、`Workspace Write`、`Full access` 和 `Auto review`,在中文界面中是「仅可查看」「工作区内修改」「完全权限」和 `Auto review`。显式 host 标签保持原样,未知 kebab-case 名称渲染为 Title Case;`auto` 带有 `EXP` badge,并在可见选择时要求实验风险确认。`custom` 只是显示状态,绝非目标。 -实时目录撤销某个预设时,composer 关闭对应的待确认对话框,并用 Session 的当前值替代已不可用的乐观选择。已经提交的命令在响应结束前继续保持忙碌状态。 +实时目录撤销某个预设时,composer 关闭对应的待确认对话框,并用 Session 的当前值替代已不可用的乐观选择。每次共享目录发布还会关闭已打开的 slash 选择器或其确认对话框,不消费草稿;重新打开时读取当前目录。已经提交的命令在响应结束前继续保持忙碌状态。 ### 设置行 diff --git a/packages/client/ui-permission-presets/package.json b/packages/client/ui-permission-presets/package.json index a43b7bf755..8ec0e078ca 100644 --- a/packages/client/ui-permission-presets/package.json +++ b/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:^" diff --git a/packages/client/ui-permission-presets/src/client/index.ts b/packages/client/ui-permission-presets/src/client/index.ts index 4e069f9ee4..7e7a278e3d 100644 --- a/packages/client/ui-permission-presets/src/client/index.ts +++ b/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') diff --git a/packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts b/packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts index 43be29b667..e0360d6246 100644 --- a/packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts +++ b/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() 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()! diff --git a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx index d07cbb18c2..0472875209 100644 --- a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx +++ b/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) => { - 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) => { + 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). */ <> - {fileLink ? ( + {openFile !== undefined ? (