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:
gsxdsm
2026-07-12 18:47:08 -07:00
parent ddf2f3d956
commit f7e678bc8d
2 changed files with 191 additions and 22 deletions

View File

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

View File

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