fix(approval): keep generic question fallback answerable

This commit is contained in:
creatixchu committed 2026-10-20 13:08:24 +08:00
1 parent 6f6f889c95
commit b7c9d1d7a5
6 files changed
+45 -12

No files matched your search

+35 -1
View File
@@ -12,7 +12,7 @@ import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Browser, Locator, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { afterAll, beforeAll, describe, expect, it, onTestFailed, onTestFinished } from 'vitest'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { TIMED_WAIT_PARAMETER } from '@deepseek-ai/dsh-user-questions'
@@ -700,3 +700,37 @@ describe.skipIf(MODE === 'record')('web e2e: late-answered question transcript',
])
})
})
it.each(['another-feature', 'cua-app-access'])('answers %s approval without a specialized composer', async (id) => {
const resources: { scaffold?: WebScaffold; browser?: Browser; waiting?: Promise<unknown> } = {}
const controller = new AbortController()
onTestFinished(async () => {
controller.abort()
await resources.waiting
await resources.browser?.close()
await resources.scaffold?.close()
})
const scaffold = await launchWebScaffold()
resources.scaffold = scaffold
const browser = await chromium.launch()
resources.browser = browser
const page = await newEnglishPage(browser)
const tripwire = watchConsole(page)
onTestFailed(() => saveFailureShot(page, `web-approval-fallback-${id}`))
await page.goto(scaffold.authenticatedUrl)
await connectFreshWorkspace(page, scaffold.workspaceCwd)
const agent = scaffold.ctx.agents.list()[0]!
const result = scaffold.ctx.userQuestions.ask({ agent, signal: controller.signal, questions: [{
id, question: 'Allow fixture access?', detail: 'Only this request.',
options: [{ label: 'Allow' }, { label: 'Deny' }],
intent: { kind: 'approval', approve: 'Allow', reject: 'Deny' },
}] })
resources.waiting = Promise.allSettled([result])
const question = page.locator('[data-question-key]')
await question.getByRole('radio', { name: 'Deny', exact: true }).click()
await question.getByRole('button', { name: 'Submit', exact: true }).click()
expect(await result).toEqual({ answers: [{ id, selected: ['Deny'] }] })
await question.waitFor({ state: 'hidden' })
expect(tripwire.pageErrors).toEqual([])
})
@@ -18,11 +18,11 @@
en: 8d3815c19858ac85
zh: 94fc905fa202d334
/deepseek-ai-dsh-client-ui-user-questions/use-this-package/answering:
en: cc19c065d0aeac1e
zh: 91c8cbe08b438e7f
en: 0fa156d3d698cf0e
zh: cbcd082b9a188d6b
/deepseek-ai-dsh-client-ui-user-questions/use-this-package/reading-a-settled-question-back:
en: 8e8449ab411b9916
zh: 0129d68b84458e8e
en: 10975f68ff9c6fcc
zh: 92246aadc363f0eb
/deepseek-ai-dsh-client-ui-user-questions/use-this-package/the-plan-review-card:
en: 6b12f1362c614a39
zh: 27d9cf54d4cbd5d2
+2 -2
View File
@@ -31,14 +31,14 @@ When the agent asks a question, the attached card temporarily occupies the compo
A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps its title, navigation, and submission actions fixed while long detail and choices share an internal scroll region. "Skip" retains other drafts and emits the existing blank `{ selected: [] }` result for that item. Close is not an answer: a question the Host named by tool call only leaves the composer seat, and its `ask_user_question` tool call row brings the panel back; a request that carries no tool call has no row to return from, so closing it rejects the whole wait as `ASK_CANCELLED`.
Fixed approval questions use the same pending-question and answer channel. A feature plugin can supply a higher-priority composer presentation. When none matches, the generic form keeps the offered options answerable; the caller still decides which responses grant permission.
### Reading a settled question back
Each late reply is shown once, including when it opens a new Turn. Replies follow normal Chat process grouping and whole-Turn folding. Expand the containing Turn and work group to read a reply that has been folded away.
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.
@@ -31,14 +31,14 @@ Web 客户端在聊天输入框旁显示 agent(智能体)的提问。用户
用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。限高卡片保持标题、导航与提交动作固定,超长的详情与选项共享内部滚动区。「跳过」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 结果。关闭不是一种回答:Host 以工具调用命名的问题只是离开编辑器位置,其 `ask_user_question` 工具调用行会把面板带回来;不携带工具调用的请求没有可返回的行,关闭它仍以 `ASK_CANCELLED` 拒绝整个等待。
固定审批问题复用待处理问题和回答通道。功能插件可以提供优先级更高的 composer;没有匹配的呈现时,通用表单仍可选择并提交原有选项,由调用方判断哪些回答可以授予权限。
### 回看已结束的问题
每条迟到回复只显示一次,包括由它开启新轮次的情况。回复遵循 Chat 原有的过程分组与整轮折叠规则。回复被收起时,展开所属轮次和工作分组即可查看。
已经结束的问题以只读卡片打开:同一个翻页器走过已记录的回答,并标注「已回答」。所有选项与文本框都被禁用,跳过与提交动作消失,用户当时跳过的问题会在空回答处说明这一点,自由文本框只出现在当时确实用过它的回答上。关闭它会移除该卡片,工具调用行会用同一份记录再建一个。
固定审批问题沿用同一个待答问题和回答通道,由功能插件提供 composer 呈现;通用问答表单将这类请求留给该呈现处理。
### plan-review 卡片
`plan-review` 意图——由 `dsh-plan-mode` 在 `exit_plan_mode` 审阅上设置——渲染紧凑审批卡片:顶部为带“查看全文”链接的“计划待审”条带,正文显示计划标题和两行纯文本摘要,底部提供“要求修改”和“同意执行”。条带使用共享 warning 状态点,并在任一操作结算期间切换为 ongoing loading。通过链接或聊天历史中的常驻卡片可在侧边栏阅读完整计划。“同意执行”用提问方的批准标签回答;“要求修改”以 `ASK_CANCELLED` 拒绝等待,让编辑器归位供用户输入反馈,不会提交批准。卡片不单列拒绝按钮。
@@ -383,8 +383,7 @@ export function apply(ctx: ClientContext): void {
{
name: 'conversation.composer',
select: ({ pendingInteraction }: ComposerChainProps): PendingQuestion | null =>
pendingInteraction instanceof PendingQuestion && pendingInteraction.questions[0]?.intent?.kind !== 'approval'
? pendingInteraction : null,
pendingInteraction instanceof PendingQuestion ? pendingInteraction : null,
locale: NS,
store: questionDraftStore,
inject: () => ({ keyedHooks: { questionCard: (key: string) => cards.source(key) } }),
@@ -380,7 +380,7 @@ describe('apply', () => {
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()
intent: { kind: 'approval', approve: 'Allow', reject: 'Deny' } }]) })?.questions[0]?.intent?.kind).toBe('approval')
expect(pending).toMatchObject({ kind: 'question', sessionId: SESSION_ID, questions: QUESTIONS, callId: undefined })
expect(pending.snapshot()).toEqual({
state: 'open', waitState: 'counting', countdown: undefined,