From c5c6282f447dba202c79cf0e3b7524712d2c2748 Mon Sep 17 00:00:00 2001 From: qer Date: Mon, 6 Jul 2026 02:36:31 +0800 Subject: [PATCH] feat(web): render AskUserQuestion result as an option list (#1391) * feat(web): render AskUserQuestion result as an option list Parse the AskUserQuestion tool result and echo the full option list, highlighting the chosen option(s) and dimming the rest, instead of dumping raw JSON. Handles single/multi select, free-text Other answers, and the dismissed state. Answers are zipped back to the input questions by index since the input carries no ids. * fix(web): drop the stray indent in the tool-call card body The expanded body of every tool-call card carried a hard-coded 36px left indent (a magic number meant to align with the header name). Drop it so expanded content aligns with the header's 11px padding instead. The design-system preview page mirrored the same rule and is updated to match. * fix(web): align markdown diff block with the design system Restyle the local ```diff renderer in chat Markdown to match the ~/diff panel: code text keeps the normal ink colour, the +/- sign carries the add/del colour, and rows use a soft background with an inset accent bar instead of dyeing the text green/red. The header and copy button now match the standard code-block chrome (height, hover, focus ring). * fix(web): match the markdown diff block chrome to code blocks Make the local markdown diff renderer's shell identical to a regular code block: swap the text copy button for the same icon button, inherit the header's text-xs sizing, and keep the container / header / code-area padding, background, radius and shadow in lockstep with .code-block-container and .code-block-header. The diff-specific row tinting (soft background, inset accent bar, coloured sign) is kept since that is what makes it a diff. * fix(web): fall back to raw output for non-answer AskUserQuestion results Background launches and error cases return plain-text tool output (task_id/status lines, or a failure reason), not the { answers } JSON the card expects. The card used to render an empty, fully-unselected option list in those cases, hiding the task id or error. Now the card only renders the option list when the output parses as the answer payload; otherwise it shows the raw output. Addresses review feedback (P2). * fix(web): localize AskUserQuestion result labels The card hard-coded user-visible strings (Dismissed, answer/answers, Answered, the (+N more) summary), so Chinese-locale transcripts mixed in English. Move them into the en/zh tools locale files and read them via t(). Addresses review feedback (P2). --- .changeset/web-ask-user-question-result.md | 5 + .changeset/web-markdown-diff-block.md | 5 + .changeset/web-tool-row-body-padding.md | 5 + .../kimi-web/src/components/chat/Markdown.vue | 117 +++++--- apps/kimi-web/src/components/chat/ToolRow.vue | 4 +- .../chat/tool-calls/AskUserTool.vue | 266 ++++++++++++++++++ .../chat/tool-calls/askUserToolParse.ts | 126 +++++++++ .../chat/tool-calls/toolRegistry.ts | 2 + apps/kimi-web/src/i18n/locales/en/tools.ts | 8 + apps/kimi-web/src/i18n/locales/zh/tools.ts | 8 + apps/kimi-web/src/lib/toolMeta.ts | 2 + apps/kimi-web/src/views/DesignSystemView.vue | 2 +- .../kimi-web/test/ask-user-tool-parse.test.ts | 142 ++++++++++ 13 files changed, 650 insertions(+), 42 deletions(-) create mode 100644 .changeset/web-ask-user-question-result.md create mode 100644 .changeset/web-markdown-diff-block.md create mode 100644 .changeset/web-tool-row-body-padding.md create mode 100644 apps/kimi-web/src/components/chat/tool-calls/AskUserTool.vue create mode 100644 apps/kimi-web/src/components/chat/tool-calls/askUserToolParse.ts create mode 100644 apps/kimi-web/test/ask-user-tool-parse.test.ts diff --git a/.changeset/web-ask-user-question-result.md b/.changeset/web-ask-user-question-result.md new file mode 100644 index 000000000..cd5146ccc --- /dev/null +++ b/.changeset/web-ask-user-question-result.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Render AskUserQuestion answers as a readable option list with the chosen option(s) highlighted, instead of raw JSON. diff --git a/.changeset/web-markdown-diff-block.md b/.changeset/web-markdown-diff-block.md new file mode 100644 index 000000000..3679acf5b --- /dev/null +++ b/.changeset/web-markdown-diff-block.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Align the markdown diff code block with the design system: code text keeps the normal ink colour while the sign and a soft row background carry the change, matching the ~/diff panel. diff --git a/.changeset/web-tool-row-body-padding.md b/.changeset/web-tool-row-body-padding.md new file mode 100644 index 000000000..ce4f4d896 --- /dev/null +++ b/.changeset/web-tool-row-body-padding.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Drop the stray left indent in the tool-call card body so expanded content aligns with the header. diff --git a/apps/kimi-web/src/components/chat/Markdown.vue b/apps/kimi-web/src/components/chat/Markdown.vue index cf6b83fa3..65397f4e1 100644 --- a/apps/kimi-web/src/components/chat/Markdown.vue +++ b/apps/kimi-web/src/components/chat/Markdown.vue @@ -20,6 +20,7 @@ import { copyTextToClipboard } from '../../lib/clipboard'; import * as katexWorkerModule from 'markstream-vue/workers/katexRenderer.worker?worker&type=module'; import * as mermaidWorkerModule from 'markstream-vue/workers/mermaidParser.worker?worker&type=module'; import Tooltip from '../ui/Tooltip.vue'; +import Icon from '../ui/Icon.vue'; // px-based CSS build (our app is px, not rem). Imported here so the styles // load wherever Markdown is used; scoped overrides below re-skin it to // Terminal Pro. Importing the same file from multiple components is a no-op @@ -383,14 +384,24 @@ const segments = computed(() => { return out; }); -// Lines of a diff block, classed by +/- for colouring (escaped by Vue's text -// interpolation in the template). -function diffLines(code: string): { cls: string; text: string }[] { +// Lines of a diff block, split into a sign + the code text so the row can be +// skinned like the ~/diff panel (DiffLines.vue): the code text keeps the normal +// ink colour and only the +/- sign carries the add/del colour. The leading +// marker (a single '+', '-', or the context-line space) is stripped from the +// text so the code columns line up. Escaped by Vue's text interpolation. +type DiffRowType = 'add' | 'del' | 'hunk' | 'ctx'; +interface DiffRow { + type: DiffRowType; + sign: string; + text: string; +} +function diffLines(code: string): DiffRow[] { return code.split('\n').map((line) => { - if (/^\+(?!\+\+)/.test(line)) return { cls: 'diff-add', text: line }; - if (/^-(?!--)/.test(line)) return { cls: 'diff-del', text: line }; - if (line.startsWith('@@')) return { cls: 'diff-hunk', text: line }; - return { cls: 'diff-ctx', text: line }; + if (line.startsWith('@@')) return { type: 'hunk', sign: '', text: line }; + if (/^\+(?!\+\+)/.test(line)) return { type: 'add', sign: '+', text: line.slice(1) }; + if (/^-(?!--)/.test(line)) return { type: 'del', sign: '-', text: line.slice(1) }; + if (line.startsWith(' ')) return { type: 'ctx', sign: '', text: line.slice(1) }; + return { type: 'ctx', sign: '', text: line }; }); } @@ -433,16 +444,17 @@ function copyDiff(code: string, idx: number) {
diff -
{{ ln.text }}
+ class="diff-line" + :class="`diff-${ln.type}`" + >{{ ln.sign }}{{ ln.text }} @@ -745,9 +757,12 @@ function copyDiff(code: string, idx: number) { } /* --------------------------------------------------------------------------- - Local ```diff renderer — same look as the code blocks above, with the - original +/- line colouring (green additions, red deletions). markstream - would strip the markers + drop deletions, so we render diffs ourselves. + Local ```diff renderer — same chrome as the code blocks above, with the + diff rows skinned like the ~/diff panel (DiffLines.vue): a soft row + background and an inset accent bar mark the change, the +/- sign carries + the colour, and the code text itself keeps the normal ink colour so it + stays legible. markstream would strip the markers + drop deletions, so we + render diffs ourselves. --------------------------------------------------------------------------- */ .diff-wrap { margin: 0.6em 0; @@ -760,61 +775,85 @@ function copyDiff(code: string, idx: number) { .diff-bar { display: flex; align-items: center; - justify-content: flex-end; gap: 6px; - padding: 3px 8px; + padding: 4px 12px; background: var(--color-surface); border-bottom: 1px solid var(--color-line); + color: var(--color-text-muted); + font: var(--text-xs) var(--font-mono); } .diff-lang { - font: var(--text-xs) var(--font-mono); - color: var(--color-text-muted); margin-right: auto; - letter-spacing: 0.04em; } +/* Copy button — mirrors the §03 IconButton / code-block action: muted glyph, + sunken hover, soft radius, shared focus ring. */ .diff-copy { - background: none; - border: none; - cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; color: var(--color-text-muted); - font: var(--text-sm) var(--font-mono); - padding: 0 2px; - line-height: 1; + background: transparent; + border: none; + border-radius: var(--radius-sm); + cursor: pointer; + padding: 2px 6px; + transition: background var(--duration-base) var(--ease-out), + color var(--duration-base) var(--ease-out); } .diff-copy:hover { - color: var(--color-accent); + background: var(--color-surface-sunken); + color: var(--color-text); +} +.diff-copy:focus-visible { + outline: none; + box-shadow: var(--p-focus-ring); } .diff-pre { margin: 0; - padding: 10px 12px; + padding: 12px 0; overflow-x: auto; background: var(--color-surface-sunken); } .diff-pre code { - font: var(--text-sm) var(--font-mono); + display: block; + width: max-content; + min-width: 100%; + font: var(--text-sm)/1.65 var(--font-mono); color: var(--color-text); } -.diff-pre code span { +.diff-line { display: block; - padding-left: 8px; - border-left: 2px solid transparent; - margin-left: -12px; - padding-right: 12px; + width: 100%; + padding: 0 14px; +} +.diff-sign { + display: inline-block; + width: 14px; + text-align: center; + color: var(--color-text-muted); + user-select: none; +} +.diff-text { + color: var(--color-text); } .diff-add { + background: var(--color-success-soft); + box-shadow: inset 2px 0 0 color-mix(in srgb, var(--color-success) 55%, transparent); +} +.diff-add .diff-sign { color: var(--color-success); - background: color-mix(in srgb, var(--color-success) 10%, transparent); - border-left-color: var(--color-success) !important; } .diff-del { + background: var(--color-danger-soft); + box-shadow: inset 2px 0 0 color-mix(in srgb, var(--color-danger) 55%, transparent); +} +.diff-del .diff-sign { color: var(--color-danger); - background: color-mix(in srgb, var(--color-danger) 10%, transparent); - border-left-color: var(--color-danger) !important; } .diff-hunk { - color: var(--color-accent); + background: var(--color-surface); } -.diff-ctx { +.diff-hunk .diff-text { color: var(--color-text-muted); } diff --git a/apps/kimi-web/src/components/chat/ToolRow.vue b/apps/kimi-web/src/components/chat/ToolRow.vue index 3c26e26ba..239d1a4cb 100644 --- a/apps/kimi-web/src/components/chat/ToolRow.vue +++ b/apps/kimi-web/src/components/chat/ToolRow.vue @@ -168,7 +168,7 @@ defineEmits<{ toggle: [] }>(); color: var(--color-danger); } -/* Expanded detail: sunken panel under the row, indented to align with the name. +/* Expanded detail: sunken panel under the row. Collapses/expands via a height transition; `interpolate-size: allow-keywords` (set on :root) lets `height: auto` interpolate instead of snap. The visual styles live on `.bb-pad` so they clip cleanly inside the 0-height clip box. */ @@ -181,7 +181,7 @@ defineEmits<{ toggle: [] }>(); height: auto; } .bb-pad { - padding: 0 11px 11px 36px; + padding: 0 11px 11px; background: var(--color-surface-sunken); border-top: 1px solid var(--color-line); color: var(--color-text); diff --git a/apps/kimi-web/src/components/chat/tool-calls/AskUserTool.vue b/apps/kimi-web/src/components/chat/tool-calls/AskUserTool.vue new file mode 100644 index 000000000..f14b19df8 --- /dev/null +++ b/apps/kimi-web/src/components/chat/tool-calls/AskUserTool.vue @@ -0,0 +1,266 @@ + + + + + + diff --git a/apps/kimi-web/src/components/chat/tool-calls/askUserToolParse.ts b/apps/kimi-web/src/components/chat/tool-calls/askUserToolParse.ts new file mode 100644 index 000000000..9e0c07604 --- /dev/null +++ b/apps/kimi-web/src/components/chat/tool-calls/askUserToolParse.ts @@ -0,0 +1,126 @@ +// Pure parsers for the AskUserQuestion tool card. Kept separate from the SFC so +// the index-zip / id-decode logic is unit-testable without a DOM. +// +// Wire shape (from agent-core SCHEMAS §6.4): +// tool.arg : JSON { questions: [{ question, header, options[{label,description}], multi_select }] } +// Input questions carry NO id — order === broker order. +// tool.output[0]: on a successful answer, JSON { answers: Record, note? } +// qid = `q_`; value = `opt__` (single), +// `opt__,opt__` (multi, comma-joined), free-text +// (Other), or `opt_…,` (multi+Other). skipped → omitted. +// Dismissed → { answers: {}, note }. +// : on a background launch, plain text (`task_id: …\nstatus: …`); +// on an error (e.g. unsupported interactive questions), plain +// text. Those are NOT the answer payload and must be shown raw. + +export interface AskOption { + label: string; + description: string; +} + +export interface AskQuestion { + question: string; + header: string; + options: AskOption[]; + multiSelect: boolean; +} + +export interface AskOutput { + /** True only when the output parsed as the answer payload (`{ answers: {...} }`). + * False for background / error plain-text output, which the card must show raw. */ + recognized: boolean; + answers: Record; + note: string; +} + +export interface Resolved { + /** Option indices picked for this question. */ + selected: Set; + /** Free-text "Other" segment, when the answer carried one. */ + otherText: string; + /** The flattened value was the literal `true` — answered, but no concrete + option to echo back onto the list. */ + indeterminate: boolean; +} + +export function parseAskInput(arg: string): AskQuestion[] { + if (!arg) return []; + try { + const obj = JSON.parse(arg) as Record; + const raw = obj['questions']; + if (!Array.isArray(raw)) return []; + const out: AskQuestion[] = []; + for (const q of raw) { + if (!q || typeof q !== 'object') continue; + const qr = q as Record; + const opts: AskOption[] = Array.isArray(qr['options']) + ? (qr['options'] as unknown[]).map(o => { + const or = (o && typeof o === 'object' ? o : {}) as Record; + return { + label: typeof or['label'] === 'string' ? or['label'] : '', + description: typeof or['description'] === 'string' ? or['description'] : '', + }; + }) + : []; + out.push({ + question: typeof qr['question'] === 'string' ? qr['question'] : '', + header: typeof qr['header'] === 'string' ? qr['header'] : '', + options: opts, + multiSelect: qr['multi_select'] === true, + }); + } + return out; + } catch { + return []; + } +} + +const EMPTY: AskOutput = { recognized: false, answers: {}, note: '' }; + +export function parseAskOutput(output: string[] | undefined): AskOutput { + const line = output?.[0]; + if (!line) return EMPTY; + let obj: unknown; + try { + obj = JSON.parse(line); + } catch { + // Plain-text output (background `task_id/status`, error message) — show raw. + return EMPTY; + } + if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return EMPTY; + const raw = (obj as Record)['answers']; + // The answer payload is the only shape we render specially; anything else + // (a JSON object without an `answers` record) falls back to raw output. + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return EMPTY; + const answers: Record = {}; + for (const [k, v] of Object.entries(raw as Record)) { + if (typeof v === 'string') answers[k] = v; + else if (v === true) answers[k] = true; + } + return { + recognized: true, + answers, + note: typeof (obj as Record)['note'] === 'string' + ? ((obj as Record)['note'] as string) + : '', + }; +} + +const OPT_ID = /^opt_\d+_(\d+)$/; + +/** Decode one question's flattened answer into picked option indices plus any + * free-text "Other" segment. Option ids carry their own index, so this is + * exact rather than a label match; non-`opt_` segments are treated as the + * Other text (joined back with `,` in case the free text itself contained one). */ +export function resolveAnswer(value: string | true | undefined): Resolved { + if (value === undefined) return { selected: new Set(), otherText: '', indeterminate: false }; + if (value === true) return { selected: new Set(), otherText: '', indeterminate: true }; + const selected = new Set(); + const others: string[] = []; + for (const seg of value.split(',')) { + const m = OPT_ID.exec(seg); + if (m) selected.add(Number(m[1])); + else if (seg.length > 0) others.push(seg); + } + return { selected, otherText: others.join(','), indeterminate: false }; +} diff --git a/apps/kimi-web/src/components/chat/tool-calls/toolRegistry.ts b/apps/kimi-web/src/components/chat/tool-calls/toolRegistry.ts index fa65832de..f21e6857d 100644 --- a/apps/kimi-web/src/components/chat/tool-calls/toolRegistry.ts +++ b/apps/kimi-web/src/components/chat/tool-calls/toolRegistry.ts @@ -3,6 +3,7 @@ import type { Component } from 'vue'; import type { ToolCall } from '../../../types'; import { normalizeToolName } from '../../../lib/toolMeta'; import AgentTool from './AgentTool.vue'; +import AskUserTool from './AskUserTool.vue'; import EditTool from './EditTool.vue'; import GenericTool from './GenericTool.vue'; import MediaTool from './MediaTool.vue'; @@ -19,5 +20,6 @@ export function resolveToolRenderer(tool: ToolCall): ToolRenderer { // `task` — `agent` here would be dead code and route subagent calls to // GenericTool, dropping the inline "Open" button for the detail panel. if (name === 'task') return AgentTool; + if (name === 'askuserquestion') return AskUserTool; return GenericTool; } diff --git a/apps/kimi-web/src/i18n/locales/en/tools.ts b/apps/kimi-web/src/i18n/locales/en/tools.ts index 04de2f1a2..fe81c4acd 100644 --- a/apps/kimi-web/src/i18n/locales/en/tools.ts +++ b/apps/kimi-web/src/i18n/locales/en/tools.ts @@ -11,6 +11,7 @@ export default { search: 'Search', todo: 'Todo', task: 'Task', + ask_user: 'Question', }, chip: { lines: '{count} lines', @@ -25,4 +26,11 @@ export default { error: 'failed', done: 'done', }, + ask: { + dismissed: 'Dismissed', + answer: '{count} answer', + answers: '{count} answers', + answered: 'Answered', + more: '(+{count} more)', + }, } as const; diff --git a/apps/kimi-web/src/i18n/locales/zh/tools.ts b/apps/kimi-web/src/i18n/locales/zh/tools.ts index bc2782d4f..1fe96fda9 100644 --- a/apps/kimi-web/src/i18n/locales/zh/tools.ts +++ b/apps/kimi-web/src/i18n/locales/zh/tools.ts @@ -11,6 +11,7 @@ export default { search: '搜索', todo: '待办', task: '任务', + ask_user: '提问', }, chip: { lines: '{count} 行', @@ -25,4 +26,11 @@ export default { error: '有失败', done: '已完成', }, + ask: { + dismissed: '已忽略', + answer: '{count} 个回答', + answers: '{count} 个回答', + answered: '已回答', + more: '(还有 {count} 个)', + }, } as const; diff --git a/apps/kimi-web/src/lib/toolMeta.ts b/apps/kimi-web/src/lib/toolMeta.ts index d1e7842a7..38bc2e453 100644 --- a/apps/kimi-web/src/lib/toolMeta.ts +++ b/apps/kimi-web/src/lib/toolMeta.ts @@ -23,6 +23,7 @@ const TOOL_LABEL_KEYS: Record = { search: 'tools.label.search', todo: 'tools.label.todo', task: 'tools.label.task', + askuserquestion: 'tools.label.ask_user', }; // --------------------------------------------------------------------------- @@ -89,6 +90,7 @@ const TOOL_GLYPH: Record = { web_fetch: 'globe', todo: 'check-list', task: 'sparkles', + askuserquestion: 'help-circle', }; export function toolGlyph(name: string): string { diff --git a/apps/kimi-web/src/views/DesignSystemView.vue b/apps/kimi-web/src/views/DesignSystemView.vue index 0e757b7cf..df7067dfe 100644 --- a/apps/kimi-web/src/views/DesignSystemView.vue +++ b/apps/kimi-web/src/views/DesignSystemView.vue @@ -2132,7 +2132,7 @@ onUnmounted(() => { .p-tool-row.expanded .tr-car { transform: rotate(90deg); } /* Detail after a row is expanded (code / output) */ - .p-tool-detail { padding: 0 11px 11px 36px; background: var(--p-surface-sunken); border-top: 1px solid var(--p-line); } + .p-tool-detail { padding: 0 11px 11px; background: var(--p-surface-sunken); border-top: 1px solid var(--p-line); } .p-tool-detail .p-code { margin-top: 10px; } /* ===== Chat: Composer ===== */ diff --git a/apps/kimi-web/test/ask-user-tool-parse.test.ts b/apps/kimi-web/test/ask-user-tool-parse.test.ts new file mode 100644 index 000000000..33b73a0eb --- /dev/null +++ b/apps/kimi-web/test/ask-user-tool-parse.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from 'vitest'; +import { + parseAskInput, + parseAskOutput, + resolveAnswer, +} from '../src/components/chat/tool-calls/askUserToolParse'; + +const ARG = JSON.stringify({ + questions: [ + { + question: 'Which auth provider?', + header: 'Auth', + multi_select: false, + options: [ + { label: 'Clerk', description: 'Native Vercel Marketplace' }, + { label: 'Auth0', description: 'Enterprise SSO' }, + ], + }, + { + question: 'Where to deploy?', + header: 'Deploy', + multi_select: true, + options: [ + { label: 'Vercel', description: 'Zero-config' }, + { label: 'Fly.io', description: 'Edge' }, + { label: 'AWS', description: 'Full control' }, + ], + }, + ], +}); + +describe('parseAskInput', () => { + it('reads questions, options, header and multi_select', () => { + const qs = parseAskInput(ARG); + expect(qs).toHaveLength(2); + expect(qs[0]).toMatchObject({ header: 'Auth', multiSelect: false }); + expect(qs[0].options.map(o => o.label)).toEqual(['Clerk', 'Auth0']); + expect(qs[1]).toMatchObject({ header: 'Deploy', multiSelect: true }); + expect(qs[1].options).toHaveLength(3); + }); + + it('defaults missing optional fields and tolerates malformed input', () => { + expect(parseAskInput('')).toEqual([]); + expect(parseAskInput('not json')).toEqual([]); + expect(parseAskInput('{}')).toEqual([]); + expect(parseAskInput(JSON.stringify({ questions: 'nope' }))).toEqual([]); + // partial option entries degrade to empty strings, not a throw + const qs = parseAskInput(JSON.stringify({ questions: [{ options: [{ label: 'A' }, null] }] })); + expect(qs[0].options).toEqual([ + { label: 'A', description: '' }, + { label: '', description: '' }, + ]); + }); +}); + +describe('parseAskOutput', () => { + it('recognizes an answer payload and reads answers', () => { + const out = parseAskOutput([JSON.stringify({ answers: { q_0: 'opt_0_1' }, note: '' })]); + expect(out.recognized).toBe(true); + expect(out.answers).toEqual({ q_0: 'opt_0_1' }); + }); + + it('keeps string and true values, drops others', () => { + const out = parseAskOutput([JSON.stringify({ answers: { a: 'x', b: true, c: 3, d: null } })]); + expect(out.recognized).toBe(true); + expect(out.answers).toEqual({ a: 'x', b: true }); + }); + + it('recognizes the dismissed payload (empty answers + note)', () => { + const out = parseAskOutput([ + JSON.stringify({ answers: {}, note: 'User dismissed the question without answering.' }), + ]); + expect(out.recognized).toBe(true); + expect(Object.keys(out.answers)).toHaveLength(0); + expect(out.note).toContain('dismissed'); + }); + + it('does not recognize plain-text background output', () => { + const out = parseAskOutput(['task_id: abc\ndescription: run it\nstatus: running']); + expect(out.recognized).toBe(false); + }); + + it('does not recognize plain-text error output', () => { + expect(parseAskOutput(['Interactive questions are not supported in this session.']).recognized).toBe(false); + }); + + it('does not recognize JSON that is not the answer payload', () => { + expect(parseAskOutput([JSON.stringify({ foo: 'bar' })]).recognized).toBe(false); + expect(parseAskOutput([JSON.stringify({ answers: 'nope' })]).recognized).toBe(false); + expect(parseAskOutput([JSON.stringify(['x'])]).recognized).toBe(false); + }); + + it('tolerates missing output', () => { + expect(parseAskOutput(undefined)).toEqual({ recognized: false, answers: {}, note: '' }); + expect(parseAskOutput([])).toEqual({ recognized: false, answers: {}, note: '' }); + }); +}); + +describe('resolveAnswer', () => { + it('decodes a single-select option id to its index', () => { + const r = resolveAnswer('opt_0_1'); + expect([...r.selected]).toEqual([1]); + expect(r.otherText).toBe(''); + expect(r.indeterminate).toBe(false); + }); + + it('decodes a comma-joined multi-select into several indices', () => { + const r = resolveAnswer('opt_1_0,opt_1_2'); + expect(r.selected).toEqual(new Set([0, 2])); + }); + + it('treats a free-text value as an Other answer', () => { + const r = resolveAnswer('Use OIDC instead of static keys'); + expect(r.selected.size).toBe(0); + expect(r.otherText).toBe('Use OIDC instead of static keys'); + }); + + it('splits a multi+Other value into options plus the free-text segment', () => { + const r = resolveAnswer('opt_0_0,opt_0_2,Custom thing'); + expect(r.selected).toEqual(new Set([0, 2])); + expect(r.otherText).toBe('Custom thing'); + }); + + it('joins non-id segments back so Other text containing a comma survives', () => { + const r = resolveAnswer('opt_0_1,alpha,beta'); + expect([...r.selected]).toEqual([1]); + expect(r.otherText).toBe('alpha,beta'); + }); + + it('marks the literal true as indeterminate', () => { + const r = resolveAnswer(true); + expect(r.indeterminate).toBe(true); + expect(r.selected.size).toBe(0); + }); + + it('returns an empty result for skipped / unanswered questions', () => { + const r = resolveAnswer(undefined); + expect(r.selected.size).toBe(0); + expect(r.otherText).toBe(''); + expect(r.indeterminate).toBe(false); + }); +});