fix(user-interaction): address review feedback

This commit is contained in:
Yichen Jiang
2026-07-30 17:59:37 +08:00
parent 31a498b1db
commit 3ba4d40e6a
16 changed files with 119 additions and 37 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-30-multi-select-custom-answer-composition.md
2026-07-30-multi-select-custom-answer-composition.md: 7194f4a79f1dd49eba4a9b626d75203fced06544
2026-07-30-multi-select-custom-answer-composition.zh.md: fac09c8db0ebf2dd4a84ade7aa7868128656025d
2026-07-30-multi-select-custom-answer-composition.md: 581beec89a0f0018ec2df687f5dfe1b1b5b86d22
2026-07-30-multi-select-custom-answer-composition.zh.md: 5c9cb59822aca3fbf49fbbdf522c76f963df3480

View File

@@ -10,7 +10,7 @@ The user-interaction result vocabulary carries selected option labels and option
## 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 projects its checked option set when custom text is submitted; and the Web host accepts the combined response after applying its existing id, label, uniqueness, batch, and non-empty-text validation.
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.
@@ -22,4 +22,4 @@ Single-select and optionless questions keep exclusive semantics: custom text ove
## 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, TUI, host-response, tool-projection, and assembled keyless TUI coverage pin the combined result; single-select host coverage pins the remaining exclusivity rule.
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.

View File

@@ -10,7 +10,7 @@ Status: implemented
## 决策
对于 `multiSelect: true` 的问题,一个回答项可以同时包含非空 `selected` 数组与非空 `custom` 文本。无论用户先选择选项还是先输入自定义文本Web 草稿都会保留两个值;提交自定义文本TUI 会投影其已勾选的选项集合Web 宿主则在应用现有的 id、标签、唯一性、批次和非空文本校验后接受组合响应。
对于 `multiSelect: true` 的问题,一个回答项可以同时包含非空 `selected` 数组与非空 `custom` 文本。无论用户先选择选项还是先输入自定义文本Web 草稿都会保留两个值;TUI 在选项与自定义模式之间切换时会保留待提交自定义文本,并在任一模式提交时将其与已勾选的标签一同投影Web 宿主则在应用现有的 id、标签、唯一性、批次和非空文本校验后接受组合响应。
单选题和无选项问题仍保持互斥语义:自定义文本会覆盖任何已选中的选项。结果形状仍为 `{ id, selected, custom? }`,因此协议或工具输出 schema 均无需变更。
@@ -22,4 +22,4 @@ Status: implemented
## 后果
多选 UI 可以完整表达用户的回答,不会丢弃任一来源。提供方和消费方继续使用现有 DTO而请求感知的校验器会根据 `multiSelect` 判断是否允许组合。Web、TUI、宿主响应、工具投影和组装后的无密钥 TUI 覆盖固定组合结果;单选题的宿主覆盖则固定其余的互斥规则。
多选 UI 可以完整表达用户的回答,不会丢弃任一来源。提供方和消费方继续使用现有 DTO而请求感知的校验器会根据 `multiSelect` 判断是否允许组合。Web 组件与组装浏览器的覆盖率、TUI 覆盖率、宿主响应覆盖率和工具投影覆盖率共同固定组合结果。Web、TUI 与工具投影覆盖率还固定了仅含标签的回答形态;组装后的无密钥 TUI 覆盖固定终端中的组合回答流程,单选题的宿主覆盖则固定其余的互斥规则。

View File

@@ -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',
])
})
})

View File

@@ -4,13 +4,14 @@
- 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 "复制":
- img
- button "在新对话中分支":
- img
- button "编辑":
- img
- button "▸ 上下文注入"
- button "Think The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that.":
- img
- img

View File

@@ -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"

View File

