- 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
276 lines
8.5 KiB
TypeScript
276 lines
8.5 KiB
TypeScript
/**
|
|
* Covers error-state persistence, SSE error broadcasts, and retry recovery flows
|
|
* for planning, subtask breakdown, and mission interview sessions.
|
|
*/
|
|
|
|
// @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 } from "../ai-session-store.js";
|
|
import {
|
|
__resetPlanningState,
|
|
__setCreateFnAgent,
|
|
createSession,
|
|
getSession,
|
|
planningStreamManager,
|
|
retrySession,
|
|
setAiSessionStore as setPlanningAiSessionStore,
|
|
submitResponse,
|
|
} from "../planning.js";
|
|
import {
|
|
__resetSubtaskBreakdownState,
|
|
createSubtaskSession,
|
|
getSubtaskSession,
|
|
retrySubtaskSession,
|
|
setAiSessionStore as setSubtaskAiSessionStore,
|
|
subtaskStreamManager,
|
|
} from "../subtask-breakdown.js";
|
|
import {
|
|
__resetMissionInterviewState,
|
|
createMissionInterviewSession,
|
|
getMissionInterviewSession,
|
|
missionInterviewStreamManager,
|
|
retryMissionInterviewSession,
|
|
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-error-recovery-"));
|
|
}
|
|
|
|
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 error recovery", () => {
|
|
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("captures planning parse failures as error state, preserves history, and allows retry", async () => {
|
|
const errorEvents: string[] = [];
|
|
const unsubscribe = planningStreamManager.subscribe("pending", () => {
|
|
// placeholder; replaced below once session id exists
|
|
});
|
|
unsubscribe();
|
|
|
|
__setCreateFnAgent(
|
|
async () =>
|
|
createMockAgent([
|
|
JSON.stringify({
|
|
type: "question",
|
|
data: { id: "q-1", type: "text", question: "First question" },
|
|
}),
|
|
"not-json",
|
|
"still-not-json",
|
|
]),
|
|
);
|
|
|
|
const { sessionId } = await createSession("127.0.0.101", "Planning error flow", undefined, "/tmp/project");
|
|
|
|
const unsubscribeError = planningStreamManager.subscribe(sessionId, (event) => {
|
|
if (event.type === "error") {
|
|
errorEvents.push(String(event.data));
|
|
}
|
|
});
|
|
|
|
const responseAfterFailure = await submitResponse(
|
|
sessionId,
|
|
{ "q-1": "trigger error" },
|
|
"/tmp/project",
|
|
);
|
|
expect(responseAfterFailure.type).toBe("question");
|
|
if (responseAfterFailure.type === "question") {
|
|
// currentQuestion remains the same when parsing fails
|
|
expect(responseAfterFailure.data.id).toBe("q-1");
|
|
}
|
|
|
|
const persistedError = aiSessionStore.get(sessionId);
|
|
expect(persistedError?.status).toBe("error");
|
|
expect(persistedError?.error).toContain("AI returned no valid JSON");
|
|
expect(JSON.parse(persistedError?.conversationHistory ?? "[]")).toHaveLength(1);
|
|
expect(errorEvents.length).toBeGreaterThan(0);
|
|
|
|
__setCreateFnAgent(
|
|
async () =>
|
|
createMockAgent([
|
|
JSON.stringify({
|
|
type: "question",
|
|
data: { id: "q-retry", type: "text", question: "Recovered question" },
|
|
}),
|
|
]),
|
|
);
|
|
|
|
await retrySession(sessionId, "/tmp/project");
|
|
|
|
const persistedRecovered = aiSessionStore.get(sessionId);
|
|
expect(persistedRecovered?.status).toBe("awaiting_input");
|
|
expect(persistedRecovered?.error).toBeNull();
|
|
expect(getSession(sessionId)?.currentQuestion?.id).toBe("q-retry");
|
|
|
|
unsubscribeError();
|
|
});
|
|
|
|
it("captures subtask generation errors, broadcasts SSE error, and retries to completion", async () => {
|
|
const subtaskErrors: string[] = [];
|
|
|
|
mockCreateFnAgent.mockImplementationOnce(async () => createMockAgent(["{not-json"]));
|
|
|
|
const session = await createSubtaskSession("Subtask error flow", undefined, "/tmp/project");
|
|
|
|
const unsubscribe = subtaskStreamManager.subscribe(session.sessionId, (event) => {
|
|
if (event.type === "error") {
|
|
subtaskErrors.push(String(event.data));
|
|
}
|
|
});
|
|
|
|
await waitFor(() => aiSessionStore.get(session.sessionId)?.status === "error");
|
|
|
|
const persistedError = aiSessionStore.get(session.sessionId);
|
|
expect(persistedError?.status).toBe("error");
|
|
expect(String(persistedError?.error).length).toBeGreaterThan(0);
|
|
expect(subtaskErrors.length).toBeGreaterThan(0);
|
|
|
|
mockCreateFnAgent.mockImplementationOnce(async () =>
|
|
createMockAgent([
|
|
JSON.stringify({
|
|
subtasks: [
|
|
{
|
|
id: "subtask-1",
|
|
title: "Recovered",
|
|
description: "Recovered after retry",
|
|
suggestedSize: "S",
|
|
dependsOn: [],
|
|
},
|
|
],
|
|
}),
|
|
]),
|
|
);
|
|
|
|
await retrySubtaskSession(session.sessionId, "/tmp/project");
|
|
|
|
await waitFor(() => aiSessionStore.get(session.sessionId)?.status === "complete");
|
|
expect(getSubtaskSession(session.sessionId)?.status).toBe("complete");
|
|
expect(aiSessionStore.get(session.sessionId)?.error).toBeNull();
|
|
|
|
unsubscribe();
|
|
});
|
|
|
|
it("captures mission parse failure with history preserved and recovers via retry", async () => {
|
|
const missionErrors: string[] = [];
|
|
|
|
mockCreateFnAgent.mockImplementation(async () =>
|
|
createMockAgent([
|
|
JSON.stringify({
|
|
type: "question",
|
|
data: { id: "q-m-1", type: "text", question: "Mission question" },
|
|
}),
|
|
"invalid-json",
|
|
"invalid-json-again",
|
|
]),
|
|
);
|
|
|
|
const sessionId = await createMissionInterviewSession("127.0.0.111", "Mission error flow", "/tmp/project");
|
|
await waitFor(() => Boolean(getMissionInterviewSession(sessionId)?.currentQuestion));
|
|
|
|
const unsubscribe = missionInterviewStreamManager.subscribe(sessionId, (event) => {
|
|
if (event.type === "error") {
|
|
missionErrors.push(String(event.data));
|
|
}
|
|
});
|
|
|
|
await submitMissionInterviewResponse(sessionId, { "q-m-1": "trigger mission error" }, "/tmp/project");
|
|
|
|
const persistedError = aiSessionStore.get(sessionId);
|
|
expect(persistedError?.status).toBe("error");
|
|
expect(String(persistedError?.error)).toContain("AI returned no valid JSON");
|
|
expect(JSON.parse(persistedError?.conversationHistory ?? "[]")).toHaveLength(1);
|
|
expect(missionErrors.length).toBeGreaterThan(0);
|
|
|
|
mockCreateFnAgent.mockImplementation(async () =>
|
|
createMockAgent([
|
|
JSON.stringify({
|
|
type: "question",
|
|
data: { id: "q-m-retry", type: "text", question: "Recovered mission question" },
|
|
}),
|
|
]),
|
|
);
|
|
|
|
await retryMissionInterviewSession(sessionId, "/tmp/project");
|
|
|
|
const persistedRecovered = aiSessionStore.get(sessionId);
|
|
expect(persistedRecovered?.status).toBe("awaiting_input");
|
|
expect(persistedRecovered?.error).toBeNull();
|
|
expect(getMissionInterviewSession(sessionId)?.currentQuestion?.id).toBe("q-m-retry");
|
|
|
|
unsubscribe();
|
|
});
|
|
});
|