diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.i18n.yaml new file mode 100644 index 0000000000..2f06390bdf --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.md +2026-07-30-multi-select-custom-answer-composition.md: 581beec89a0f0018ec2df687f5dfe1b1b5b86d22 +2026-07-30-multi-select-custom-answer-composition.zh.md: 5c9cb59822aca3fbf49fbbdf522c76f963df3480 diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.md b/.agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.md new file mode 100644 index 0000000000..581beec89a --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.md @@ -0,0 +1,25 @@ +# Agent Note: Multi-select custom answer composition + +Status: implemented + +English | [中文](2026-07-30-multi-select-custom-answer-composition.zh.md) + +## Problem + +The user-interaction result vocabulary carries selected option labels and optional custom text in separate fields, but its original semantics made them mutually exclusive for every question. On a multi-select question, opening or typing the custom answer discarded labels the user had already selected. The TUI returned only the custom text, and the Web host rejected a client response that preserved both fields. + +## Decision + +For a question with `multiSelect: true`, one answer item may contain both a non-empty `selected` array and non-empty `custom` text. Web drafts preserve both values regardless of whether the user selects an option or types custom text first; the TUI retains pending custom text across option/custom mode switches and projects it with checked labels from either submit mode; and the Web host accepts the combined response after applying its existing id, label, uniqueness, batch, and non-empty-text validation. + +Single-select and optionless questions keep exclusive semantics: custom text overrides any selected option. The result shape remains `{ id, selected, custom? }`, so no wire or tool-output schema changes. + +## Alternatives considered + +**Encode custom text as another `selected` label.** Rejected because it would erase the distinction between caller-provided option labels and human-authored text, weakening validation and forcing consumers to infer which value was custom. + +**Allow `selected` and `custom` together for every question.** Rejected because a single-select question represents one answer; permitting a selected option plus custom text would make its cardinality ambiguous. The combined form is limited to questions that explicitly opt into multiple answers. + +## Consequences + +Multi-select UIs can represent the user's complete answer without discarding either source. Providers and consumers retain the existing DTO, while request-aware validators interpret the allowed combination from `multiSelect`. Web component and assembled-browser coverage, TUI coverage, host-response coverage, and tool-projection coverage pin the combined result. Web, TUI, and tool-projection coverage also retain labels-only answers; assembled keyless TUI coverage pins the combined terminal flow, and single-select host coverage pins the remaining exclusivity rule. diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.zh.md b/.agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.zh.md new file mode 100644 index 0000000000..5c9cb59822 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.zh.md @@ -0,0 +1,25 @@ +# Agent Note: 多选题自定义答案组合 + +Status: implemented + +[English](2026-07-30-multi-select-custom-answer-composition.md) | 中文 + +## 问题 + +用户交互结果的词汇分别通过不同字段携带选中的选项标签和可选的自定义文本,但最初的语义要求每个问题的这两个字段互斥。对于多选题,打开自定义答案或输入文本会丢弃用户已选中的标签。TUI 只返回自定义文本,而 Web 宿主会拒绝同时保留两个字段的客户端响应。 + +## 决策 + +对于 `multiSelect: true` 的问题,一个回答项可以同时包含非空 `selected` 数组与非空 `custom` 文本。无论用户先选择选项还是先输入自定义文本,Web 草稿都会保留两个值;TUI 在选项与自定义模式之间切换时会保留待提交的自定义文本,并在任一模式提交时将其与已勾选的标签一同投影;Web 宿主则在应用现有的 id、标签、唯一性、批次和非空文本校验后接受组合响应。 + +单选题和无选项问题仍保持互斥语义:自定义文本会覆盖任何已选中的选项。结果形状仍为 `{ id, selected, custom? }`,因此协议或工具输出 schema 均无需变更。 + +## 考虑过的替代方案 + +**把自定义文本编码为另一个 `selected` 标签。** 不予采纳,因为这样会抹去调用方提供的选项标签与用户填写文本之间的区别,削弱校验,并迫使消费方推断哪个值属于自定义内容。 + +**允许所有问题同时使用 `selected` 与 `custom`。** 不予采纳,因为单选题只表示一个回答;允许选中选项与自定义文本并存会使其基数含义模糊。组合形式仅适用于显式选择多项回答的问题。 + +## 后果 + +多选 UI 可以完整表达用户的回答,不会丢弃任一来源。提供方和消费方继续使用现有 DTO,而请求感知的校验器会根据 `multiSelect` 判断是否允许组合。Web 组件与组装浏览器的覆盖率、TUI 覆盖率、宿主响应覆盖率和工具投影覆盖率共同固定组合结果。Web、TUI 与工具投影覆盖率还固定了仅含标签的回答形态;组装后的无密钥 TUI 覆盖率固定终端中的组合回答流程,单选题的宿主覆盖率则固定其余的互斥规则。 diff --git a/apps/cli/tests/fixtures/tui-scripted-llm.ts b/apps/cli/tests/fixtures/tui-scripted-llm.ts index 79c4f0504a..c9bd2e1fe6 100644 --- a/apps/cli/tests/fixtures/tui-scripted-llm.ts +++ b/apps/cli/tests/fixtures/tui-scripted-llm.ts @@ -117,8 +117,19 @@ class ScriptedTuiAdapter extends LlmAdapter { const blocks = lastMessage?.content ?? [] if (blocks.some(block => block.type === 'tool-result')) { - const answered = blocks.some(block => block.type === 'tool-result' && block.toolCallId === BASH_FAILURE_CALL_ID) - for (const chunk of textChunks(answered ? BASH_FAILURE_TEXT : FINAL_TEXT)) yield chunk + const answeredBash = blocks.some(block => + block.type === 'tool-result' && block.toolCallId === BASH_FAILURE_CALL_ID) + if (answeredBash) { + for (const chunk of textChunks(BASH_FAILURE_TEXT)) yield chunk + return + } + const toolResultText = blocks.flatMap(block => block.type === 'tool-result' + ? block.content.flatMap(content => content.type === 'text' ? [content.text] : []) + : []).join('\n') + if (toolResultText !== '{"answers":[{"id":"mode","selected":["Safe"],"custom":"Release notes"}]}') { + throw new Error(`the scripted TUI request received an unexpected question answer: ${toolResultText}`) + } + for (const chunk of textChunks(FINAL_TEXT)) yield chunk return } if (lastText.includes(BASH_FAILURE_PROBE)) { @@ -140,6 +151,7 @@ class ScriptedTuiAdapter extends LlmAdapter { id: 'mode', header: 'Execution mode', question: 'How should the scripted run proceed?', + multi_select: true, options: [ { label: 'Safe', description: 'Use the guarded path.' }, { label: 'Fast', description: 'Use the shorter path.' }, diff --git a/apps/cli/tests/tui-keyless-smoke.e2e.ts b/apps/cli/tests/tui-keyless-smoke.e2e.ts index 70c9c0fa65..17b33f37ce 100644 --- a/apps/cli/tests/tui-keyless-smoke.e2e.ts +++ b/apps/cli/tests/tui-keyless-smoke.e2e.ts @@ -259,6 +259,7 @@ const SELECT_PRO_MODEL = [ { waitFor: 'scripted TUI ready.', send: '/model\r' }, { waitFor: 'Select model', send: '\x1b[B\x1b[Z\r' }, ] as const +const ANSWER_MULTI_WITH_CUSTOM = ' \tRelease notes\r' describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { it.each([ @@ -450,7 +451,10 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { // The question text first appears in the streamed tool-call card. Wait // for the dialog's input legend so Enter cannot arrive before it owns // terminal input when pre-dispatch policy yields. - { waitFor: 'Tab custom answer • ↑/↓ navigate • Enter submit • Esc interrupt', send: '\r' }, + { + waitFor: 'Tab custom answer • ↑/↓ navigate • Space toggle • Enter submit • Esc interrupt', + send: ANSWER_MULTI_WITH_CUSTOM, + }, { waitFor: 'Decision received. Scripted TUI run complete.', send: '' }, // Session title: the first user message drives the first-message-llm // provider's tool-less title call; the scripted adapter answers it, the @@ -476,6 +480,7 @@ describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { expect(output).not.toContain('\u001B[999CMODEL_CURSOR') expect(output).not.toContain('\u009B31mMODEL_C1') expect(output).toContain('Safe') + expect(output).toContain('Release notes') expect(output).toContain('\u001B]0;scripted session title — DeepSeek Harness\u0007') expect(output).toContain('Session status') expect(output).toContain('Title') @@ -861,7 +866,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { actions: [ ...SELECT_PRO_MODEL, { waitFor: 'Model selected: tui-scripted/tui-scripted-model-pro.', send: 'exercise the TUI\r' }, - { waitFor: 'How should the scripted run proceed?', send: '\r' }, + { waitFor: 'How should the scripted run proceed?', send: ANSWER_MULTI_WITH_CUSTOM }, { waitFor: 'Decision received. Scripted TUI run complete.', send: '/exit\r' }, ], inspect: async (cwd) => { context = await readLoggedRequestContext(cwd) }, diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index 37f6964c73..ebbaf759c4 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -23,15 +23,16 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/question-composer', import.meta.url)) const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md') -// Second golden: the answered transcript — the question resolved into its -// tool round trip and the final reply, the state the waiting golden cannot see. +const COMPOSED_EXPECTED = join(SNAPSHOT_DIR, 'composed.expected.md') +// Final golden: the answered transcript — the question resolved into its tool +// round trip and the final reply, the state the composer goldens cannot see. const ANSWERED_EXPECTED = join(SNAPSHOT_DIR, 'answered.expected.md') const MODE = webSnapshotMode() // The options carry long descriptions on purpose: the squeeze assertion below // needs option copy that WRAPS, which is the only shape that reproduces a // collapsed row painting its copy outside its own box. -const PROMPT = 'Use the ask_user_question tool to ask me exactly one question with id "color", question "Which color do you prefer?", header "Pick one", and two options: label "Blue" with description "A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.", and label "Green" with description "A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions." After I answer, reply with the single word DONE and stop.' +const PROMPT = 'Use the ask_user_question tool to ask me exactly one multi-select question with id "color", question "Which color do you prefer?", header "Pick one", and two options: label "Blue" with description "A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.", and label "Green" with description "A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions." Set multi_select to true. After I answer, reply with the single word DONE and stop.' describe('web e2e: resident question composer round trip', () => { let scaffold: WebScaffold @@ -124,9 +125,17 @@ describe('web e2e: resident question composer round trip', () => { await page.setViewportSize(original) } - await composer.getByRole('radio', { name: 'Blue' }).click() - // Submit: Enter on the focused option (the composer's documented submit). - await composer.getByRole('radio', { name: 'Blue' }).press('Enter') + const blue = composer.getByRole('checkbox', { name: 'Blue' }) + await blue.click() + const custom = composer.getByRole('textbox') + await custom.fill('Include accessibility notes') + expect(await blue.getAttribute('aria-checked')).toBe('true') + expect(await custom.inputValue()).toBe('Include accessibility notes') + if (MODE !== 'record') { + const snapshot = await captureStableAria(page, '[data-question-key]', scaffold.workspaceCwd) + await compareOrRefreshGolden(COMPOSED_EXPECTED, snapshot, MODE) + } + await custom.press('Enter') const sessionId = await settled if (MODE === 'record') { @@ -135,7 +144,14 @@ describe('web e2e: resident question composer round trip', () => { } // World state: the tool result carries the chosen answer, and DONE lands. const results = sessionEvents.filter(e => e.type === 'tool/result') - expect(JSON.stringify(results.at(-1))).toContain('Blue') + const answerText = results.flatMap(event => event.data.message.content.flatMap(block => + block.type === 'tool-result' + ? block.content.filter(item => item.type === 'text').map(item => item.text) + : [], + )).at(-1) + expect(JSON.parse(answerText ?? '')).toEqual({ + answers: [{ id: 'color', selected: ['Blue'], custom: 'Include accessibility notes' }], + }) await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1) // Composer gone; regular input restored. expect(await page.locator('[data-question-key]').count()).toBe(0) @@ -149,6 +165,11 @@ describe('web e2e: resident question composer round trip', () => { }, 200_000) it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { - await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'ui.expected.md', 'answered.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, [ + 'session.jsonl', + 'ui.expected.md', + 'composed.expected.md', + 'answered.expected.md', + ]) }) }) diff --git a/apps/web/tests/snapshots/question-composer/answered.expected.md b/apps/web/tests/snapshots/question-composer/answered.expected.md index b5ed63a63e..7fdddfa8a8 100644 --- a/apps/web/tests/snapshots/question-composer/answered.expected.md +++ b/apps/web/tests/snapshots/question-composer/answered.expected.md @@ -4,7 +4,7 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" -- text: "Use the ask_user_question tool to ask me exactly one question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" After I answer, reply with the single word DONE and stop. {{clock}}" +- text: "Use the ask_user_question tool to ask me exactly one multi-select question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" Set multi_select to true. After I answer, reply with the single word DONE and stop. {{clock}}" - button "Copy": - img - button "Branch into a new conversation" [disabled]: diff --git a/apps/web/tests/snapshots/question-composer/composed.expected.md b/apps/web/tests/snapshots/question-composer/composed.expected.md new file mode 100644 index 0000000000..c18e6225c6 --- /dev/null +++ b/apps/web/tests/snapshots/question-composer/composed.expected.md @@ -0,0 +1,17 @@ +- region "Which color do you prefer?": + - text: Pick one + - heading "Which color do you prefer?" [level=2] + - button "Dismiss all questions": + - img + - group: + - checkbox "Blue" [checked]: Blue A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards. + - checkbox "Green": Green A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions. + - textbox "Type your answer": Include accessibility notes + - button "Previous question" [disabled]: + - img + - text: 1 / 1 + - button "Next question" [disabled]: + - img + - status + - button "Skip this question" + - button "Submit" diff --git a/apps/web/tests/snapshots/question-composer/session.jsonl b/apps/web/tests/snapshots/question-composer/session.jsonl index 2ac86783c2..7dac5f236d 100644 --- a/apps/web/tests/snapshots/question-composer/session.jsonl +++ b/apps/web/tests/snapshots/question-composer/session.jsonl @@ -1,20 +1,20 @@ {"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785150167878,"cwd":"{{cwd}}/workspace"} {"type":"turn/start","seq":0,"time":1785150167924,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}} -{"type":"user/message","seq":1,"time":1785150167925,"data":{"content":[{"type":"text","text":"Use the ask_user_question tool to ask me exactly one question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" After I answer, reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"} +{"type":"user/message","seq":1,"time":1785150167925,"data":{"content":[{"type":"text","text":"Use the ask_user_question tool to ask me exactly one multi-select question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" Set multi_select to true. After I answer, reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1785150167927,"data":{"title":"Use the ask_user_question tool to","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1785150167928,"data":{"turn":1,"step":1}} {"type":"request/header","seq":4,"time":1785150167929,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"assistant/chunk","seq":5,"time":1785150168452,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} {"type":"reasoning-chunks","seq0":6,"time0":1785150168452,"data":{"turn":1,"step":1,"index":0,"dt":[87,26,1,0,0,0,38,0,0,0,0,1,12,27,0,27,0,0,1,25,0],"texts":["The"," user"," wants"," me"," to"," use"," the"," ask","_user","_","question"," tool"," with"," specific"," parameters","."," Let"," me"," do"," exactly"," that","."]}} {"type":"assistant/chunk","seq":28,"time":1785150168775,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","seq0":29,"time0":1785150168776,"data":{"turn":1,"step":1,"index":1,"dt":[25,1,0,0,0,25,0,0,0,26,1,0,0,0,0,25,1,0,0,0,0,25,1,0,0,0,0,25,1,0,0,0,1,25,0,0,0,0,1,25,1,0,0,0,0,25,1,0,0,26,0,0,1,0,24,1,0,0,0,1,26,1,0,0,0,0,25,0,1,0,0,0,25,1,0,0,25,0,0,0,0,1,25,0,0,1,0,0,25,0,0,0,1,0,26,1,24],"id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","args":["","{","\"","questions","\"",": ","[","{\"","id","\":"," \"","color","\","," \"","question","\":"," \"","Which"," color"," do"," you"," prefer","?\","," \"","header","\":"," \"","Pick"," one","\","," \"","options","\":"," [","{\"","label","\":"," \"","Blue","\","," \"","description","\":"," \"","A"," cool"," recessive"," hue"," that"," reads"," as"," calm"," and"," trustworthy"," in"," long"," reading"," sessions"," and"," dense"," dash","boards",".\"","},"," {\"","label","\":"," \"","Green","\","," \"","description","\":"," \"","A"," rest","ful"," mid","-spect","rum"," hue"," with"," the"," highest"," perceived"," brightness",","," easiest"," on"," the"," eye"," over"," long"," sessions",".\"","}]","}]","}"]}} +{"type":"tool-call-chunks","seq0":29,"time0":1785150168776,"data":{"turn":1,"step":1,"index":1,"dt":[25,1,0,0,0,25,0,0,0,26,1,0,0,0,0,25,1,0,0,0,0,25,1,0,0,0,0,25,1,0,0,0,1,25,0,0,0,0,1,25,1,0,0,0,0,25,1,0,0,26,0,0,1,0,24,1,0,0,0,1,26,1,0,0,0,0,25,0,1,0,0,0,25,1,0,0,25,0,0,0,0,1,25,0,0,1,0,0,25,0,0,0,1,0,26,1,24],"id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","args":["","{","\"","questions","\"",": ","[","{\"","id","\":"," \"","color","\","," \"","question","\":"," \"","Which"," color"," do"," you"," prefer","?\","," \"","header","\":"," \"","Pick"," one","\", \"multi_select\": true,"," \"","options","\":"," [","{\"","label","\":"," \"","Blue","\","," \"","description","\":"," \"","A"," cool"," recessive"," hue"," that"," reads"," as"," calm"," and"," trustworthy"," in"," long"," reading"," sessions"," and"," dense"," dash","boards",".\"","},"," {\"","label","\":"," \"","Green","\","," \"","description","\":"," \"","A"," rest","ful"," mid","-spect","rum"," hue"," with"," the"," highest"," perceived"," brightness",","," easiest"," on"," the"," eye"," over"," long"," sessions",".\"","}]","}]","}"]}} {"type":"assistant/chunk","seq":127,"time":1785150169308,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that."}}}} -{"type":"assistant/chunk","seq":128,"time":1785150169308,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"}}}} +{"type":"assistant/chunk","seq":128,"time":1785150169308,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"multi_select\": true, \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"}}}} {"type":"assistant/chunk","seq":129,"time":1785150169308,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":113,"outputTokens":158,"cacheReadTokens":4096,"reasoningTokens":22}}}} {"type":"assistant/chunk","seq":130,"time":1785150169308,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":131,"time":1785150169311,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that."},{"type":"tool-call","id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":113,"outputTokens":158,"cacheReadTokens":4096,"reasoningTokens":22}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130],"surfaceOp":"append"} -{"type":"tool/call","seq":132,"time":1785150169312,"data":{"turn":1,"step":1,"callId":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"}} -{"type":"tool/result","seq":133,"time":1785150169787,"data":{"turn":1,"step":1,"callId":"call_00_Cijldc88LYmVPCXYUsRq1617","content":[{"type":"text","text":"{\"answers\":[{\"id\":\"color\",\"selected\":[\"Blue\"]}]}"}],"isError":false},"sourceEventSeqs":[132],"surfaceOp":"append"} +{"type":"assistant/message","seq":131,"time":1785150169311,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that."},{"type":"tool-call","id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"multi_select\": true, \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":113,"outputTokens":158,"cacheReadTokens":4096,"reasoningTokens":22}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130],"surfaceOp":"append"} +{"type":"tool/call","seq":132,"time":1785150169312,"data":{"turn":1,"step":1,"callId":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"multi_select\": true, \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"}} +{"type":"tool/result","seq":133,"time":1785150169787,"data":{"turn":1,"step":1,"callId":"call_00_Cijldc88LYmVPCXYUsRq1617","content":[{"type":"text","text":"{\"answers\":[{\"id\":\"color\",\"selected\":[\"Blue\"],\"custom\":\"Include accessibility notes\"}]}"}],"isError":false},"sourceEventSeqs":[132],"surfaceOp":"append"} {"type":"step/end","seq":134,"time":1785150169790,"data":{"turn":1,"step":1}} {"type":"step/start","seq":135,"time":1785150169790,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":136,"time":1785150170605,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/apps/web/tests/snapshots/question-composer/ui.expected.md b/apps/web/tests/snapshots/question-composer/ui.expected.md index 894f84d9ba..c2ee767319 100644 --- a/apps/web/tests/snapshots/question-composer/ui.expected.md +++ b/apps/web/tests/snapshots/question-composer/ui.expected.md @@ -3,9 +3,9 @@ - heading "Which color do you prefer?" [level=2] - button "Dismiss all questions": - img - - radiogroup: - - radio "Blue": 1 Blue A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards. - - radio "Green": 2 Green A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions. + - group: + - checkbox "Blue": Blue A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards. + - checkbox "Green": Green A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions. - textbox "Type your answer" - button "Previous question" [disabled]: - img diff --git a/docs/core-data-structures/user-interaction.i18n.yaml b/docs/core-data-structures/user-interaction.i18n.yaml index bc281d9bb7..36352bb747 100644 --- a/docs/core-data-structures/user-interaction.i18n.yaml +++ b/docs/core-data-structures/user-interaction.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/user-interaction.md -user-interaction.md: 2483382eee07d379b13d456149096c8afab70e4b -user-interaction.zh.md: 57f57277975597fff712705cc741ae8e78643642 +user-interaction.md: 16478168c8fcfeafbd3fdbbf30e732de81a3a8dd +user-interaction.zh.md: 7c8baaf189d759ca609c1d51652120f973ae48b6 diff --git a/docs/core-data-structures/user-interaction.md b/docs/core-data-structures/user-interaction.md index 2483382eee..16478168c8 100644 --- a/docs/core-data-structures/user-interaction.md +++ b/docs/core-data-structures/user-interaction.md @@ -86,14 +86,14 @@ interface AskUserQuestionRequest { ## Answer -Providers return one answer item per question id. `selected` contains selected option labels, and `custom` carries a free-form "Other" answer when the user typed one. When `custom` is present, `selected` is empty; custom text is an answer override, not a supplement to selected choices. A UI may also use an item with empty `selected` and no `custom` to preserve a skipped question in an otherwise completed batch. +Providers return one answer item per question id. `selected` contains selected option labels, and `custom` carries a free-form "Other" answer when the user typed one. 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 also use an item with empty `selected` and no `custom` to preserve a skipped question in an otherwise completed batch. ```ts type-equiv /** Answer to one question. */ interface AskUserQuestionAnswerItem { /** The answered question id. */ id: string - /** Selected option labels. Empty for custom or unanswered choices. */ + /** Selected option labels. May accompany custom text for a multi-select question. */ selected: string[] /** Optional free-text "Other" answer. */ custom?: string diff --git a/docs/core-data-structures/user-interaction.zh.md b/docs/core-data-structures/user-interaction.zh.md index 57f5727797..7c8baaf189 100644 --- a/docs/core-data-structures/user-interaction.zh.md +++ b/docs/core-data-structures/user-interaction.zh.md @@ -86,14 +86,14 @@ interface AskUserQuestionRequest { ## 回答 -提供方为每个问题 id 返回一个回答项。`selected` 包含选中的选项标签,`custom` 在用户输入自由文本时携带「其他」回答。当 `custom` 存在时,`selected` 为空;自定义文本是对选中项的覆盖,而非补充。UI 也可以使用 `selected` 为空且不含 `custom` 的回答项,在其余问题均已完成的批次中保留被跳过的问题。 +提供方为每个问题 id 返回一个回答项。`selected` 包含选中的选项标签,`custom` 在用户输入自由文本时携带「其他」回答。对于单选题,`custom` 会覆盖选中的选项,且 `selected` 为空。对于多选题,`custom` 可以补充 `selected` 中的标签。UI 也可以使用 `selected` 为空且不含 `custom` 的回答项,在其余问题均已完成的批次中保留被跳过的问题。 ```ts type-equiv /** Answer to one question. */ interface AskUserQuestionAnswerItem { /** The answered question id. */ id: string - /** Selected option labels. Empty for custom or unanswered choices. */ + /** Selected option labels. May accompany custom text for a multi-select question. */ selected: string[] /** Optional free-text "Other" answer. */ custom?: string diff --git a/packages/client/ui-question/README.i18n.yaml b/packages/client/ui-question/README.i18n.yaml index 914485fbbb..f1ef187514 100644 --- a/packages/client/ui-question/README.i18n.yaml +++ b/packages/client/ui-question/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-question/README.md -README.md: 5ebba2a1da6e6108b82e9deb235b84f987600345 -README.zh.md: 0aa6428a9b6472fc5b525c11b4716ebc50c378c3 +README.md: 72d94396771eec0a90b96008b1fd5e4a736a398c +README.zh.md: 3c2b12b30dd2858c7b8f99193829c3274f3f8228 diff --git a/packages/client/ui-question/README.md b/packages/client/ui-question/README.md index 5ebba2a1da..72d9439677 100644 --- a/packages/client/ui-question/README.md +++ b/packages/client/ui-question/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Web `ask_user_question` feature plugin. Its host half mounts `dsh-tool-ask-user` only when the Web feature is selected; its browser half registers the `question` entry in the conversation-owned `conversation.composer` keyed slot. -The component renders one question at a time with progress navigation, single- and multi-select choices, recommendation badges derived from label suffixes, and custom answers. 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. Single-select choices advance immediately, and Enter submits once every question is answered or skipped; Enter during IME composition confirms the input candidate without advancing. It submits one structured answer batch for the whole request: “Skip this question” retains other drafts and emits the existing blank `{ selected: [] }` shape for that item, while close rejects the whole wait as `ASK_CANCELLED`. +The component renders one question at a time with progress navigation, single- and multi-select choices, recommendation badges derived from label suffixes, and custom answers. 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. Single-select choices advance immediately, and Enter submits once every question is answered or skipped; Enter during IME composition confirms the input candidate without advancing. It submits one structured answer batch for the whole request: “Skip this question” retains other drafts and emits the existing blank `{ selected: [] }` shape for that item, while close rejects the whole wait as `ASK_CANCELLED`. A request whose single question declares a presentation intent renders as that intent's own surface instead. `plan-review` — set by `dsh-plan-mode` on the `exit_plan_mode` review — takes the waiting-approval card shape: a `Plan review` strip, the plan as the scrolling markdown body, the question text as the card's accessible name, and one decision row of `Chat about it` / `Refuse` / `Approve`. Approve and Refuse answer with the asker's own option labels (the intent names which label approves, so the verdict never rides option order) and keep the asker's descriptions as tooltips; `Chat about it` rejects the wait as `ASK_CANCELLED`, returning the composer so the user can say what they want instead. The card claims a request only when it can send every answer that request allows: one question, the intent declared, the plan present as `detail`, the named approve label offered, and a binary single choice (at most one option besides approve, not multi-select). Anything else — no intent, a batch of several questions, a missing plan, an approve label naming no option, a third option, a multi-select decision — stays on the generic flow, which can express it. An intent changes the layout, never which answers are reachable. diff --git a/packages/client/ui-question/README.zh.md b/packages/client/ui-question/README.zh.md index 0aa6428a9b..3c2b12b30d 100644 --- a/packages/client/ui-question/README.zh.md +++ b/packages/client/ui-question/README.zh.md @@ -4,7 +4,7 @@ Web `ask_user_question` 功能插件。只有选择 Web 功能时,其主机侧才会挂载 `dsh-tool-ask-user`;浏览器侧会把 `question` 配置项注册到会话拥有的 `conversation.composer` 键控 slot 中。 -组件每次渲染一个问题,提供进度导航、单选和多选选项、由标签后缀派生的推荐徽标,以及自定义答案。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信内容策略。封顶卡片保持标题、导航与提交动作固定,超长的详情与选项共享内部滚动区。单选选项会立即前进;所有问题均已回答或跳过后,Enter 会提交;IME 输入法组合期间按 Enter 只会确认输入候选,不会前进。组件为整个请求提交一批结构化答案:「跳过此问题」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 形状;关闭则以 `ASK_CANCELLED` 拒绝整个等待。 +组件每次渲染一个问题,提供进度导航、单选和多选选项、由标签后缀派生的推荐徽标,以及自定义答案。用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信内容策略。封顶卡片保持标题、导航与提交动作固定,超长的详情与选项共享内部滚动区。单选选项会立即前进;所有问题均已回答或跳过后,Enter 会提交;IME 输入法组合期间按 Enter 只会确认输入候选,不会前进。组件为整个请求提交一批结构化答案:「跳过此问题」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 形状;关闭则以 `ASK_CANCELLED` 拒绝整个等待。 若某个请求的唯一问题声明了呈现意图,则改为渲染该意图自己的界面。`plan-review` —— 由 `dsh-plan-mode` 在 `exit_plan_mode` 审阅上设置 —— 采用等待审批卡片的形状:一条 `Plan review` 条带、计划作为可滚动的 markdown 主体、问题文本作为卡片的无障碍名称,以及一行 `Chat about it` / `Refuse` / `Approve` 的决定操作。Approve 与 Refuse 用提问方自己的选项标签回答(意图指名哪个标签表示批准,因此裁决绝不依赖选项顺序),并把提问方的描述保留为 tooltip;`Chat about it` 以 `ASK_CANCELLED` 拒绝该等待,让编辑器归位,用户可以直接说出他想说的话。卡片只在能够发出该请求允许的每一个答案时才接管:只有一个问题、声明了意图、计划以 `detail` 存在、提供了被指名的批准标签,且是二元单选(除批准外最多一个选项,且非多选)。其他任何情形 —— 没有意图、一批含多个问题、缺少计划、批准标签未命中任何选项、出现第三个选项、多选决定 —— 都留在能够表达它的通用流程上。意图改变的只是布局,从不改变可达的答案。 diff --git a/packages/client/ui-question/src/client/QuestionComposer.tsx b/packages/client/ui-question/src/client/QuestionComposer.tsx index abe87f567e..596e1ec727 100644 --- a/packages/client/ui-question/src/client/QuestionComposer.tsx +++ b/packages/client/ui-question/src/client/QuestionComposer.tsx @@ -98,12 +98,13 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick { updateDraft((current) => { - const selected = question.multiSelect === true - ? current.selected.includes(label) + if (question.multiSelect === true) { + const selected = current.selected.includes(label) ? current.selected.filter(item => item !== label) : [...current.selected, label] - : [label] - return { selected, custom: '', skipped: false } + return { ...current, selected, skipped: false } + } + return { selected: [label], custom: '', skipped: false } }) if (question.multiSelect !== true && index < questions.length - 1) { setIndex(current => current + 1) @@ -129,7 +130,7 @@ function QuestionFlow({ pending, t }: { pending: PendingQuestion } & Pick): void => { const value = event.target.value updateDraft(current => ({ - ...current, selected: [], custom: value, skipped: false, + ...current, + selected: question.multiSelect === true ? current.selected : [], + custom: value, + skipped: false, })) } diff --git a/packages/client/ui-question/tests/question-composer.spec.tsx b/packages/client/ui-question/tests/question-composer.spec.tsx index e31d756a9f..91475f43e4 100644 --- a/packages/client/ui-question/tests/question-composer.spec.tsx +++ b/packages/client/ui-question/tests/question-composer.spec.tsx @@ -104,13 +104,19 @@ describe('QuestionComposer', () => { fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' })) fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' })) fireEvent.click(screen.getByRole('checkbox', { name: '代码质量' })) - fireEvent.keyDown(screen.getByRole('checkbox', { name: '代码质量' }), { key: 'Enter' }) + const multiCustom = screen.getByPlaceholderText('输入你的答案') + fireEvent.change(multiCustom, { target: { value: '沟通能力' } }) + fireEvent.click(screen.getByRole('checkbox', { name: '产品判断' })) + expect(screen.getByRole('checkbox', { name: '系统设计' }).getAttribute('aria-checked')).toBe('true') + expect(screen.getByRole('checkbox', { name: '代码质量' }).getAttribute('aria-checked')).toBe('true') + expect((multiCustom as HTMLInputElement).value).toBe('沟通能力') + fireEvent.keyDown(multiCustom, { key: 'Enter' }) // The domain face encoded the whole batch into one carrier envelope. expect(respond).toHaveBeenCalledWith(answeredEnvelope('question-1', [ { id: 'profile', selected: ['工程落地型 (Recommended)'] }, { id: 'detail', selected: [], custom: '要能独立排查线上问题' }, - { id: 'signals', selected: ['系统设计', '代码质量'] }, + { id: 'signals', selected: ['系统设计', '代码质量', '产品判断'], custom: '沟通能力' }, ])) expect(screen.getByRole('button', { name: '正在提交…' }).disabled).toBe(true) }) @@ -233,6 +239,11 @@ describe('QuestionComposer', () => { fireEvent.keyDown(custom, { key: 'Enter' }) fireEvent.click(screen.getByRole('checkbox', { name: '系统设计' })) fireEvent.click(screen.getByRole('button', { name: '提交' })) + expect(respond).toHaveBeenNthCalledWith(1, answeredEnvelope('second', [ + { id: 'profile', selected: ['工程落地型 (Recommended)'] }, + { id: 'detail', selected: [], custom: 'x' }, + { id: 'signals', selected: ['系统设计'] }, + ])) expect(await screen.findByText('网络中断')).toBeTruthy() expect(screen.getByRole('button', { name: '提交' }).disabled).toBe(false) diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 4f0e10cb87..e7b813d391 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md -README.md: d4af267ef46f327bdabf31429b291ffd04308203 -README.zh.md: f148cfa0c023b54d62016c5211205d391b634544 +README.md: ed04a3431e4c9114b3115119c1e69d378cec77ae +README.zh.md: e50a7f49cb8753f5b26dd27cb1d48a9cc18e9fd7 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index d4af267ef4..ed04a3431e 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -10,6 +10,8 @@ Wire messages form a four-quadrant discriminated union — who initiates × requ The layering/protocol decisions are recorded in the [GUI layering and RPC protocol RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md); the browser-side consumption architecture in the [web client architecture RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md). +Question responses are validated against their pending request before the first answer claims it. A multi-select item may carry both requested option labels in `selected` and non-empty `custom` text; a single-select item must use one or the other. Duplicate labels, unknown labels, mismatched ids, incomplete batches, and empty custom text are rejected as `bad-response`. + `session.history` reads an attached Session in memory or inspects a cold log through persistence without resuming or publishing an Agent, then pages on append-origin message boundaries. `maxMessages` counts `user/message`, `assistant/message`, and `steering/message` events that entered the surface by appending, so a model-only replacement copy consumes no quota. Each page stays one contiguous raw event range, which keeps a compaction's log-only provenance on the same page as the replacement that cites it. `session.history`'s tail page (`beforeSeq` absent) additionally carries an optional `projections` block — the watermark snapshot of every unit registered on `ctx.sessionProjections` (`@deepseek-ai/dsh-session-projection`), with `asOfSeq` = the last event seq the values reflect (`-1` on an empty log). The gateway also subscribes to the registry's change feed and mints a `session/projection` mux frame per changed unit (`{sessionId, key, value, seq}` — live push state, never logged; clients hold one generic per-session value store under higher-seq-wins). The carrier holds zero domain knowledge (each value passed its unit's own schema inside the registry; the wire schemas keep `values`/`value` wide); loadOlder pages never carry the block, and a composition without the registry serves histories without either surface. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index f148cfa0c0..e50a7f49cb 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -10,6 +10,8 @@ 分层与协议决策记录在 [GUI 分层与 RPC 协议 RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)中;浏览器侧消费架构记录在 [Web 客户端架构 RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md)中。 +首个回答认领待处理请求之前,系统会对照该请求校验问题响应。多选题的回答项可以同时携带 `selected` 中的请求选项标签与非空 `custom` 文本;单选题的回答项必须二选一。标签重复、标签未知、id 不匹配、批次不完整以及自定义文本为空都会以 `bad-response` 拒绝。 + `session.history` 会读取已附加 Session 的内存状态,或通过持久化检查冷日志,而不会恢复或发布 agent(智能体),然后按追加来源的消息边界分页。`maxMessages` 统计以追加方式进入 surface 的 `user/message`、`assistant/message` 和 `steering/message` 事件,因此仅供模型使用的替换副本不占用配额。每一页仍是一段连续的原始事件区间,从而让压缩(compaction)的仅日志溯源信息与引用它的替换留在同一页。 `session.history` 的尾页(不带 `beforeSeq`)额外携带一个可选的 `projections` 块——`ctx.sessionProjections`(`@deepseek-ai/dsh-session-projection`)上每个已注册单元的水位线快照,`asOfSeq` = 这些值共同反映到的最后一个事件 seq(空日志为 `-1`)。网关还订阅注册表的变更流,为每个状态发生变化的单元铸造一个 `session/projection` mux 帧(`{sessionId, key, value, seq}`——实时推送状态,绝不入日志;客户端按 seq 高者胜维护一个按会话的通用值仓)。载体不持有任何领域知识(每个值在注册表内部已过其单元自己的 schema;协议 schema 对 `values`/`value` 保持宽松);loadOlder 页永不携带该块,未装注册表的组合则两个面都不提供。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index e037167c6c..fd19541590 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -412,8 +412,10 @@ function matchesQuestions(payload: QuestionResponsePayload, pending: PendingQues if (new Set(answer.selected).size !== answer.selected.length) return false const custom = answer.custom?.trim() if (custom !== undefined && custom === '') return false - if (custom !== undefined && answer.selected.length > 0) return false - if (question.multiSelect !== true && answer.selected.length > 1) return false + if (question.multiSelect !== true) { + if (custom !== undefined && answer.selected.length > 0) return false + if (answer.selected.length > 1) return false + } const labels = new Set(question.options?.map(option => option.label) ?? []) return answer.selected.every(label => labels.has(label)) }) diff --git a/packages/host/apiproxy/tests/api-proxy-question.spec.ts b/packages/host/apiproxy/tests/api-proxy-question.spec.ts new file mode 100644 index 0000000000..e8eaae813f --- /dev/null +++ b/packages/host/apiproxy/tests/api-proxy-question.spec.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import SessionStore from '@deepseek-ai/dsh-session' +import UserInteractionService from '@deepseek-ai/dsh-user-interaction' +import type { ApiProxy, MuxFrame, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api' +import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' +import { createApiProxy } from '../src/api-proxy.ts' + +async function harness(): Promise<{ ctx: Context; api: ApiProxy }> { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(UserInteractionService) + return { + ctx, + api: createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp', workspaceRoot: '/tmp' }), + } +} + +function agent(id: string): Agent { + return { id } as unknown as Agent +} + +function openMux(api: ApiProxy, abort: AbortController): { + envelopes: RpcRequest[] + waitForQuestion(): Promise>> +} { + const envelopes: RpcRequest[] = [] + let resolveQuestion!: (value: RpcRequest>) => void + const question = new Promise>>((resolve) => { + resolveQuestion = resolve + }) + void (async () => { + for await (const envelope of api.events.mux({ rpcId: RpcId('question-mux'), payload: {} }, abort.signal)) { + envelopes.push(envelope) + if (envelope.payload.type === 'question/requested') { + resolveQuestion(envelope as RpcRequest>) + } + } + })() + return { envelopes, waitForQuestion: () => question } +} + +function answer( + envelope: RpcRequest>, + selected: string[], + custom?: string, +): Parameters[0] { + return { + type: 'client-response', + rpcId: envelope.rpcId, + result: { + ok: true, + value: { + sessionId: envelope.payload.sessionId, + answer: { + answers: [{ + id: envelope.payload.questions[0]?.id, + selected, + ...custom === undefined ? {} : { custom }, + }], + }, + }, + }, + } +} + +describe('question response validation', () => { + it('accepts selected options with custom text for multi-select questions', async () => { + const { ctx, api } = await harness() + const abort = new AbortController() + const mux = openMux(api, abort) + const asked = ctx.userInteraction.ask({ + agent: agent('session-multi'), + questions: [{ + id: 'targets', + question: 'Choose targets and add another', + multiSelect: true, + options: [{ label: 'Code' }, { label: 'Docs' }], + }], + }) + const envelope = await mux.waitForQuestion() + + expect(await api.respond(answer(envelope, ['Code', 'Docs'], 'Release notes'))) + .toEqual({ accepted: true }) + await expect(asked).resolves.toEqual({ + answers: [{ id: 'targets', selected: ['Code', 'Docs'], custom: 'Release notes' }], + }) + expect(mux.envelopes.some(item => item.payload.type === 'question/resolved')).toBe(true) + abort.abort() + }) + + it('keeps selected options and custom text mutually exclusive for single-select questions', async () => { + const { ctx, api } = await harness() + const abort = new AbortController() + const mux = openMux(api, abort) + const asked = ctx.userInteraction.ask({ + agent: agent('session-single'), + questions: [{ + id: 'target', + question: 'Choose one target', + options: [{ label: 'Code' }, { label: 'Docs' }], + }], + }) + const envelope = await mux.waitForQuestion() + + expect(await api.respond(answer(envelope, ['Code'], 'Release notes'))) + .toEqual({ accepted: false, reason: 'bad-response' }) + expect(await api.respond(answer(envelope, [], 'Release notes'))) + .toEqual({ accepted: true }) + await expect(asked).resolves.toEqual({ + answers: [{ id: 'target', selected: [], custom: 'Release notes' }], + }) + abort.abort() + }) +}) diff --git a/packages/ui/tool-ask-user/README.i18n.yaml b/packages/ui/tool-ask-user/README.i18n.yaml index 7b8e667b58..85beed8474 100644 --- a/packages/ui/tool-ask-user/README.i18n.yaml +++ b/packages/ui/tool-ask-user/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/ui/tool-ask-user/README.md -README.md: 8e779f4025c20cd200344efb7cb8cd6bc09ba64d -README.zh.md: acaffec0764404a0e0e842ffc2b4efdee8869c4f +README.md: 64da4d75d01a0df0ae51b1557ed1c796317b906f +README.zh.md: 48a5b5d0d1aadff8a522d0b6100c6d0479b948a5 diff --git a/packages/ui/tool-ask-user/README.md b/packages/ui/tool-ask-user/README.md index 8e779f4025..64da4d75d0 100644 --- a/packages/ui/tool-ask-user/README.md +++ b/packages/ui/tool-ask-user/README.md @@ -15,7 +15,7 @@ Model-facing `ask_user_question` tool over `ctx.userInteraction`. It lets the mo - `options` — optional choices with `label` and `description`. If recommending a choice, put it first and append `(Recommended)` to that label. - `multi_select` — whether that question may return more than one selected option. -The tool calls `ctx.userInteraction.ask()` and returns canonical `{ answers: [{ id, selected, custom? }] }`. `selected` contains option labels; `custom` is present only for a free-form answer and overrides selected choices. The Native renderer preserves the compact JSON text shape `{ "answers": [{ "id": "...", "selected": ["..."], "custom": "..." }] }`. +The tool calls `ctx.userInteraction.ask()` and returns canonical `{ answers: [{ id, selected, custom? }] }`. `selected` contains option labels; `custom` carries a free-form answer, supplementing `selected` for a multi-select question and overriding it for a single-select question. The Native renderer preserves the compact JSON text shape `{ "answers": [{ "id": "...", "selected": ["..."], "custom": "..." }] }`. ## Role diff --git a/packages/ui/tool-ask-user/README.zh.md b/packages/ui/tool-ask-user/README.zh.md index acaffec076..48a5b5d0d1 100644 --- a/packages/ui/tool-ask-user/README.zh.md +++ b/packages/ui/tool-ask-user/README.zh.md @@ -15,7 +15,7 @@ - `options`:可选选项,包含 `label` 和 `description`。如需推荐某个选项,请将其置于首位,并在该标签末尾追加 `(Recommended)`。 - `multi_select`:该问题是否可以返回多个选中的选项。 -工具调用 `ctx.userInteraction.ask()`,并返回规范的 `{ answers: [{ id, selected, custom? }] }`。`selected` 包含选项标签;仅当用户自由填写回答时才会出现 `custom`,并覆盖选中的选项。Native 渲染器会保留紧凑的 JSON 文本形式 `{ "answers": [{ "id": "...", "selected": ["..."], "custom": "..." }] }`。 +工具调用 `ctx.userInteraction.ask()`,并返回规范的 `{ answers: [{ id, selected, custom? }] }`。`selected` 包含选项标签;`custom` 携带自由填写的回答,对于多选题会补充 `selected`,对于单选题则会覆盖它。Native renderer 会保留紧凑的 JSON 文本形式 `{ "answers": [{ "id": "...", "selected": ["..."], "custom": "..." }] }`。 ## 职责 diff --git a/packages/ui/tool-ask-user/tests/tool-ask-user.spec.ts b/packages/ui/tool-ask-user/tests/tool-ask-user.spec.ts index 395986aed1..0c55e33ed7 100644 --- a/packages/ui/tool-ask-user/tests/tool-ask-user.spec.ts +++ b/packages/ui/tool-ask-user/tests/tool-ask-user.spec.ts @@ -140,7 +140,8 @@ describe('ask_user_question tool', () => { async ask() { return { answers: [ - { id: 'targets', selected: ['tests', 'docs'] }, + { id: 'targets', selected: ['tests', 'docs'], custom: 'release notes' }, + { id: 'labels-only', selected: ['tests'] }, { id: 'notes', selected: [], custom: 'ship today' }, ], } @@ -159,6 +160,12 @@ describe('ask_user_question tool', () => { options: [{ label: 'tests' }, { label: 'docs' }], multi_select: true, }, + { + id: 'labels-only', + question: 'Which labels should I keep?', + options: [{ label: 'tests' }, { label: 'docs' }], + multi_select: true, + }, { id: 'notes', question: 'Any note?' }, ], }, @@ -168,13 +175,14 @@ describe('ask_user_question tool', () => { if (result.isError) throw new Error('expected ask_user_question success') expect(result.value).toEqual({ answers: [ - { id: 'targets', selected: ['tests', 'docs'] }, + { id: 'targets', selected: ['tests', 'docs'], custom: 'release notes' }, + { id: 'labels-only', selected: ['tests'] }, { id: 'notes', selected: [], custom: 'ship today' }, ], }) expect(result.content).toEqual([{ type: 'text', - text: '{"answers":[{"id":"targets","selected":["tests","docs"]},{"id":"notes","selected":[],"custom":"ship today"}]}', + text: '{"answers":[{"id":"targets","selected":["tests","docs"],"custom":"release notes"},{"id":"labels-only","selected":["tests"]},{"id":"notes","selected":[],"custom":"ship today"}]}', }]) }) diff --git a/packages/ui/tui/README.i18n.yaml b/packages/ui/tui/README.i18n.yaml index a2487c1b20..de0cf52845 100644 --- a/packages/ui/tui/README.i18n.yaml +++ b/packages/ui/tui/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/ui/tui/README.md -README.md: e441cf949d15ea76f93efc89f970aa2096e83153 -README.zh.md: cc98ea4405ff57b56059e283ecf1b9c8d4d95b91 +README.md: 5199e75d732f384497b28518920cc2c775c600c2 +README.zh.md: 02d4431fdab575b5596152c985b72594a948b081 diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index e441cf949d..5199e75d73 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -156,7 +156,7 @@ Append-only; newly visible content follows the reusable request prefix and does #### What the model sees -When a consumer calls `ctx.userInteraction.ask()`, this provider presents each question in order and returns selected option labels or `custom` text. Abort, cancellation, or UI disposal becomes `Error: ask_user_question was interrupted before the user answered` through `dsh-tool-ask-user`. +When a consumer calls `ctx.userInteraction.ask()`, this provider presents each question in order and returns selected option labels, `custom` text, or both for a multi-select question. Pending custom text survives switching back to options and joins checked labels on a later options-mode submit. Abort, cancellation, or UI disposal becomes `Error: ask_user_question was interrupted before the user answered` through `dsh-tool-ask-user`. #### Token effect diff --git a/packages/ui/tui/README.zh.md b/packages/ui/tui/README.zh.md index cc98ea4405..02d4431fda 100644 --- a/packages/ui/tui/README.zh.md +++ b/packages/ui/tui/README.zh.md @@ -156,7 +156,7 @@ Paths prefixed with @ are files explicitly referenced by the user. Use the read #### 模型看到的内容 -消费方调用 `ctx.userInteraction.ask()` 时,此提供方会按顺序显示各个问题,并返回选中选项标签或 `custom` 文本。中止、取消或 UI dispose 会变为 `Error: ask_user_question was interrupted before the user answered`;该转换由 `dsh-tool-ask-user` 完成。 +消费方调用 `ctx.userInteraction.ask()` 时,此提供方会按顺序显示各个问题,并返回选中选项标签、`custom` 文本,或为多选题同时返回两者。切回选项后,待提交的自定义文本仍会保留,并在之后从选项模式提交时与已勾选的标签一同返回。中止、取消或 UI dispose 会变为 `Error: ask_user_question was interrupted before the user answered`;该转换由 `dsh-tool-ask-user` 完成。 #### Token 影响 diff --git a/packages/ui/tui/src/components/dialogs.ts b/packages/ui/tui/src/components/dialogs.ts index a92fdae87a..6867a30240 100644 --- a/packages/ui/tui/src/components/dialogs.ts +++ b/packages/ui/tui/src/components/dialogs.ts @@ -894,12 +894,15 @@ export class QuestionDialog implements Component, Focusable { if (this.selected.has(this.selectedIndex)) this.selected.delete(this.selectedIndex) else this.selected.add(this.selectedIndex) } else if (matchesKey(data, Key.enter)) { - const indices = this.question.multiSelect ? [...this.selected].sort((a, b) => a - b) : [this.selectedIndex] - if (indices.length === 0) { + const selected = this.question.multiSelect + ? this.selectedOptionLabels() + : [options[this.selectedIndex]?.label].filter((label): label is string => label !== undefined) + const custom = this.question.multiSelect ? this.input.getValue().trim() : '' + if (selected.length === 0 && custom === '') { this.error = 'Select at least one option, or press Tab for a custom answer.' return } - this.done({ selected: indices.map(index => options[index]?.label).filter((label): label is string => label !== undefined) }) + this.done({ selected, ...(custom === '' ? {} : { custom }) }) } else if (matchesKey(data, Key.tab) || data.toLowerCase() === 'c') { this.mode = 'custom' this.selectedBlockPage = { offset: 0, size: 1, maxOffset: 0 } @@ -915,7 +918,17 @@ export class QuestionDialog implements Component, Focusable { this.error = 'Enter an answer before submitting.' return } - this.done({ selected: [], custom }) + this.done({ + selected: this.question.multiSelect ? this.selectedOptionLabels() : [], + custom, + }) + } + + private selectedOptionLabels(): string[] { + return [...this.selected] + .sort((a, b) => a - b) + .map(index => this.options[index]?.label) + .filter((label): label is string => label !== undefined) } /** Page backward through an oversized option, then through question detail. */ @@ -981,9 +994,12 @@ export class QuestionDialog implements Component, Focusable { } headerLines.push('') - const customHint = this.palette.dim(this.options.length > 0 - ? 'Enter submit • Esc options' - : 'Enter submit • Esc cancel') + const customControls = [ + ...(this.options.length > 0 && this.question.multiSelect ? [`${this.selected.size} selected`] : []), + 'Enter submit', + this.options.length > 0 ? 'Esc options' : 'Esc cancel', + ] + const customHint = this.palette.dim(customControls.join(' • ')) const footerLines: string[] = [] if (this.mode === 'custom') { for (const line of this.input.render(innerWidth)) footerLines.push(line) diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index c5c12ffc5d..4ecf78d54a 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -5584,8 +5584,29 @@ describe('TUI user-interaction dialogs', () => { result.terminal.send(' ') result.terminal.send('\x1b[B') result.terminal.send(' ') + result.terminal.send('\t') + await tick() + expect(result.terminal.output).toContain('2 selected • Enter submit • Esc options') + result.terminal.send('Tests') result.terminal.send('\r') - await expect(multi).resolves.toEqual({ answers: [{ id: 'targets', selected: ['Code', 'Docs'] }] }) + await expect(multi).resolves.toEqual({ + answers: [{ id: 'targets', selected: ['Code', 'Docs'], custom: 'Tests' }], + }) + + const labelsOnly = result.ctx.userInteraction.ask({ + questions: [{ + id: 'labels-only', + question: 'Pick one target', + multiSelect: true, + options: [{ label: 'Code' }, { label: 'Docs' }], + }], + }) + await tick() + result.terminal.send(' ') + result.terminal.send('\r') + await expect(labelsOnly).resolves.toEqual({ + answers: [{ id: 'labels-only', selected: ['Code'] }], + }) const custom = result.ctx.userInteraction.ask({ questions: [{ id: 'other', question: 'Choose or type', options: [{ label: 'Default' }] }], @@ -5628,7 +5649,6 @@ describe('TUI user-interaction dialogs', () => { options: [{ label: 'One', description: 'first' }, { label: 'Two' }], }], }) - const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) await tick() result.terminal.send('\x1b[A') result.terminal.send('\x1b[B') @@ -5644,11 +5664,17 @@ describe('TUI user-interaction dialogs', () => { }) result.terminal.send('c') await tick() + result.terminal.send('keep this') + await tick() + expect(result.terminal.output).toContain('0 selected • Enter submit • Esc options') result.terminal.send('\x1b') await tick() expect(result.terminal.output).toContain('Space toggle') - result.terminal.send('\x03') - await rejected + result.terminal.send(' ') + result.terminal.send('\r') + await expect(answer).resolves.toEqual({ + answers: [{ id: 'options', selected: ['One'], custom: 'keep this' }], + }) await dispose(result) }) diff --git a/packages/ui/user-interaction/README.i18n.yaml b/packages/ui/user-interaction/README.i18n.yaml index a74e6ec71b..1909864c26 100644 --- a/packages/ui/user-interaction/README.i18n.yaml +++ b/packages/ui/user-interaction/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/ui/user-interaction/README.md -README.md: d62e75d110b8be339c5f9449b0834320f695ac99 -README.zh.md: 55258e85e56df2375ed8f195fa0b3b731a9cb816 +README.md: c7fec590d6e44a13b94cc682f5e069b2d3c5e416 +README.zh.md: 340af3541a09a528aa0fcc580bda070ea703f39e diff --git a/packages/ui/user-interaction/README.md b/packages/ui/user-interaction/README.md index d62e75d110..c7fec590d6 100644 --- a/packages/ui/user-interaction/README.md +++ b/packages/ui/user-interaction/README.md @@ -20,7 +20,7 @@ Abstract user-interaction seam. It owns `ctx.userInteraction`, the service a mod - `UserInteractionProvider` — UI implementation with `ask(request)`. - `UserInteractionError` — `HarnessError` subclass with codes such as `EMPTY_QUESTIONS`, `BAD_INTENT`, `NO_PROVIDER`, `DUPLICATE_PROVIDER`, and `ASK_ABORTED`. -When an answer includes `custom`, `selected` is empty; custom text is an override rather than a supplement to selected choices. A UI may preserve a skipped item as `{ id, selected: [] }`, keeping the existing answer shape while retaining other answers in the batch. +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. ### Presentation intent diff --git a/packages/ui/user-interaction/README.zh.md b/packages/ui/user-interaction/README.zh.md index 55258e85e5..340af3541a 100644 --- a/packages/ui/user-interaction/README.zh.md +++ b/packages/ui/user-interaction/README.zh.md @@ -20,7 +20,7 @@ - `UserInteractionProvider`:包含 `ask(request)` 的 UI 实现。 - `UserInteractionError`:`HarnessError` 的子类,包含 `EMPTY_QUESTIONS`、`BAD_INTENT`、`NO_PROVIDER`、`DUPLICATE_PROVIDER` 和 `ASK_ABORTED` 等代码。 -当回答包含 `custom` 时,`selected` 为空;自定义文本是所选选项的替代,而不是补充。UI 可以把跳过的条目保留为 `{ id, selected: [] }`,既维持现有回答形态,也保留该批次中的其他回答。 +对于单选题,`custom` 会覆盖选中的选项,且 `selected` 为空。对于多选题,`custom` 可以补充 `selected` 中的标签。UI 可以把跳过的条目保留为 `{ id, selected: [] }`,既维持现有回答形态,也保留该批次中的其他回答。 ### 呈现意图 diff --git a/packages/ui/user-interaction/src/types.ts b/packages/ui/user-interaction/src/types.ts index 63949e81a1..51edfc6bf7 100644 --- a/packages/ui/user-interaction/src/types.ts +++ b/packages/ui/user-interaction/src/types.ts @@ -53,7 +53,7 @@ export interface AskUserQuestionItem { export interface AskUserQuestionAnswerItem { /** The answered question id. */ id: string - /** Selected option labels. Empty for custom or unanswered choices. */ + /** Selected option labels. May accompany custom text for a multi-select question. */ selected: string[] /** Optional free-text "Other" answer. */ custom?: string