Files
fusion/packages/dashboard/src/__tests__/ai-session-store.test.ts
gsxdsm 6685d832fa chore: consolidate remaining tests into __tests__/ dirs and refactor mocks
Mostly mechanical cleanup left over from the earlier test-consolidation pass:
- Update import paths to ../../ for mocks now that test files moved deeper
- Simplify mock setup (drop usePluginUiSlots inline mock, etc.)
- Move engine ipc + runtimes tests into __tests__/ subdirs
- Move dashboard utils tests into __tests__/ subdir
- Refresh fusion-plugin-hermes-runtime/dist artifacts

build-exe.test.ts: spawn-import fix from a parallel branch (resolved during
worktree merge of the CSS extraction work).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 19:39:20 -07:00

435 lines
15 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach, 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,
SESSION_CLEANUP_DEFAULT_MAX_AGE_MS,
type AiSessionRow,
type AiSessionStatus,
} from "../ai-session-store.js";
import { resetDiagnosticsSink, setDiagnosticsSink, type LogEntry } from "../ai-session-diagnostics.js";
describe("AiSessionStore", () => {
let tmpRoot: string;
let db: Database;
let store: AiSessionStore;
beforeEach(() => {
tmpRoot = mkdtempSync(join(tmpdir(), "kb-ai-session-store-"));
db = new Database(join(tmpRoot, ".fusion"));
db.init();
store = new AiSessionStore(db);
});
afterEach(async () => {
store.stopScheduledCleanup();
resetDiagnosticsSink();
vi.useRealTimers();
try {
db.close();
} catch {
// no-op
}
await rm(tmpRoot, { recursive: true, force: true });
});
function makeRow(id: string, status: AiSessionStatus, projectId: string | null = null): AiSessionRow {
const now = new Date().toISOString();
return {
id,
type: "planning",
status,
title: `Session ${id}`,
inputPayload: JSON.stringify({ plan: `plan-${id}` }),
conversationHistory: "[]",
currentQuestion: null,
result: status === "complete" ? JSON.stringify({ title: "Done" }) : null,
thinkingOutput: "",
error: status === "error" ? "boom" : null,
projectId,
createdAt: now,
updatedAt: now,
};
}
function seedSession(params: {
id: string;
status: AiSessionStatus;
ageMs?: number;
projectId?: string | null;
currentQuestion?: object | null;
error?: string | null;
}): void {
const { id, status, ageMs = 0, projectId = null, currentQuestion = null, error } = params;
const row = makeRow(id, status, projectId);
row.currentQuestion = currentQuestion ? JSON.stringify(currentQuestion) : null;
row.error = error ?? row.error;
store.upsert(row);
if (ageMs > 0) {
const staleTs = new Date(Date.now() - ageMs).toISOString();
db.prepare("UPDATE ai_sessions SET updatedAt = ? WHERE id = ?").run(staleTs, id);
}
}
function captureDiagnostics(): LogEntry[] {
const entries: LogEntry[] = [];
setDiagnosticsSink((level, scope, message, context) => {
entries.push({
level,
scope,
message,
context,
timestamp: new Date(),
});
});
return entries;
}
it("cleanupOld removes only stale terminal sessions and emits deleted events", () => {
const deletedIds: string[] = [];
store.on("ai_session:deleted", (id) => deletedIds.push(id));
seedSession({ id: "S-complete", status: "complete", ageMs: 2 * 60 * 60 * 1000 });
seedSession({ id: "S-error", status: "error", ageMs: 2 * 60 * 60 * 1000 });
seedSession({ id: "S-generating", status: "generating", ageMs: 2 * 60 * 60 * 1000 });
seedSession({ id: "S-awaiting", status: "awaiting_input", ageMs: 2 * 60 * 60 * 1000 });
const removed = store.cleanupOld(60 * 60 * 1000);
expect(removed).toBe(2);
expect(store.get("S-complete")).toBeNull();
expect(store.get("S-error")).toBeNull();
expect(store.get("S-generating")).not.toBeNull();
expect(store.get("S-awaiting")).not.toBeNull();
expect(deletedIds.sort()).toEqual(["S-complete", "S-error"]);
});
it("cleanupStaleSessions removes stale terminal and orphaned sessions with summary", () => {
seedSession({ id: "S-complete-old", status: "complete", ageMs: 8 * 24 * 60 * 60 * 1000 });
seedSession({ id: "S-error-old", status: "error", ageMs: 8 * 24 * 60 * 60 * 1000 });
seedSession({ id: "S-generating-old", status: "generating", ageMs: 8 * 24 * 60 * 60 * 1000 });
seedSession({ id: "S-awaiting-old", status: "awaiting_input", ageMs: 8 * 24 * 60 * 60 * 1000 });
seedSession({ id: "S-generating-fresh", status: "generating", ageMs: 2 * 24 * 60 * 60 * 1000 });
const summary = store.cleanupStaleSessions();
expect(summary).toEqual({
terminalDeleted: 2,
orphanedDeleted: 2,
totalDeleted: 4,
});
expect(store.get("S-complete-old")).toBeNull();
expect(store.get("S-error-old")).toBeNull();
expect(store.get("S-generating-old")).toBeNull();
expect(store.get("S-awaiting-old")).toBeNull();
expect(store.get("S-generating-fresh")).not.toBeNull();
});
it("cleanupStaleSessions emits structured diagnostics with cleanup summary counts", () => {
const diagnostics = captureDiagnostics();
seedSession({ id: "S-complete-old", status: "complete", ageMs: 8 * 24 * 60 * 60 * 1000 });
seedSession({ id: "S-error-old", status: "error", ageMs: 8 * 24 * 60 * 60 * 1000 });
seedSession({ id: "S-generating-old", status: "generating", ageMs: 8 * 24 * 60 * 60 * 1000 });
const summary = store.cleanupStaleSessions();
expect(summary).toEqual({
terminalDeleted: 2,
orphanedDeleted: 1,
totalDeleted: 3,
});
expect(diagnostics).toContainEqual(
expect.objectContaining({
level: "info",
scope: "ai-session-store",
message: "Cleanup removed stale sessions",
context: expect.objectContaining({
terminalDeleted: 2,
orphanedDeleted: 1,
totalDeleted: 3,
maxAgeMs: SESSION_CLEANUP_DEFAULT_MAX_AGE_MS,
operation: "cleanup-stale-sessions",
}),
}),
);
});
it("cleanupStaleSessions respects explicit maxAgeMs values", () => {
seedSession({ id: "S-complete-older", status: "complete", ageMs: 2 * 60 * 60 * 1000 });
seedSession({ id: "S-awaiting-older", status: "awaiting_input", ageMs: 2 * 60 * 60 * 1000 });
seedSession({ id: "S-error-recent", status: "error", ageMs: 30 * 60 * 1000 });
const summary = store.cleanupStaleSessions(60 * 60 * 1000);
expect(summary).toEqual({
terminalDeleted: 1,
orphanedDeleted: 1,
totalDeleted: 2,
});
expect(store.get("S-complete-older")).toBeNull();
expect(store.get("S-awaiting-older")).toBeNull();
expect(store.get("S-error-recent")).not.toBeNull();
});
it("cleanupStaleSessions defaults to 7-day max age", () => {
seedSession({ id: "S-complete-6days", status: "complete", ageMs: SESSION_CLEANUP_DEFAULT_MAX_AGE_MS - 60_000 });
seedSession({ id: "S-complete-8days", status: "complete", ageMs: SESSION_CLEANUP_DEFAULT_MAX_AGE_MS + 60_000 });
const summary = store.cleanupStaleSessions();
expect(summary).toEqual({
terminalDeleted: 1,
orphanedDeleted: 0,
totalDeleted: 1,
});
expect(store.get("S-complete-6days")).not.toBeNull();
expect(store.get("S-complete-8days")).toBeNull();
});
it("startScheduledCleanup and stopScheduledCleanup control cleanup interval", () => {
vi.useFakeTimers();
seedSession({ id: "S-old", status: "complete", ageMs: 2 * 60 * 1000 });
store.startScheduledCleanup(1_000, 60_000);
vi.advanceTimersByTime(1_000);
expect(store.get("S-old")).toBeNull();
seedSession({ id: "S-old-2", status: "complete", ageMs: 2 * 60 * 1000 });
store.stopScheduledCleanup();
vi.advanceTimersByTime(5_000);
expect(store.get("S-old-2")).not.toBeNull();
});
it("startScheduledCleanup emits structured error diagnostics and remains non-fatal on cleanup failure", () => {
vi.useFakeTimers();
const diagnostics = captureDiagnostics();
const cleanupSpy = vi
.spyOn(store, "cleanupStaleSessions")
.mockImplementation(() => {
throw new Error("boom");
});
store.startScheduledCleanup(1_000, 60_000);
expect(() => vi.advanceTimersByTime(2_000)).not.toThrow();
expect(cleanupSpy).toHaveBeenCalledTimes(2);
expect(diagnostics).toContainEqual(
expect.objectContaining({
level: "error",
scope: "ai-session-store",
message: "Scheduled cleanup failed",
context: expect.objectContaining({
ttlMs: 60_000,
operation: "scheduled-cleanup",
error: expect.objectContaining({ message: "boom" }),
}),
}),
);
});
it("supports configurable TTL values", () => {
seedSession({ id: "S-older", status: "complete", ageMs: 2 * 60 * 60 * 1000 });
seedSession({ id: "S-recent", status: "complete", ageMs: 30 * 60 * 1000 });
const removedWithShortTtl = store.cleanupOld(60 * 60 * 1000);
expect(removedWithShortTtl).toBe(1);
expect(store.get("S-older")).toBeNull();
expect(store.get("S-recent")).not.toBeNull();
const removedWithLongTtl = store.cleanupOld(3 * 60 * 60 * 1000);
expect(removedWithLongTtl).toBe(0);
});
it("recoverStaleSessions keeps recoverable sessions and marks unrecoverable ones as error", () => {
seedSession({
id: "S-recoverable",
status: "generating",
currentQuestion: { id: "q-1", type: "text", question: "Continue?" },
});
seedSession({ id: "S-broken", status: "generating", currentQuestion: null });
const recovered = store.recoverStaleSessions();
expect(recovered).toBe(2);
expect(store.get("S-recoverable")?.status).toBe("awaiting_input");
expect(store.get("S-broken")?.status).toBe("error");
expect(store.get("S-broken")?.error).toBe("Session interrupted — please restart");
});
it("recoverStaleSessions emits structured diagnostics when stale sessions are recovered", () => {
const diagnostics = captureDiagnostics();
seedSession({
id: "S-recoverable",
status: "generating",
currentQuestion: { id: "q-1", type: "text", question: "Continue?" },
});
seedSession({ id: "S-broken", status: "generating", currentQuestion: null });
const recovered = store.recoverStaleSessions();
expect(recovered).toBe(2);
expect(diagnostics).toContainEqual(
expect.objectContaining({
level: "info",
scope: "ai-session-store",
message: "Recovered stale sessions after restart",
context: expect.objectContaining({
recovered: 2,
operation: "recover-stale-sessions",
}),
}),
);
});
it("listActive returns generating/awaiting_input/error sessions", () => {
seedSession({ id: "S-generating", status: "generating" });
seedSession({ id: "S-awaiting", status: "awaiting_input" });
seedSession({ id: "S-complete", status: "complete" });
seedSession({ id: "S-error", status: "error" });
const active = store.listActive();
expect(active.map((session) => session.status).sort()).toEqual(["awaiting_input", "error", "generating"]);
expect(active.map((session) => session.id).sort()).toEqual(["S-awaiting", "S-error", "S-generating"]);
});
it("listActive filters by projectId", () => {
seedSession({ id: "S-a1", status: "generating", projectId: "project-a" });
seedSession({ id: "S-a2", status: "awaiting_input", projectId: "project-a" });
seedSession({ id: "S-a3", status: "error", projectId: "project-a" });
seedSession({ id: "S-b1", status: "awaiting_input", projectId: "project-b" });
seedSession({ id: "S-a-done", status: "complete", projectId: "project-a" });
const projectA = store.listActive("project-a");
expect(projectA).toHaveLength(3);
expect(projectA.map((session) => session.id).sort()).toEqual(["S-a1", "S-a2", "S-a3"]);
expect(projectA.every((session) => session.projectId === "project-a")).toBe(true);
});
it("ping updates updatedAt for existing sessions without emitting updates", () => {
seedSession({ id: "S-ping", status: "awaiting_input" });
const staleTs = new Date(Date.now() - 60_000).toISOString();
db.prepare("UPDATE ai_sessions SET updatedAt = ? WHERE id = ?").run(staleTs, "S-ping");
const onUpdated = vi.fn();
store.on("ai_session:updated", onUpdated);
const updated = store.ping("S-ping");
expect(updated).toBe(true);
expect(store.get("S-ping")?.updatedAt).not.toBe(staleTs);
expect(onUpdated).not.toHaveBeenCalled();
});
it("ping returns false for nonexistent sessions", () => {
const onUpdated = vi.fn();
store.on("ai_session:updated", onUpdated);
expect(store.ping("missing-session")).toBe(false);
expect(onUpdated).not.toHaveBeenCalled();
});
it("updateStatus atomically transitions status and clears error when omitted", () => {
seedSession({ id: "S-retry", status: "error", error: "Transient failure" });
const onUpdated = vi.fn();
store.on("ai_session:updated", onUpdated);
const updated = store.updateStatus("S-retry", "generating");
expect(updated).toBe(true);
expect(store.get("S-retry")?.status).toBe("generating");
expect(store.get("S-retry")?.error).toBeNull();
expect(onUpdated).toHaveBeenCalledTimes(1);
expect(onUpdated).toHaveBeenCalledWith(
expect.objectContaining({
id: "S-retry",
status: "generating",
}),
);
});
it("updateStatus sets explicit error and returns false for missing session", () => {
seedSession({ id: "S-failed", status: "generating" });
expect(store.updateStatus("S-failed", "error", "Agent crashed")).toBe(true);
expect(store.get("S-failed")?.status).toBe("error");
expect(store.get("S-failed")?.error).toBe("Agent crashed");
expect(store.updateStatus("S-missing", "error", "Nope")).toBe(false);
});
it("listRecoverable returns awaiting_input and generating sessions", () => {
seedSession({ id: "S-generating", status: "generating", ageMs: 3_000 });
seedSession({ id: "S-awaiting", status: "awaiting_input", ageMs: 1_000 });
seedSession({ id: "S-complete", status: "complete" });
const recoverable = store.listRecoverable();
expect(recoverable.map((session) => session.id)).toEqual(["S-awaiting", "S-generating"]);
expect(recoverable.map((session) => session.status).sort()).toEqual(["awaiting_input", "generating"]);
});
it("listRecoverable excludes complete and error sessions", () => {
seedSession({ id: "S-complete", status: "complete" });
seedSession({ id: "S-error", status: "error" });
const recoverable = store.listRecoverable();
expect(recoverable).toEqual([]);
});
it("listRecoverable filters by projectId", () => {
seedSession({ id: "S-a1", status: "generating", projectId: "project-a" });
seedSession({ id: "S-a2", status: "awaiting_input", projectId: "project-a" });
seedSession({ id: "S-b1", status: "awaiting_input", projectId: "project-b" });
const projectA = store.listRecoverable("project-a");
expect(projectA).toHaveLength(2);
expect(projectA.map((session) => session.id).sort()).toEqual(["S-a1", "S-a2"]);
expect(projectA.every((session) => session.projectId === "project-a")).toBe(true);
});
it("listRecoverable returns full AiSessionRow objects", () => {
seedSession({
id: "S-full",
status: "awaiting_input",
projectId: "project-a",
currentQuestion: { id: "q-1", type: "text", question: "Next?" },
});
const [row] = store.listRecoverable();
expect(row).toMatchObject({
id: "S-full",
type: "planning",
status: "awaiting_input",
title: "Session S-full",
inputPayload: expect.any(String),
conversationHistory: expect.any(String),
currentQuestion: expect.any(String),
result: null,
thinkingOutput: expect.any(String),
error: null,
projectId: "project-a",
createdAt: expect.any(String),
updatedAt: expect.any(String),
});
});
});