import { describe, expect, it, vi } from 'vitest'; import { formatFormAnswers, hasUnterminatedQuestionForm, splitOnQuestionForms, parsePartialQuestionForm, stripTrailingOpenQuestionForm, } from '../../src/artifacts/question-form'; const VALID_BODY = `{ "questions": [ { "id": "platform", "label": "Platform", "type": "radio", "options": ["Mobile", "Desktop", "Responsive"], "required": true } ] }`; describe('unterminated question-form detection', () => { // Production repro (OD Next strategy turn, no clarification needed): the // model narrated its decision *into* an open tag instead of emitting a form. // The tail is prose, so it can never become a form body — treating the tag // as "a form is still streaming" spins the loading skeleton forever, hides // every character after the tag, and latches the turn as awaiting input. const PROSE_TAIL = '策略判断信息充足,将直接进入生产。\n\n 无需提出'; it('does not treat a prose tail after the open tag as a form in flight', () => { const result = stripTrailingOpenQuestionForm(PROSE_TAIL); expect(result.hadOpenForm).toBe(false); // Nothing may be swallowed: the narration after the tag is ordinary prose. expect(result.text).toBe(PROSE_TAIL); expect(hasUnterminatedQuestionForm(PROSE_TAIL)).toBe(false); }); it('still reports a form in flight while its body is a plausible JSON prefix', () => { const streaming = [ // stream stopped right after the tag — the body may still arrive 'One quick check:\n', 'One quick check:\n\n', // bare JSON prefix 'One quick check:\n{', 'One quick check:\n{"id":"x","questions":[', // fenced ```json wrapper, including the fence itself mid-stream 'One quick check:\n\n``', 'One quick check:\n\n```json', 'One quick check:\n\n```json\n{"questions":[', // the alias tag behaves identically 'One quick check:\n{"questions":[', ]; for (const input of streaming) { const result = stripTrailingOpenQuestionForm(input); expect({ input, hadOpenForm: result.hadOpenForm }).toEqual({ input, hadOpenForm: true }); expect(result.text).toBe('One quick check:\n'); expect(hasUnterminatedQuestionForm(input)).toBe(true); } }); it('keeps scanning past a prose tag so a real form later in the message still counts', () => { const input = 'Head.\n 无需提出\n\nActually:\n{"questions":['; const result = stripTrailingOpenQuestionForm(input); expect(result.hadOpenForm).toBe(true); expect(result.text).toBe('Head.\n 无需提出\n\nActually:\n'); }); it('leaves a completed form block alone', () => { const input = `Intro.\n\n${VALID_BODY}\n\nOutro.`; const result = stripTrailingOpenQuestionForm(input); expect(result.hadOpenForm).toBe(false); expect(result.text).toBe(input); }); }); describe('form content language (lang)', () => { it('parses a top-level lang tag from the complete form body', () => { const input = [ '', '{ "lang": "zh-CN", "questions": [', ' { "id": "output", "label": "我们要做什么?", "type": "radio", "options": ["海报", "网页"] }', '] }', '', ].join('\n'); const segments = splitOnQuestionForms(input); expect(segments[0]?.kind).toBe('form'); if (segments[0]?.kind !== 'form') return; expect(segments[0].form.lang).toBe('zh-CN'); }); it('never exposes a defaultValue on the still-streaming trailing question', () => { // Red spec for the truncated-prefill freeze (production run beaf2da0): // while a question object is still streaming, the partial-JSON repair can // terminate mid-default — `"default": ["历史背景与经过", "抗战精` repairs // to ["历史背景与经过", "抗战精"], and `"default": "单位教` to "单位教". // The card adopts a streamed default only while the answer is still // empty, so a truncated adoption freezes garbage that the completed // default can never overwrite. The in-flight question must therefore // expose NO defaultValue until its braces close. const head = '\n' + '{ "questions": [\n' + ' { "id": "focus", "label": "内容重点(最多选2项)", "type": "checkbox", "maxSelections": 2,\n' + ' "default": ["历史背景与经过", "抗战精'; const midDefault = parsePartialQuestionForm(head); const focusMid = midDefault?.questions.find((q) => q.id === 'focus'); expect(focusMid).toBeTruthy(); expect(focusMid?.defaultValue).toBeUndefined(); const closed = parsePartialQuestionForm( head + '神与当代意义"],\n' + ' "options": ["历史背景与经过", "重要战役与事件", "抗战精神与当代意义"] },\n' + ' { "id": "tone", "label": "整体基调", "type": "radio", "default": "庄重', ); // First question closed → its complete default surfaces; the trailing // in-flight question ("tone", mid-default "庄重…") still exposes none. const focusClosed = closed?.questions.find((q) => q.id === 'focus'); expect(focusClosed?.defaultValue).toEqual(['历史背景与经过', '抗战精神与当代意义']); const toneInFlight = closed?.questions.find((q) => q.id === 'tone'); expect(toneInFlight).toBeTruthy(); expect(toneInFlight?.defaultValue).toBeUndefined(); }); it('adopts a streaming lang only once its string literal terminates', () => { // A half-streamed tag like "zh-C" must not resolve to a dictionary; the // field appears once the closing quote lands (same churn rule as `id`). const partial = parsePartialQuestionForm( '\n{ "lang": "zh-C', ); expect(partial?.lang).toBeUndefined(); const complete = parsePartialQuestionForm( '\n{ "lang": "zh-CN", "questions": [', ); expect(complete?.lang).toBe('zh-CN'); }); }); describe('splitOnQuestionForms', () => { it('renders the legacy child-tag form persisted by older conversations', () => { const input = [ '', ' ', ' ', ' ', ' ', ' ', '', ].join('\n'); expect(splitOnQuestionForms(input)).toEqual([ { kind: 'form', raw: input, form: { id: 'audio-brief', title: 'Audio brief', questions: [ { id: 'format', label: 'Which format?', type: 'select', required: true, options: [ { label: 'MP3', value: 'mp3' }, { label: 'WAV', value: 'wav' }, ], }, { id: 'mood', label: 'Describe the mood', type: 'text', placeholder: 'Warm and concise', }, ], }, }, ]); }); it('keeps a streamed legacy child-tag form hidden instead of leaking markup', () => { const partial = [ 'One quick check.\n', '', '', '', ].join(''); expect(stripTrailingOpenQuestionForm(partial)).toEqual({ text: 'One quick check.\n', hadOpenForm: true, }); expect(parsePartialQuestionForm(partial)).toMatchObject({ id: 'audio-brief', title: 'Audio brief', }); }); it('normalizes string and object question options', () => { const input = [ '', '{', ' "questions": [', ' {', ' "id": "platform",', ' "label": "Primary surface",', ' "type": "radio",', ' "required": true,', ' "options": [', ' "Responsive",', ' { "label": "Mobile (iOS/Android)", "description": "Phone-first app prototype", "value": "mobile" },', ' { "label": "Desktop web", "description": "Browser-first prototype" },', ' { "description": "Missing label" }', ' ]', ' }', ' ]', '}', '', ].join('\n'); const segments = splitOnQuestionForms(input); expect(segments).toHaveLength(1); expect(segments[0]).toMatchObject({ kind: 'form' }); if (segments[0]?.kind !== 'form') throw new Error('expected parsed form segment'); expect(segments[0].form.questions[0]?.options).toEqual([ { label: 'Responsive', value: 'Responsive' }, { label: 'Mobile (iOS/Android)', value: 'mobile', description: 'Phone-first app prototype', }, { label: 'Desktop web', value: 'Desktop web', description: 'Browser-first prototype', }, ]); }); it('parses richer web input controls and custom-choice metadata', () => { const input = [ '', '{', ' "questions": [', ' { "id": "accent", "label": "Accent", "type": "color", "defaultValue": "#ff5500" },', ' { "id": "intensity", "label": "Intensity", "type": "range", "min": 1, "max": 10, "step": 1 },', ' { "id": "deadline", "label": "Deadline", "type": "date" },', ' { "id": "source", "label": "Reference URL", "type": "url" },', ' { "id": "brief", "label": "Upload brief", "type": "file", "multiple": true, "accept": "image/*,.pdf" },', ' { "id": "exact", "label": "Exact id", "type": "select", "allowCustom": false, "options": [{ "label": "A", "value": "a" }] },', ' { "id": "tone", "label": "Tone", "type": "radio", "customLabel": "Something else", "customPlaceholder": "Describe it", "options": ["Sharp"] }', ' ]', '}', '', ].join('\n'); const segment = splitOnQuestionForms(input).find((s) => s.kind === 'form'); if (!segment || segment.kind !== 'form') throw new Error('expected parsed form'); expect(segment.form.questions.map((q) => q.type)).toEqual([ 'color', 'range', 'date', 'url', 'file', 'select', 'radio', ]); expect(segment.form.questions[1]).toMatchObject({ min: 1, max: 10, step: 1 }); expect(segment.form.questions[4]).toMatchObject({ multiple: true, accept: 'image/*,.pdf' }); expect(segment.form.questions[5]).toMatchObject({ allowCustom: false }); expect(segment.form.questions[6]).toMatchObject({ customLabel: 'Something else', customPlaceholder: 'Describe it', }); }); it('parses the agent-recommended `default` prefill the prompt contract ships', () => { // The system prompt instructs every question to carry a brief-inferred // recommended `default` (option value for radio/select, array for // checkbox, text otherwise) so the user can submit the form unchanged. const input = [ '', '{', ' "questions": [', ' { "id": "brand", "label": "Brand context", "type": "radio", "default": "pick_direction",', ' "options": [{ "label": "Pick a direction for me", "value": "pick_direction" }] },', ' { "id": "tone", "label": "Tone", "type": "checkbox", "default": ["Modern minimal", "tech"],', ' "options": ["Modern minimal", { "label": "Tech / utility", "value": "tech" }] },', ' { "id": "scale", "label": "Roughly how much?", "type": "text", "default": "8 slides" }', ' ]', '}', '', ].join('\n'); const segment = splitOnQuestionForms(input).find((s) => s.kind === 'form'); if (!segment || segment.kind !== 'form') throw new Error('expected parsed form'); expect(segment.form.questions[0]?.defaultValue).toBe('pick_direction'); // Label-form defaults canonicalize to stable option values. expect(segment.form.questions[1]?.defaultValue).toEqual(['Modern minimal', 'tech']); expect(segment.form.questions[2]?.defaultValue).toBe('8 slides'); }); it('preserves stable option values when formatting object-option answers', () => { const text = formatFormAnswers( { id: 'discovery', title: 'Quick brief', questions: [ { id: 'platform', label: 'Primary surface', type: 'radio', options: [ { label: 'Mobile (iOS/Android)', value: 'mobile' }, { label: 'Desktop web', value: 'Desktop web' }, ], }, ], }, { platform: 'mobile' }, ); expect(text).toContain('- Primary surface: Mobile (iOS/Android) [value: mobile]'); }); it('parses the canonical tag', () => { const out = splitOnQuestionForms(`prose\n${VALID_BODY}\nmore`); expect(out.map((s) => s.kind)).toEqual(['text', 'form', 'text']); if (out[1]?.kind === 'form') { expect(out[1].form.id).toBe('d'); expect(out[1].form.questions).toHaveLength(1); } }); it('parses the deliveryFormat/container array payload from Feishu acceptance', () => { const input = [ '要的话我可以直接按这张图出 motion overlay。先选一下输出偏好:', '', JSON.stringify([ { id: 'deliveryFormat', prompt: '导出格式?', type: 'radio', options: [ { id: 'mov', label: 'MOV(透明背景,可直接导入剪映 / PR / FCP 叠加)' }, { id: 'webm', label: 'WebM(透明背景,适合网页 / 浏览器播放)' }, { id: 'preview', label: '预览图 / 预览工程(适合先确认效果)' }, { id: 'remotion', label: 'Remotion 工程(适合继续编辑和拼装)' }, ], }, { id: 'container', prompt: '对话外壳?', type: 'radio', options: [ { id: 'none', label: '不要外壳,只要气泡' }, { id: 'phone', label: '手机聊天界面' }, ], }, ]), '', ].join('\n'); const out = splitOnQuestionForms(input); expect(out.map((s) => s.kind)).toEqual(['text', 'form']); const form = out[1]?.kind === 'form' ? out[1].form : null; expect(form?.questions.map((q) => [q.id, q.label, q.type])).toEqual([ ['deliveryFormat', '导出格式?', 'radio'], ['container', '对话外壳?', 'radio'], ]); expect(form?.questions[0]?.options?.map((option) => option.value)).toEqual([ 'mov', 'webm', 'preview', 'remotion', ]); }); it('normalizes partially missing question fields instead of dropping the form', () => { const out = splitOnQuestionForms( `${JSON.stringify([ { prompt: 'Pick one', options: ['A', 'B'] }, { id: 'notes', type: 'textarea' }, ])}`, ); expect(out.map((s) => s.kind)).toEqual(['form']); const form = out[0]?.kind === 'form' ? out[0].form : null; expect(form?.questions).toMatchObject([ { id: 'q1', label: 'Pick one', type: 'radio' }, { id: 'notes', label: 'notes', type: 'textarea' }, ]); }); it('accepts as an alias for (#1194)', () => { const out = splitOnQuestionForms(`${VALID_BODY}`); expect(out.map((s) => s.kind)).toEqual(['form']); if (out[0]?.kind === 'form') { expect(out[0].form.id).toBe('brief'); expect(out[0].form.title).toBe('Quick brief'); expect(out[0].form.questions[0]?.id).toBe('platform'); } }); it('handles mixed casing on the alias (e.g. )', () => { const out = splitOnQuestionForms(`${VALID_BODY}`); expect(out.map((s) => s.kind)).toEqual(['form']); }); it('does not close one tag with the other tag name', () => { const out = splitOnQuestionForms(`${VALID_BODY}`); expect(out.map((s) => s.kind)).toEqual(['text']); }); it('replaces malformed JSON bodies with a safe fallback and diagnostics', () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); const out = splitOnQuestionForms(`not json`); expect(out.map((s) => s.kind)).toEqual(['text']); expect(out[0]).toMatchObject({ kind: 'text', text: 'The assistant sent a question form that could not be rendered. Please ask it to resend the questions.', }); expect(out[0]?.kind === 'text' ? out[0].text : '').not.toContain(''); expect(out[0]?.kind === 'text' ? out[0].text : '').not.toContain('not json'); expect(warn).toHaveBeenCalledWith( '[question-form] failed to render inline question form', expect.objectContaining({ reason: 'invalid-json', tagName: 'ask-question' }), ); warn.mockRestore(); }); it('keeps unterminated tags as prose without swallowing trailing text', () => { const out = splitOnQuestionForms(`leading ${VALID_BODY}`); expect(out).toHaveLength(1); expect(out[0]).toMatchObject({ kind: 'text' }); }); it('finds close tags without Unicode index desync (#1194)', () => { const out = splitOnQuestionForms(`prefix İ suffix${VALID_BODY}`); expect(out.map((s) => s.kind)).toEqual(['text', 'form']); if (out[1]?.kind === 'form') { expect(out[1].form.id).toBe('x'); } }); it('unwinds a false-positive open tag mentioned in prose and re-parses the real form', () => { // Model mentioned the tag name inside backtick-quoted prose before // emitting the real form — the first open match must not consume the real // close tag, or the real form is lost. const input = `my first output should be \`\`.\n\n` + `Let me write a custom form:\n\n` + `${VALID_BODY}\n\n` + `Now I'll proceed.`; const out = splitOnQuestionForms(input); const forms = out.filter((segment) => segment.kind === 'form'); expect(forms).toHaveLength(1); expect(forms[0]?.form).toMatchObject({ id: 'discovery', title: 'Quick brief', questions: [{ id: 'platform', label: 'Platform', type: 'radio', required: true, options: [{ label: 'Mobile' }, { label: 'Desktop' }, { label: 'Responsive' }] }], }); // Segments must reconstruct the input without gaps or duplication. const reconstructed = out .map((s) => (s.kind === 'form' ? s.raw : s.text)) .join(''); expect(reconstructed).toBe(input); }); it('unwinds a tag-name mismatch — prose mentions but the real form is ', () => { const input = `In your output you'll see \`\` tags.\n\n` + `${VALID_BODY}\n\n` + `Done.`; const out = splitOnQuestionForms(input); const forms = out.filter((segment) => segment.kind === 'form'); expect(forms).toHaveLength(1); expect(forms[0]?.form).toMatchObject({ id: 'real', title: 'Brief', questions: [{ id: 'platform', label: 'Platform', type: 'radio', required: true, options: [{ label: 'Mobile' }, { label: 'Desktop' }, { label: 'Responsive' }] }], }); const reconstructed = out .map((s) => (s.kind === 'form' ? s.raw : s.text)) .join(''); expect(reconstructed).toBe(input); }); }); describe('parsePartialQuestionForm (true token-by-token streaming)', () => { it('surfaces a question before its object closes, then grows its options', () => { const midLabel = '{"questions":[{"id":"platform","label":"Platform"'; expect(parsePartialQuestionForm(midLabel)?.questions.map((q) => q.label)).toEqual(['Platform']); const oneOption = '{"questions":[{"id":"platform","label":"Platform","type":"radio","options":["Mobile"'; expect(parsePartialQuestionForm(oneOption)?.questions[0]?.options?.map((o) => o.label)).toEqual([ 'Mobile', ]); const twoOptions = '{"questions":[{"id":"platform","label":"Platform","type":"radio","options":["Mobile","Desktop"'; expect(parsePartialQuestionForm(twoOptions)?.questions[0]?.options?.map((o) => o.label)).toEqual([ 'Mobile', 'Desktop', ]); }); it('holds back a trailing question until its label exists (no id placeholder flicker)', () => { const buf = '{"questions":[{"id":"a","label":"First","type":"text"},{"id":"b"'; expect(parsePartialQuestionForm(buf)?.questions.map((q) => q.label)).toEqual(['First']); }); it('reads title/id from the open tag before any question arrives', () => { const f = parsePartialQuestionForm('{"questions":['); expect(f?.id).toBe('discovery'); expect(f?.title).toBe('Quick brief'); expect(f?.questions).toEqual([]); }); it('does not adopt a partial body id (no char-by-char churn)', () => { // No tag attr; the body `id` is still arriving — the preview id must NOT // follow the partial token (which would remount the editable panel). expect(parsePartialQuestionForm('{"id":"d')?.id).toBe('discovery'); expect(parsePartialQuestionForm('{"id":"disc')?.id).toBe('discovery'); }); it('adopts the body id once complete so it matches the final parse (no swap remount)', () => { // Once the id string literal is terminated, the preview adopts it — which // is exactly what tryParseForm assigns after the close tag, so there's no // preview→final identity change to remount the panel. const buf = '{"id":"discovery-form","questions":[{"id":"a","label":"Q"}]}'; expect(parsePartialQuestionForm(buf)?.id).toBe('discovery-form'); const seg = splitOnQuestionForms(`${buf}`).find((s) => s.kind === 'form'); expect(seg && seg.kind === 'form' ? seg.form.id : null).toBe('discovery-form'); }); it('ignores a nested question id when deriving the form id', () => { expect( parsePartialQuestionForm('{"questions":[{"id":"platform","label":"P"}')?.id, ).toBe('discovery'); }); it('does not let nested or legacy description data become form metadata', () => { // No form-level id on the tag or top-level body — only a question-level // id. The form id must stay the stable fallback, not adopt "platform" // (which would change the live panel's identity mid-stream). const f = parsePartialQuestionForm( '{"description":"legacy","questions":[{"id":"platform","label":"Platform","description":"nested"', ); expect(f?.id).toBe('discovery'); expect(f).not.toHaveProperty('description'); expect(f?.questions.map((q) => q.label)).toEqual(['Platform']); }); it('keeps streaming through a closed ```json fence before the close tag arrives', () => { // Fenced body fully written, trailing ``` present, but // not yet — the preview must stay populated, not drop to empty. const buf = '```json\n{"questions":[{"id":"a","label":"First","type":"text"}]}\n```'; expect(parsePartialQuestionForm(buf)?.questions.map((q) => q.label)).toEqual(['First']); }); it('keeps streaming while only a partial trailing fence has arrived', () => { const buf = '```json\n{"questions":[{"id":"a","label":"First","type":"text"}]}\n``'; expect(parsePartialQuestionForm(buf)?.questions.map((q) => q.label)).toEqual(['First']); }); it('does not strip backticks that are content of a label still being typed', () => { // The trailing ``` here is inside an open string value, not a closing // fence — it must survive into the preview, not be trimmed to "Use ". const buf = '{"questions":[{"id":"a","label":"Use ```'; expect(parsePartialQuestionForm(buf)?.questions[0]?.label).toContain('```'); }); it('carries a custom submitLabel through the streaming preview', () => { const buf = '{"submitLabel":"Generate brief","questions":[{"id":"a","label":"First","type":"text"}'; expect(parsePartialQuestionForm(buf)?.submitLabel).toBe('Generate brief'); }); it('keeps already-visible questions when a boolean value is split across deltas', () => { // `required` cut mid-`true` must not drop the question already shown. const buf = '{"questions":[{"id":"a","label":"Q","required":t'; expect(parsePartialQuestionForm(buf)?.questions.map((q) => q.label)).toEqual(['Q']); }); it('streams a single closed no-id question before the close tag arrives', () => { // The object is complete (its `}` streamed) but has no explicit id and no // `]`/close tag yet. Its fallback id (q1) is already final, so it should // show now rather than waiting for the close tag. const buf = '{"questions":[{"label":"First","type":"text"}'; expect(parsePartialQuestionForm(buf)?.questions.map((q) => q.label)).toEqual(['First']); expect(parsePartialQuestionForm(buf)?.questions.map((q) => q.id)).toEqual(['q1']); }); it('still holds a no-id question whose object is not yet closed', () => { // Same content but the object is still open (no `}`): it might still gain // an id, so surfacing it now risks an id swap that orphans an answer. const buf = '{"questions":[{"label":"First","type":"text"'; expect(parsePartialQuestionForm(buf)?.questions).toEqual([]); }); it('holds a label-first question until its canonical id is determinable', () => { // label streamed, the in-flight object has no id yet — surfacing it now // would force a later id swap that orphans an in-progress answer, so it // waits. Once the id streams it appears with the canonical id. const labelFirst = parsePartialQuestionForm( '{"questions":[{"label":"Platform"', ); expect(labelFirst?.questions).toEqual([]); const idLater = parsePartialQuestionForm( '{"questions":[{"label":"Platform","id":"platform"', ); expect(idLater?.questions[0]?.id).toBe('platform'); }); it('gives preview question ids identical to the final parse (no orphaned answers on swap)', () => { const finalIds = (input: string) => { const seg = splitOnQuestionForms(input).find((s) => s.kind === 'form'); return seg && seg.kind === 'form' ? seg.form.questions.map((q) => q.id) : []; }; // id-bearing question: preview id == final canonical id. const withId = '{"questions":[{"id":"platform","label":"Platform","type":"radio","options":["A"]}'; expect(parsePartialQuestionForm(withId)?.questions.map((q) => q.id)).toEqual(['platform']); expect(finalIds(`${withId}]}`)).toEqual(['platform']); // no-id question that has closed (not in-flight): preview falls back to the // same index id the final parse will assign. const noId = '{"questions":[{"label":"First","type":"text"},{"id":"b"'; expect(parsePartialQuestionForm(noId)?.questions.map((q) => q.id)).toEqual(['q1']); expect(finalIds('{"questions":[{"label":"First","type":"text"}]}')).toEqual(['q1']); }); });