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:
@@ -21,10 +21,14 @@ import type {
|
||||
MissionEvent,
|
||||
MissionHealth,
|
||||
} from "@fusion/core";
|
||||
import type { AiSessionRow } from "./ai-session-store.js";
|
||||
import {
|
||||
__resetMissionInterviewState,
|
||||
createMissionInterviewSession,
|
||||
missionInterviewStreamManager,
|
||||
setAiSessionStore,
|
||||
getMissionInterviewSession,
|
||||
submitMissionInterviewResponse,
|
||||
} from "./mission-interview.js";
|
||||
|
||||
// Mock MissionStore factory
|
||||
@@ -456,6 +460,84 @@ function buildApp(options?: {
|
||||
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("POST /api/missions", () => {
|
||||
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.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 ─────────────────────────
|
||||
|
||||
@@ -154,17 +154,25 @@ export type MissionInterviewStreamEvent =
|
||||
/** Callback function for streaming events */
|
||||
export type MissionInterviewStreamCallback = (event: MissionInterviewStreamEvent, eventId?: number) => void;
|
||||
|
||||
interface MissionInterviewHistoryEntry {
|
||||
question: PlanningQuestion;
|
||||
response: unknown;
|
||||
thinkingOutput?: string;
|
||||
}
|
||||
|
||||
/** In-memory interview session */
|
||||
interface MissionInterviewSession {
|
||||
id: string;
|
||||
ip: string;
|
||||
missionId: string;
|
||||
missionTitle: string;
|
||||
history: Array<{ question: PlanningQuestion; response: unknown }>;
|
||||
history: MissionInterviewHistoryEntry[];
|
||||
currentQuestion?: PlanningQuestion;
|
||||
summary?: MissionPlanSummary;
|
||||
agent?: AgentResult;
|
||||
thinkingOutput: string;
|
||||
/** Thinking output generated while producing currentQuestion */
|
||||
lastGeneratedThinking: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -285,7 +293,7 @@ function buildMissionInterviewSessionFromRow(row: AiSessionRow): MissionIntervie
|
||||
ip: payload.ip ?? "",
|
||||
missionId: payload.missionId ?? "",
|
||||
missionTitle: payload.missionTitle ?? row.title,
|
||||
history: safeParseJson<Array<{ question: PlanningQuestion; response: unknown }>>(
|
||||
history: safeParseJson<MissionInterviewHistoryEntry[]>(
|
||||
row.conversationHistory,
|
||||
[],
|
||||
{ throwOnError: true, fieldName: "conversationHistory" },
|
||||
@@ -303,6 +311,7 @@ function buildMissionInterviewSessionFromRow(row: AiSessionRow): MissionIntervie
|
||||
}) ?? undefined)
|
||||
: undefined,
|
||||
thinkingOutput: row.thinkingOutput,
|
||||
lastGeneratedThinking: row.thinkingOutput || "",
|
||||
createdAt,
|
||||
updatedAt,
|
||||
agent: undefined,
|
||||
@@ -845,6 +854,7 @@ async function continueAgentConversation(session: MissionInterviewSession, messa
|
||||
|
||||
if (parsed.type === "question") {
|
||||
session.currentQuestion = parsed.data;
|
||||
session.lastGeneratedThinking = session.thinkingOutput;
|
||||
session.updatedAt = new Date();
|
||||
persistMissionSession(session, "awaiting_input");
|
||||
missionInterviewStreamManager.broadcast(session.id, {
|
||||
@@ -900,6 +910,7 @@ export async function createMissionInterviewSession(
|
||||
missionTitle,
|
||||
history: [],
|
||||
thinkingOutput: "",
|
||||
lastGeneratedThinking: "",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
@@ -942,6 +953,7 @@ export async function submitMissionInterviewResponse(
|
||||
session.history.push({
|
||||
question: session.currentQuestion,
|
||||
response: responses,
|
||||
thinkingOutput: session.lastGeneratedThinking || "",
|
||||
});
|
||||
persistMissionSession(session, "generating");
|
||||
|
||||
|
||||
@@ -115,6 +115,39 @@ function setupMockAgent(responses?: string[]) {
|
||||
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 {
|
||||
rows = new Map<string, AiSessionRow>();
|
||||
|
||||
@@ -508,6 +541,75 @@ describe("planning module", () => {
|
||||
"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", () => {
|
||||
|
||||
@@ -120,11 +120,17 @@ export type PlanningStreamEvent =
|
||||
/** Callback function for streaming events */
|
||||
export type PlanningStreamCallback = (event: PlanningStreamEvent, eventId?: number) => void;
|
||||
|
||||
interface PlanningHistoryEntry {
|
||||
question: PlanningQuestion;
|
||||
response: unknown;
|
||||
thinkingOutput?: string;
|
||||
}
|
||||
|
||||
interface Session {
|
||||
id: string;
|
||||
ip: string;
|
||||
initialPlan: string;
|
||||
history: Array<{ question: PlanningQuestion; response: unknown }>;
|
||||
history: PlanningHistoryEntry[];
|
||||
currentQuestion?: PlanningQuestion;
|
||||
summary?: PlanningSummary;
|
||||
/** AI agent session for real-time interaction */
|
||||
@@ -133,6 +139,8 @@ interface Session {
|
||||
streamCallback?: PlanningStreamCallback;
|
||||
/** Accumulated thinking output for display */
|
||||
thinkingOutput: string;
|
||||
/** Thinking output generated while producing currentQuestion */
|
||||
lastGeneratedThinking: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -260,7 +268,7 @@ function buildSessionFromRow(row: AiSessionRow): Session {
|
||||
id: row.id,
|
||||
ip: payload.ip ?? "",
|
||||
initialPlan: payload.initialPlan ?? row.title,
|
||||
history: safeParseJson<Array<{ question: PlanningQuestion; response: unknown }>>(
|
||||
history: safeParseJson<PlanningHistoryEntry[]>(
|
||||
row.conversationHistory,
|
||||
[],
|
||||
{ throwOnError: true, fieldName: "conversationHistory" },
|
||||
@@ -278,6 +286,7 @@ function buildSessionFromRow(row: AiSessionRow): Session {
|
||||
}) ?? undefined)
|
||||
: undefined,
|
||||
thinkingOutput: row.thinkingOutput,
|
||||
lastGeneratedThinking: row.thinkingOutput || "",
|
||||
createdAt,
|
||||
updatedAt,
|
||||
agent: undefined,
|
||||
@@ -546,6 +555,7 @@ export async function createSession(
|
||||
initialPlan,
|
||||
history: [],
|
||||
thinkingOutput: "",
|
||||
lastGeneratedThinking: "",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
@@ -720,6 +730,7 @@ export async function createSessionWithAgent(
|
||||
initialPlan,
|
||||
history: [],
|
||||
thinkingOutput: "",
|
||||
lastGeneratedThinking: "",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
@@ -952,6 +963,7 @@ async function continueAgentConversation(session: Session, message: string): Pro
|
||||
|
||||
if (parsed.type === "question") {
|
||||
session.currentQuestion = parsed.data;
|
||||
session.lastGeneratedThinking = session.thinkingOutput;
|
||||
session.updatedAt = new Date();
|
||||
persistSession(session, "awaiting_input");
|
||||
planningStreamManager.broadcast(session.id, {
|
||||
@@ -1188,6 +1200,7 @@ export async function submitResponse(
|
||||
session.history.push({
|
||||
question: session.currentQuestion,
|
||||
response: responses,
|
||||
thinkingOutput: session.lastGeneratedThinking || "",
|
||||
});
|
||||
persistSession(session, "generating");
|
||||
|
||||
|
||||
@@ -188,7 +188,7 @@ function persistSubtaskSession(session: SubtaskInternalSession, status: "generat
|
||||
status,
|
||||
title: session.initialDescription.slice(0, 120),
|
||||
inputPayload: JSON.stringify({ initialDescription: session.initialDescription }),
|
||||
conversationHistory: "[]",
|
||||
conversationHistory: JSON.stringify([{ thinkingOutput: session.thinkingOutput || "" }]),
|
||||
currentQuestion: null,
|
||||
result: session.subtasks.length > 0 ? JSON.stringify(session.subtasks) : null,
|
||||
thinkingOutput: session.thinkingOutput,
|
||||
|
||||
Reference in New Issue
Block a user