feat(FN-1151): preserve and display planning conversation history
- Persist per-turn thinking output for planning and mission interview sessions, including history serialization and recovery-safe state fields - Add shared conversation history parsing/API types and a reusable timeline component with formatted answers and expandable AI reasoning - Restore and render conversation history across Planning Mode, Mission Interview, and Subtask Breakdown modals during resume and live question flow - Harden response submission with nullable-question guards and expand unit/e2e coverage for history rendering and persistence behavior
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import type { PlanningQuestion } from "@fusion/core";
|
||||
import { ConversationHistory } from "../ConversationHistory";
|
||||
|
||||
const baseQuestion: PlanningQuestion = {
|
||||
id: "q-scope",
|
||||
type: "single_select",
|
||||
question: "What is the project scope?",
|
||||
options: [
|
||||
{ id: "small", label: "Small" },
|
||||
{ id: "medium", label: "Medium" },
|
||||
],
|
||||
};
|
||||
|
||||
describe("ConversationHistory", () => {
|
||||
it("renders question and formatted response pairs", () => {
|
||||
render(
|
||||
<ConversationHistory
|
||||
entries={[
|
||||
{
|
||||
question: baseQuestion,
|
||||
response: { "q-scope": "medium" },
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Q1")).toBeDefined();
|
||||
expect(screen.getByText("What is the project scope?")).toBeDefined();
|
||||
expect(screen.getByText("Medium")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows thinking output when expanded", () => {
|
||||
render(
|
||||
<ConversationHistory
|
||||
entries={[
|
||||
{
|
||||
question: { ...baseQuestion, id: "q1" },
|
||||
response: { q1: "small" },
|
||||
thinkingOutput: "Internal reasoning for first question",
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText("Internal reasoning for first question")).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Show AI thinking/i }));
|
||||
|
||||
expect(screen.getByText("Internal reasoning for first question")).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns null for empty entries", () => {
|
||||
const { container } = render(<ConversationHistory entries={[]} />);
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("renders entries that only contain thinking output", () => {
|
||||
render(
|
||||
<ConversationHistory
|
||||
entries={[
|
||||
{
|
||||
thinkingOutput: "Reasoning captured during subtask generation",
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("AI Reasoning")).toBeDefined();
|
||||
fireEvent.click(screen.getByRole("button", { name: /Show AI reasoning/i }));
|
||||
expect(screen.getByText("Reasoning captured during subtask generation")).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ vi.mock("../../api", () => ({
|
||||
createMissionFromInterview: vi.fn(),
|
||||
connectMissionInterviewStream: vi.fn(),
|
||||
fetchAiSession: vi.fn(),
|
||||
parseConversationHistory: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/modalPersistence", () => ({
|
||||
@@ -27,6 +28,7 @@ const mockCancelMissionInterview = vi.mocked(api.cancelMissionInterview);
|
||||
const mockCreateMissionFromInterview = vi.mocked(api.createMissionFromInterview);
|
||||
const mockConnectMissionInterviewStream = vi.mocked(api.connectMissionInterviewStream);
|
||||
const mockFetchAiSession = vi.mocked(api.fetchAiSession);
|
||||
const mockParseConversationHistory = vi.mocked(api.parseConversationHistory);
|
||||
const mockGetMissionGoal = vi.mocked(modalPersistence.getMissionGoal);
|
||||
|
||||
const sampleQuestionSingle: PlanningQuestion = {
|
||||
@@ -84,6 +86,15 @@ describe("MissionInterviewModal", () => {
|
||||
mockCancelMissionInterview.mockResolvedValue(undefined);
|
||||
mockCreateMissionFromInterview.mockResolvedValue({ id: "MS-001", title: "Created mission" } as any);
|
||||
mockFetchAiSession.mockResolvedValue(null);
|
||||
mockParseConversationHistory.mockImplementation((raw: string) => {
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
mockGetMissionGoal.mockReturnValue("");
|
||||
|
||||
mockConnectMissionInterviewStream.mockImplementation((_sessionId, _projectId, handlers) => {
|
||||
|
||||
Reference in New Issue
Block a user