@@ -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}}"},"role":"user","id":"6deba879-8787-4853-a5f2-0d108a08eb2d"},"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}}"},"role":"user","id":"6deba879-8787-4853-a5f2-0d108a08eb2d"},"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","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,"usage":{"inputTokens":113,"outputTokens":158,"cacheReadTokens":4096,"reasoningTokens":22},"message":{"role":"assistant","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.\"}]}]}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"cdb1676c-e781-41ee-8f28-a3595371d729"}},"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,"message":{"source":{"kind":"tool","callId":"call_00_Cijldc88LYmVPCXYUsRq1617"},"content":[{"type":"tool-result","toolCallId":"call_00_Cijldc88LYmVPCXYUsRq1617","content":[{"type":"text","text":"{\"answers\":[{\"id\":\"color\",\"selected\":[\"Blue\"]}]}"}],"isError":false}],"role":"user","id":"c69ef39f-6f62-439f-b3f8-e8d10fba572f"}},"sourceEventSeqs":[132],"surfaceOp":"append"}
{"type":"assistant/message","seq":131,"time":1785150169311,"data":{"turn":1,"step":1,"usage":{"inputTokens":113,"outputTokens":158,"cacheReadTokens":4096,"reasoningTokens":22},"message":{"role":"assistant","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.\"}]}]}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"cdb1676c-e781-41ee-8f28-a3595371d729"}},"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,"message":{"source":{"kind":"tool","callId":"call_00_Cijldc88LYmVPCXYUsRq1617"},"content":[{"type":"tool-result","toolCallId":"call_00_Cijldc88LYmVPCXYUsRq1617","content":[{"type":"text","text":"{\"answers\":[{\"id\":\"color\",\"selected\":[\"Blue\"],\"custom\":\"Include accessibility notes\"}]}"}],"isError":false}],"role":"user","id":"c69ef39f-6f62-439f-b3f8-e8d10fba572f"}},"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"}}}

View File

@@ -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

View File

@@ -238,6 +238,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<HTMLButtonElement>('button', { name: '提交' }).disabled).toBe(false)

View File

@@ -280,8 +280,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 && question.multiSelect !== true) 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))
})

View File

@@ -141,6 +141,7 @@ describe('ask_user_question tool', () => {
return {
answers: [
{ 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?' },
],
},
@@ -169,12 +176,13 @@ describe('ask_user_question tool', () => {
expect(result.value).toEqual({
answers: [
{ 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"],"custom":"release notes"},{"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"}]}',
}])
})

View File

@@ -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: c8eb81b2d76c1616647baba37692ed8cd42e89dc
README.zh.md: 680bb89f12cbbad871010ed025cfa6d4369bb0a3
README.md: 3b1c67dceadfe18a8d72bedc6a321a3fa86a3c90
README.zh.md: a87858833eeb9220c709748eb5bbee3ff132eb8f

View File

@@ -153,7 +153,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, `custom` text, or both for a multi-select question. 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

View File

@@ -153,7 +153,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 影响

View File

@@ -804,11 +804,12 @@ export class QuestionDialog implements Component, Focusable {
const selected = this.question.multiSelect
? this.selectedOptionLabels()
: [options[this.selectedIndex]?.label].filter((label): label is string => label !== undefined)
if (selected.length === 0) {
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 })
this.done({ selected, ...(custom === '' ? {} : { custom }) })
} else if (matchesKey(data, Key.tab) || data.toLowerCase() === 'c') {
this.mode = 'custom'
this.error = ''
@@ -854,7 +855,12 @@ export class QuestionDialog implements Component, Focusable {
push('')
if (this.mode === 'custom') {
for (const line of this.input.render(innerWidth)) push(line)
push(this.palette.dim(this.options.length > 0 ? 'Enter submit • Esc options' : 'Enter submit • Esc cancel'))
const controls = [
...(this.options.length > 0 && this.question.multiSelect ? [`${this.selected.size} selected`] : []),
'Enter submit',
this.options.length > 0 ? 'Esc options' : 'Esc cancel',
]
push(this.palette.dim(controls.join(' • ')))
} else {
const options = this.options
const start = Math.max(0, Math.min(

View File

@@ -4697,12 +4697,29 @@ describe('TUI user-interaction dialogs', () => {
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'], 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' }] }],
})
@@ -4744,7 +4761,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')
@@ -4760,11 +4776,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)
})