feat(FN-1151): preserve and display planning conversation history

- Persist per-turn thinking output for planning and mission interview sessions, including history serialization and recovery-safe state fields
- Add shared conversation history parsing/API types and a reusable timeline component with formatted answers and expandable AI reasoning
- Restore and render conversation history across Planning Mode, Mission Interview, and Subtask Breakdown modals during resume and live question flow
- Harden response submission with nullable-question guards and expand unit/e2e coverage for history rendering and persistence behavior
This commit is contained in:
gsxdsm
2026-04-08 14:17:20 -07:00
parent 8354a86a82
commit 324f28a90c
15 changed files with 1023 additions and 10 deletions

View File

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

View File

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

View File

@@ -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", () => {

View File

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

View File

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