From 948f411a700766f6ff82f9bf4cfcff38a4e4a8a3 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 20 Oct 2026 11:34:25 +0800 Subject: [PATCH 1/3] feat(cua): present application consent with fixed approval actions --- apps/web/tests/approval-composer.e2e.ts | 11 +++ apps/web/tests/cua-permissions.e2e.ts | 15 ++- .../application-approval.expected.md | 8 ++ docs/subsystems/user-questions.i18n.yaml | 4 +- docs/subsystems/user-questions.md | 19 +++- docs/subsystems/user-questions.zh.md | 19 +++- packages/client/ui-approval/README.i18n.yaml | 8 +- packages/client/ui-approval/README.md | 4 +- packages/client/ui-approval/README.zh.md | 4 +- .../ui-approval/src/client/ApprovalCard.tsx | 89 ++++++++++++++++++ .../src/client/ApprovalPanel.module.css | 23 +++++ .../ui-approval/src/client/ApprovalPanel.tsx | 92 +++---------------- .../ui-approval/src/client/contract/slots.ts | 38 +++++++- .../client/ui-approval/src/client/index.ts | 9 +- .../client/ui-approval/src/client/locales.ts | 2 + .../tests/ui-approval.client.spec.tsx | 39 +++++++- .../client/ui-user-questions/README.i18n.yaml | 4 +- packages/client/ui-user-questions/README.md | 2 + .../client/ui-user-questions/README.zh.md | 2 + .../ui-user-questions/src/client/index.ts | 3 +- .../tests/browser-plugin.client.spec.ts | 3 + .../experimental/cua-macos/README.i18n.yaml | 4 +- packages/experimental/cua-macos/README.md | 2 +- packages/experimental/cua-macos/README.zh.md | 2 +- .../CuaNative/RequestCoordinator.swift | 7 +- packages/experimental/cua/README.i18n.yaml | 4 +- packages/experimental/cua/README.md | 2 +- packages/experimental/cua/README.zh.md | 2 +- packages/experimental/cua/package.json | 10 +- .../cua/src/application-access.ts | 17 +++- .../cua/src/client/ApplicationApproval.tsx | 46 ++++++++++ packages/experimental/cua/src/client/mount.ts | 7 ++ packages/experimental/cua/src/index.ts | 8 +- .../application-approval.client.spec.tsx | 83 +++++++++++++++++ .../cua/tests/mount.client.spec.ts | 7 ++ .../experimental/cua/tsconfig.client.json | 9 ++ .../src/client/slot-catalog.ts | 5 +- .../extensions/tool-cordis/src/api-catalog.ts | 2 +- .../user-questions/README.i18n.yaml | 4 +- packages/interaction/user-questions/README.md | 2 +- .../interaction/user-questions/README.zh.md | 2 +- .../interaction/user-questions/src/index.ts | 32 ++++--- .../interaction/user-questions/src/types.ts | 17 ++++ .../tests/user-questions.spec.ts | 37 +++++++- pnpm-lock.yaml | 9 ++ .../cua-native/workspace/native-helper.mjs | 2 +- 46 files changed, 572 insertions(+), 148 deletions(-) create mode 100644 apps/web/tests/expected/cua-permissions/application-approval.expected.md create mode 100644 packages/client/ui-approval/src/client/ApprovalCard.tsx create mode 100644 packages/experimental/cua/src/client/ApplicationApproval.tsx create mode 100644 packages/experimental/cua/tests/application-approval.client.spec.tsx diff --git a/apps/web/tests/approval-composer.e2e.ts b/apps/web/tests/approval-composer.e2e.ts index a3a8959014..bdb23ec123 100644 --- a/apps/web/tests/approval-composer.e2e.ts +++ b/apps/web/tests/approval-composer.e2e.ts @@ -112,6 +112,17 @@ describe.each(MODE === 'record' ? ['button'] as const : ['button', 'keyboard'] a await panel.waitFor({ timeout: MODE === 'record' ? 180_000 : 60_000 }) const scroll = panel.locator('[data-approval-scroll]') await expect.poll(() => scroll.getByText(/tok/).count(), { timeout: 15_000 }).toBeGreaterThan(0) + const defaults = await panel.evaluate((root: HTMLElement) => { + const before = root.style.textAlign + root.style.textAlign = 'center' + try { + return { + alignment: getComputedStyle(root.querySelector('[data-approval-scroll]')!).textAlign, + wrapping: getComputedStyle(root.querySelector('button')!.parentElement!).flexWrap, + } + } finally { root.style.textAlign = before } + }) + expect(defaults).toEqual({ alignment: 'center', wrapping: 'nowrap' }) if (MODE !== 'record') { const snapshot = await captureStableAria(page, '[data-approval-key]', scaffold.workspaceCwd) diff --git a/apps/web/tests/cua-permissions.e2e.ts b/apps/web/tests/cua-permissions.e2e.ts index 18072ae8b1..6262cb7701 100644 --- a/apps/web/tests/cua-permissions.e2e.ts +++ b/apps/web/tests/cua-permissions.e2e.ts @@ -59,13 +59,20 @@ it.skipIf(process.platform !== 'darwin')('keeps system requests pending until fr callId: ToolCallId('cua-app-approval'), name: 'run_code', arguments: { code: 'return await tools.cua({ method: "getApp", params: { selector: "Fixture" } })', description: 'Request application access' } }) - const approval = page.locator('[data-question-key]') + const approval = page.locator('[data-approval-key]') await Promise.race([approval.waitFor(), grant.then((result) => { throw new Error(`Application selection completed before consent: ${JSON.stringify(result)}`) })]) - expect(await approval.textContent()).toContain('Fixture') - await approval.getByRole('radio', { name: 'Always allow', exact: true }).click() - await approval.getByRole('button', { name: 'Submit', exact: true }).click() + expect(await approval.getByText('Fixture', { exact: true }).count()).toBe(1) + expect(await approval.getByRole('textbox').count()).toBe(0) + expect(await approval.locator('[data-approval-scroll]').evaluate(element => getComputedStyle(element).textAlign)).toBe('left') + expect(await approval.getByRole('button', { name: 'Always allow', exact: true }) + .evaluate(button => getComputedStyle(button.parentElement!).flexWrap)).toBe('wrap') + await expect.poll(() => approval.locator('img').evaluate((image: HTMLImageElement) => image.naturalWidth)).toBe(1) + expect(await approval.getByText('/Applications/Fixture.app', { exact: true }).count()).toBe(0) + await compareOrRefreshGolden(fileURLToPath(new URL('./expected/cua-permissions/application-approval.expected.md', import.meta.url)), + await captureStableAria(page, '[data-approval-key]', scaffold.workspaceCwd), webSnapshotMode()) + await approval.getByRole('button', { name: 'Always allow', exact: true }).click() expect((await grant).isError).toBe(false) await approval.waitFor({ state: 'hidden' }) await page.getByRole('button', { name: 'Plugins', exact: true }).click() diff --git a/apps/web/tests/expected/cua-permissions/application-approval.expected.md b/apps/web/tests/expected/cua-permissions/application-approval.expected.md new file mode 100644 index 0000000000..96f5eb74e3 --- /dev/null +++ b/apps/web/tests/expected/cua-permissions/application-approval.expected.md @@ -0,0 +1,8 @@ +- text: Waiting for approval +- group "Approval details": + - strong: Fixture + - text: Allow Computer Use to launch, read, capture and control this application? + - paragraph: Session access lasts until DSH restarts or Computer Use is disabled. +- button "Reject" +- button "Allow for this session" +- button "Always allow" diff --git a/docs/subsystems/user-questions.i18n.yaml b/docs/subsystems/user-questions.i18n.yaml index 10731879a7..808a19468f 100644 --- a/docs/subsystems/user-questions.i18n.yaml +++ b/docs/subsystems/user-questions.i18n.yaml @@ -9,8 +9,8 @@ en: 88cceb3cf383248e zh: 8e1d2627823b1ffc /user-interaction/presentation-intent: - en: aca3811ea3f0c2ef - zh: 6cf2aea40c7dece8 + en: 9aa9b7fb16455a6e + zh: d820978a3f652e92 /user-interaction/question-item: en: 1ad4373e6fdfe4c7 zh: efb8d41ebe7224f8 diff --git a/docs/subsystems/user-questions.md b/docs/subsystems/user-questions.md index e409bff747..7ff030b698 100644 --- a/docs/subsystems/user-questions.md +++ b/docs/subsystems/user-questions.md @@ -22,7 +22,7 @@ interface AskUserQuestionOption { ## Presentation intent -`AskUserQuestionIntent` optionally declares a known decision kind. It is tagged on `kind` so intents can be added; a UI that does not recognise a tag renders the generic option list. An intent changes presentation only — a UI honouring it answers with the same option labels a generic UI would send, so the caller reads the same answer fields either way. `approve` names the affirmative option instead of relying on option order. `ask()` rejects the two assertions no type can carry: an `approve` naming none of its own question's options, and an intent on a question with no `detail`. +`AskUserQuestionIntent` changes presentation while preserving option labels and the caller’s decision policy. `plan-review` names the approve option and requires plan detail. `approval` requires one blocking question with detail and distinct reject, approve and optional always-allow labels covering exactly the offered options. It rejects multi-select and timed waits. The CUA frontend renders its fixed approvals through the existing composer chain; ordinary questions and plan review keep their existing renderers. ```ts type-equiv /** @@ -43,6 +43,23 @@ type AskUserQuestionIntent = { approve: string /** Logged tool invocation whose arguments contain the reviewed plan. */ callId?: ToolCallId +} | { + /** A blocking permission decision with fixed actions and no free-text answer. */ + kind: 'approval' + /** Option label granting the scope described by the caller. */ + approve: string + /** Option label refusing the requested access. */ + reject: string + /** Optional persistent grant offered only when the caller supports it. */ + always?: string + /** Metadata for human recognition, independent of the caller's authorization identity. */ + subject?: { name: string; icon?: string } + /** Localized presentation; option labels remain the wire answer values. */ + display?: { + question: { en: string; [locale: string]: string } + detail?: { en: string; [locale: string]: string } + approve?: { en: string; [locale: string]: string } + } } ``` diff --git a/docs/subsystems/user-questions.zh.md b/docs/subsystems/user-questions.zh.md index 7b75b08dc6..176db66aec 100644 --- a/docs/subsystems/user-questions.zh.md +++ b/docs/subsystems/user-questions.zh.md @@ -22,7 +22,7 @@ interface AskUserQuestionOption { ## 呈现意图 -`AskUserQuestionIntent` 可选地声明一种已知的决策类型。它以 `kind` 作为标记,以便后续加入更多意图;不认识某个标记的 UI 会渲染通用选项列表。意图只改变呈现——遵循它的 UI 回答的选项标签与通用 UI 相同,因此调用方读到的答案字段一致。`approve` 指明表示同意的选项,而不依赖选项顺序。`ask()` 拒绝两种类型无法表达的断言:`approve` 未命名该问题自己的任何选项,以及没有 `detail` 的问题声明了意图。 +`AskUserQuestionIntent` 改变呈现方式,保留选项标签和调用方的决策规则。`plan-review` 指定批准选项,并要求提供计划正文。`approval` 要求只有一个阻塞式问题,带有说明以及互不相同的拒绝、允许和可选的始终允许标签,完整对应已提供的选项,不支持多选或计时等待。CUA 前端通过现有 composer 呈现自己的固定审批;普通问答和计划审阅继续使用原有呈现。 ```ts type-equiv /** @@ -43,6 +43,23 @@ type AskUserQuestionIntent = { approve: string /** Logged tool invocation whose arguments contain the reviewed plan. */ callId?: ToolCallId +} | { + /** A blocking permission decision with fixed actions and no free-text answer. */ + kind: 'approval' + /** Option label granting the scope described by the caller. */ + approve: string + /** Option label refusing the requested access. */ + reject: string + /** Optional persistent grant offered only when the caller supports it. */ + always?: string + /** Metadata for human recognition, independent of the caller's authorization identity. */ + subject?: { name: string; icon?: string } + /** Localized presentation; option labels remain the wire answer values. */ + display?: { + question: { en: string; [locale: string]: string } + detail?: { en: string; [locale: string]: string } + approve?: { en: string; [locale: string]: string } + } } ``` diff --git a/packages/client/ui-approval/README.i18n.yaml b/packages/client/ui-approval/README.i18n.yaml index 1b646cd6a8..da9aa9eb7e 100644 --- a/packages/client/ui-approval/README.i18n.yaml +++ b/packages/client/ui-approval/README.i18n.yaml @@ -12,8 +12,8 @@ en: 8d6c7c6e63f849f6 zh: d89fa273e30b99d3 /deepseek-ai-dsh-client-ui-approval/table-of-contents: - en: 2bfa677a50bd924c - zh: 2f4b55ab42747bea + en: 4d8f80fd7fb1b422 + zh: 286100dd6b607ed7 /deepseek-ai-dsh-client-ui-approval/model-experience: en: 8161dce49e9f899c zh: a17401e1e78ba6be @@ -21,8 +21,8 @@ en: adf7112e0691b89e zh: 14eeddcff985119f /deepseek-ai-dsh-client-ui-approval/known-limitations-and-deferred-work: - en: ae644102ed52f7ee - zh: e109de1d517a3a94 + en: 23588a4cd32f363c + zh: 19402f024f0f926a /deepseek-ai-dsh-client-ui-approval/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: e88aeb617ffb74ea diff --git a/packages/client/ui-approval/README.md b/packages/client/ui-approval/README.md index ca21af33fb..61389c5000 100644 --- a/packages/client/ui-approval/README.md +++ b/packages/client/ui-approval/README.md @@ -20,6 +20,8 @@ Browser approval presentation over the Agent-scoped Remote Event waterfall. The Focus the approval detail region to approve with Enter or reject with Escape. The mounted plugin reserves both keys against editable shortcuts. Enter on the focused Reject button retains its native reject action. Input controls and IME candidates keep their own keys. Keyboard and pointer actions share one pending-request lock; a withdrawn or replaced request cannot accept another answer, and an earlier failed answer cannot unlock its replacement. +When offered, **Always allow** is the highlighted primary button. Otherwise, the current-request allow action is primary. + ## Model Experience @@ -33,7 +35,7 @@ None; approval request and response rendering does not alter a model request. -- **The panel exposes transient decisions only** — it supports allow-once and reject; persistent permission policy remains owned by Host-side approval packages. Requester-supplied localized presentation copy follows the UI language without changing the audit reason or translating model-generated text. +- The shared `conversation.approval.card` Factory presents reject, allow and an optional always-allow action, with caller-owned scope, subject metadata and answer callbacks. Tool approvals retain their two outcomes. Application consent can reuse the card without adopting tool-approval policy. Subject metadata enables left-aligned content and wrapping actions; callers that omit it retain inherited alignment and a single action row. Icons and labels identify the subject visually; the caller owns identity verification and saved grants. diff --git a/packages/client/ui-approval/README.zh.md b/packages/client/ui-approval/README.zh.md index a9a714ef68..310eff5b89 100644 --- a/packages/client/ui-approval/README.zh.md +++ b/packages/client/ui-approval/README.zh.md @@ -20,6 +20,8 @@ kind: "package-reference" 聚焦审批详情区域后,Enter 批准,Esc 拒绝。插件挂载期间,这两个按键不能分配给可编辑快捷键。聚焦“拒绝”按钮后,Enter 保留按钮原生拒绝操作。输入控件与输入法候选保留各自的按键。键盘和指针操作共用同一待处理请求锁;已撤销或替换的请求不能再次作答,较早请求的失败也不会解锁替代请求。 +提供“始终允许”时,将它显示为高亮主按钮;否则高亮当前请求的允许按钮。 + ## 模型体验 @@ -33,7 +35,7 @@ kind: "package-reference" -- **面板只提供临时决定**——它支持仅本次允许和拒绝;持久权限策略仍由 Host 侧审批包拥有。请求方提供的本地化展示文案跟随界面语言,不改写审计原因,也不翻译模型生成的文本。 +- 共用的 `conversation.approval.card` Factory 展示拒绝、允许和可选的始终允许操作,授权范围、对象信息和回答回调由调用方提供。工具审批保留原来的两种选择。应用授权可以复用卡片,同时保留独立的授权规则。传入对象信息时,内容左对齐,按钮允许换行;未传入时沿用继承的对齐方式和单排按钮。图标和名称用于识别对象,身份校验和授权保存由调用方负责。 diff --git a/packages/client/ui-approval/src/client/ApprovalCard.tsx b/packages/client/ui-approval/src/client/ApprovalCard.tsx new file mode 100644 index 0000000000..50d1b4cffb --- /dev/null +++ b/packages/client/ui-approval/src/client/ApprovalCard.tsx @@ -0,0 +1,89 @@ +/** Shared approval presentation; each caller retains its decision policy and answer channel. */ +import { useEffect, useRef, useState, type KeyboardEvent } from 'react' +import { Button, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' +import type { FactoryComponentPropsOf } from '@deepseek-ai/dsh-client-ui-slots' +import type {} from './contract/slots.ts' +import css from './ApprovalPanel.module.css' + +/** + * Render one decision with caller-owned actions and an optional persistent choice. + * @param props Request identity, copy, actions and correlated detail. + * @returns The shared approval card, reset for each request. + */ +export function ApprovalCard(props: FactoryComponentPropsOf<'conversation.approval.card'>) { + return +} + +function ApprovalFlow(props: FactoryComponentPropsOf<'conversation.approval.card'>) { + const { requestKey, title, description, subject, allowLabel, canAnswer, onAllow, onReject, onAlways, t } = props + const detail = props.callId === undefined ? null : props.renderSlot('conversation.approval.detail', { callId: props.callId }) + const [answered, setAnswered] = useState(false) + const waiting = useRef(false) + const active = useRef(true) + const composing = useRef(false) + const compositionEnded = useRef(false) + useEffect(() => { + active.current = true + return () => { active.current = false } + }, []) + const answer = (send: () => Promise): void => { + if (waiting.current || !canAnswer()) return + waiting.current = true + setAnswered(true) + void send().catch(() => { + if (!active.current || !canAnswer()) return + waiting.current = false + setAnswered(false) + }) + } + const keydown = (event: KeyboardEvent): void => { + const element = event.target as Element + if (event.defaultPrevented || !event.currentTarget.contains(document.activeElement) + || element.closest('input, textarea, select, [contenteditable="true"], [contenteditable=""]') !== null) return + if (event.key !== 'Enter' && event.key !== 'Escape') return + if (event.key === 'Enter' && element.closest('button, a[href], [role="button"]') !== null) return + if (event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return + event.preventDefault() + event.stopPropagation() + // oxlint-disable-next-line typescript/no-deprecated -- IME 229 covers engines without isComposing. + if (event.repeat || composing.current || compositionEnded.current || event.nativeEvent.isComposing || event.keyCode === 229) return + answer(event.key === 'Enter' ? onAllow : onReject) + } + return ( +
{ compositionEnded.current = false }} + onCompositionStartCapture={() => { composing.current = true }} + onCompositionEndCapture={() => { composing.current = false; compositionEnded.current = true }}> +
+
{t('waiting')}
+
+ {subject !== undefined &&
+ {subject.icon !== undefined && } + {subject.name} +
} +
{title}
+ {description !== undefined &&

{description}

} + {detail !== null &&
{detail}
} +
+
+ + + {onAlways !== undefined && } +
+
+
+ ) +} diff --git a/packages/client/ui-approval/src/client/ApprovalPanel.module.css b/packages/client/ui-approval/src/client/ApprovalPanel.module.css index edf5a7bf79..b6a21e092e 100644 --- a/packages/client/ui-approval/src/client/ApprovalPanel.module.css +++ b/packages/client/ui-approval/src/client/ApprovalPanel.module.css @@ -65,3 +65,26 @@ color: var(--dsw-alias-state-error-primary); border-color: transparent; } + +.subject { + display: flex; + align-items: center; + gap: 12px; + color: var(--dsw-alias-label-primary); + font-size: 16px; +} + +.subject img { + flex-shrink: 0; + object-fit: contain; +} + +.description { + margin: 0; + color: var(--dsw-alias-label-secondary); + font-size: 13px; + line-height: 20px; +} + +.withSubject .body { text-align: left; } +.withSubject .actionRow { flex-wrap: wrap; } diff --git a/packages/client/ui-approval/src/client/ApprovalPanel.tsx b/packages/client/ui-approval/src/client/ApprovalPanel.tsx index bdfcba3f02..b77a757bab 100644 --- a/packages/client/ui-approval/src/client/ApprovalPanel.tsx +++ b/packages/client/ui-approval/src/client/ApprovalPanel.tsx @@ -1,88 +1,20 @@ -/** Composer takeover for one pending approval waterfall. */ -import { useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from 'react' -import { Button, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' -import type { ApprovalComposerProps, PendingApproval } from './contract/slots.ts' -import css from './ApprovalPanel.module.css' +/** Composer adapter for one pending tool-approval waterfall. */ +import type { ApprovalComposerProps } from './contract/slots.ts' /** - * Render one pending approval and its optional Tool-owned detail. - * @param props - selector-matched request and standard Slot props. + * Render the shared card using the tool approval's one-shot decision policy. + * @param props Selector-matched request and standard Slot props. * @returns The approval composer takeover. */ export function ApprovalPanel(props: ApprovalComposerProps) { const approval = props.matched - const detail = approval.callId === undefined - ? null - : props.renderSlot('conversation.approval.detail', { callId: approval.callId }) const reason = approval.displayReason === undefined ? approval.reason : props.resolveReason(approval.displayReason) - return -} - -function ApprovalFlow({ pending, reason, detail, t }: { - pending: PendingApproval - reason: string | undefined - detail: ReactNode - t: ApprovalComposerProps['t'] -}) { - const [answered, setAnswered] = useState(false) - const waiting = useRef(false) - const active = useRef(true) - const composing = useRef(false) - const compositionEnded = useRef(false) - useEffect(() => { - active.current = true - return () => { active.current = false } - }, []) - const answer = (outcome: 'allowed-once' | 'rejected'): void => { - if (waiting.current || !pending.answerable) return - waiting.current = true - setAnswered(true) - void pending.answer(outcome).catch(() => { - if (!active.current || !pending.answerable) return - waiting.current = false - setAnswered(false) - }) - } - const keydown = (event: KeyboardEvent): void => { - const element = event.target as Element - if (event.defaultPrevented || !event.currentTarget.contains(document.activeElement) - || element.closest('input, textarea, select, [contenteditable="true"], [contenteditable=""]') !== null) return - if (event.key !== 'Enter' && event.key !== 'Escape') return - if (event.key === 'Enter' && element.closest('button, a[href], [role="button"]') !== null) return - if (event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return - event.preventDefault() - event.stopPropagation() - // oxlint-disable-next-line typescript/no-deprecated -- IME 229 covers engines without isComposing. - if (event.repeat || composing.current || compositionEnded.current || event.nativeEvent.isComposing || event.keyCode === 229) return - answer(event.key === 'Enter' ? 'allowed-once' : 'rejected') - } - return ( -
{ compositionEnded.current = false }} - onCompositionStartCapture={() => { composing.current = true }} - onCompositionEndCapture={() => { composing.current = false; compositionEnded.current = true }}> -
-
{t('waiting')}
-
-
{reason ?? t('escalation', { toolName: pending.toolName })}
- {detail !== null &&
{detail}
} -
-
- - -
-
-
- ) + return props.renderFactorySlot('conversation.approval.card', { + requestKey: approval.key, + title: reason ?? props.t('escalation', { toolName: approval.toolName }), + ...(approval.callId === undefined ? {} : { callId: approval.callId }), + canAnswer: () => approval.answerable, + onAllow: () => approval.answer('allowed-once'), + onReject: () => approval.answer('rejected'), + }) } diff --git a/packages/client/ui-approval/src/client/contract/slots.ts b/packages/client/ui-approval/src/client/contract/slots.ts index 2558b40ea4..e2b88d474b 100644 --- a/packages/client/ui-approval/src/client/contract/slots.ts +++ b/packages/client/ui-approval/src/client/contract/slots.ts @@ -2,7 +2,7 @@ import type { ToolCallId } from '@deepseek-ai/dsh-llm' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { - PropsLocale, PropsRenderSlots, PropsRuntime, + PropsLocale, PropsRenderFactories, PropsRuntime, } from '@deepseek-ai/dsh-client-ui-slots' import type { ApprovalKey } from '../locales.ts' @@ -32,6 +32,16 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { approval: ApprovalKey } + interface SlotFactoryMap { + /** Shared decision card; the caller owns authorization and persistence. */ + 'conversation.approval.card': { + scope: 'session' + props: ApprovalCardInput + locale: 'approval' + children: { 'conversation.approval.detail': { kind: 'single'; scope: 'session' } } + } + } + interface SlotMap { /** Optional detail for the Tool call correlated with an approval request. */ 'conversation.approval.detail': { @@ -188,7 +198,31 @@ export interface ApprovalInjected { /** Full props of the approval composer takeover. */ export type ApprovalComposerProps = PropsRuntime<'conversation.composer'> - & PropsRenderSlots<'conversation.approval.detail'> + & PropsRenderFactories & { matched: PendingApproval } & PropsLocale<'approval'> & ApprovalInjected + +/** Data and decisions accepted by the shared approval card. */ +export interface ApprovalCardInput { + /** Identity whose change resets the pending submission. */ + requestKey: string + /** Localized question describing the requested operation. */ + title: string + /** Localized scope or consequence of the decision. */ + description?: string + /** Subject metadata for human recognition; never an authorization identity. */ + subject?: { name: string; icon?: string } + /** Optional tool-owned detail rendered through the existing child slot. */ + callId?: ToolCallId + /** Caller-specific scope label; ordinary approvals use Allow once. */ + allowLabel?: string + /** Whether the caller still accepts an answer. */ + canAnswer: () => boolean + /** Grant this request with the caller's stated scope. */ + onAllow: () => Promise + /** Refuse this request. */ + onReject: () => Promise + /** Explicit persistent choice; absent when the caller cannot save permission. */ + onAlways?: () => Promise +} diff --git a/packages/client/ui-approval/src/client/index.ts b/packages/client/ui-approval/src/client/index.ts index ca6d674e36..fd0b04a599 100644 --- a/packages/client/ui-approval/src/client/index.ts +++ b/packages/client/ui-approval/src/client/index.ts @@ -8,11 +8,13 @@ import type { PendingInteractionPublisher } from '@deepseek-ai/dsh-client-ui-ses import type { TypertClientEventListener } from '@deepseek-ai/dsh-typert-protocol' import type {} from '@deepseek-ai/dsh-client-locale/client' import type { ShortcutCommandId } from '@deepseek-ai/dsh-client-shortcuts/client' +import { ApprovalCard } from './ApprovalCard.tsx' import { ApprovalPanel } from './ApprovalPanel.tsx' import { PendingApproval } from './contract/slots.ts' import { en, zh } from './locales.ts' export type { + ApprovalCardInput, ApprovalComposerProps, ApprovalDecision, ApprovalDetailOwnerProps, @@ -88,6 +90,10 @@ export function apply(ctx: ClientContext): void { const registerPendingInteraction = ctx.uiSession.registerPendingInteraction( () => 0, ) + ctx.slots.registerFactory({ + name: 'conversation.approval.card', scope: 'session', locale: NS, + children: { 'conversation.approval.detail': { kind: 'single', scope: 'session' } }, + }, ApprovalCard) ctx.slots.inject('conversation.composer', () => ctx.slots.register({ name: 'conversation.composer', priority: 1, @@ -97,9 +103,6 @@ export function apply(ctx: ClientContext): void { inject: () => ({ resolveReason: (reason: NonNullable) => ctx.locale.resolveText(reason), }), - children: { - 'conversation.approval.detail': { kind: 'single', scope: 'session' }, - }, }, ApprovalPanel)) ctx.remote.$on('approval/request', function (request, next) { return answerApproval(ctx, this, request, next, registerPendingInteraction) diff --git a/packages/client/ui-approval/src/client/locales.ts b/packages/client/ui-approval/src/client/locales.ts index e28f2ce74a..2a8aae7ec8 100644 --- a/packages/client/ui-approval/src/client/locales.ts +++ b/packages/client/ui-approval/src/client/locales.ts @@ -7,6 +7,7 @@ export const zh = { escalation: '工具 {toolName} 请求越权执行', reject: '拒绝', allowOnce: '允许一次', + allowAlways: '始终允许', } satisfies Record /** Approval dictionary key union. */ @@ -19,4 +20,5 @@ export const en = { escalation: 'Tool {toolName} requests privileged execution', reject: 'Reject', allowOnce: 'Allow once', + allowAlways: 'Always allow', } satisfies Record diff --git a/packages/client/ui-approval/tests/ui-approval.client.spec.tsx b/packages/client/ui-approval/tests/ui-approval.client.spec.tsx index e9edc316a5..753258b8f1 100644 --- a/packages/client/ui-approval/tests/ui-approval.client.spec.tsx +++ b/packages/client/ui-approval/tests/ui-approval.client.spec.tsx @@ -5,8 +5,10 @@ import type { ToolCallId } from '@deepseek-ai/dsh-llm' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' +import { ApprovalCard } from '../src/client/ApprovalCard.tsx' +import type { FactoryComponentPropsOf } from '@deepseek-ai/dsh-client-ui-slots' import { ApprovalPanel } from '../src/client/ApprovalPanel.tsx' -import type { ApprovalComposerProps, ApprovalInjected } from '../src/client/contract/slots.ts' +import type { ApprovalCardInput, ApprovalComposerProps, ApprovalInjected } from '../src/client/contract/slots.ts' import { PendingApproval } from '../src/client/contract/slots.ts' import { apply, inject } from '../src/client/index.ts' import { apply as nodeApply } from '../src/index.ts' @@ -32,6 +34,7 @@ interface PluginBench { readonly disposeLocale: ReturnType readonly register: ReturnType readonly injectSlot: ReturnType + readonly shortcuts: ReturnType string }) => () => void>> releasePending(): Promise registration(): { options: { @@ -82,8 +85,11 @@ async function setupPlugin(): Promise { } as never) ctx.provide('sessions', { scopeOf } as never) ctx.provide('uiSession', { registerPendingInteraction } as never) - ctx.provide('slots', { inject: injectSlot, register } as never) + ctx.provide('slots', { inject: injectSlot, register, registerFactory: vi.fn() } as never) + const shortcuts = vi.fn((entry: { label: () => string }) => { entry.label(); return () => {} }) + ctx.provide('shortcuts', { registerFixed: shortcuts } as never) ctx.provide('locale', { + bind: () => (key: string) => key, register: vi.fn(() => disposeLocale), resolveText: (reason: NonNullable) => reason.en, } as never) @@ -100,6 +106,7 @@ async function setupPlugin(): Promise { disposeLocale, register, injectSlot, + shortcuts, async releasePending() { const delegates = [...pending.values()] pending.clear() @@ -211,6 +218,7 @@ describe('approval Remote Event consumer', () => { expect(next).toHaveBeenCalledOnce() expect(bench.pending.getSnapshot()).toEqual([]) expect(bench.register).toHaveBeenCalledOnce() + expect(bench.shortcuts.mock.calls.map(([entry]) => entry.label())).toEqual(['allowOnce', 'reject']) }) it('publishes one scoped takeover, returns the answer, and keeps stable registrations', async () => { @@ -314,7 +322,7 @@ describe('approval Remote Event consumer', () => { function panelProps( pending: PendingApproval, - renderSlot: ApprovalComposerProps['renderSlot'] = vi.fn(() => null), + renderSlot: FactoryComponentPropsOf<'conversation.approval.card'>['renderSlot'] = vi.fn(() => null), ): ApprovalComposerProps { const messages: Record = { waiting: 'Waiting', @@ -322,10 +330,12 @@ function panelProps( escalation: `Tool ${pending.toolName} asks`, reject: 'Reject', allowOnce: 'Allow once', + allowAlways: 'Always allow', } return { matched: pending, - renderSlot, + renderFactorySlot: (_name: string, input: ApprovalCardInput) => messages[key] ?? key } as FactoryComponentPropsOf<'conversation.approval.card'>} />, resolveReason: (reason: NonNullable) => reason.en, t: (key: string) => messages[key] ?? key, } as ApprovalComposerProps @@ -340,7 +350,7 @@ describe('ApprovalPanel', () => { expect(screen.getByText('Tool bash asks')).toBeTruthy() expect(document.querySelector('[data-approval-key] [data-state="warning"]')).not.toBeNull() expect(screen.getByRole('group', { name: 'Approval details' })).toBeTruthy() - expect(props.renderSlot).not.toHaveBeenCalled() + expect(screen.queryByText('pnpm test')).toBeNull() fireEvent.click(screen.getByRole('button', { name: 'Reject' })) expect(document.querySelector('[data-approval-key]')?.getAttribute('aria-busy')).toBe('true') @@ -503,3 +513,22 @@ describe('package entries', () => { expect(() => { nodeApply() }).not.toThrow() }) }) + +it.each(['allow', 'reject', 'always'] as const)('settles a fixed application decision through %s without free text', async (choice) => { + const onAllow = vi.fn(async () => {}), onReject = vi.fn(async () => {}), onAlways = vi.fn(async () => {}) + const actions = { allow: onAllow, reject: onReject, always: onAlways } + render( true, onAllow, onReject, onAlways, renderSlot: vi.fn(() => null), + t: (key: string) => ({ reject: 'Reject', allowAlways: 'Always allow' })[key] ?? key, + } as FactoryComponentPropsOf<'conversation.approval.card'>} />) + expect(screen.getByText('Calculator')).toBeTruthy() + expect(document.querySelector('img')?.getAttribute('src')).toBe('data:image/png;base64,aWNvbg==') + expect(screen.queryByRole('textbox')).toBeNull() + const label = { allow: 'Allow for this task', reject: 'Reject', always: 'Always allow' }[choice] + fireEvent.click(screen.getByRole('button', { name: label })) + fireEvent.click(screen.getByRole('button', { name: label })) + await waitFor(() => { expect(actions[choice]).toHaveBeenCalledOnce() }) + expect(Object.values(actions).filter(action => action.mock.calls.length > 0)).toEqual([actions[choice]]) +}) diff --git a/packages/client/ui-user-questions/README.i18n.yaml b/packages/client/ui-user-questions/README.i18n.yaml index 78ff5697f1..e317d03367 100644 --- a/packages/client/ui-user-questions/README.i18n.yaml +++ b/packages/client/ui-user-questions/README.i18n.yaml @@ -21,8 +21,8 @@ en: cc19c065d0aeac1e zh: 91c8cbe08b438e7f /deepseek-ai-dsh-client-ui-user-questions/use-this-package/reading-a-settled-question-back: - en: 10975f68ff9c6fcc - zh: 92246aadc363f0eb + en: 8e8449ab411b9916 + zh: 0129d68b84458e8e /deepseek-ai-dsh-client-ui-user-questions/use-this-package/the-plan-review-card: en: 6b12f1362c614a39 zh: 27d9cf54d4cbd5d2 diff --git a/packages/client/ui-user-questions/README.md b/packages/client/ui-user-questions/README.md index d57e359de7..21bd536737 100644 --- a/packages/client/ui-user-questions/README.md +++ b/packages/client/ui-user-questions/README.md @@ -37,6 +37,8 @@ Each late reply is shown once, including when it opens a new Turn. Replies follo A question that already settled opens as a read-only card: the same pager over the recorded answers, marked `Answered`. Every option and text field is disabled, Skip and the submit action are gone, a question the user skipped says so in place of its empty answer, and a free-text field appears only where the recorded answer used one. Closing it removes the card, and the tool call row builds another from the same record. +Fixed approval questions use the same pending-question and answer channel. Their feature plugin supplies the composer presentation; the generic question form leaves these requests to that contribution. + ### The plan-review card A `plan-review` intent — set by `dsh-plan-mode` on the `exit_plan_mode` review — renders a compact approval card: a `Plan review` strip with a `View full plan` link, the plan title and a two-line plain-text summary, and `Request changes` / `Approve` actions. The strip uses the shared warning dot and changes it to the ongoing loader while either action settles. The complete plan opens in the sidebar through the link or its permanent Chat card. Approve answers with the asker's approval label; `Request changes` rejects the wait as `ASK_CANCELLED`, returning the composer for the user's feedback without submitting an approval. The card has no separate refusal button. diff --git a/packages/client/ui-user-questions/README.zh.md b/packages/client/ui-user-questions/README.zh.md index 2145663976..6cc13b0bd6 100644 --- a/packages/client/ui-user-questions/README.zh.md +++ b/packages/client/ui-user-questions/README.zh.md @@ -37,6 +37,8 @@ Web 客户端在聊天输入框旁显示 agent(智能体)的提问。用户 已经结束的问题以只读卡片打开:同一个翻页器走过已记录的回答,并标注「已回答」。所有选项与文本框都被禁用,跳过与提交动作消失,用户当时跳过的问题会在空回答处说明这一点,自由文本框只出现在当时确实用过它的回答上。关闭它会移除该卡片,工具调用行会用同一份记录再建一个。 +固定审批问题沿用同一个待答问题和回答通道,由功能插件提供 composer 呈现;通用问答表单将这类请求留给该呈现处理。 + ### plan-review 卡片 `plan-review` 意图——由 `dsh-plan-mode` 在 `exit_plan_mode` 审阅上设置——渲染紧凑审批卡片:顶部为带“查看全文”链接的“计划待审”条带,正文显示计划标题和两行纯文本摘要,底部提供“要求修改”和“同意执行”。条带使用共享 warning 状态点,并在任一操作结算期间切换为 ongoing loading。通过链接或聊天历史中的常驻卡片可在侧边栏阅读完整计划。“同意执行”用提问方的批准标签回答;“要求修改”以 `ASK_CANCELLED` 拒绝等待,让编辑器归位供用户输入反馈,不会提交批准。卡片不单列拒绝按钮。 diff --git a/packages/client/ui-user-questions/src/client/index.ts b/packages/client/ui-user-questions/src/client/index.ts index 8ee3962c39..c96c3d5a89 100644 --- a/packages/client/ui-user-questions/src/client/index.ts +++ b/packages/client/ui-user-questions/src/client/index.ts @@ -383,7 +383,8 @@ export function apply(ctx: ClientContext): void { { name: 'conversation.composer', select: ({ pendingInteraction }: ComposerChainProps): PendingQuestion | null => - pendingInteraction instanceof PendingQuestion ? pendingInteraction : null, + pendingInteraction instanceof PendingQuestion && pendingInteraction.questions[0]?.intent?.kind !== 'approval' + ? pendingInteraction : null, locale: NS, store: questionDraftStore, inject: () => ({ keyedHooks: { questionCard: (key: string) => cards.source(key) } }), diff --git a/packages/client/ui-user-questions/tests/browser-plugin.client.spec.ts b/packages/client/ui-user-questions/tests/browser-plugin.client.spec.ts index 84b5d04b21..f12d6b7336 100644 --- a/packages/client/ui-user-questions/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-user-questions/tests/browser-plugin.client.spec.ts @@ -378,6 +378,9 @@ describe('apply', () => { ) => PendingQuestion | null expect(select({ pendingInteraction: undefined })).toBeNull() expect(select({ pendingInteraction: pending })).toBe(pending) + expect(select({ pendingInteraction: new PendingQuestion(SESSION_ID, PLAN_QUESTIONS) })?.kind).toBe('plan-review') + expect(select({ pendingInteraction: new PendingQuestion(SESSION_ID, [{ id: 'cua-app-access', question: 'Allow?', + intent: { kind: 'approval', approve: 'Allow', reject: 'Deny' } }]) })).toBeNull() expect(pending).toMatchObject({ kind: 'question', sessionId: SESSION_ID, questions: QUESTIONS, callId: undefined }) expect(pending.snapshot()).toEqual({ state: 'open', waitState: 'counting', countdown: undefined, diff --git a/packages/experimental/cua-macos/README.i18n.yaml b/packages/experimental/cua-macos/README.i18n.yaml index c2cb1bfdfa..6826839de7 100644 --- a/packages/experimental/cua-macos/README.i18n.yaml +++ b/packages/experimental/cua-macos/README.i18n.yaml @@ -12,8 +12,8 @@ en: 4549f02fc53854a5 zh: 6f74f1130b6623a8 /deepseek-ai-dsh-experimental-cua-macos/table-of-contents: - en: 9353d17b05f2f88c - zh: 6b2c6b4ee0046294 + en: 867d9ec544719acf + zh: 92348599582dc33a /deepseek-ai-dsh-experimental-cua-macos/use-this-package: en: 7126e254aaf42772 zh: fa05ac2346e022ff diff --git a/packages/experimental/cua-macos/README.md b/packages/experimental/cua-macos/README.md index 5a15eb8dbe..7b5e1b2466 100644 --- a/packages/experimental/cua-macos/README.md +++ b/packages/experimental/cua-macos/README.md @@ -20,7 +20,7 @@ Inspect macOS permissions, select running applications and windows, read accessi - [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) - [Dev Note](#dev-note) -The helper supplies bounded 64-pixel PNG application icons for permission-page application lists. Icon reads do not launch an application or inspect its windows; executable and signing checks still determine authorization. +The helper supplies bounded 64-pixel PNG application icons for permission prompts and saved-application lists. Icon reads do not launch an application or inspect its windows; executable and signing checks still determine authorization. The shared helper queries the process macOS considers responsible for it and captures that application’s name and icon before announcing readiness. A Desktop app launched from Terminal can therefore identify Terminal even when another GUI application sits between it and the helper. Permission queries retain this metadata after the responsible application exits and across connections sharing the helper. If the system query or application metadata is unavailable, the field is omitted and the UI uses the system prompt for guidance. diff --git a/packages/experimental/cua-macos/README.zh.md b/packages/experimental/cua-macos/README.zh.md index 223079a130..c935086842 100644 --- a/packages/experimental/cua-macos/README.zh.md +++ b/packages/experimental/cua-macos/README.zh.md @@ -20,7 +20,7 @@ kind: "package-reference" - [已知限制与后续工作](#known-limitations-and-deferred-work) - [开发备注](#dev-note) -helper 为权限页的应用列表提供 64 像素 PNG 图标。读取图标不会启动应用或检查窗口,授权仍由可执行文件和签名校验决定。 +helper 为授权提示和已授权应用列表提供 64 像素 PNG 图标。读取图标不会启动应用或检查窗口,授权仍由可执行文件和签名校验决定。 共享 helper 查询 macOS 为它记录的责任进程,在发布就绪状态前保存对应应用的名称和图标。因此,从 Terminal 启动 Desktop 时,即使中间还有另一个 GUI 应用,也能识别到 Terminal。责任应用退出后,权限查询仍保留这些信息,后续复用该 helper 的连接读取同一份记录。系统查询或应用信息不可用时省略该字段,由界面引导用户按系统弹窗选择授权对象。 diff --git a/packages/experimental/cua-macos/native/Sources/CuaNative/RequestCoordinator.swift b/packages/experimental/cua-macos/native/Sources/CuaNative/RequestCoordinator.swift index 99ea8634a9..280cd358ad 100644 --- a/packages/experimental/cua-macos/native/Sources/CuaNative/RequestCoordinator.swift +++ b/packages/experimental/cua-macos/native/Sources/CuaNative/RequestCoordinator.swift @@ -195,7 +195,12 @@ import Darwin let outcome = try await worker.execute(request.method, params: params, catalog: catalog, installed: installed, trace: trace) trace?.mark("worker.end") switch outcome { - case .result(let value): result = value + case .result(let value): + if request.method == "getApp", var fields = value.object { + let path = installed?.path ?? catalog.applications.first { Double($0.pid) == value["pid"].number }?.path + if let path, let icon = ApplicationCatalog.icon(at: path) { fields["applicationIcon"] = .string(icon) } + result = .object(fields) + } else { result = value } case .capture(let target): let image = try await capture.capture(target, cancellation: cancellation, trace: trace) result = try await worker.finishCapture(target, image: image, trace: trace) diff --git a/packages/experimental/cua/README.i18n.yaml b/packages/experimental/cua/README.i18n.yaml index 4c76d9b5e3..d82263aa92 100644 --- a/packages/experimental/cua/README.i18n.yaml +++ b/packages/experimental/cua/README.i18n.yaml @@ -12,8 +12,8 @@ en: f4d9cc48587d62a2 zh: d1bac6c1fe7eb29e /deepseek-ai-dsh-cua/table-of-contents: - en: 4081cf444a77bf20 - zh: def91d58969ec281 + en: 400de8528e57cf00 + zh: 1eb88c23eb4b511f /deepseek-ai-dsh-cua/use-this-package: en: ceb54d964dc355b6 zh: 0892066fbceef7b9 diff --git a/packages/experimental/cua/README.md b/packages/experimental/cua/README.md index 8279d70d9b..939ae24305 100644 --- a/packages/experimental/cua/README.md +++ b/packages/experimental/cua/README.md @@ -20,7 +20,7 @@ Inspect applications, retain window objects across PTC calls, and send actions t - [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) - [Dev Note](#dev-note) -Application consent uses the existing User Questions options and remains independent of the session tool-approval policy. The provider accepts only one exact offered label without custom text. Native icons accompany permission-page observations and remain outside saved identities and model results. System checks, saved grants and refusals, and icons use separate queries. +Application consent uses the fixed approval presentation of User Questions and remains independent of the session tool-approval policy. Native icons accompany the prompt and permission-page observations, but remain outside saved identities and model results. System checks, saved grants and refusals, and icons use separate queries so each result can appear without waiting for the others. ----- diff --git a/packages/experimental/cua/README.zh.md b/packages/experimental/cua/README.zh.md index 5f3202407e..6c53b0a5b2 100644 --- a/packages/experimental/cua/README.zh.md +++ b/packages/experimental/cua/README.zh.md @@ -20,7 +20,7 @@ kind: "package-reference" - [已知限制与后续工作](#known-limitations-and-deferred-work) - [开发备注](#dev-note) -应用授权使用现有 User Questions 选项,仍独立于会话的工具审批策略。提供方只接受一个准确匹配所给选项的标签,附带自由文本的回答不会授权。本机图标用于权限页展示,不进入保存的身份信息或模型结果。系统权限、持久授权与拒绝记录、图标分别查询。 +应用授权使用 User Questions 的固定审批呈现,仍独立于会话的工具审批策略。本机图标用于授权提示和权限页展示,不进入保存的身份信息或模型结果。系统权限、持久授权与拒绝记录、图标分别查询,各项结果返回后即可显示。 ----- diff --git a/packages/experimental/cua/package.json b/packages/experimental/cua/package.json index adfdd19789..b30d00b670 100644 --- a/packages/experimental/cua/package.json +++ b/packages/experimental/cua/package.json @@ -129,7 +129,10 @@ "@testing-library/react": "^16.1.0", "@deepseek-ai/dsh-loader-smoke": "workspace:*", "@deepseek-ai/dsh-util-values": "workspace:*", - "@deepseek-ai/dsh-brand": "workspace:*" + "@deepseek-ai/dsh-brand": "workspace:*", + "@deepseek-ai/dsh-client-ui-user-questions": "workspace:*", + "@deepseek-ai/dsh-client-ui-approval": "workspace:*", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:*" }, "dsh": { "client": { @@ -138,7 +141,10 @@ "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-plugin-manager", "@deepseek-ai/dsh-client-ui-settings", - "@deepseek-ai/dsh-client-ui-layout" + "@deepseek-ai/dsh-client-ui-layout", + "@deepseek-ai/dsh-client-ui-user-questions", + "@deepseek-ai/dsh-client-ui-approval", + "@deepseek-ai/dsh-client-ui-conversation" ], "platform": "web" } diff --git a/packages/experimental/cua/src/application-access.ts b/packages/experimental/cua/src/application-access.ts index 4b34223012..d5922bbce8 100644 --- a/packages/experimental/cua/src/application-access.ts +++ b/packages/experimental/cua/src/application-access.ts @@ -124,10 +124,11 @@ export class ApplicationAccess { * @param handle Helper handle for a running instance or an approved pending launch. * @param identity Native executable/signing identity. * @param signal Calling tool lifetime. + * @param icon Native application icon for the permission card; never saved as an identity. * @returns Completion after consent; refusals reject before launch, AX, screenshots, or input. */ async select(agent: Agent, client: NativeClient, handle: CuaAppHandle, - identity: CuaApplicationGrant & { persistable: boolean }, signal: AbortSignal): Promise { + identity: CuaApplicationGrant & { persistable: boolean }, signal: AbortSignal, icon?: string): Promise { await this.withOperation(agent, client, identity, signal, signal => this.selectInOrder(agent, identity.id, async () => { signal.throwIfAborted() const existing = this.clients.get(client)?.handles.get(handle) @@ -147,7 +148,18 @@ export class ApplicationAccess { result = await questions.ask({ agent, signal, questions: [{ id: 'cua-app-access', question: `Allow Computer Use to launch if needed, read, capture, and control ${identity.name}?`, detail: 'Session access lasts until DSH restarts or Computer Use is disabled.', - options }] }) + options, intent: { + kind: 'approval', approve: 'Allow for this session', reject: 'Deny', + ...(identity.persistable && this.options.save ? { always: 'Always allow' } : {}), + subject: { name: identity.name, ...(icon === undefined ? {} : { icon }) }, + display: { + question: { en: 'Allow Computer Use to launch, read, capture and control this application?', + zh: '允许 Computer Use 启动、读取、截图和控制此应用吗?' }, + detail: { en: 'Session access lasts until DSH restarts or Computer Use is disabled.', + zh: '会话授权在 DSH 重启或关闭 Computer Use 后失效。' }, + approve: { en: 'Allow for this session', zh: '本会话允许' }, + }, + } }] }) } catch (error) { if (error instanceof UserQuestionError && error.code === 'ASK_CANCELLED') { this.invalidate(identity.id, agent) @@ -196,6 +208,7 @@ export class ApplicationAccess { * @param client Helper owning the application handle. * @param handle Selected application. * @param signal Calling tool lifetime. + * @param icon Native application icon for the permission card; never saved as an identity. * @param dispatch One native operation, with revocation cancellation. * @returns The native result, with no implicit retry after consent or cancellation. */ diff --git a/packages/experimental/cua/src/client/ApplicationApproval.tsx b/packages/experimental/cua/src/client/ApplicationApproval.tsx new file mode 100644 index 0000000000..d49c90a2dd --- /dev/null +++ b/packages/experimental/cua/src/client/ApplicationApproval.tsx @@ -0,0 +1,46 @@ +/** Computer Use presentation over the existing pending-question answer channel. */ +import type { PropsRenderFactories, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type {} from '@deepseek-ai/dsh-client-ui-user-questions/client' +import type {} from '@deepseek-ai/dsh-client-ui-approval/client' + +/** + * Select only the provider's fixed application question; other interactions keep their renderer. + * @param pending The session's currently elected interaction. + * @returns Its application decision, or null for another interaction. + */ +export function selectApplicationApproval(pending: ComposerChainProps['pendingInteraction']) { + if (pending?.kind !== 'question' || pending.questions.length !== 1) return null + const item = pending.questions[0] as (typeof pending.questions)[number] + if (item.id !== 'cua-app-access' || item.intent?.kind !== 'approval') return null + return { pending, item, intent: item.intent } +} + +/** Composer bindings for an application decision and caller-owned localized copy. */ +export type ApplicationApprovalProps = PropsRuntime<'conversation.composer'> & PropsRenderFactories & { + matched: NonNullable> + resolveText: (text: { en: string; [locale: string]: string }) => string +} + +/** + * Render the shared approval card without creating another request or answer lifetime. + * @param props The existing pending question and renderer bindings. + * @returns Fixed actions with the provider's original answer labels. + */ +export function ApplicationApproval({ matched: { pending, item, intent }, resolveText, renderFactorySlot }: ApplicationApprovalProps) { + const always = intent.always + const answer = (label: string) => pending.answer({ answers: [{ id: item.id, selected: [label] }] }) + return renderFactorySlot('conversation.approval.card', { + requestKey: pending.key, + title: intent.display === undefined ? item.question : resolveText(intent.display.question), + ...(item.detail === undefined ? {} : { + description: intent.display?.detail === undefined ? item.detail : resolveText(intent.display.detail), + }), + ...(intent.subject === undefined ? {} : { subject: intent.subject }), + allowLabel: intent.display?.approve === undefined ? intent.approve : resolveText(intent.display.approve), + canAnswer: () => pending.snapshot().channel !== 'none' && !pending.snapshot().closed, + onAllow: () => answer(intent.approve), + onReject: () => answer(intent.reject), + ...(always === undefined ? {} : { onAlways: () => answer(always) }), + }) +} diff --git a/packages/experimental/cua/src/client/mount.ts b/packages/experimental/cua/src/client/mount.ts index bc43e9bd8b..69a35f1cbf 100644 --- a/packages/experimental/cua/src/client/mount.ts +++ b/packages/experimental/cua/src/client/mount.ts @@ -8,6 +8,8 @@ import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' import type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/client' import type {} from '@deepseek-ai/dsh-client-ui-settings/client' +import type { ComposerChainProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { ApplicationApproval, selectApplicationApproval } from './ApplicationApproval.tsx' import { PermissionController } from './controller.ts' import { PermissionPanel, PermissionSetup, PermissionToast } from './PermissionPanel.tsx' import { en, NS, zh, type CuaPermissionLocaleKey } from './locales.ts' @@ -34,6 +36,11 @@ export async function mountCuaPermissions(ctx: Context, contribution: TypertRemo ctx.effect(() => ctx.locale.register(NS, { en, zh })) const controller = new PermissionController(ctx.remote.cuaAccess) const face = controller.face() + ctx.slots.inject('conversation.composer', () => ctx.slots.register({ + name: 'conversation.composer', priority: -1, locale: NS, + select: ({ pendingInteraction }: ComposerChainProps) => selectApplicationApproval(pendingInteraction), + inject: () => ({ resolveText: (text: { en: string; [locale: string]: string }) => ctx.locale.resolveText(text) }), + }, ApplicationApproval)) const preset = ctx.configForms.get<{ default: string; selectedDefault?: string }>('agent-preset-registry') const settings = { ...face, diff --git a/packages/experimental/cua/src/index.ts b/packages/experimental/cua/src/index.ts index 1cec00f09a..39c581ab41 100644 --- a/packages/experimental/cua/src/index.ts +++ b/packages/experimental/cua/src/index.ts @@ -16,7 +16,7 @@ import { NativeClient, NativeError, type NativeClientConfig, type NativeChannelF import { ApplicationAuthorizationResult, installCuaAccess } from './access.ts' import type { CuaApplicationGrant, CuaApplicationId } from './access-types.ts' import type { CuaAppHandle, CuaOperation } from './types.ts' -import { AppResult, NativeScreenshotResult, Request, ScreenshotResult, StateResult, WindowsResult } from './protocol.ts' +import { ApplicationIcon, AppResult, NativeScreenshotResult, Request, ScreenshotResult, StateResult, WindowsResult } from './protocol.ts' import { CuaCoordinates } from './coordinates.ts' import { createScope, type Scope } from '@deepseek-ai/dsh-scope' import type {} from '@deepseek-ai/dsh-agent-preset-registry' @@ -268,8 +268,10 @@ export function installCua(ctx: Context, config: CuaConfig, backend: CuaBackend) : await requestNative() if (method === 'getApp') { const prepared = AppResult.extend({ pid: AppResult.shape.pid.nullable() }).parse(result) - const { authorization } = z.object({ authorization: ApplicationAuthorizationResult }).parse(result) - await access.select(agent, client, prepared.appHandle, authorization, signal) + const { authorization, applicationIcon } = z.object({ authorization: ApplicationAuthorizationResult, + applicationIcon: ApplicationIcon.optional(), + }).parse(result) + await access.select(agent, client, prepared.appHandle, authorization, signal, applicationIcon) signal.throwIfAborted() const selected = prepared.pid === null ? AppResult.parse(await access.run(client, prepared.appHandle, signal, diff --git a/packages/experimental/cua/tests/application-approval.client.spec.tsx b/packages/experimental/cua/tests/application-approval.client.spec.tsx new file mode 100644 index 0000000000..e40f26eb95 --- /dev/null +++ b/packages/experimental/cua/tests/application-approval.client.spec.tsx @@ -0,0 +1,83 @@ +// @vitest-environment jsdom +/** Fixed decisions retain the caller's answer labels and pending-question lifetime. */ +import { cleanup, render } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import type { ApprovalCardInput } from '@deepseek-ai/dsh-client-ui-approval/client' +import { PendingQuestion, createWaterfallRequest } from '../../../client/ui-user-questions/src/client/contract/slots.ts' +import { ApplicationApproval, selectApplicationApproval, type ApplicationApprovalProps } from '../src/client/ApplicationApproval.tsx' + +type AskUserQuestionItem = PendingQuestion['questions'][number] +type ApprovalIntent = Extract, { kind: 'approval' }> +type SessionId = ConstructorParameters[0] + +afterEach(cleanup) + +it('leaves ordinary questions, plan reviews and other approval intents to their existing renderers', () => { + const application: AskUserQuestionItem = { id: 'cua-app-access', question: 'Allow?', detail: 'Scope', + options: [{ label: 'Allow' }, { label: 'Deny' }], intent: { kind: 'approval', approve: 'Allow', reject: 'Deny' } } + expect(selectApplicationApproval(undefined)).toBeNull() + for (const questions of [[], [application, application], [{ ...application, id: 'another-feature' }], + [{ id: 'cua-app-access', question: 'Plain question' }], + [{ ...application, intent: { kind: 'plan-review' as const, approve: 'Allow' } }], + ]) { + expect(selectApplicationApproval(new PendingQuestion('s1' as SessionId, questions))).toBeNull() + } +}) + +it.each(['allow', 'reject', 'always'] as const)('returns the exact %s option from the shared card', async (choice) => { + const intent: ApprovalIntent = { + kind: 'approval', approve: 'task-value', reject: 'deny-value', always: 'saved-value', + subject: { name: 'Calculator' }, display: { question: { en: 'Allow?', zh: '允许吗?' }, + detail: { en: 'Scope', zh: '本次任务' }, approve: { en: 'Task', zh: '本次任务允许' } }, + } + const item: AskUserQuestionItem = { id: 'cua-app-access', question: 'English question', detail: 'English detail', intent } + const pending = new PendingQuestion('s1' as SessionId, [item]) + const request = createWaterfallRequest(undefined, undefined, () => {}) + pending.attachWaterfall(request.channel) + const factory = vi.fn((_name: string, _input: ApprovalCardInput) => null) + render( text.zh ?? text.en } as ApplicationApprovalProps} />) + const [name, card] = factory.mock.calls[0]! + expect(name).toBe('conversation.approval.card') + expect(card).toMatchObject({ title: '允许吗?', description: '本次任务', allowLabel: '本次任务允许', subject: { name: 'Calculator' } }) + expect(card.canAnswer()).toBe(true) + await ({ allow: card.onAllow, reject: card.onReject, always: card.onAlways! })[choice]() + expect(await request.result).toEqual({ answers: [{ id: 'cua-app-access', selected: [{ allow: 'task-value', reject: 'deny-value', always: 'saved-value' }[choice]] }] }) + pending.close() + expect(card.canAnswer()).toBe(false) +}) + +it('keeps unsupported persistence absent and uses caller text when no localized metadata exists', () => { + const item: AskUserQuestionItem = { id: 'cua-app-access', question: 'Allow?', + intent: { kind: 'approval', approve: 'Task', reject: 'Deny' } } + const pending = new PendingQuestion('s1' as SessionId, [item]) + const factory = vi.fn((_name: string, _input: ApprovalCardInput) => null) + const props = { matched: selectApplicationApproval(pending)!, renderFactorySlot: factory, + resolveText: text => text.en } as ApplicationApprovalProps + const view = render() + expect(factory.mock.calls[0]?.[1]).toMatchObject({ title: 'Allow?', allowLabel: 'Task' }) + expect(factory.mock.calls[0]?.[1]).not.toHaveProperty('onAlways') + expect(factory.mock.calls[0]?.[1]).not.toHaveProperty('subject') + expect(factory.mock.calls[0]?.[1]).not.toHaveProperty('description') + expect(factory.mock.calls[0]?.[1].canAnswer()).toBe(false) + const detailed = new PendingQuestion('s1' as SessionId, [{ ...item, detail: 'Scope' }]) + view.rerender() + expect(factory.mock.calls.at(-1)?.[1].description).toBe('Scope') +}) + +it('uses the original question cancellation and refuses late approval', async () => { + const pending = new PendingQuestion('s1' as SessionId, [{ id: 'cua-app-access', question: 'Allow?', + intent: { kind: 'approval', approve: 'Allow', reject: 'Deny' } }]) + const controller = new AbortController() + const request = createWaterfallRequest(undefined, controller.signal, (channel) => { pending.detachWaterfall(channel) }) + pending.attachWaterfall(request.channel) + const factory = vi.fn((_name: string, _input: ApprovalCardInput) => null) + render( text.en } as ApplicationApprovalProps} />) + const card = factory.mock.calls[0]![1] + const rejected = expect(request.result).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + controller.abort() + await rejected + expect(card.canAnswer()).toBe(false) + await expect(card.onAllow()).rejects.toThrow('no channel accepts an answer yet') +}) diff --git a/packages/experimental/cua/tests/mount.client.spec.ts b/packages/experimental/cua/tests/mount.client.spec.ts index 02145516f6..c8059093d6 100644 --- a/packages/experimental/cua/tests/mount.client.spec.ts +++ b/packages/experimental/cua/tests/mount.client.spec.ts @@ -10,6 +10,7 @@ import type { CuaApplicationState, CuaSystemPermissionState } from '../src/acces import { expect, it, vi } from 'vitest' import type { PermissionFace } from '../src/client/controller.ts' import { mountCuaPermissions, inject } from '../src/client/mount.ts' +import type { ApplicationApprovalProps } from '../src/client/ApplicationApproval.tsx' const contribution: TypertRemoteContribution = { package: '@deepseek-ai/dsh-cua', descriptors: [] } const value: CuaApplicationState & { system: CuaSystemPermissionState } = { @@ -60,6 +61,7 @@ async function fixture(fail = false) { ctx.provide('locale', new LocaleRuntime(ctx)) await ctx.plugin(SlotRegistry) ctx.slots.register({ name: 'root', children: { + 'conversation.composer': { kind: 'chain', scope: 'session' }, 'plugins.bundle.activation': { kind: 'keyed', scope: 'root' }, 'plugins.bundle.config': { kind: 'keyed', scope: 'root' }, 'shell.overlay': { kind: 'list', scope: 'root' }, } } as never, () => null) @@ -75,6 +77,11 @@ it('withdraws the permission Remote and all controls with the optional plugin', try { const fiber = f.ctx.plugin({ inject: [...inject], apply: ctx => mountCuaPermissions(ctx, contribution) }) await fiber + const approval = f.ctx.slots.entries('conversation.composer')[0]! + const select = approval.select as (owner: { pendingInteraction: undefined }) => unknown + expect(select({ pendingInteraction: undefined })).toBeNull() + const presentation = approval.inject!() as Pick + expect(presentation.resolveText({ en: 'Localized', zh: 'Localized' })).toBe('Localized') const entry = f.ctx.slots.entries('plugins.bundle.config')[0]! const actions = entry.inject!() actionsOf(actions) diff --git a/packages/experimental/cua/tsconfig.client.json b/packages/experimental/cua/tsconfig.client.json index 03db4207e8..d56f8bc27d 100644 --- a/packages/experimental/cua/tsconfig.client.json +++ b/packages/experimental/cua/tsconfig.client.json @@ -51,6 +51,15 @@ }, { "path": "../../util/brand" + }, + { + "path": "../../client/ui-user-questions/tsconfig.json" + }, + { + "path": "../../client/ui-approval/tsconfig.json" + }, + { + "path": "../../client/ui-conversation/tsconfig.client.json" } ] } diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index 267ba267d7..9d3af52bbf 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -113,13 +113,13 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ keyDomain: '', hookContext: '', slotInject: '', - declaredBy: 'an entry in \'conversation.composer\' (client-ui-approval), so it exists while that entry is mounted', + declaredBy: 'factory \'conversation.approval.card\' (client-ui-approval), so it exists while that definition is registered', occupants: [ 'client-ui-chat ApprovalCommand', ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.approval.detail\', () => ctx.slots.register(\n { name: \'conversation.approval.detail\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-approval/src/client/contract/slots.ts:37', + source: 'packages/client/ui-approval/src/client/contract/slots.ts:47', }, { key: 'conversation.chat.assistant-actions', @@ -604,6 +604,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'client-ui-approval ApprovalPanel', 'client-ui-subagent SubagentReadOnlyComposer', 'client-ui-user-questions QuestionComposer', + 'cua ApplicationApproval', ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer\', () => ctx.slots.register(\n { name: \'conversation.composer\', select: owner => null },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index e2541c5eb6..f085272e2e 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -4975,7 +4975,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'AskUserQuestionIntent', - declaration: 'export type AskUserQuestionIntent = {\n kind: \'plan-review\';\n approve: string;\n callId?: ToolCallId;\n};', + declaration: 'export type AskUserQuestionIntent = {\n kind: \'plan-review\';\n approve: string;\n callId?: ToolCallId;\n} | {\n kind: \'approval\';\n approve: string;\n reject: string;\n always?: string;\n subject?: {\n name: string;\n icon?: string;\n };\n display?: {\n question: {\n en: string;\n [locale: string]: string;\n };\n detail?: {\n en: string;\n [locale: string]: string;\n };\n approve?: {\n en: string;\n [locale: string]: string;\n };\n };\n};', }, { name: 'AskUserQuestionItem', diff --git a/packages/interaction/user-questions/README.i18n.yaml b/packages/interaction/user-questions/README.i18n.yaml index 7f8068bc63..5af5c57285 100644 --- a/packages/interaction/user-questions/README.i18n.yaml +++ b/packages/interaction/user-questions/README.i18n.yaml @@ -15,8 +15,8 @@ en: 382a21c0ea17b2e4 zh: 1a4335e571de92dd /deepseek-ai-dsh-user-questions/ask-a-question: - en: 0c45d3161b0df13a - zh: efff84a7917a799e + en: 2898b11dff62d4d9 + zh: 09b6d9c75476a3a5 /deepseek-ai-dsh-user-questions/role: en: 1a321b5a204dc4dd zh: 5e30055a3774e606 diff --git a/packages/interaction/user-questions/README.md b/packages/interaction/user-questions/README.md index 3cdb0dd87c..d08f935fe7 100644 --- a/packages/interaction/user-questions/README.md +++ b/packages/interaction/user-questions/README.md @@ -28,7 +28,7 @@ Call `ask()` when work cannot continue without the answer. Call `askTimed()` whe For a single-select question, `custom` overrides the selected choice and `selected` is empty. For a multi-select question, `custom` may supplement the labels in `selected`. A UI may preserve a skipped item as `{ id, selected: [] }`, keeping the existing answer shape while retaining other answers in the batch. -A question may carry a presentation `intent`, which declares that it IS a known kind of decision so a UI that recognises the tag may present it as such; the one tag is `plan-review`, whose `detail` is the plan under review and whose `approve` names the affirmative option. An intent changes presentation only: a UI honouring it answers with the same option labels a generic UI would send, and a UI that does not know the tag renders the generic option list. `ask()` rejects with `BAD_INTENT` the two assertions no type can carry: an `approve` naming none of that question's own options, and an intent on a question with no `detail`. `dsh-plan-mode` sets it on the `exit_plan_mode` review question. +A question may carry a presentation `intent`. `plan-review` names the approve option and uses `detail` as the plan. `approval` presents one blocking question as fixed reject, allow and optional always-allow actions; it may include subject metadata and localized copy. Its action labels must be distinct and cover exactly the offered options, with no multi-select or timed wait. `ask()` rejects invalid intents with `BAD_INTENT`. Both presentations return the original option labels, and an older UI can render the same options generically. The caller continues to own authorization and persistence. When a request carries an agent, `ask()` authenticates its exact identity through the live `AgentRegistry` and admits only a runtime root. A live child cannot open a human interaction. An agentless programmatic request remains available to unscoped local waterfall listeners and fails with `NO_PROVIDER` when none accepts it. diff --git a/packages/interaction/user-questions/README.zh.md b/packages/interaction/user-questions/README.zh.md index cea78870d6..8d09fe2c48 100644 --- a/packages/interaction/user-questions/README.zh.md +++ b/packages/interaction/user-questions/README.zh.md @@ -28,7 +28,7 @@ kind: "package-reference" 对于单选题,`custom` 会覆盖选中的选项,且 `selected` 为空。对于多选题,`custom` 可以补充 `selected` 中的标签。UI 可以把跳过的条目保留为 `{ id, selected: [] }`,既维持现有回答形态,也保留该批次中的其他回答。 -问题可以携带呈现意图 `intent`,声明它就是某种已知决策,识别该标记的 UI 可以按该决策呈现;目前唯一的标记是 `plan-review`,其 `detail` 是待审阅的计划,`approve` 指明表示同意的选项。意图只改变呈现:遵循它的 UI 回答的选项标签与通用 UI 相同,不认识该标记的 UI 则渲染通用选项列表。`ask()` 会以 `BAD_INTENT` 拒绝两种类型无法表达的断言:`approve` 未命名该问题自己的任何选项,以及没有 `detail` 的问题声明了意图。`dsh-plan-mode` 在 `exit_plan_mode` 的审阅问题上设置它。 +问题可以携带呈现意图 `intent`。`plan-review` 指定批准选项,并将 `detail` 作为待审阅计划。`approval` 将一个阻塞式问题显示为拒绝、允许和可选的始终允许操作,可附带对象信息和本地化文案。操作标签必须互不相同,并完整对应已提供的选项,不支持多选或计时等待。`ask()` 以 `BAD_INTENT` 拒绝无效意图。两种呈现都返回原始选项标签,旧界面仍可显示通用选项列表。授权与持久化仍由调用方负责。 请求包含 agent 时,`ask()` 会通过当前 `AgentRegistry` 验证该 agent 与注册表中的存活实例是同一对象,并且只允许运行时根调用。存活子级不能发起人机交互。不含 agent 的程序化请求仍会交给本地未限定 scope 的 waterfall listener,若无人接受则以 `NO_PROVIDER` 失败。 diff --git a/packages/interaction/user-questions/src/index.ts b/packages/interaction/user-questions/src/index.ts index 846a8a65be..58ccd2f616 100644 --- a/packages/interaction/user-questions/src/index.ts +++ b/packages/interaction/user-questions/src/index.ts @@ -298,26 +298,28 @@ export class UserQuestionService extends TypertRemoteService { } const agent = request.agent if (agent !== undefined) this.assertLiveRoot(agent) - // A presentation intent asserts two things the types cannot: that the - // named approve label is one of this question's own options, and that a - // plan-review carries the plan it is a review of. A UI honouring the - // intent answers with that label, and shows that detail as the plan, so - // either gap would put a choice the asker never offered — or an approval of - // something invisible — in front of the user. Caught at the asker, where - // the mistake is, rather than in each UI. for (const question of request.questions) { const intent = question.intent if (intent === undefined) continue - if (!(question.options ?? []).some(option => option.label === intent.approve)) { - throw new UserQuestionError( - `question ${question.id} declares intent ${intent.kind} whose approve label ` - + `${JSON.stringify(intent.approve)} names none of its options`, - 'BAD_INTENT') + const labels = intent.kind === 'approval' + ? [intent.approve, intent.reject, ...intent.always === undefined ? [] : [intent.always]] + : [intent.approve] + const options = question.options ?? [] + if (labels.some(label => !options.some(option => option.label === label))) { + if (intent.kind === 'plan-review') { + throw new UserQuestionError( + `question ${question.id} declares intent ${intent.kind} whose approve label ` + + `${JSON.stringify(intent.approve)} names none of its options`, + 'BAD_INTENT') + } + throw new UserQuestionError(`question ${question.id} declares intent ${intent.kind} with an unoffered action`, 'BAD_INTENT') } if (question.detail === undefined) { - throw new UserQuestionError( - `question ${question.id} declares intent ${intent.kind} without the detail it reviews`, - 'BAD_INTENT') + throw new UserQuestionError(`question ${question.id} declares intent ${intent.kind} without the detail it reviews`, 'BAD_INTENT') + } + if (intent.kind === 'approval' && (request.questions.length !== 1 || question.multiSelect === true + || request.wait !== undefined || new Set(labels).size !== labels.length || options.length !== labels.length)) { + throw new UserQuestionError('approval presentation requires one blocking question with distinct fixed actions', 'BAD_INTENT') } } const noAnswerer = () => Promise.reject(new UserQuestionError( diff --git a/packages/interaction/user-questions/src/types.ts b/packages/interaction/user-questions/src/types.ts index 8cccf99c3c..b90dc8d70a 100644 --- a/packages/interaction/user-questions/src/types.ts +++ b/packages/interaction/user-questions/src/types.ts @@ -32,6 +32,23 @@ export type AskUserQuestionIntent = { approve: string /** Logged tool invocation whose arguments contain the reviewed plan. */ callId?: ToolCallId +} | { + /** A blocking permission decision with fixed actions and no free-text answer. */ + kind: 'approval' + /** Option label granting the scope described by the caller. */ + approve: string + /** Option label refusing the requested access. */ + reject: string + /** Optional persistent grant offered only when the caller supports it. */ + always?: string + /** Metadata for human recognition, independent of the caller's authorization identity. */ + subject?: { name: string; icon?: string } + /** Localized presentation; option labels remain the wire answer values. */ + display?: { + question: { en: string; [locale: string]: string } + detail?: { en: string; [locale: string]: string } + approve?: { en: string; [locale: string]: string } + } } /** One question in a user-questions request. */ diff --git a/packages/interaction/user-questions/tests/user-questions.spec.ts b/packages/interaction/user-questions/tests/user-questions.spec.ts index 1b03fa746a..e7df1c69b7 100644 --- a/packages/interaction/user-questions/tests/user-questions.spec.ts +++ b/packages/interaction/user-questions/tests/user-questions.spec.ts @@ -312,7 +312,8 @@ describe('UserQuestionService', () => { ...(options === undefined ? {} : { options }), intent: { kind: 'plan-review', approve: 'Ship it' }, }], - })).rejects.toMatchObject({ name: 'UserQuestionError', code: 'BAD_INTENT' }) + })).rejects.toMatchObject({ name: 'UserQuestionError', code: 'BAD_INTENT', + message: 'question plan-review declares intent plan-review whose approve label "Ship it" names none of its options' }) } expect(p.ask).not.toHaveBeenCalled() }) @@ -331,7 +332,8 @@ describe('UserQuestionService', () => { options: [{ label: 'Approve' }, { label: 'Keep planning' }], intent: { kind: 'plan-review', approve: 'Approve' }, }], - })).rejects.toMatchObject({ name: 'UserQuestionError', code: 'BAD_INTENT' }) + })).rejects.toMatchObject({ name: 'UserQuestionError', code: 'BAD_INTENT', + message: 'question plan-review declares intent plan-review without the detail it reviews' }) expect(p.ask).not.toHaveBeenCalled() }) @@ -906,3 +908,34 @@ describe('late replies', () => { await ctx.fiber.dispose() }) }) + +describe('fixed approval presentation', () => { + const question = { id: 'access', question: 'Allow access?', detail: 'Scope of access', + options: [{ label: 'Task' }, { label: 'Deny' }, { label: 'Always' }], + intent: { kind: 'approval' as const, approve: 'Task', reject: 'Deny', always: 'Always' } } + it('preserves the explicit choices for an independent caller policy', async () => { + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + await ctx.plugin(UserQuestionService) + const p = provider('Always') + registerAnswerer(ctx, p) + expect(await ctx.userQuestions.ask({ questions: [question] })).toEqual({ answers: [{ id: 'access', selected: ['Always'] }] }) + expect(p.seen[0]?.questions[0]?.intent).toEqual(question.intent) + }) + it.each(['unoffered', 'duplicate', 'extra', 'multiple', 'multiselect', 'timed'] as const)('rejects %s approval actions before display', async (kind) => { + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + await ctx.plugin(UserQuestionService) + const p = provider() + registerAnswerer(ctx, p) + const item = structuredClone(question) + if (kind === 'unoffered') item.intent.approve = 'Unknown' + if (kind === 'duplicate') item.intent.always = 'Task' + if (kind === 'extra') item.options.push({ label: 'Something else' }) + const questions = kind === 'multiple' ? [item, { ...item, id: 'other' }] : [{ ...item, multiSelect: kind === 'multiselect' }] + await expect(ctx.userQuestions.ask({ questions, + ...(kind === 'timed' ? { wait: { callId: ToolCallId('call') } } : {}), + })).rejects.toMatchObject({ code: 'BAD_INTENT' }) + expect(p.seen).toEqual([]) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f4487f549b..b92bcf90df 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8611,6 +8611,12 @@ importers: '@deepseek-ai/dsh-client-test-runtime': specifier: workspace:* version: link:../../test-support/client-runtime + '@deepseek-ai/dsh-client-ui-approval': + specifier: workspace:* + version: link:../../client/ui-approval + '@deepseek-ai/dsh-client-ui-conversation': + specifier: workspace:* + version: link:../../client/ui-conversation '@deepseek-ai/dsh-client-ui-layout': specifier: workspace:* version: link:../../client/ui-layout @@ -8629,6 +8635,9 @@ importers: '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:* version: link:../../client/ui-slots + '@deepseek-ai/dsh-client-ui-user-questions': + specifier: workspace:* + version: link:../../client/ui-user-questions '@deepseek-ai/dsh-config-editor': specifier: workspace:* version: link:../../boot/config-editor diff --git a/snapshots/session/cua-native/workspace/native-helper.mjs b/snapshots/session/cua-native/workspace/native-helper.mjs index e36445ca94..2bbaa83188 100755 --- a/snapshots/session/cua-native/workspace/native-helper.mjs +++ b/snapshots/session/cua-native/workspace/native-helper.mjs @@ -142,7 +142,7 @@ function createApplicationState(socket, record, application, captureBroker, conn } if (!['Fixture', 'test.fixture', '9'].includes(params.selector)) refuse('APP_NOT_FOUND', 'Select the running Fixture application') acquired = true - return { ...app, authorization: {"id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "name": "Fixture", "executablePath": "/fixture/app", "persistable": true} } + return { ...app, applicationIcon: `data:image/png;base64,${png}`, authorization: {"id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "name": "Fixture", "executablePath": "/fixture/app", "persistable": true} } } if (!acquired || params.appHandle !== app.appHandle) refuse('APP_NOT_FOUND', 'Acquire an application in this helper first') if (method === 'show') return { dispatched: true, verified: false, windowId: params.windowId ?? window.windowId, foregroundChanged: false } From 067ca57b8a6de1b89eaab5d606c5619dfdcb3409 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 20 Oct 2026 11:36:28 +0800 Subject: [PATCH 2/3] docs(approval): regenerate question intent type catalog --- docs/persistence-catalog.i18n.yaml | 4 ++-- docs/persistence-catalog.md | 2 +- docs/persistence-catalog.zh.md | 2 +- docs/persistence-schema.json | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index ed3dfb8f6c..9801348cab 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -1857,8 +1857,8 @@ en: 993991e8f05665bb zh: a03931839e647bac /session-persistence-event-catalog/resolved-persistence-types/kind-user-question-reply: - en: 77a8fc6a2f3a9319 - zh: 4dc1de1358b51a8d + en: 246940ea354e5493 + zh: 9f3f462ec3bc2000 /session-persistence-event-catalog/resolved-persistence-types/kind-webhook-form-notice: en: 0b74295f7c5c8f11 zh: 5f1213cec87f6be0 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index da02534d25..31c569dfef 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -7647,7 +7647,7 @@ SHA-256: `5654685d7557ec747e74887237065e626a5bdfa426ca43f7fbb0b6fee9b0916c` SHA-256: `c55827fba7018198772c50e37cb7d3f60a3d8bb6e94b07de72dee0d9f8968005` -Sources: [`packages/interaction/user-questions/src/types.ts:117`](../packages/interaction/user-questions/src/types.ts) +Sources: [`packages/interaction/user-questions/src/types.ts:134`](../packages/interaction/user-questions/src/types.ts) | Property | Presence | Type | |---|---|---| diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 062a1e58b1..180e56c4f7 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -7649,7 +7649,7 @@ SHA-256: `5654685d7557ec747e74887237065e626a5bdfa426ca43f7fbb0b6fee9b0916c` SHA-256: `c55827fba7018198772c50e37cb7d3f60a3d8bb6e94b07de72dee0d9f8968005` -来源:[`packages/interaction/user-questions/src/types.ts:117`](../packages/interaction/user-questions/src/types.ts) +来源:[`packages/interaction/user-questions/src/types.ts:134`](../packages/interaction/user-questions/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index d3bbdddd21..e3b8acc649 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -25420,7 +25420,7 @@ "digest": "c55827fba7018198772c50e37cb7d3f60a3d8bb6e94b07de72dee0d9f8968005", "names": [], "sources": [ - "packages/interaction/user-questions/src/types.ts:117" + "packages/interaction/user-questions/src/types.ts:134" ] }, { From 6df01745c534b42739a3698236dd3b2a568ba154 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 20 Oct 2026 11:38:37 +0800 Subject: [PATCH 3/3] docs(approval): align parameter docs and event references --- docs/event-producer-consumer.md | 2 +- docs/event-producer-consumer.zh.md | 2 +- packages/experimental/cua/src/application-access.ts | 1 - 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 063f53ed2c..7d2be52f6a 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -81,7 +81,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:154`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `auto-review`, [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:191`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | | `tools/result` | `emit` | [`packages/core/tools/src/index.ts:199`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present) | -| `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:157`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` | +| `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:174`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` | | `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:39`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `remote-access`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 78f2a02da4..001dbea1bb 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -83,7 +83,7 @@ | `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:154`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `auto-review`, [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:191`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | | `tools/result` | `emit` | [`packages/core/tools/src/index.ts:199`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present) | -| `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:157`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` | +| `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:174`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` | | `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:39`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `remote-access`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | diff --git a/packages/experimental/cua/src/application-access.ts b/packages/experimental/cua/src/application-access.ts index d5922bbce8..82d7d2a74a 100644 --- a/packages/experimental/cua/src/application-access.ts +++ b/packages/experimental/cua/src/application-access.ts @@ -208,7 +208,6 @@ export class ApplicationAccess { * @param client Helper owning the application handle. * @param handle Selected application. * @param signal Calling tool lifetime. - * @param icon Native application icon for the permission card; never saved as an identity. * @param dispatch One native operation, with revocation cancellation. * @returns The native result, with no implicit retry after consent or cancellation. */