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:
@@ -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}`));
|
||||||
|
|||||||
151
packages/dashboard/app/components/ConversationHistory.tsx
Normal file
151
packages/dashboard/app/components/ConversationHistory.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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(),
|
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) => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 ─────────────────────────
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user