Files
fusion/packages/dashboard/src/__tests__/session-resume-history.test.ts
Fusion 0b9bf62a2a refactor(FN-2162): rename kb-agent identifiers to fn-agent
- Rename core loader, dashboard server chat/planning routes, and frontend agent IDs/storage keys from kb-agent to fn-agent naming
- Update dashboard hooks and components (agent list, chat view, quick chat) to use the new fn agent key prefixes consistently
- Refresh engine, dashboard, core, and CLI tests/mocks to remove remaining kb-agent route and temp prefix references
- Update storage/gap-analysis docs to reflect fn agent key names and add a @gsxdsm/fusion patch changeset for the rename
2026-04-19 20:48:14 -07:00

299 lines
9.6 KiB
TypeScript

/**
* Covers resume/restore behavior with persisted conversation history,
* thinking output continuity, and fresh-session history initialization.
*/
// @vitest-environment node
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync } from "node:fs";
import { rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Database } from "@fusion/core";
import { AiSessionStore, type AiSessionRow } from "../ai-session-store.js";
import {
__resetPlanningState,
__setCreateFnAgent,
createSession,
getSession,
setAiSessionStore as setPlanningAiSessionStore,
submitResponse,
} from "../planning.js";
import {
__resetSubtaskBreakdownState,
getSubtaskSession,
setAiSessionStore as setSubtaskAiSessionStore,
} from "../subtask-breakdown.js";
import {
__resetMissionInterviewState,
createMissionInterviewSession,
getMissionInterviewSession,
setAiSessionStore as setMissionAiSessionStore,
submitMissionInterviewResponse,
} from "../mission-interview.js";
const { mockCreateFnAgent } = vi.hoisted(() => ({
mockCreateFnAgent: vi.fn(),
}));
vi.mock("@fusion/engine", () => ({
createFnAgent: mockCreateFnAgent,
}));
function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "kb-session-resume-history-"));
}
function createMockAgent(responses: string[]) {
const queue = [...responses];
const messages: Array<{ role: string; content: string }> = [];
return {
session: {
state: { messages },
prompt: vi.fn(async (_input: string) => {
const response = queue.shift() ?? queue[queue.length - 1] ?? "{}";
messages.push({ role: "assistant", content: response });
}),
dispose: vi.fn(),
},
};
}
async function waitFor(check: () => boolean, timeoutMs = 2000): Promise<void> {
const start = Date.now();
while (!check()) {
if (Date.now() - start > timeoutMs) {
throw new Error("Timed out waiting for condition");
}
await new Promise((resolve) => setTimeout(resolve, 10));
}
}
describe("session resume + history restore", () => {
let tmpDir: string;
let db: Database;
let aiSessionStore: AiSessionStore;
beforeEach(() => {
vi.clearAllMocks();
__resetPlanningState();
__resetSubtaskBreakdownState();
__resetMissionInterviewState();
tmpDir = makeTmpDir();
db = new Database(join(tmpDir, ".fusion"));
db.init();
aiSessionStore = new AiSessionStore(db);
setPlanningAiSessionStore(aiSessionStore);
setSubtaskAiSessionStore(aiSessionStore);
setMissionAiSessionStore(aiSessionStore);
});
afterEach(async () => {
__setCreateFnAgent(undefined as any);
__resetPlanningState();
__resetSubtaskBreakdownState();
__resetMissionInterviewState();
try {
db.close();
} catch {
// no-op
}
await rm(tmpDir, { recursive: true, force: true });
});
it("restores planning history/thinking from SQLite and resumes with replayed context", async () => {
const now = new Date().toISOString();
const row: AiSessionRow = {
id: "planning-resume-1",
type: "planning",
status: "awaiting_input",
title: "Planning resume",
inputPayload: JSON.stringify({ ip: "127.0.0.1", initialPlan: "Resume planning" }),
conversationHistory: JSON.stringify([
{
question: { id: "q-1", type: "text", question: "What are we building?" },
response: { "q-1": "A resume flow" },
thinkingOutput: "turn-1-thinking",
},
]),
currentQuestion: JSON.stringify({ id: "q-2", type: "text", question: "Any constraints?" }),
result: null,
thinkingOutput: "latest-thinking",
error: null,
projectId: null,
createdAt: now,
updatedAt: now,
lockedByTab: null,
lockedAt: null,
};
aiSessionStore.upsert(row);
const resumedAgent = createMockAgent([
"context-ack",
JSON.stringify({
type: "question",
data: { id: "q-3", type: "text", question: "What timeline?" },
}),
]);
const createFnAgentSpy = vi.fn(async () => resumedAgent);
__setCreateFnAgent(createFnAgentSpy as any);
const restored = getSession(row.id);
expect(restored).toBeDefined();
expect(restored?.history).toHaveLength(1);
expect(restored?.history[0]?.thinkingOutput).toBe("turn-1-thinking");
expect(restored?.thinkingOutput).toBe("latest-thinking");
expect(restored?.lastGeneratedThinking).toBe("latest-thinking");
const response = await submitResponse(row.id, { "q-2": "No constraints" }, "/tmp/project");
expect(response.type).toBe("question");
if (response.type === "question") {
expect(response.data.id).toBe("q-3");
}
expect(createFnAgentSpy).toHaveBeenCalledTimes(1);
expect(resumedAgent.session.prompt).toHaveBeenCalledTimes(2);
expect(resumedAgent.session.prompt.mock.calls[0]?.[0]).toContain("Previous conversation summary");
expect(resumedAgent.session.prompt.mock.calls[1]?.[0]).toContain("Any constraints?");
});
it("restores mission interview history/thinking and resumes with replayed context", async () => {
const now = new Date().toISOString();
const row: AiSessionRow = {
id: "mission-resume-1",
type: "mission_interview",
status: "awaiting_input",
title: "Mission resume",
inputPayload: JSON.stringify({ ip: "127.0.0.1", missionId: "M-1", missionTitle: "Mission title" }),
conversationHistory: JSON.stringify([
{
question: { id: "q-m-1", type: "text", question: "What is the mission?" },
response: { "q-m-1": "Ship a dashboard" },
thinkingOutput: "mission-turn-1-thinking",
},
]),
currentQuestion: JSON.stringify({ id: "q-m-2", type: "text", question: "Any technical constraints?" }),
result: null,
thinkingOutput: "mission-latest-thinking",
error: null,
projectId: null,
createdAt: now,
updatedAt: now,
lockedByTab: null,
lockedAt: null,
};
aiSessionStore.upsert(row);
const resumedAgent = createMockAgent([
"context-ack",
JSON.stringify({
type: "question",
data: { id: "q-m-3", type: "text", question: "Who are the users?" },
}),
]);
const createFnAgentSpy = vi.fn(async () => resumedAgent);
mockCreateFnAgent.mockImplementation(createFnAgentSpy);
const restored = getMissionInterviewSession(row.id);
expect(restored).toBeDefined();
expect(restored?.history).toHaveLength(1);
expect(restored?.history[0]?.thinkingOutput).toBe("mission-turn-1-thinking");
expect(restored?.thinkingOutput).toBe("mission-latest-thinking");
expect(restored?.lastGeneratedThinking).toBe("mission-latest-thinking");
const response = await submitMissionInterviewResponse(
row.id,
{ "q-m-2": "None" },
"/tmp/project",
);
expect(response.type).toBe("question");
if (response.type === "question") {
expect(response.data.id).toBe("q-m-3");
}
expect(createFnAgentSpy).toHaveBeenCalledTimes(1);
expect(resumedAgent.session.prompt).toHaveBeenCalledTimes(2);
expect(resumedAgent.session.prompt.mock.calls[0]?.[0]).toContain("Previous conversation summary");
expect(resumedAgent.session.prompt.mock.calls[1]?.[0]).toContain("Any technical constraints?");
});
it("restores persisted subtask session state from SQLite", () => {
const now = new Date().toISOString();
const subtasks = [
{
id: "subtask-1",
title: "Analyze",
description: "Analyze requirements",
suggestedSize: "S",
dependsOn: [],
},
];
const row: AiSessionRow = {
id: "subtask-resume-1",
type: "subtask",
status: "generating",
title: "Subtask resume",
inputPayload: JSON.stringify({ initialDescription: "Break down this task" }),
conversationHistory: JSON.stringify([{ thinkingOutput: "subtask-thinking" }]),
currentQuestion: null,
result: JSON.stringify(subtasks),
thinkingOutput: "subtask-latest-thinking",
error: null,
projectId: null,
createdAt: now,
updatedAt: now,
lockedByTab: null,
lockedAt: null,
};
aiSessionStore.upsert(row);
const restored = getSubtaskSession(row.id);
expect(restored).toBeDefined();
expect(restored?.sessionId).toBe(row.id);
expect(restored?.status).toBe("generating");
expect(restored?.subtasks).toEqual(subtasks);
const persistedRow = aiSessionStore.get(row.id);
expect(JSON.parse(persistedRow?.conversationHistory ?? "[]")).toEqual([
{ thinkingOutput: "subtask-thinking" },
]);
});
it("starts fresh planning and mission sessions with empty history", async () => {
__setCreateFnAgent(
async () =>
createMockAgent([
JSON.stringify({
type: "question",
data: { id: "q-fresh-plan", type: "text", question: "Plan question" },
}),
]),
);
mockCreateFnAgent.mockImplementation(async () =>
createMockAgent([
JSON.stringify({
type: "question",
data: { id: "q-fresh-mission", type: "text", question: "Mission question" },
}),
]),
);
const planning = await createSession("127.0.0.88", "Fresh planning", undefined, "/tmp/project");
const missionSessionId = await createMissionInterviewSession("127.0.0.89", "Fresh mission", "/tmp/project");
await waitFor(() => Boolean(getMissionInterviewSession(missionSessionId)?.currentQuestion));
expect(getSession(planning.sessionId)?.history).toEqual([]);
expect(getMissionInterviewSession(missionSessionId)?.history).toEqual([]);
});
});