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:
gsxdsm
2026-04-08 14:17:20 -07:00
parent d5063c6b26
commit 375d46d31b
15 changed files with 1023 additions and 10 deletions

View File

@@ -3302,6 +3302,12 @@ export interface AiSessionSummary {
updatedAt: string; updatedAt: string;
} }
export interface ConversationHistoryEntry {
question?: PlanningQuestion;
response?: Record<string, unknown>;
thinkingOutput?: string;
}
export interface AiSessionDetail extends AiSessionSummary { export interface AiSessionDetail extends AiSessionSummary {
inputPayload: string; inputPayload: string;
conversationHistory: string; conversationHistory: string;
@@ -3312,6 +3318,17 @@ export interface AiSessionDetail extends AiSessionSummary {
createdAt: string; createdAt: string;
} }
export function parseConversationHistory(raw: string): ConversationHistoryEntry[] {
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
export async function fetchAiSessions(projectId?: string): Promise<AiSessionSummary[]> { export async function fetchAiSessions(projectId?: string): Promise<AiSessionSummary[]> {
const params = projectId ? `?projectId=${encodeURIComponent(projectId)}` : ""; const params = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
const res = await fetch(buildApiUrl(`/ai-sessions${params}`)); const res = await fetch(buildApiUrl(`/ai-sessions${params}`));

View File

@@ -0,0 +1,151 @@
import type { PlanningQuestion } from "@fusion/core";
import { useState } from "react";
import type { ConversationHistoryEntry } from "../api";
interface ConversationHistoryProps {
entries: ConversationHistoryEntry[];
defaultShowThinking?: boolean;
}
interface NumberedEntry extends ConversationHistoryEntry {
questionNumber: number | null;
}
function getResponseValue(entry: ConversationHistoryEntry): unknown {
const { question, response } = entry;
if (!question) return response;
if (response && typeof response === "object" && !Array.isArray(response)) {
const record = response as Record<string, unknown>;
if (question.id in record) {
return record[question.id];
}
}
return response;
}
function formatResponse(question: PlanningQuestion, responseValue: unknown): string {
switch (question.type) {
case "text": {
if (typeof responseValue === "string") return responseValue;
return responseValue == null ? "" : String(responseValue);
}
case "single_select": {
if (typeof responseValue === "string") {
const selected = question.options?.find((option) => option.id === responseValue);
return selected?.label ?? responseValue;
}
return responseValue == null ? "" : String(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(", ");
}
return responseValue == null ? "" : String(responseValue);
}
case "confirm": {
if (responseValue === true) return "Yes";
if (responseValue === false) return "No";
return responseValue == null ? "" : String(responseValue);
}
default:
return responseValue == null ? "" : JSON.stringify(responseValue);
}
}
function normalizeEntries(entries: ConversationHistoryEntry[]): NumberedEntry[] {
let questionCounter = 0;
const normalized: NumberedEntry[] = [];
for (const entry of entries) {
if (entry.question) {
questionCounter += 1;
normalized.push({ ...entry, questionNumber: questionCounter });
continue;
}
if (entry.thinkingOutput) {
normalized.push({ ...entry, questionNumber: null });
}
}
return normalized;
}
export function ConversationHistory({ entries, defaultShowThinking = false }: ConversationHistoryProps) {
const [expandedThinking, setExpandedThinking] = useState<Record<number, boolean>>({});
const normalizedEntries = normalizeEntries(entries);
if (normalizedEntries.length === 0) {
return null;
}
return (
<div className="conversation-history" data-testid="conversation-history">
{normalizedEntries.map((entry, index) => {
const hasQuestion = Boolean(entry.question);
const hasThinking = Boolean(entry.thinkingOutput);
const isExpanded = expandedThinking[index] ?? defaultShowThinking;
const responseValue = hasQuestion ? getResponseValue(entry) : undefined;
const formattedResponse =
entry.question && responseValue !== undefined
? formatResponse(entry.question, responseValue)
: "";
return (
<div key={`${entry.question?.id ?? "thinking"}-${index}`} className="conversation-entry">
{hasQuestion ? (
<div className="conversation-entry-question">
<span className="conversation-entry-question-label">Q{entry.questionNumber}</span>
<p>{entry.question?.question}</p>
</div>
) : (
<div className="conversation-entry-question">
<span className="conversation-entry-question-label">AI Reasoning</span>
</div>
)}
{hasQuestion && (
<div className="conversation-entry-response">
<strong>Your response</strong>
<p>{formattedResponse || "—"}</p>
</div>
)}
{hasThinking && (
<div className="conversation-entry-thinking">
<button
type="button"
className="conversation-thinking-toggle"
onClick={() => {
setExpandedThinking((current) => ({
...current,
[index]: !isExpanded,
}));
}}
aria-expanded={isExpanded}
>
<span aria-hidden="true">{isExpanded ? "▾" : "▸"}</span>
{isExpanded
? `Hide ${hasQuestion ? "AI thinking" : "AI reasoning"}`
: `Show ${hasQuestion ? "AI thinking" : "AI reasoning"}`}
</button>
{isExpanded && <pre>{entry.thinkingOutput}</pre>}
</div>
)}
</div>
);
})}
</div>
);
}

View File

@@ -8,6 +8,7 @@ const mockCancelMissionInterview = vi.fn();
const mockCreateMissionFromInterview = vi.fn(); const mockCreateMissionFromInterview = vi.fn();
const mockConnectMissionInterviewStream = vi.fn(); const mockConnectMissionInterviewStream = vi.fn();
const mockFetchAiSession = vi.fn(); const mockFetchAiSession = vi.fn();
const mockParseConversationHistory = vi.fn();
vi.mock("../api", () => ({ vi.mock("../api", () => ({
startMissionInterview: (...args: any[]) => mockStartMissionInterview(...args), startMissionInterview: (...args: any[]) => mockStartMissionInterview(...args),
@@ -16,6 +17,7 @@ vi.mock("../api", () => ({
createMissionFromInterview: (...args: any[]) => mockCreateMissionFromInterview(...args), createMissionFromInterview: (...args: any[]) => mockCreateMissionFromInterview(...args),
connectMissionInterviewStream: (...args: any[]) => mockConnectMissionInterviewStream(...args), connectMissionInterviewStream: (...args: any[]) => mockConnectMissionInterviewStream(...args),
fetchAiSession: (...args: any[]) => mockFetchAiSession(...args), fetchAiSession: (...args: any[]) => mockFetchAiSession(...args),
parseConversationHistory: (...args: any[]) => mockParseConversationHistory(...args),
})); }));
vi.mock("../hooks/modalPersistence", () => ({ vi.mock("../hooks/modalPersistence", () => ({
@@ -44,6 +46,15 @@ describe("MissionInterviewModal", () => {
mockStartMissionInterview.mockResolvedValue({ sessionId: "mission-session-1" }); mockStartMissionInterview.mockResolvedValue({ sessionId: "mission-session-1" });
mockFetchAiSession.mockResolvedValue(null); mockFetchAiSession.mockResolvedValue(null);
mockParseConversationHistory.mockImplementation((raw: string) => {
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
});
mockConnectMissionInterviewStream.mockImplementation((_sessionId, _projectId, handlers) => { mockConnectMissionInterviewStream.mockImplementation((_sessionId, _projectId, handlers) => {
streamHandlers = handlers; streamHandlers = handlers;
return { return {

View File

@@ -7,7 +7,9 @@ import {
createMissionFromInterview, createMissionFromInterview,
connectMissionInterviewStream, connectMissionInterviewStream,
fetchAiSession, fetchAiSession,
parseConversationHistory,
type MissionPlanSummary, type MissionPlanSummary,
type ConversationHistoryEntry,
type MissionPlanMilestone, type MissionPlanMilestone,
type MissionPlanSlice, type MissionPlanSlice,
type MissionPlanFeature, type MissionPlanFeature,
@@ -35,6 +37,7 @@ import {
Trash2, Trash2,
Minimize2, Minimize2,
} from "lucide-react"; } from "lucide-react";
import { ConversationHistory } from "./ConversationHistory";
interface MissionInterviewModalProps { interface MissionInterviewModalProps {
isOpen: boolean; isOpen: boolean;
@@ -74,6 +77,7 @@ export function MissionInterviewModal({
const [view, setView] = useState<ViewState>({ type: "initial" }); const [view, setView] = useState<ViewState>({ type: "initial" });
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [responseHistory, setResponseHistory] = useState<QuestionResponse[]>([]); const [responseHistory, setResponseHistory] = useState<QuestionResponse[]>([]);
const [conversationHistory, setConversationHistory] = useState<ConversationHistoryEntry[]>([]);
const [editedSummary, setEditedSummary] = useState<MissionPlanSummary | null>(null); const [editedSummary, setEditedSummary] = useState<MissionPlanSummary | null>(null);
const [hasProgress, setHasProgress] = useState(false); const [hasProgress, setHasProgress] = useState(false);
const hasAutoStartedRef = useRef(false); const hasAutoStartedRef = useRef(false);
@@ -92,6 +96,8 @@ export function MissionInterviewModal({
setError(null); setError(null);
setStreamingOutput(""); setStreamingOutput("");
setResponseHistory([]);
setConversationHistory([]);
setIsReconnecting(false); setIsReconnecting(false);
setView({ type: "loading" }); setView({ type: "loading" });
@@ -188,6 +194,16 @@ export function MissionInterviewModal({
fetchAiSession(resumeSessionId).then((session) => { fetchAiSession(resumeSessionId).then((session) => {
if (cancelled || !session) return; if (cancelled || !session) return;
const parsedHistory = parseConversationHistory(session.conversationHistory);
setConversationHistory(parsedHistory);
setResponseHistory(
parsedHistory
.map((entry) => entry.response)
.filter((response): response is QuestionResponse =>
Boolean(response && typeof response === "object" && !Array.isArray(response)),
),
);
if (session.status === "awaiting_input" && session.currentQuestion) { if (session.status === "awaiting_input" && session.currentQuestion) {
try { try {
clearMissionGoal(projectId); clearMissionGoal(projectId);
@@ -320,6 +336,7 @@ export function MissionInterviewModal({
setView({ type: "initial" }); setView({ type: "initial" });
setError(null); setError(null);
setResponseHistory([]); setResponseHistory([]);
setConversationHistory([]);
setEditedSummary(null); setEditedSummary(null);
setStreamingOutput(""); setStreamingOutput("");
setIsReconnecting(false); setIsReconnecting(false);
@@ -355,12 +372,19 @@ export function MissionInterviewModal({
const { sessionId } = view; const { sessionId } = view;
setError(null); setError(null);
setResponseHistory((prev) => [...prev, responses]);
setConversationHistory((prev) => [
...prev,
{
question: view.question,
response: responses,
},
]);
setView({ type: "loading" }); setView({ type: "loading" });
setStreamingOutput(""); setStreamingOutput("");
try { try {
await respondToMissionInterview(sessionId, responses, projectId); await respondToMissionInterview(sessionId, responses, projectId);
setResponseHistory((prev) => [...prev, responses]);
setHasProgress(true); setHasProgress(true);
} catch (err: any) { } catch (err: any) {
setError(err.message || "Failed to submit response"); setError(err.message || "Failed to submit response");
@@ -387,6 +411,7 @@ export function MissionInterviewModal({
setView({ type: "initial" }); setView({ type: "initial" });
setError(null); setError(null);
setResponseHistory([]); setResponseHistory([]);
setConversationHistory([]);
setEditedSummary(null); setEditedSummary(null);
setStreamingOutput(""); setStreamingOutput("");
setIsReconnecting(false); setIsReconnecting(false);
@@ -527,6 +552,7 @@ export function MissionInterviewModal({
<InterviewQuestionForm <InterviewQuestionForm
question={view.question} question={view.question}
progress={getProgress()} progress={getProgress()}
historyEntries={conversationHistory}
onSubmit={handleSubmitResponse} onSubmit={handleSubmitResponse}
/> />
)} )}
@@ -534,6 +560,7 @@ export function MissionInterviewModal({
{view.type === "summary" && editedSummary && ( {view.type === "summary" && editedSummary && (
<MissionPlanReview <MissionPlanReview
summary={editedSummary} summary={editedSummary}
historyEntries={conversationHistory}
onSummaryChange={setEditedSummary} onSummaryChange={setEditedSummary}
onApprove={handleApprovePlan} onApprove={handleApprovePlan}
onStartOver={() => { onStartOver={() => {
@@ -541,6 +568,7 @@ export function MissionInterviewModal({
setHasProgress(false); setHasProgress(false);
setEditedSummary(null); setEditedSummary(null);
setResponseHistory([]); setResponseHistory([]);
setConversationHistory([]);
streamConnectionRef.current?.close(); streamConnectionRef.current?.close();
streamConnectionRef.current = null; streamConnectionRef.current = null;
}} }}
@@ -558,10 +586,11 @@ export function MissionInterviewModal({
interface InterviewQuestionFormProps { interface InterviewQuestionFormProps {
question: PlanningQuestion; question: PlanningQuestion;
progress: number; progress: number;
historyEntries: ConversationHistoryEntry[];
onSubmit: (responses: QuestionResponse) => void; onSubmit: (responses: QuestionResponse) => void;
} }
function InterviewQuestionForm({ question, progress, onSubmit }: InterviewQuestionFormProps) { function InterviewQuestionForm({ question, progress, historyEntries, onSubmit }: InterviewQuestionFormProps) {
const [response, setResponse] = useState<QuestionResponse>({}); const [response, setResponse] = useState<QuestionResponse>({});
const [textValue, setTextValue] = useState(""); const [textValue, setTextValue] = useState("");
@@ -598,6 +627,13 @@ function InterviewQuestionForm({ question, progress, onSubmit }: InterviewQuesti
return ( return (
<div className="planning-question-form"> <div className="planning-question-form">
<div className="planning-view-scroll planning-question-scroll"> <div className="planning-view-scroll planning-question-scroll">
{historyEntries.length > 0 && (
<>
<ConversationHistory entries={historyEntries} />
<div className="conversation-separator" />
</>
)}
<div className="planning-question-panel"> <div className="planning-question-panel">
<div className="planning-progress"> <div className="planning-progress">
<div className="planning-progress-bar"> <div className="planning-progress-bar">
@@ -726,6 +762,7 @@ function InterviewQuestionForm({ question, progress, onSubmit }: InterviewQuesti
interface MissionPlanReviewProps { interface MissionPlanReviewProps {
summary: MissionPlanSummary; summary: MissionPlanSummary;
historyEntries: ConversationHistoryEntry[];
onSummaryChange: (summary: MissionPlanSummary) => void; onSummaryChange: (summary: MissionPlanSummary) => void;
onApprove: () => void; onApprove: () => void;
onStartOver: () => void; onStartOver: () => void;
@@ -734,6 +771,7 @@ interface MissionPlanReviewProps {
function MissionPlanReview({ function MissionPlanReview({
summary, summary,
historyEntries,
onSummaryChange, onSummaryChange,
onApprove, onApprove,
onStartOver, onStartOver,
@@ -838,6 +876,13 @@ function MissionPlanReview({
return ( return (
<div className="planning-summary"> <div className="planning-summary">
<div className="planning-view-scroll planning-summary-scroll"> <div className="planning-view-scroll planning-summary-scroll">
{historyEntries.length > 0 && (
<>
<ConversationHistory entries={historyEntries} />
<div className="conversation-separator" />
</>
)}
<div className="planning-summary-header"> <div className="planning-summary-header">
<CheckCircle size={24} style={{ color: "var(--color-success)" }} /> <CheckCircle size={24} style={{ color: "var(--color-success)" }} />
<h4>Mission Plan Ready</h4> <h4>Mission Plan Ready</h4>

View File

@@ -14,6 +14,7 @@ const mockCreateTaskFromPlanning = vi.fn();
const mockStartPlanningBreakdown = vi.fn(); const mockStartPlanningBreakdown = vi.fn();
const mockCreateTasksFromPlanning = vi.fn(); const mockCreateTasksFromPlanning = vi.fn();
const mockFetchAiSession = vi.fn(); const mockFetchAiSession = vi.fn();
const mockParseConversationHistory = vi.fn();
const mockFetchModels = vi.fn(); const mockFetchModels = vi.fn();
const mockUploadAttachment = vi.fn(); const mockUploadAttachment = vi.fn();
const mockDeleteAttachment = vi.fn(); const mockDeleteAttachment = vi.fn();
@@ -36,6 +37,7 @@ vi.mock("../api", () => ({
startPlanningBreakdown: (...args: any[]) => mockStartPlanningBreakdown(...args), startPlanningBreakdown: (...args: any[]) => mockStartPlanningBreakdown(...args),
createTasksFromPlanning: (...args: any[]) => mockCreateTasksFromPlanning(...args), createTasksFromPlanning: (...args: any[]) => mockCreateTasksFromPlanning(...args),
fetchAiSession: (...args: any[]) => mockFetchAiSession(...args), fetchAiSession: (...args: any[]) => mockFetchAiSession(...args),
parseConversationHistory: (...args: any[]) => mockParseConversationHistory(...args),
uploadAttachment: (...args: any[]) => mockUploadAttachment(...args), uploadAttachment: (...args: any[]) => mockUploadAttachment(...args),
deleteAttachment: (...args: any[]) => mockDeleteAttachment(...args), deleteAttachment: (...args: any[]) => mockDeleteAttachment(...args),
updateTask: (...args: any[]) => mockUpdateTask(...args), updateTask: (...args: any[]) => mockUpdateTask(...args),
@@ -134,6 +136,15 @@ describe("PlanningModeModal", () => {
mockStartPlanningStreaming.mockResolvedValue({ sessionId: "session-123" }); mockStartPlanningStreaming.mockResolvedValue({ sessionId: "session-123" });
mockStartPlanningBreakdown.mockResolvedValue({ sessionId: "session-123", subtasks: [] }); mockStartPlanningBreakdown.mockResolvedValue({ sessionId: "session-123", subtasks: [] });
mockFetchAiSession.mockResolvedValue(null); mockFetchAiSession.mockResolvedValue(null);
mockParseConversationHistory.mockImplementation((raw: string) => {
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
});
mockFetchModels.mockResolvedValue({ mockFetchModels.mockResolvedValue({
models: mockModels, models: mockModels,
favoriteProviders: [], favoriteProviders: [],
@@ -532,6 +543,164 @@ describe("PlanningModeModal", () => {
}); });
}); });
describe("Conversation history", () => {
it("restores all persisted Q&A pairs when resuming a session", async () => {
const resumedQuestion: PlanningQuestion = {
id: "q-current",
type: "text",
question: "What should we prioritize next?",
description: "Current question",
};
const restoredHistory = [
{
question: {
id: "q1",
type: "single_select",
question: "What scope do you need?",
options: [
{ id: "small", label: "Small" },
{ id: "medium", label: "Medium" },
],
},
response: { q1: "medium" },
thinkingOutput: "Reasoning for scope question",
},
{
question: {
id: "q2",
type: "text",
question: "List your acceptance criteria",
},
response: { q2: "Must support offline mode" },
thinkingOutput: "Reasoning for criteria question",
},
];
mockFetchAiSession.mockResolvedValueOnce({
id: "session-awaiting-1",
type: "planning",
status: "awaiting_input",
title: "Resume with history",
inputPayload: JSON.stringify({ initialPlan: "Build planning history restore" }),
conversationHistory: JSON.stringify(restoredHistory),
currentQuestion: JSON.stringify(resumedQuestion),
result: null,
thinkingOutput: "",
error: null,
projectId: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
});
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
resumeSessionId="session-awaiting-1"
/>,
);
await waitFor(() => {
expect(mockParseConversationHistory).toHaveBeenCalledWith(JSON.stringify(restoredHistory));
});
await waitFor(() => {
expect(screen.getByText("What scope do you need?")).toBeDefined();
});
expect(screen.getByText("List your acceptance criteria")).toBeDefined();
expect(screen.getByText("Medium")).toBeDefined();
expect(screen.getByText("Must support offline mode")).toBeDefined();
expect(screen.getByText("What should we prioritize next?")).toBeDefined();
});
it("starts fresh sessions with empty conversation history", async () => {
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
/>,
);
fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), {
target: { value: "Build auth system" },
});
fireEvent.click(screen.getByText("Start Planning"));
await waitFor(() => {
expect(screen.getByText("What is the scope?")).toBeDefined();
});
expect(screen.queryByTestId("conversation-history")).toBeNull();
});
it("appends submitted responses to visible conversation history", async () => {
const secondQuestion: PlanningQuestion = {
id: "q-requirements",
type: "text",
question: "What are the key requirements?",
description: "Describe the requirements",
};
let streamHandlers: any;
mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => {
streamHandlers = handlers;
setTimeout(() => {
handlers.onQuestion?.(mockQuestion);
}, 10);
return {
close: vi.fn(),
isConnected: vi.fn().mockReturnValue(true),
};
});
mockRespondToPlanning.mockImplementation(async () => {
setTimeout(() => {
streamHandlers?.onQuestion?.(secondQuestion);
}, 10);
return { sessionId: "session-123", currentQuestion: null, summary: null };
});
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
/>,
);
fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), {
target: { value: "Build auth system" },
});
fireEvent.click(screen.getByText("Start Planning"));
await waitFor(() => {
expect(screen.getByText("What is the scope?")).toBeDefined();
});
fireEvent.click(screen.getByText("Medium"));
fireEvent.click(screen.getByRole("button", { name: "Continue" }));
await waitFor(() => {
expect(screen.getByText("What are the key requirements?")).toBeDefined();
});
expect(screen.getByTestId("conversation-history")).toBeDefined();
expect(screen.getByText("What is the scope?")).toBeDefined();
expect(screen.getByText("Medium")).toBeDefined();
});
});
describe("Question view", () => { describe("Question view", () => {
it("renders single_select question with options", async () => { it("renders single_select question with options", async () => {
const { container } = render( const { container } = render(

View File

@@ -6,12 +6,14 @@ import {
createTaskFromPlanning, createTaskFromPlanning,
connectPlanningStream, connectPlanningStream,
fetchAiSession, fetchAiSession,
parseConversationHistory,
startPlanningBreakdown, startPlanningBreakdown,
createTasksFromPlanning, createTasksFromPlanning,
fetchModels, fetchModels,
type PlanningSession, type PlanningSession,
type SubtaskItem, type SubtaskItem,
type ModelInfo, type ModelInfo,
type ConversationHistoryEntry,
} from "../api"; } from "../api";
import { import {
savePlanningDescription, savePlanningDescription,
@@ -20,6 +22,7 @@ import {
} from "../hooks/modalPersistence"; } from "../hooks/modalPersistence";
import { Lightbulb, X, Loader2, CheckCircle, ArrowLeft, ArrowRight, Sparkles, ListTree, GripVertical, ArrowUp, ArrowDown, Plus, Trash2, Minimize2 } from "lucide-react"; import { Lightbulb, X, Loader2, CheckCircle, ArrowLeft, ArrowRight, Sparkles, ListTree, GripVertical, ArrowUp, ArrowDown, Plus, Trash2, Minimize2 } from "lucide-react";
import { CustomModelDropdown } from "./CustomModelDropdown"; import { CustomModelDropdown } from "./CustomModelDropdown";
import { ConversationHistory } from "./ConversationHistory";
interface PlanningModeModalProps { interface PlanningModeModalProps {
isOpen: boolean; isOpen: boolean;
@@ -77,6 +80,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
const [view, setView] = useState<ViewState>({ type: "initial" }); const [view, setView] = useState<ViewState>({ type: "initial" });
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [responseHistory, setResponseHistory] = useState<QuestionResponse[]>([]); const [responseHistory, setResponseHistory] = useState<QuestionResponse[]>([]);
const [conversationHistory, setConversationHistory] = useState<ConversationHistoryEntry[]>([]);
const [editedSummary, setEditedSummary] = useState<PlanningSummary | null>(null); const [editedSummary, setEditedSummary] = useState<PlanningSummary | null>(null);
// Use ref instead of state for hasAutoStarted to handle React StrictMode double-render. // Use ref instead of state for hasAutoStarted to handle React StrictMode double-render.
// In StrictMode, components render twice but state persists across renders, // In StrictMode, components render twice but state persists across renders,
@@ -142,6 +146,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
setError(null); setError(null);
setStreamingOutput(""); setStreamingOutput("");
setConversationHistory([]);
setResponseHistory([]);
setIsReconnecting(false); setIsReconnecting(false);
setView({ type: "loading" }); setView({ type: "loading" });
@@ -250,6 +256,15 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
if (cancelled || !session) return; if (cancelled || !session) return;
currentSessionIdRef.current = resumeSessionId; currentSessionIdRef.current = resumeSessionId;
const parsedHistory = parseConversationHistory(session.conversationHistory);
setConversationHistory(parsedHistory);
setResponseHistory(
parsedHistory
.map((entry) => entry.response)
.filter((response): response is QuestionResponse =>
Boolean(response && typeof response === "object" && !Array.isArray(response)),
),
);
if (session.status === "awaiting_input" && session.currentQuestion) { if (session.status === "awaiting_input" && session.currentQuestion) {
clearPlanningDescription(projectId); clearPlanningDescription(projectId);
@@ -355,6 +370,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
setView({ type: "initial" }); setView({ type: "initial" });
setError(null); setError(null);
setResponseHistory([]); setResponseHistory([]);
setConversationHistory([]);
setEditedSummary(null); setEditedSummary(null);
setStreamingOutput(""); setStreamingOutput("");
setIsReconnecting(false); setIsReconnecting(false);
@@ -384,6 +400,12 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
const { session } = view; const { session } = view;
const sessionId = session.sessionId; const sessionId = session.sessionId;
const activeQuestion = session.currentQuestion;
if (!activeQuestion) {
setError("No active question in session");
return;
}
setError(null); setError(null);
// Keep the existing SSE connection alive - do NOT close it! // Keep the existing SSE connection alive - do NOT close it!
@@ -392,20 +414,27 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
// This prevents the race condition where events are missed because // This prevents the race condition where events are missed because
// the frontend disconnects and reconnects after the API call. // the frontend disconnects and reconnects after the API call.
setResponseHistory((prev) => [...prev, responses]);
setConversationHistory((prev) => [
...prev,
{
question: activeQuestion,
response: responses,
},
]);
setView({ type: "loading" }); setView({ type: "loading" });
setStreamingOutput(""); // Clear old thinking output when entering loading state setStreamingOutput(""); // Clear old thinking output when entering loading state
try { try {
// Submit response - AI will broadcast events via the already-connected stream // Submit response - AI will broadcast events via the already-connected stream
await respondToPlanning(sessionId, responses, projectId); await respondToPlanning(sessionId, responses, projectId);
setResponseHistory((prev) => [...prev, responses]);
// Events (question/summary) will arrive via the existing SSE stream // Events (question/summary) will arrive via the existing SSE stream
} catch (err: any) { } catch (err: any) {
setError(err.message || "Failed to submit response"); setError(err.message || "Failed to submit response");
setView({ type: "question", session }); setView({ type: "question", session });
} }
}, },
[view] [projectId, view]
); );
const handleCreateTask = useCallback(async () => { const handleCreateTask = useCallback(async () => {
@@ -458,6 +487,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
setView({ type: "initial" }); setView({ type: "initial" });
setError(null); setError(null);
setResponseHistory([]); setResponseHistory([]);
setConversationHistory([]);
setEditedSummary(null); setEditedSummary(null);
setStreamingOutput(""); setStreamingOutput("");
setPlanningModelProvider(undefined); setPlanningModelProvider(undefined);
@@ -683,6 +713,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
<QuestionForm <QuestionForm
question={view.session.currentQuestion} question={view.session.currentQuestion}
progress={getProgress()} progress={getProgress()}
historyEntries={conversationHistory}
onSubmit={handleSubmitResponse} onSubmit={handleSubmitResponse}
onBack={responseHistory.length > 0 ? handleBack : undefined} onBack={responseHistory.length > 0 ? handleBack : undefined}
/> />
@@ -692,6 +723,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
{view.type === "summary" && editedSummary && ( {view.type === "summary" && editedSummary && (
<SummaryView <SummaryView
summary={editedSummary} summary={editedSummary}
historyEntries={conversationHistory}
onSummaryChange={setEditedSummary} onSummaryChange={setEditedSummary}
tasks={tasks} tasks={tasks}
onCreateTask={handleCreateTask} onCreateTask={handleCreateTask}
@@ -736,11 +768,12 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
interface QuestionFormProps { interface QuestionFormProps {
question: PlanningQuestion; question: PlanningQuestion;
progress: number; progress: number;
historyEntries: ConversationHistoryEntry[];
onSubmit: (responses: QuestionResponse) => void; onSubmit: (responses: QuestionResponse) => void;
onBack?: () => void; onBack?: () => void;
} }
function QuestionForm({ question, progress, onSubmit, onBack }: QuestionFormProps) { function QuestionForm({ question, progress, historyEntries, onSubmit, onBack }: QuestionFormProps) {
const [response, setResponse] = useState<QuestionResponse>({}); const [response, setResponse] = useState<QuestionResponse>({});
const [textValue, setTextValue] = useState(""); const [textValue, setTextValue] = useState("");
@@ -778,6 +811,13 @@ function QuestionForm({ question, progress, onSubmit, onBack }: QuestionFormProp
return ( return (
<div className="planning-question-form"> <div className="planning-question-form">
<div className="planning-view-scroll planning-question-scroll"> <div className="planning-view-scroll planning-question-scroll">
{historyEntries.length > 0 && (
<>
<ConversationHistory entries={historyEntries} />
<div className="conversation-separator" />
</>
)}
<div className="planning-question-panel"> <div className="planning-question-panel">
<div className="planning-progress"> <div className="planning-progress">
<div className="planning-progress-bar"> <div className="planning-progress-bar">
@@ -910,6 +950,7 @@ function QuestionForm({ question, progress, onSubmit, onBack }: QuestionFormProp
interface SummaryViewProps { interface SummaryViewProps {
summary: PlanningSummary; summary: PlanningSummary;
historyEntries: ConversationHistoryEntry[];
onSummaryChange: (summary: PlanningSummary) => void; onSummaryChange: (summary: PlanningSummary) => void;
tasks: Task[]; tasks: Task[];
onCreateTask: () => void; onCreateTask: () => void;
@@ -920,6 +961,7 @@ interface SummaryViewProps {
function SummaryView({ function SummaryView({
summary, summary,
historyEntries,
onSummaryChange, onSummaryChange,
tasks, tasks,
onCreateTask, onCreateTask,
@@ -943,6 +985,13 @@ function SummaryView({
return ( return (
<div className="planning-summary"> <div className="planning-summary">
<div className="planning-view-scroll planning-summary-scroll"> <div className="planning-view-scroll planning-summary-scroll">
{historyEntries.length > 0 && (
<>
<ConversationHistory entries={historyEntries} />
<div className="conversation-separator" />
</>
)}
<div className="planning-summary-header"> <div className="planning-summary-header">
<CheckCircle size={24} style={{ color: "var(--color-success)" }} /> <CheckCircle size={24} style={{ color: "var(--color-success)" }} />
<h4>Planning Complete!</h4> <h4>Planning Complete!</h4>

View File

@@ -6,7 +6,9 @@ import {
createTasksFromBreakdown, createTasksFromBreakdown,
cancelSubtaskBreakdown, cancelSubtaskBreakdown,
fetchAiSession, fetchAiSession,
parseConversationHistory,
type SubtaskItem, type SubtaskItem,
type ConversationHistoryEntry,
} from "../api"; } from "../api";
import { import {
saveSubtaskDescription, saveSubtaskDescription,
@@ -14,6 +16,7 @@ import {
clearSubtaskDescription, clearSubtaskDescription,
} from "../hooks/modalPersistence"; } from "../hooks/modalPersistence";
import { CheckCircle, Loader2, ListTree, Plus, Trash2, X, GripVertical, ArrowUp, ArrowDown, Minimize2 } from "lucide-react"; import { CheckCircle, Loader2, ListTree, Plus, Trash2, X, GripVertical, ArrowUp, ArrowDown, Minimize2 } from "lucide-react";
import { ConversationHistory } from "./ConversationHistory";
interface SubtaskBreakdownModalProps { interface SubtaskBreakdownModalProps {
isOpen: boolean; isOpen: boolean;
@@ -64,6 +67,7 @@ function hasDependencyCycle(subtasks: SubtaskItem[]): boolean {
export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onTasksCreated, parentTaskId, projectId, resumeSessionId }: SubtaskBreakdownModalProps) { export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onTasksCreated, parentTaskId, projectId, resumeSessionId }: SubtaskBreakdownModalProps) {
const [view, setView] = useState<ViewState>({ type: "initial" }); const [view, setView] = useState<ViewState>({ type: "initial" });
const [subtasks, setSubtasks] = useState<SubtaskItem[]>([]); const [subtasks, setSubtasks] = useState<SubtaskItem[]>([]);
const [conversationHistory, setConversationHistory] = useState<ConversationHistoryEntry[]>([]);
const [thinkingOutput, setThinkingOutput] = useState(""); const [thinkingOutput, setThinkingOutput] = useState("");
const [showThinking, setShowThinking] = useState(true); const [showThinking, setShowThinking] = useState(true);
const [isReconnecting, setIsReconnecting] = useState(false); const [isReconnecting, setIsReconnecting] = useState(false);
@@ -102,6 +106,7 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
streamRef.current = null; streamRef.current = null;
setView({ type: "initial" }); setView({ type: "initial" });
setSubtasks([]); setSubtasks([]);
setConversationHistory([]);
setThinkingOutput(""); setThinkingOutput("");
setShowThinking(true); setShowThinking(true);
setIsReconnecting(false); setIsReconnecting(false);
@@ -134,6 +139,7 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
const beginBreakdown = useCallback(async () => { const beginBreakdown = useCallback(async () => {
if (!localDescription.trim()) return; if (!localDescription.trim()) return;
setError(null); setError(null);
setConversationHistory([]);
setThinkingOutput(""); setThinkingOutput("");
setIsReconnecting(false); setIsReconnecting(false);
@@ -191,6 +197,10 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
try { try {
const session = await fetchAiSession(resumeSessionId); const session = await fetchAiSession(resumeSessionId);
if (!session) return; if (!session) return;
const parsedHistory = parseConversationHistory(session.conversationHistory);
setConversationHistory(parsedHistory);
if (session.status === "generating" || session.status === "awaiting_input") { if (session.status === "generating" || session.status === "awaiting_input") {
setThinkingOutput(session.thinkingOutput ?? ""); setThinkingOutput(session.thinkingOutput ?? "");
setView({ type: "generating", sessionId: resumeSessionId }); setView({ type: "generating", sessionId: resumeSessionId });
@@ -407,6 +417,12 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
{view.type === "generating" && ( {view.type === "generating" && (
<div className="planning-loading"> <div className="planning-loading">
{conversationHistory.length > 0 && (
<>
<ConversationHistory entries={conversationHistory} defaultShowThinking={true} />
<div className="conversation-separator" />
</>
)}
<Loader2 size={40} className="spin" style={{ color: "var(--todo)" }} /> <Loader2 size={40} className="spin" style={{ color: "var(--todo)" }} />
<p>AI is generating subtasks...</p> <p>AI is generating subtasks...</p>
<div className="planning-thinking-container"> <div className="planning-thinking-container">
@@ -425,6 +441,13 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
{(view.type === "editing" || view.type === "creating") && ( {(view.type === "editing" || view.type === "creating") && (
<div className="planning-summary"> <div className="planning-summary">
<div className="planning-view-scroll planning-summary-scroll"> <div className="planning-view-scroll planning-summary-scroll">
{conversationHistory.length > 0 && (
<>
<ConversationHistory entries={conversationHistory} />
<div className="conversation-separator" />
</>
)}
<div className="planning-summary-header"> <div className="planning-summary-header">
<CheckCircle size={24} style={{ color: "var(--color-success)" }} /> <CheckCircle size={24} style={{ color: "var(--color-success)" }} />
<h4>Review your subtasks</h4> <h4>Review your subtasks</h4>

View File

@@ -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();
});
});

View File

@@ -13,6 +13,7 @@ vi.mock("../../api", () => ({
createMissionFromInterview: vi.fn(), createMissionFromInterview: vi.fn(),
connectMissionInterviewStream: vi.fn(), connectMissionInterviewStream: vi.fn(),
fetchAiSession: vi.fn(), fetchAiSession: vi.fn(),
parseConversationHistory: vi.fn(),
})); }));
vi.mock("../../hooks/modalPersistence", () => ({ vi.mock("../../hooks/modalPersistence", () => ({
@@ -27,6 +28,7 @@ const mockCancelMissionInterview = vi.mocked(api.cancelMissionInterview);
const mockCreateMissionFromInterview = vi.mocked(api.createMissionFromInterview); const mockCreateMissionFromInterview = vi.mocked(api.createMissionFromInterview);
const mockConnectMissionInterviewStream = vi.mocked(api.connectMissionInterviewStream); const mockConnectMissionInterviewStream = vi.mocked(api.connectMissionInterviewStream);
const mockFetchAiSession = vi.mocked(api.fetchAiSession); const mockFetchAiSession = vi.mocked(api.fetchAiSession);
const mockParseConversationHistory = vi.mocked(api.parseConversationHistory);
const mockGetMissionGoal = vi.mocked(modalPersistence.getMissionGoal); const mockGetMissionGoal = vi.mocked(modalPersistence.getMissionGoal);
const sampleQuestionSingle: PlanningQuestion = { const sampleQuestionSingle: PlanningQuestion = {
@@ -84,6 +86,15 @@ describe("MissionInterviewModal", () => {
mockCancelMissionInterview.mockResolvedValue(undefined); mockCancelMissionInterview.mockResolvedValue(undefined);
mockCreateMissionFromInterview.mockResolvedValue({ id: "MS-001", title: "Created mission" } as any); mockCreateMissionFromInterview.mockResolvedValue({ id: "MS-001", title: "Created mission" } as any);
mockFetchAiSession.mockResolvedValue(null); 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(""); mockGetMissionGoal.mockReturnValue("");
mockConnectMissionInterviewStream.mockImplementation((_sessionId, _projectId, handlers) => { mockConnectMissionInterviewStream.mockImplementation((_sessionId, _projectId, handlers) => {

View File

@@ -25657,3 +25657,130 @@ body[data-color-theme="terminal"][data-theme="light"]::before {
justify-content: flex-end; justify-content: flex-end;
} }
} }
/* === Conversation History Timeline === */
.conversation-history {
border-left: 2px solid var(--border-primary);
padding: 0 0 0 12px;
margin: 0 0 16px;
max-height: 280px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 12px;
}
.conversation-entry {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 4px;
}
.conversation-entry-question {
color: var(--text-secondary);
display: flex;
flex-direction: column;
gap: 4px;
font-size: 13px;
line-height: 1.45;
}
.conversation-entry-question-label {
display: inline-flex;
align-items: center;
width: fit-content;
padding: 2px 8px;
border-radius: 999px;
background: color-mix(in srgb, var(--bg-secondary) 80%, transparent);
border: 1px solid var(--border-primary);
color: var(--text-primary);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
}
.conversation-entry-response {
border: 1px solid var(--border-primary);
border-radius: 8px;
background: var(--bg-secondary);
padding: 8px 10px;
color: var(--text-primary);
display: flex;
flex-direction: column;
gap: 4px;
font-size: 13px;
line-height: 1.45;
}
.conversation-entry-thinking {
display: flex;
flex-direction: column;
gap: 6px;
}
.conversation-entry-thinking pre {
margin: 0;
border: 1px solid var(--border-primary);
border-radius: 8px;
background: color-mix(in srgb, var(--bg-secondary) 88%, transparent);
padding: 10px;
color: var(--text-secondary);
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.45;
white-space: pre-wrap;
word-break: break-word;
}
.conversation-thinking-toggle {
min-height: 44px;
width: fit-content;
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid var(--border-primary);
border-radius: 8px;
background: transparent;
color: var(--text-secondary);
padding: 8px 12px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: background-color var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast);
}
.conversation-thinking-toggle:hover {
background: var(--bg-secondary);
color: var(--text-primary);
}
.conversation-thinking-toggle:focus-visible {
outline: 2px solid var(--todo);
outline-offset: 2px;
}
.conversation-separator {
border-top: 1px solid var(--border-primary);
margin: 4px 0 16px;
}
@media (max-width: 768px) {
.conversation-history {
max-height: 220px;
margin-bottom: 12px;
padding-left: 10px;
}
.conversation-entry-question,
.conversation-entry-response {
font-size: 14px;
}
.conversation-thinking-toggle {
width: 100%;
justify-content: center;
min-height: 44px;
}
}

View File

@@ -21,10 +21,14 @@ import type {
MissionEvent, MissionEvent,
MissionHealth, MissionHealth,
} from "@fusion/core"; } from "@fusion/core";
import type { AiSessionRow } from "./ai-session-store.js";
import { import {
__resetMissionInterviewState, __resetMissionInterviewState,
createMissionInterviewSession, createMissionInterviewSession,
missionInterviewStreamManager, missionInterviewStreamManager,
setAiSessionStore,
getMissionInterviewSession,
submitMissionInterviewResponse,
} from "./mission-interview.js"; } from "./mission-interview.js";
// Mock MissionStore factory // Mock MissionStore factory
@@ -456,6 +460,84 @@ function buildApp(options?: {
return { app, store, missionStore: store.getMissionStore() }; return { app, store, missionStore: store.getMissionStore() };
} }
class MockAiSessionStore {
rows = new Map<string, AiSessionRow>();
upsert(row: AiSessionRow): void {
this.rows.set(row.id, row);
}
updateThinking(id: string, thinkingOutput: string): void {
const row = this.rows.get(id);
if (!row) {
return;
}
this.rows.set(id, {
...row,
thinkingOutput,
updatedAt: new Date().toISOString(),
});
}
delete(id: string): void {
this.rows.delete(id);
}
get(id: string): AiSessionRow | null {
return this.rows.get(id) ?? null;
}
listRecoverable(): AiSessionRow[] {
return [...this.rows.values()].filter(
(row) => row.status === "awaiting_input" || row.status === "generating",
);
}
on(): this {
return this;
}
off(): this {
return this;
}
}
function buildMissionInterviewRow(
overrides: Partial<AiSessionRow> & Pick<AiSessionRow, "id" | "status">,
): AiSessionRow {
const now = new Date().toISOString();
return {
id: overrides.id,
type: "mission_interview",
status: overrides.status,
title: overrides.title ?? "Recovered mission interview session",
inputPayload:
overrides.inputPayload ??
JSON.stringify({
ip: "127.0.0.1",
missionId: "M-RECOVERED",
missionTitle: "Recovered mission interview",
}),
conversationHistory: overrides.conversationHistory ?? "[]",
currentQuestion:
overrides.currentQuestion ??
JSON.stringify({
id: "q-existing",
type: "text",
question: "What are we building?",
description: "context",
}),
result: overrides.result ?? null,
thinkingOutput: overrides.thinkingOutput ?? "Recovered thinking",
error: overrides.error ?? null,
projectId: overrides.projectId ?? null,
createdAt: overrides.createdAt ?? now,
updatedAt: overrides.updatedAt ?? now,
};
}
describe("Mission API", () => { describe("Mission API", () => {
describe("POST /api/missions", () => { describe("POST /api/missions", () => {
it("should create a mission with the default auto-advance state", async () => { it("should create a mission with the default auto-advance state", async () => {
@@ -1797,6 +1879,132 @@ describe("Mission API", () => {
expect(res.status).toBe(400); expect(res.status).toBe(400);
expect(res.body.error).toContain("sessionId"); expect(res.body.error).toContain("sessionId");
}); });
it("captures generated thinking for the next mission interview question", async () => {
const store = new MockAiSessionStore();
const sessionId = "mission-thinking-capture";
store.rows.set(
sessionId,
buildMissionInterviewRow({
id: sessionId,
status: "awaiting_input",
thinkingOutput: "First-turn mission reasoning",
}),
);
setAiSessionStore(store as any);
const session = getMissionInterviewSession(sessionId);
expect(session).toBeDefined();
if (!session) {
throw new Error("Expected mission interview session to exist");
}
const messages: Array<{ role: string; content: string }> = [];
session.agent = {
session: {
state: { messages },
prompt: vi.fn(async (message: string) => {
messages.push({ role: "user", content: message });
session.thinkingOutput += "Generated follow-up reasoning";
messages.push({
role: "assistant",
content: JSON.stringify({
type: "question",
data: {
id: "q-followup",
type: "text",
question: "What should we deliver first?",
description: "Clarify order",
},
}),
});
}),
dispose: vi.fn(),
},
} as any;
const response = await submitMissionInterviewResponse(
sessionId,
{ "q-existing": "Ship collaborative editing" },
"/tmp/project",
);
expect(response.type).toBe("question");
expect(getMissionInterviewSession(sessionId)?.lastGeneratedThinking).toBe(
"Generated follow-up reasoning",
);
});
it("stores and persists per-turn mission interview thinking in conversation history", async () => {
const store = new MockAiSessionStore();
const sessionId = "mission-thinking-history";
store.rows.set(
sessionId,
buildMissionInterviewRow({
id: sessionId,
status: "awaiting_input",
thinkingOutput: "First-turn stored reasoning",
}),
);
setAiSessionStore(store as any);
const session = getMissionInterviewSession(sessionId);
expect(session).toBeDefined();
if (!session) {
throw new Error("Expected mission interview session to exist");
}
const messages: Array<{ role: string; content: string }> = [];
session.agent = {
session: {
state: { messages },
prompt: vi.fn(async (message: string) => {
messages.push({ role: "user", content: message });
session.thinkingOutput += "Second-turn mission reasoning";
messages.push({
role: "assistant",
content: JSON.stringify({
type: "question",
data: {
id: "q-next",
type: "text",
question: "Who owns implementation?",
description: "Team ownership",
},
}),
});
}),
dispose: vi.fn(),
},
} as any;
await submitMissionInterviewResponse(
sessionId,
{ "q-existing": "Need milestone planning" },
"/tmp/project",
);
const inMemorySession = getMissionInterviewSession(sessionId);
expect(inMemorySession?.history[0]).toMatchObject({
question: expect.objectContaining({ id: "q-existing" }),
response: { "q-existing": "Need milestone planning" },
thinkingOutput: "First-turn stored reasoning",
});
const persistedRow = store.get(sessionId);
expect(persistedRow).not.toBeNull();
const persistedHistory = JSON.parse(persistedRow!.conversationHistory) as Array<{
question: { id: string };
response: Record<string, unknown>;
thinkingOutput?: string;
}>;
expect(persistedHistory[0]).toMatchObject({
question: expect.objectContaining({ id: "q-existing" }),
response: { "q-existing": "Need milestone planning" },
thinkingOutput: "First-turn stored reasoning",
});
});
}); });
// ── Regression: Generated ID format acceptance ───────────────────────── // ── Regression: Generated ID format acceptance ─────────────────────────

View File

@@ -154,17 +154,25 @@ export type MissionInterviewStreamEvent =
/** Callback function for streaming events */ /** Callback function for streaming events */
export type MissionInterviewStreamCallback = (event: MissionInterviewStreamEvent, eventId?: number) => void; export type MissionInterviewStreamCallback = (event: MissionInterviewStreamEvent, eventId?: number) => void;
interface MissionInterviewHistoryEntry {
question: PlanningQuestion;
response: unknown;
thinkingOutput?: string;
}
/** In-memory interview session */ /** In-memory interview session */
interface MissionInterviewSession { interface MissionInterviewSession {
id: string; id: string;
ip: string; ip: string;
missionId: string; missionId: string;
missionTitle: string; missionTitle: string;
history: Array<{ question: PlanningQuestion; response: unknown }>; history: MissionInterviewHistoryEntry[];
currentQuestion?: PlanningQuestion; currentQuestion?: PlanningQuestion;
summary?: MissionPlanSummary; summary?: MissionPlanSummary;
agent?: AgentResult; agent?: AgentResult;
thinkingOutput: string; thinkingOutput: string;
/** Thinking output generated while producing currentQuestion */
lastGeneratedThinking: string;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
} }
@@ -285,7 +293,7 @@ function buildMissionInterviewSessionFromRow(row: AiSessionRow): MissionIntervie
ip: payload.ip ?? "", ip: payload.ip ?? "",
missionId: payload.missionId ?? "", missionId: payload.missionId ?? "",
missionTitle: payload.missionTitle ?? row.title, missionTitle: payload.missionTitle ?? row.title,
history: safeParseJson<Array<{ question: PlanningQuestion; response: unknown }>>( history: safeParseJson<MissionInterviewHistoryEntry[]>(
row.conversationHistory, row.conversationHistory,
[], [],
{ throwOnError: true, fieldName: "conversationHistory" }, { throwOnError: true, fieldName: "conversationHistory" },
@@ -303,6 +311,7 @@ function buildMissionInterviewSessionFromRow(row: AiSessionRow): MissionIntervie
}) ?? undefined) }) ?? undefined)
: undefined, : undefined,
thinkingOutput: row.thinkingOutput, thinkingOutput: row.thinkingOutput,
lastGeneratedThinking: row.thinkingOutput || "",
createdAt, createdAt,
updatedAt, updatedAt,
agent: undefined, agent: undefined,
@@ -845,6 +854,7 @@ async function continueAgentConversation(session: MissionInterviewSession, messa
if (parsed.type === "question") { if (parsed.type === "question") {
session.currentQuestion = parsed.data; session.currentQuestion = parsed.data;
session.lastGeneratedThinking = session.thinkingOutput;
session.updatedAt = new Date(); session.updatedAt = new Date();
persistMissionSession(session, "awaiting_input"); persistMissionSession(session, "awaiting_input");
missionInterviewStreamManager.broadcast(session.id, { missionInterviewStreamManager.broadcast(session.id, {
@@ -900,6 +910,7 @@ export async function createMissionInterviewSession(
missionTitle, missionTitle,
history: [], history: [],
thinkingOutput: "", thinkingOutput: "",
lastGeneratedThinking: "",
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
}; };
@@ -942,6 +953,7 @@ export async function submitMissionInterviewResponse(
session.history.push({ session.history.push({
question: session.currentQuestion, question: session.currentQuestion,
response: responses, response: responses,
thinkingOutput: session.lastGeneratedThinking || "",
}); });
persistMissionSession(session, "generating"); persistMissionSession(session, "generating");

View File

@@ -115,6 +115,39 @@ function setupMockAgent(responses?: string[]) {
return agent; return agent;
} }
function setupMockStreamingAgent(options?: {
responses?: string[];
thinkingPerPrompt?: string[];
}) {
const responses = options?.responses ?? STANDARD_QUESTION_RESPONSES;
const thinkingPerPrompt = options?.thinkingPerPrompt ?? [];
let promptIndex = 0;
const createKbAgentSpy = vi.fn(async (agentOptions?: { onThinking?: (delta: string) => void }) => {
const messages: Array<{ role: string; content: string }> = [];
return {
session: {
state: { messages },
prompt: vi.fn(async (message: string) => {
messages.push({ role: "user", content: message });
const thinking = thinkingPerPrompt[promptIndex];
if (thinking) {
agentOptions?.onThinking?.(thinking);
}
const response = responses[promptIndex] ?? responses[responses.length - 1];
messages.push({ role: "assistant", content: response });
promptIndex += 1;
}),
dispose: vi.fn(),
},
};
});
__setCreateKbAgent(createKbAgentSpy as any);
return { createKbAgentSpy };
}
class MockAiSessionStore extends EventEmitter { class MockAiSessionStore extends EventEmitter {
rows = new Map<string, AiSessionRow>(); rows = new Map<string, AiSessionRow>();
@@ -508,6 +541,75 @@ describe("planning module", () => {
"cannot be resumed without project context", "cannot be resumed without project context",
); );
}); });
it("captures first generated question thinking in lastGeneratedThinking", async () => {
setupMockStreamingAgent({
responses: STANDARD_QUESTION_RESPONSES,
thinkingPerPrompt: ["First question reasoning"],
});
const sessionId = await createSessionWithAgent(getUniqueIp(), initialPlan, TEST_ROOT_DIR);
await vi.waitFor(() => {
expect(getSession(sessionId)?.currentQuestion?.id).toBe("q-scope");
});
expect(getSession(sessionId)?.lastGeneratedThinking).toBe("First question reasoning");
});
it("stores per-turn thinking output in history entries", async () => {
setupMockStreamingAgent({
responses: STANDARD_QUESTION_RESPONSES,
thinkingPerPrompt: ["First question thinking", "Second question thinking"],
});
const sessionId = await createSessionWithAgent(getUniqueIp(), initialPlan, TEST_ROOT_DIR);
await vi.waitFor(() => {
expect(getSession(sessionId)?.currentQuestion?.id).toBe("q-scope");
});
const response = await submitResponse(sessionId, { "q-scope": "medium" }, TEST_ROOT_DIR);
expect(response.type).toBe("question");
const session = getSession(sessionId);
expect(session?.history[0]).toMatchObject({
question: expect.objectContaining({ id: "q-scope" }),
response: { "q-scope": "medium" },
thinkingOutput: "First question thinking",
});
});
it("persists per-turn thinking in conversationHistory JSON", async () => {
const store = new MockAiSessionStore();
setAiSessionStore(store as any);
setupMockStreamingAgent({
responses: STANDARD_QUESTION_RESPONSES,
thinkingPerPrompt: ["Persisted first-turn thinking", "Persisted second-turn thinking"],
});
const sessionId = await createSessionWithAgent(getUniqueIp(), initialPlan, TEST_ROOT_DIR);
await vi.waitFor(() => {
expect(getSession(sessionId)?.currentQuestion?.id).toBe("q-scope");
});
await submitResponse(sessionId, { "q-scope": "medium" }, TEST_ROOT_DIR);
const row = store.get(sessionId);
expect(row).not.toBeNull();
const persistedHistory = JSON.parse(row!.conversationHistory) as Array<{
question: PlanningQuestion;
response: Record<string, unknown>;
thinkingOutput?: string;
}>;
expect(persistedHistory[0]).toMatchObject({
question: expect.objectContaining({ id: "q-scope" }),
response: { "q-scope": "medium" },
thinkingOutput: "Persisted first-turn thinking",
});
});
}); });
describe("cancelSession", () => { describe("cancelSession", () => {

View File

@@ -120,11 +120,17 @@ export type PlanningStreamEvent =
/** Callback function for streaming events */ /** Callback function for streaming events */
export type PlanningStreamCallback = (event: PlanningStreamEvent, eventId?: number) => void; export type PlanningStreamCallback = (event: PlanningStreamEvent, eventId?: number) => void;
interface PlanningHistoryEntry {
question: PlanningQuestion;
response: unknown;
thinkingOutput?: string;
}
interface Session { interface Session {
id: string; id: string;
ip: string; ip: string;
initialPlan: string; initialPlan: string;
history: Array<{ question: PlanningQuestion; response: unknown }>; history: PlanningHistoryEntry[];
currentQuestion?: PlanningQuestion; currentQuestion?: PlanningQuestion;
summary?: PlanningSummary; summary?: PlanningSummary;
/** AI agent session for real-time interaction */ /** AI agent session for real-time interaction */
@@ -133,6 +139,8 @@ interface Session {
streamCallback?: PlanningStreamCallback; streamCallback?: PlanningStreamCallback;
/** Accumulated thinking output for display */ /** Accumulated thinking output for display */
thinkingOutput: string; thinkingOutput: string;
/** Thinking output generated while producing currentQuestion */
lastGeneratedThinking: string;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
} }
@@ -260,7 +268,7 @@ function buildSessionFromRow(row: AiSessionRow): Session {
id: row.id, id: row.id,
ip: payload.ip ?? "", ip: payload.ip ?? "",
initialPlan: payload.initialPlan ?? row.title, initialPlan: payload.initialPlan ?? row.title,
history: safeParseJson<Array<{ question: PlanningQuestion; response: unknown }>>( history: safeParseJson<PlanningHistoryEntry[]>(
row.conversationHistory, row.conversationHistory,
[], [],
{ throwOnError: true, fieldName: "conversationHistory" }, { throwOnError: true, fieldName: "conversationHistory" },
@@ -278,6 +286,7 @@ function buildSessionFromRow(row: AiSessionRow): Session {
}) ?? undefined) }) ?? undefined)
: undefined, : undefined,
thinkingOutput: row.thinkingOutput, thinkingOutput: row.thinkingOutput,
lastGeneratedThinking: row.thinkingOutput || "",
createdAt, createdAt,
updatedAt, updatedAt,
agent: undefined, agent: undefined,
@@ -546,6 +555,7 @@ export async function createSession(
initialPlan, initialPlan,
history: [], history: [],
thinkingOutput: "", thinkingOutput: "",
lastGeneratedThinking: "",
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
}; };
@@ -720,6 +730,7 @@ export async function createSessionWithAgent(
initialPlan, initialPlan,
history: [], history: [],
thinkingOutput: "", thinkingOutput: "",
lastGeneratedThinking: "",
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
}; };
@@ -952,6 +963,7 @@ async function continueAgentConversation(session: Session, message: string): Pro
if (parsed.type === "question") { if (parsed.type === "question") {
session.currentQuestion = parsed.data; session.currentQuestion = parsed.data;
session.lastGeneratedThinking = session.thinkingOutput;
session.updatedAt = new Date(); session.updatedAt = new Date();
persistSession(session, "awaiting_input"); persistSession(session, "awaiting_input");
planningStreamManager.broadcast(session.id, { planningStreamManager.broadcast(session.id, {
@@ -1188,6 +1200,7 @@ export async function submitResponse(
session.history.push({ session.history.push({
question: session.currentQuestion, question: session.currentQuestion,
response: responses, response: responses,
thinkingOutput: session.lastGeneratedThinking || "",
}); });
persistSession(session, "generating"); persistSession(session, "generating");

View File

@@ -188,7 +188,7 @@ function persistSubtaskSession(session: SubtaskInternalSession, status: "generat
status, status,
title: session.initialDescription.slice(0, 120), title: session.initialDescription.slice(0, 120),
inputPayload: JSON.stringify({ initialDescription: session.initialDescription }), inputPayload: JSON.stringify({ initialDescription: session.initialDescription }),
conversationHistory: "[]", conversationHistory: JSON.stringify([{ thinkingOutput: session.thinkingOutput || "" }]),
currentQuestion: null, currentQuestion: null,
result: session.subtasks.length > 0 ? JSON.stringify(session.subtasks) : null, result: session.subtasks.length > 0 ? JSON.stringify(session.subtasks) : null,
thinkingOutput: session.thinkingOutput, thinkingOutput: session.thinkingOutput,