FN-7894: fix conversation history rendering user's own-answer text as [object Object]
Resolves planning interview history showing raw stringified objects instead of the user's typed "Other" answer text. - Add getResponseValue resolution that mirrors the server's `_other` reserved-key contract, extracting the free-text answer separately from `_comment` metadata. - Add formatResponse support for an `other` override across single_select, multi_select, and confirm question types, appending "(user's own answer)" suffix. - Add safeFormatScalar helper to safely stringify non-string scalar/object response values instead of relying on bare String()/JSON.stringify() branches. - Add regression tests covering text/single_select/multi_select/confirm question types with `_other` responses, plus the existing `_comment` metadata path. Files changed: .../app/components/ConversationHistory.tsx | 96 +++++++++++++---- .../__tests__/ConversationHistory.test.tsx | 117 +++++++++++++++++++++ 2 files changed, 191 insertions(+), 22 deletions(-) Fusion-Task-Id: FN-7894 Fusion-Task-Lineage: 06444248-23cd-4fac-9afc-b2e2c7366cbe Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
@@ -6,6 +6,9 @@ import { useState } from "react";
|
||||
import type { ConversationHistoryEntry } from "../api";
|
||||
|
||||
const COMMENT_ICON = "💬";
|
||||
const PLANNING_OTHER_RESPONSE_KEY = "_other";
|
||||
const PLANNING_COMMENT_RESPONSE_KEY = "_comment";
|
||||
const USER_OWN_ANSWER_SUFFIX = " (user's own answer)";
|
||||
|
||||
interface ConversationHistoryProps {
|
||||
entries: ConversationHistoryEntry[];
|
||||
@@ -16,54 +19,101 @@ interface NumberedEntry extends ConversationHistoryEntry {
|
||||
questionNumber: number | null;
|
||||
}
|
||||
|
||||
function getResponseValue(entry: ConversationHistoryEntry): unknown {
|
||||
interface ResolvedResponseValue {
|
||||
value: unknown;
|
||||
other: string;
|
||||
}
|
||||
|
||||
function safeFormatScalar(value: unknown): string {
|
||||
if (value == null) return "";
|
||||
if (typeof value === "object") return JSON.stringify(value);
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:PlanningInterview 2026-07-12-18:36:
|
||||
Planning interview history must mirror the server `_other` reserved-key contract so user-authored Other answers render as human text instead of falling through to object stringification. `_comment` remains metadata for the separate comment row and is never treated as an answer value.
|
||||
*/
|
||||
function getResponseValue(entry: ConversationHistoryEntry): ResolvedResponseValue {
|
||||
const { question, response } = entry;
|
||||
if (!question) return response;
|
||||
if (!question) return { value: response, other: "" };
|
||||
|
||||
if (response && typeof response === "object" && !Array.isArray(response)) {
|
||||
const record = response as Record<string, unknown>;
|
||||
const other = typeof record[PLANNING_OTHER_RESPONSE_KEY] === "string"
|
||||
? record[PLANNING_OTHER_RESPONSE_KEY].trim()
|
||||
: "";
|
||||
|
||||
if (question.id in record) {
|
||||
return record[question.id];
|
||||
return { value: record[question.id], other };
|
||||
}
|
||||
|
||||
if (other.length > 0) {
|
||||
return { value: question.type === "text" ? other : undefined, other };
|
||||
}
|
||||
|
||||
const hasAnswerKeys = Object.keys(record).some(
|
||||
(key) => key !== PLANNING_OTHER_RESPONSE_KEY && key !== PLANNING_COMMENT_RESPONSE_KEY,
|
||||
);
|
||||
|
||||
return { value: hasAnswerKeys ? response : undefined, other: "" };
|
||||
}
|
||||
|
||||
return response;
|
||||
return { value: response, other: "" };
|
||||
}
|
||||
|
||||
function formatResponse(question: PlanningQuestion, responseValue: unknown, t: TFunction<"app">): string {
|
||||
function formatOtherAnswer(other: string): string {
|
||||
return other.length > 0 ? `${other}${USER_OWN_ANSWER_SUFFIX}` : "";
|
||||
}
|
||||
|
||||
function formatResponse(
|
||||
question: PlanningQuestion,
|
||||
responseValue: unknown,
|
||||
t: TFunction<"app">,
|
||||
other = "",
|
||||
): string {
|
||||
switch (question.type) {
|
||||
case "text": {
|
||||
if (typeof responseValue === "string") return responseValue;
|
||||
return responseValue == null ? "" : String(responseValue);
|
||||
return safeFormatScalar(responseValue);
|
||||
}
|
||||
case "single_select": {
|
||||
if (other.length > 0) {
|
||||
return formatOtherAnswer(other);
|
||||
}
|
||||
if (typeof responseValue === "string") {
|
||||
const selected = question.options?.find((option) => option.id === responseValue);
|
||||
return selected?.label ?? responseValue;
|
||||
}
|
||||
return responseValue == null ? "" : String(responseValue);
|
||||
return safeFormatScalar(responseValue);
|
||||
}
|
||||
case "multi_select": {
|
||||
if (Array.isArray(responseValue)) {
|
||||
return responseValue
|
||||
.map((value) => {
|
||||
if (typeof value !== "string") {
|
||||
return String(value);
|
||||
}
|
||||
const selected = question.options?.find((option) => option.id === value);
|
||||
return selected?.label ?? value;
|
||||
})
|
||||
.join(", ");
|
||||
const selected = responseValue.map((value) => {
|
||||
if (typeof value !== "string") {
|
||||
return safeFormatScalar(value);
|
||||
}
|
||||
const selectedOption = question.options?.find((option) => option.id === value);
|
||||
return selectedOption?.label ?? value;
|
||||
});
|
||||
if (other.length > 0) {
|
||||
selected.push(formatOtherAnswer(other));
|
||||
}
|
||||
return selected.join(", ");
|
||||
}
|
||||
return responseValue == null ? "" : String(responseValue);
|
||||
if (other.length > 0) {
|
||||
return formatOtherAnswer(other);
|
||||
}
|
||||
return safeFormatScalar(responseValue);
|
||||
}
|
||||
case "confirm": {
|
||||
if (other.length > 0) return formatOtherAnswer(other);
|
||||
if (responseValue === true) return t("conversation.confirm.yes", "Yes");
|
||||
if (responseValue === false) return t("conversation.confirm.no", "No");
|
||||
return responseValue == null ? "" : String(responseValue);
|
||||
return safeFormatScalar(responseValue);
|
||||
}
|
||||
default:
|
||||
return responseValue == null ? "" : JSON.stringify(responseValue);
|
||||
return safeFormatScalar(responseValue);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,15 +154,17 @@ export function ConversationHistory({ entries, defaultShowThinking = false }: Co
|
||||
|
||||
const responseValue = hasQuestion ? getResponseValue(entry) : undefined;
|
||||
const formattedResponse =
|
||||
entry.question && responseValue !== undefined
|
||||
? formatResponse(entry.question, responseValue, t)
|
||||
entry.question && responseValue && (responseValue.value !== undefined || responseValue.other.length > 0)
|
||||
? formatResponse(entry.question, responseValue.value, t, responseValue.other)
|
||||
: "";
|
||||
const responseRecord =
|
||||
entry.response && typeof entry.response === "object" && !Array.isArray(entry.response)
|
||||
? (entry.response as Record<string, unknown>)
|
||||
: undefined;
|
||||
const comment =
|
||||
typeof responseRecord?._comment === "string" ? responseRecord._comment.trim() : "";
|
||||
typeof responseRecord?.[PLANNING_COMMENT_RESPONSE_KEY] === "string"
|
||||
? responseRecord[PLANNING_COMMENT_RESPONSE_KEY].trim()
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div key={`${entry.question?.id ?? "thinking"}-${index}`} className="conversation-entry">
|
||||
|
||||
@@ -13,6 +13,26 @@ const baseQuestion: PlanningQuestion = {
|
||||
],
|
||||
};
|
||||
|
||||
const multiSelectQuestion: PlanningQuestion = {
|
||||
id: "q-tags",
|
||||
type: "multi_select",
|
||||
question: "Which tags apply?",
|
||||
options: [
|
||||
{ id: "frontend", label: "Frontend" },
|
||||
{ id: "backend", label: "Backend" },
|
||||
],
|
||||
};
|
||||
|
||||
const confirmQuestion: PlanningQuestion = {
|
||||
id: "q-confirm",
|
||||
type: "confirm",
|
||||
question: "Should Fusion continue?",
|
||||
};
|
||||
|
||||
function expectNoObjectObject() {
|
||||
expect(screen.queryByText(/\[object Object\]/)).toBeNull();
|
||||
}
|
||||
|
||||
describe("ConversationHistory", () => {
|
||||
it("renders question and formatted response pairs", () => {
|
||||
render(
|
||||
@@ -31,6 +51,103 @@ describe("ConversationHistory", () => {
|
||||
expect(screen.getByText("Medium")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders single-select Other responses as the user's own answer", () => {
|
||||
render(
|
||||
<ConversationHistory
|
||||
entries={[
|
||||
{
|
||||
question: baseQuestion,
|
||||
response: { _other: "my own framing" },
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("my own framing (user's own answer)")).toBeDefined();
|
||||
expectNoObjectObject();
|
||||
});
|
||||
|
||||
it("renders confirm Other responses as the user's own answer", () => {
|
||||
render(
|
||||
<ConversationHistory
|
||||
entries={[
|
||||
{
|
||||
question: confirmQuestion,
|
||||
response: { _other: "Continue only after review" },
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Continue only after review (user's own answer)")).toBeDefined();
|
||||
expectNoObjectObject();
|
||||
});
|
||||
|
||||
it("renders multi-select selected labels plus the user's Other answer", () => {
|
||||
render(
|
||||
<ConversationHistory
|
||||
entries={[
|
||||
{
|
||||
question: multiSelectQuestion,
|
||||
response: { "q-tags": ["frontend"], _other: "CLI polish" },
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Frontend, CLI polish (user's own answer)")).toBeDefined();
|
||||
expectNoObjectObject();
|
||||
});
|
||||
|
||||
it("renders multi-select Other-only responses as the user's own answer", () => {
|
||||
render(
|
||||
<ConversationHistory
|
||||
entries={[
|
||||
{
|
||||
question: multiSelectQuestion,
|
||||
response: { _other: "Docs only" },
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Docs only (user's own answer)")).toBeDefined();
|
||||
expectNoObjectObject();
|
||||
});
|
||||
|
||||
it("renders Other responses and comments together without treating comments as answers", () => {
|
||||
render(
|
||||
<ConversationHistory
|
||||
entries={[
|
||||
{
|
||||
question: baseQuestion,
|
||||
response: { _other: "custom direction", _comment: "Need this done by next sprint" },
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("custom direction (user's own answer)")).toBeDefined();
|
||||
expect(screen.getByText("💬 Need this done by next sprint")).toBeDefined();
|
||||
expectNoObjectObject();
|
||||
});
|
||||
|
||||
it("renders arbitrary object response values as JSON defensively", () => {
|
||||
render(
|
||||
<ConversationHistory
|
||||
entries={[
|
||||
{
|
||||
question: baseQuestion,
|
||||
response: { "q-scope": { nested: "value" } },
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('{"nested":"value"}')).toBeDefined();
|
||||
expectNoObjectObject();
|
||||
});
|
||||
|
||||
it("shows thinking output when expanded", () => {
|
||||
render(
|
||||
<ConversationHistory
|
||||
|
||||
Reference in New Issue
Block a user