diff --git a/src/components/chat/tools/components/ContentRenderers/QuestionAnswerContent.test.tsx b/src/components/chat/tools/components/ContentRenderers/QuestionAnswerContent.test.tsx new file mode 100644 index 00000000..0f935183 --- /dev/null +++ b/src/components/chat/tools/components/ContentRenderers/QuestionAnswerContent.test.tsx @@ -0,0 +1,77 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { QuestionAnswerContent } from './QuestionAnswerContent'; + +// Regression coverage for the chat-interface crash where an AskUserQuestion +// payload loaded from a session transcript arrives with a non-array `questions` +// or a question missing its `options` array. Rendering must degrade gracefully +// instead of throwing "TypeError: e.map is not a function". + +test('renders without throwing when questions is a non-array value', () => { + assert.doesNotThrow(() => { + renderToStaticMarkup( + React.createElement(QuestionAnswerContent, { + // Malformed: object instead of an array + questions: { 0: { question: 'q?', options: [{ label: 'a' }] } } as never, + answers: {}, + }), + ); + }); +}); + +test('renders without throwing when a question is missing options[]', () => { + assert.doesNotThrow(() => { + renderToStaticMarkup( + React.createElement(QuestionAnswerContent, { + questions: [{ question: 'Pick one?', header: 'H' } as never], + answers: { 'Pick one?': 'X' }, + }), + ); + }); +}); + +test('renders without throwing when options[] contains malformed entries', () => { + assert.doesNotThrow(() => { + renderToStaticMarkup( + React.createElement(QuestionAnswerContent, { + questions: [{ question: 'Pick one?', options: [null, 'oops', { label: 'A' }] } as never], + answers: { 'Pick one?': 'A, Custom' }, + }), + ); + }); +}); + +test('renders without throwing when a questions entry is null/non-object', () => { + assert.doesNotThrow(() => { + renderToStaticMarkup( + React.createElement(QuestionAnswerContent, { + questions: [null, 'oops', { question: 'Ok?', options: [{ label: 'A' }] }] as never, + answers: {}, + }), + ); + }); +}); + +test('renders without throwing when an answer is a non-string value', () => { + assert.doesNotThrow(() => { + renderToStaticMarkup( + React.createElement(QuestionAnswerContent, { + questions: [{ question: 'Pick one?', options: [{ label: 'A' }] }], + // Malformed: answer is an object instead of the expected string + answers: { 'Pick one?': { unexpected: true } } as never, + }), + ); + }); +}); + +test('still renders a well-formed question + answer', () => { + const html = renderToStaticMarkup( + React.createElement(QuestionAnswerContent, { + questions: [{ question: 'Pick one?', header: 'H', options: [{ label: 'A' }, { label: 'B' }] }], + answers: { 'Pick one?': 'A' }, + }), + ); + assert.ok(html.includes('Pick one?')); +}); diff --git a/src/components/chat/tools/components/ContentRenderers/QuestionAnswerContent.tsx b/src/components/chat/tools/components/ContentRenderers/QuestionAnswerContent.tsx index 90c43c6d..005e60d5 100644 --- a/src/components/chat/tools/components/ContentRenderers/QuestionAnswerContent.tsx +++ b/src/components/chat/tools/components/ContentRenderers/QuestionAnswerContent.tsx @@ -15,7 +15,11 @@ export const QuestionAnswerContent: React.FC = ({ }) => { const [expandedIdx, setExpandedIdx] = useState(null); - if (!questions || questions.length === 0) { + // Tool inputs are runtime data loaded from session transcripts and may be + // malformed (e.g. `questions` arriving as a non-array). Guard with + // Array.isArray so a single bad payload can't crash the whole chat view + // with "e.map is not a function". + if (!Array.isArray(questions) || questions.length === 0) { return null; } @@ -24,11 +28,23 @@ export const QuestionAnswerContent: React.FC = ({ return (
- {questions.map((q, idx) => { + {questions.map((rawQuestion, idx) => { + // Entries come from session transcripts and may be malformed; skip + // anything that isn't a proper question object with a string prompt. + if (!rawQuestion || typeof rawQuestion !== 'object' || typeof rawQuestion.question !== 'string') { + return null; + } + const q = rawQuestion; const answer = answers?.[q.question]; - const answerLabels = answer ? answer.split(', ') : []; + // `answer` may be a non-string (or absent) in malformed payloads. + const answerLabels = typeof answer === 'string' ? answer.split(', ') : []; const skipped = !answer; const isExpanded = expandedIdx === idx; + // `options` is typed as an array but comes from untrusted runtime data; + // keep only valid entries so `.some`/`.map` below never throw. + const options = Array.isArray(q.options) + ? q.options.filter((opt) => opt && typeof opt === 'object' && typeof opt.label === 'string') + : []; return (
= ({ {!isExpanded && answerLabels.length > 0 && (
{answerLabels.map((lbl) => { - const isCustom = !q.options.some(o => o.label === lbl); + const isCustom = !options.some(o => o.label === lbl); return ( = ({ {isExpanded && (
- {q.options.map((opt) => { + {options.map((opt) => { const wasSelected = answerLabels.includes(opt.label); return (
= ({ ); })} - {answerLabels.filter(lbl => !q.options.some(o => o.label === lbl)).map(lbl => ( + {answerLabels.filter(lbl => !options.some(o => o.label === lbl)).map(lbl => (