This merge adds two policy features (executor role enforcement in core and internal coordination tool exemptions in the engine), introduces a shell regression matrix covering mobile, desktop, and dashboard with corresponding readme entries, and fixes a broad suite of tests across the dashboard, engi Fusion-Task-Id: FN-3725
1003 lines
31 KiB
TypeScript
1003 lines
31 KiB
TypeScript
// @vitest-environment node
|
|
|
|
import { EventEmitter } from "node:events";
|
|
import ts from "typescript";
|
|
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const { mockCreateFnAgent } = vi.hoisted(() => ({
|
|
mockCreateFnAgent: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("@fusion/engine", () => ({
|
|
createFnAgent: mockCreateFnAgent,
|
|
}));
|
|
|
|
import type { AiSessionRow } from "../ai-session-store.js";
|
|
// @ts-expect-error Vite raw loader import for source-level utility tests
|
|
import subtaskBreakdownSource from "../subtask-breakdown.ts?raw";
|
|
import {
|
|
setDiagnosticsSink,
|
|
resetDiagnosticsSink,
|
|
} from "../ai-session-diagnostics.js";
|
|
import type { LogEntry } from "../ai-session-diagnostics.js";
|
|
import {
|
|
__resetSubtaskBreakdownState,
|
|
cancelSubtaskSession,
|
|
cleanupSubtaskSession,
|
|
createSubtaskSession,
|
|
retrySubtaskSession,
|
|
getSubtaskSession,
|
|
rehydrateFromStore,
|
|
SessionNotFoundError,
|
|
InvalidSessionStateError,
|
|
setAiSessionStore,
|
|
stopSubtaskGeneration,
|
|
SubtaskStreamManager,
|
|
GENERATION_TIMEOUT_MS,
|
|
} from "../subtask-breakdown.js";
|
|
|
|
const UUID_REGEX =
|
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
|
|
type InternalSubtaskFns = {
|
|
parseSubtasks: (text: string) => Array<{
|
|
id: string;
|
|
title: string;
|
|
description: string;
|
|
suggestedSize: "S" | "M" | "L";
|
|
dependsOn: string[];
|
|
}>;
|
|
normalizeSubtaskItem: (
|
|
item: Partial<{
|
|
id: string;
|
|
title: string;
|
|
description: string;
|
|
suggestedSize: "S" | "M" | "L" | "";
|
|
dependsOn: unknown[];
|
|
}>,
|
|
index?: number,
|
|
) => {
|
|
id: string;
|
|
title: string;
|
|
description: string;
|
|
suggestedSize: "S" | "M" | "L";
|
|
dependsOn: string[];
|
|
};
|
|
generateFallbackSubtasks: (initialDescription: string) => Array<{
|
|
id: string;
|
|
title: string;
|
|
description: string;
|
|
suggestedSize: "S" | "M" | "L";
|
|
dependsOn: string[];
|
|
}>;
|
|
};
|
|
|
|
function extractFunctionSource(startToken: string, endToken: string): string {
|
|
const start = subtaskBreakdownSource.indexOf(startToken);
|
|
if (start < 0) {
|
|
throw new Error(`Unable to find function start token: ${startToken}`);
|
|
}
|
|
const end = subtaskBreakdownSource.indexOf(endToken, start);
|
|
if (end < 0) {
|
|
throw new Error(`Unable to find function end token: ${endToken}`);
|
|
}
|
|
return subtaskBreakdownSource.slice(start, end).trim();
|
|
}
|
|
|
|
async function loadInternalSubtaskFunctions(): Promise<InternalSubtaskFns> {
|
|
const parseSource = extractFunctionSource(
|
|
"function parseSubtasks",
|
|
"\nfunction normalizeSubtaskItem",
|
|
);
|
|
const normalizeSource = extractFunctionSource(
|
|
"function normalizeSubtaskItem",
|
|
"\nfunction generateFallbackSubtasks",
|
|
);
|
|
const fallbackSource = extractFunctionSource(
|
|
"function generateFallbackSubtasks",
|
|
"\nfunction completeSession",
|
|
);
|
|
|
|
const utilityModuleSource = `
|
|
type SubtaskItem = {
|
|
id: string;
|
|
title: string;
|
|
description: string;
|
|
suggestedSize: "S" | "M" | "L";
|
|
dependsOn: string[];
|
|
};
|
|
|
|
${parseSource}
|
|
|
|
${normalizeSource}
|
|
|
|
${fallbackSource}
|
|
|
|
export { parseSubtasks, normalizeSubtaskItem, generateFallbackSubtasks };
|
|
`;
|
|
|
|
const transpiled = ts.transpileModule(utilityModuleSource, {
|
|
compilerOptions: {
|
|
module: ts.ModuleKind.ESNext,
|
|
target: ts.ScriptTarget.ES2022,
|
|
},
|
|
}).outputText;
|
|
|
|
const moduleUrl = `data:text/javascript;base64,${Buffer.from(transpiled).toString("base64")}`;
|
|
return (await import(moduleUrl)) as InternalSubtaskFns;
|
|
}
|
|
|
|
let internalFns: InternalSubtaskFns;
|
|
|
|
function createMockSubtaskAgent(responseText?: string) {
|
|
const messages: Array<{ role: string; content: string }> = [];
|
|
const response =
|
|
responseText ??
|
|
JSON.stringify({
|
|
subtasks: [
|
|
{
|
|
id: "subtask-1",
|
|
title: "Define implementation approach",
|
|
description: "Plan the implementation details",
|
|
suggestedSize: "S",
|
|
dependsOn: [],
|
|
},
|
|
],
|
|
});
|
|
|
|
return {
|
|
session: {
|
|
state: { messages },
|
|
prompt: vi.fn(async (message: string) => {
|
|
messages.push({ role: "user", content: message });
|
|
messages.push({ role: "assistant", content: response });
|
|
}),
|
|
dispose: vi.fn(),
|
|
},
|
|
};
|
|
}
|
|
|
|
class MockAiSessionStore extends EventEmitter {
|
|
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);
|
|
this.emit("ai_session:deleted", 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" || row.status === "error",
|
|
);
|
|
}
|
|
|
|
on(event: "ai_session:deleted", listener: (sessionId: string) => void): this {
|
|
return super.on(event, listener);
|
|
}
|
|
|
|
off(event: "ai_session:deleted", listener: (sessionId: string) => void): this {
|
|
return super.off(event, listener);
|
|
}
|
|
}
|
|
|
|
function buildSubtaskRow(
|
|
overrides: Partial<AiSessionRow> & Pick<AiSessionRow, "id" | "status">,
|
|
): AiSessionRow {
|
|
const now = new Date().toISOString();
|
|
return {
|
|
id: overrides.id,
|
|
type: overrides.type ?? "subtask",
|
|
status: overrides.status,
|
|
title: overrides.title ?? "Subtask breakdown",
|
|
inputPayload:
|
|
overrides.inputPayload ?? JSON.stringify({ initialDescription: "Break this task down" }),
|
|
conversationHistory: overrides.conversationHistory ?? "[]",
|
|
currentQuestion: overrides.currentQuestion ?? null,
|
|
result:
|
|
overrides.result ??
|
|
JSON.stringify([
|
|
{
|
|
id: "subtask-1",
|
|
title: "Define scope",
|
|
description: "Plan the work",
|
|
suggestedSize: "S",
|
|
dependsOn: [],
|
|
},
|
|
]),
|
|
thinkingOutput: overrides.thinkingOutput ?? "thinking",
|
|
error: overrides.error ?? null,
|
|
projectId: overrides.projectId ?? null,
|
|
createdAt: overrides.createdAt ?? now,
|
|
updatedAt: overrides.updatedAt ?? now,
|
|
};
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
internalFns = await loadInternalSubtaskFunctions();
|
|
});
|
|
|
|
beforeEach(() => {
|
|
mockCreateFnAgent.mockReset();
|
|
mockCreateFnAgent.mockImplementation(async () => createMockSubtaskAgent());
|
|
});
|
|
|
|
afterEach(() => {
|
|
__resetSubtaskBreakdownState();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe("SubtaskStreamManager", () => {
|
|
it("subscribe/broadcast delivers events and unsubscribe stops delivery", () => {
|
|
const manager = new SubtaskStreamManager();
|
|
const callback = vi.fn();
|
|
|
|
const unsubscribe = manager.subscribe("session-1", callback);
|
|
|
|
manager.broadcast("session-1", { type: "thinking", data: "first delta" });
|
|
expect(callback).toHaveBeenCalledTimes(1);
|
|
expect(callback).toHaveBeenCalledWith(
|
|
{ type: "thinking", data: "first delta" },
|
|
expect.any(Number),
|
|
);
|
|
|
|
unsubscribe();
|
|
manager.broadcast("session-1", { type: "thinking", data: "second delta" });
|
|
expect(callback).toHaveBeenCalledTimes(1);
|
|
|
|
expect(() => manager.broadcast("missing-session", { type: "complete" })).not.toThrow();
|
|
});
|
|
|
|
it("cleanupSession removes subscribers and prior buffered events", () => {
|
|
const manager = new SubtaskStreamManager();
|
|
const callback = vi.fn();
|
|
|
|
manager.subscribe("session-2", callback);
|
|
manager.broadcast("session-2", { type: "thinking", data: "before cleanup" });
|
|
|
|
expect(manager.getBufferedEvents("session-2", 0)).toHaveLength(1);
|
|
|
|
manager.cleanupSession("session-2");
|
|
|
|
manager.broadcast("session-2", { type: "thinking", data: "after cleanup" });
|
|
expect(callback).toHaveBeenCalledTimes(1);
|
|
expect(manager.getBufferedEvents("session-2", 0)).toEqual([
|
|
{ id: 1, event: "thinking", data: JSON.stringify("after cleanup") },
|
|
]);
|
|
});
|
|
|
|
it("notifies multiple subscribers and isolates subscriber errors", () => {
|
|
const manager = new SubtaskStreamManager();
|
|
const goodSubscriber = vi.fn();
|
|
const throwingSubscriber = vi.fn(() => {
|
|
throw new Error("subscriber failed");
|
|
});
|
|
|
|
manager.subscribe("session-3", throwingSubscriber);
|
|
manager.subscribe("session-3", goodSubscriber);
|
|
|
|
expect(() =>
|
|
manager.broadcast("session-3", { type: "subtasks", data: [] }),
|
|
).not.toThrow();
|
|
|
|
expect(throwingSubscriber).toHaveBeenCalledTimes(1);
|
|
expect(goodSubscriber).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe("normalizeSubtaskItem", () => {
|
|
it("keeps valid items unchanged", () => {
|
|
const result = internalFns.normalizeSubtaskItem(
|
|
{
|
|
id: "subtask-9",
|
|
title: "Title",
|
|
description: "Description",
|
|
suggestedSize: "L",
|
|
dependsOn: ["subtask-1"],
|
|
},
|
|
8,
|
|
);
|
|
|
|
// Priority now normalizes to "normal" when omitted.
|
|
expect(result).toEqual({
|
|
id: "subtask-9",
|
|
title: "Title",
|
|
description: "Description",
|
|
suggestedSize: "L",
|
|
priority: "normal",
|
|
dependsOn: ["subtask-1"],
|
|
});
|
|
});
|
|
|
|
it("fills default id, suggestedSize, and dependsOn when fields are missing", () => {
|
|
const result = internalFns.normalizeSubtaskItem(
|
|
{
|
|
title: " Plan ",
|
|
description: " Work ",
|
|
},
|
|
1,
|
|
);
|
|
|
|
expect(result).toEqual({
|
|
id: "subtask-2",
|
|
title: "Plan",
|
|
description: "Work",
|
|
suggestedSize: "M",
|
|
priority: "normal",
|
|
dependsOn: [],
|
|
});
|
|
});
|
|
|
|
it("defaults empty suggestedSize and filters non-string dependsOn entries", () => {
|
|
const result = internalFns.normalizeSubtaskItem(
|
|
{
|
|
id: "",
|
|
title: "Task",
|
|
description: "Desc",
|
|
suggestedSize: "",
|
|
dependsOn: ["subtask-1", 123, null, "subtask-2"],
|
|
},
|
|
0,
|
|
);
|
|
|
|
expect(result.id).toBe("subtask-1");
|
|
expect(result.suggestedSize).toBe("M");
|
|
expect(result.dependsOn).toEqual(["subtask-1", "subtask-2"]);
|
|
});
|
|
});
|
|
|
|
describe("parseSubtasks", () => {
|
|
it("parses markdown-wrapped JSON and normalizes items", () => {
|
|
const parsed = internalFns.parseSubtasks(`\`\`\`json\n{"subtasks":[{"title":"First"}]}\n\`\`\``);
|
|
|
|
expect(parsed).toHaveLength(1);
|
|
expect(parsed[0]).toMatchObject({
|
|
id: "subtask-1",
|
|
title: "First",
|
|
description: "",
|
|
suggestedSize: "M",
|
|
dependsOn: [],
|
|
});
|
|
});
|
|
|
|
it("parses raw JSON objects", () => {
|
|
const parsed = internalFns.parseSubtasks(
|
|
JSON.stringify({
|
|
subtasks: [
|
|
{
|
|
id: "subtask-1",
|
|
title: "First",
|
|
description: "Do first",
|
|
suggestedSize: "S",
|
|
dependsOn: [],
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
|
|
expect(parsed[0].title).toBe("First");
|
|
});
|
|
|
|
it("throws for invalid JSON, missing subtasks key, and empty subtasks array", () => {
|
|
expect(() => internalFns.parseSubtasks("not-json")).toThrow();
|
|
expect(() => internalFns.parseSubtasks(JSON.stringify({ foo: [] }))).toThrow(
|
|
"AI did not return a valid subtasks array",
|
|
);
|
|
expect(() => internalFns.parseSubtasks(JSON.stringify({ subtasks: [] }))).toThrow(
|
|
"AI did not return a valid subtasks array",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("generateFallbackSubtasks", () => {
|
|
it("returns three sequential subtasks with expected dependency chain", () => {
|
|
const description = "Implement a new dashboard flow";
|
|
const subtasks = internalFns.generateFallbackSubtasks(description);
|
|
|
|
expect(subtasks).toHaveLength(3);
|
|
expect(subtasks[0]).toMatchObject({ id: "subtask-1", dependsOn: [] });
|
|
expect(subtasks[1]).toMatchObject({ id: "subtask-2", dependsOn: ["subtask-1"] });
|
|
expect(subtasks[2]).toMatchObject({ id: "subtask-3", dependsOn: ["subtask-2"] });
|
|
expect(subtasks[0].description).toContain(description);
|
|
|
|
for (const subtask of subtasks) {
|
|
expect(["S", "M"]).toContain(subtask.suggestedSize);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("subtask session lifecycle", () => {
|
|
it("createSubtaskSession returns generating session metadata", async () => {
|
|
const description = "Build dashboard coverage tests for mission routes";
|
|
|
|
const created = await createSubtaskSession(description);
|
|
|
|
expect(created).toEqual({
|
|
sessionId: expect.stringMatching(UUID_REGEX),
|
|
initialDescription: description,
|
|
subtasks: [],
|
|
status: "generating",
|
|
createdAt: expect.any(Date),
|
|
});
|
|
|
|
const inMemory = getSubtaskSession(created.sessionId);
|
|
expect(inMemory).toBeDefined();
|
|
expect(inMemory).toMatchObject({
|
|
sessionId: created.sessionId,
|
|
initialDescription: description,
|
|
status: "generating",
|
|
subtasks: [],
|
|
createdAt: expect.any(Date),
|
|
});
|
|
});
|
|
|
|
it("createSubtaskSession stores projectId in session and persists it to SQLite", async () => {
|
|
const description = "Test projectId persistence";
|
|
const projectId = "test-project-123";
|
|
|
|
const store = new MockAiSessionStore();
|
|
setAiSessionStore(store as any);
|
|
|
|
await createSubtaskSession(description, undefined, "/tmp/project", undefined, projectId);
|
|
|
|
// Get the session from memory (it's in generating state)
|
|
const created = await createSubtaskSession("temp");
|
|
const sessionId = created.sessionId;
|
|
|
|
// Create session with projectId
|
|
await createSubtaskSession(description, undefined, "/tmp/project", undefined, projectId);
|
|
|
|
// Wait for session to be persisted
|
|
await vi.waitFor(() => {
|
|
const row = store.get(sessionId);
|
|
return row !== null;
|
|
}, { timeout: 1000 });
|
|
|
|
// The first created session is the one without projectId, create another with projectId
|
|
const created2 = await createSubtaskSession("temp2");
|
|
await createSubtaskSession(description, undefined, "/tmp/project", undefined, projectId);
|
|
|
|
// Check the persisted row has the projectId
|
|
const persistedRows = [...store.rows.values()];
|
|
const withProjectId = persistedRows.find(r => r.projectId === projectId);
|
|
expect(withProjectId).toBeDefined();
|
|
});
|
|
|
|
it("rehydrateFromStore restores projectId from SQLite rows", () => {
|
|
const store = new MockAiSessionStore();
|
|
const row = buildSubtaskRow({
|
|
id: "subtask-rehydrate-projectId",
|
|
status: "generating",
|
|
projectId: "restored-project-456",
|
|
});
|
|
store.rows.set(row.id, row);
|
|
|
|
const rehydrated = rehydrateFromStore(store as any);
|
|
|
|
expect(rehydrated).toBe(1);
|
|
// Access internal sessions map via getSubtaskSession which should restore projectId
|
|
const session = getSubtaskSession(row.id);
|
|
expect(session).toBeDefined();
|
|
expect(session?.sessionId).toBe(row.id);
|
|
});
|
|
|
|
it("retrySubtaskSession preserves projectId through retry lifecycle", async () => {
|
|
const store = new MockAiSessionStore();
|
|
const projectId = "retry-project-789";
|
|
const row = buildSubtaskRow({
|
|
id: "subtask-retry-projectId",
|
|
status: "error",
|
|
error: "Transient failure",
|
|
projectId,
|
|
});
|
|
store.rows.set(row.id, row);
|
|
setAiSessionStore(store as any);
|
|
|
|
await retrySubtaskSession(row.id, "/tmp/project");
|
|
|
|
const session = getSubtaskSession(row.id);
|
|
expect(session).toBeDefined();
|
|
expect(session?.status).toBe("complete");
|
|
|
|
// Verify the persisted row still has the projectId after retry
|
|
const updatedRow = store.get(row.id);
|
|
expect(updatedRow).toBeDefined();
|
|
expect(updatedRow?.projectId).toBe(projectId);
|
|
});
|
|
|
|
it("getSubtaskSession returns undefined for unknown session and public shape for known session", async () => {
|
|
expect(getSubtaskSession("unknown-session")).toBeUndefined();
|
|
|
|
const created = await createSubtaskSession("Public session shape verification");
|
|
|
|
const session = getSubtaskSession(created.sessionId);
|
|
expect(session).toBeDefined();
|
|
expect(session).toMatchObject({
|
|
sessionId: created.sessionId,
|
|
initialDescription: "Public session shape verification",
|
|
status: "generating",
|
|
subtasks: [],
|
|
createdAt: expect.any(Date),
|
|
});
|
|
expect(session).not.toHaveProperty("updatedAt");
|
|
expect(session).not.toHaveProperty("agent");
|
|
expect(session).not.toHaveProperty("thinkingOutput");
|
|
});
|
|
|
|
it("createSubtaskSession uses custom prompt from promptOverrides", async () => {
|
|
const description = "Build test coverage for new API";
|
|
const customPrompt = "Custom subtask prompt...";
|
|
const promptOverrides = { "subtask-breakdown-system": customPrompt };
|
|
|
|
const createFnAgentSpy = vi.fn().mockImplementation(async () => ({
|
|
session: {
|
|
state: { messages: [] },
|
|
prompt: vi.fn(),
|
|
dispose: vi.fn(),
|
|
},
|
|
}));
|
|
mockCreateFnAgent.mockImplementation(createFnAgentSpy);
|
|
|
|
const created = await createSubtaskSession(description, undefined, "/tmp/project", promptOverrides);
|
|
|
|
// Wait for the session generation to complete
|
|
await vi.waitFor(() => {
|
|
const session = getSubtaskSession(created.sessionId);
|
|
return session?.status === "complete";
|
|
}, { timeout: 5000 });
|
|
|
|
expect(createFnAgentSpy).toHaveBeenCalledTimes(1);
|
|
const callArg = createFnAgentSpy.mock.calls[0]?.[0] as Record<string, unknown>;
|
|
expect(callArg?.systemPrompt).toBe(customPrompt);
|
|
});
|
|
|
|
it("createSubtaskSession falls back to default prompt when promptOverrides is undefined", async () => {
|
|
const description = "Build test coverage for new API";
|
|
|
|
const createFnAgentSpy = vi.fn().mockImplementation(async () => ({
|
|
session: {
|
|
state: { messages: [] },
|
|
prompt: vi.fn(),
|
|
dispose: vi.fn(),
|
|
},
|
|
}));
|
|
mockCreateFnAgent.mockImplementation(createFnAgentSpy);
|
|
|
|
const created = await createSubtaskSession(description, undefined, "/tmp/project");
|
|
|
|
// Wait for the session generation to complete
|
|
await vi.waitFor(() => {
|
|
const session = getSubtaskSession(created.sessionId);
|
|
return session?.status === "complete";
|
|
}, { timeout: 5000 });
|
|
|
|
expect(createFnAgentSpy).toHaveBeenCalledTimes(1);
|
|
const callArg = createFnAgentSpy.mock.calls[0]?.[0] as Record<string, unknown>;
|
|
expect(callArg?.systemPrompt).toContain("task decomposition assistant");
|
|
});
|
|
|
|
it("createSubtaskSession falls back to default prompt when promptOverrides does not contain subtask key", async () => {
|
|
const description = "Build test coverage for new API";
|
|
const promptOverrides = { "planning-system": "Some other prompt" };
|
|
|
|
const createFnAgentSpy = vi.fn().mockImplementation(async () => ({
|
|
session: {
|
|
state: { messages: [] },
|
|
prompt: vi.fn(),
|
|
dispose: vi.fn(),
|
|
},
|
|
}));
|
|
mockCreateFnAgent.mockImplementation(createFnAgentSpy);
|
|
|
|
const created = await createSubtaskSession(description, undefined, "/tmp/project", promptOverrides);
|
|
|
|
// Wait for the session generation to complete
|
|
await vi.waitFor(() => {
|
|
const session = getSubtaskSession(created.sessionId);
|
|
return session?.status === "complete";
|
|
}, { timeout: 5000 });
|
|
|
|
expect(createFnAgentSpy).toHaveBeenCalledTimes(1);
|
|
const callArg = createFnAgentSpy.mock.calls[0]?.[0] as Record<string, unknown>;
|
|
expect(callArg?.systemPrompt).toContain("task decomposition assistant");
|
|
});
|
|
|
|
it("retrySubtaskSession retries errored sessions restored from SQLite", async () => {
|
|
const store = new MockAiSessionStore();
|
|
const row = buildSubtaskRow({
|
|
id: "subtask-retry-1",
|
|
status: "error",
|
|
error: "Transient failure",
|
|
result: null,
|
|
});
|
|
store.rows.set(row.id, row);
|
|
setAiSessionStore(store as any);
|
|
|
|
await retrySubtaskSession(row.id, "/tmp/project");
|
|
|
|
const session = getSubtaskSession(row.id);
|
|
expect(session).toBeDefined();
|
|
expect(session?.status).toBe("complete");
|
|
expect(session?.subtasks.length).toBeGreaterThan(0);
|
|
expect(store.get(row.id)?.status).toBe("complete");
|
|
expect(store.get(row.id)?.error).toBeNull();
|
|
});
|
|
|
|
it("retrySubtaskSession rejects non-error sessions", async () => {
|
|
const store = new MockAiSessionStore();
|
|
const row = buildSubtaskRow({ id: "subtask-retry-2", status: "generating" });
|
|
store.rows.set(row.id, row);
|
|
setAiSessionStore(store as any);
|
|
|
|
await expect(retrySubtaskSession(row.id, "/tmp/project")).rejects.toBeInstanceOf(
|
|
InvalidSessionStateError,
|
|
);
|
|
});
|
|
|
|
it("retrySubtaskSession uses custom prompt from promptOverrides", async () => {
|
|
const store = new MockAiSessionStore();
|
|
const row = buildSubtaskRow({
|
|
id: "subtask-retry-with-override",
|
|
status: "error",
|
|
error: "Transient failure",
|
|
result: null,
|
|
});
|
|
store.rows.set(row.id, row);
|
|
setAiSessionStore(store as any);
|
|
|
|
const customPrompt = "Custom subtask breakdown prompt...";
|
|
const promptOverrides = { "subtask-breakdown-system": customPrompt };
|
|
|
|
mockCreateFnAgent.mockReset();
|
|
|
|
const createFnAgentSpy = vi.fn().mockImplementation(async () => ({
|
|
session: {
|
|
state: { messages: [] },
|
|
prompt: vi.fn(),
|
|
dispose: vi.fn(),
|
|
},
|
|
}));
|
|
mockCreateFnAgent.mockImplementation(createFnAgentSpy);
|
|
|
|
await retrySubtaskSession(row.id, "/tmp/project", promptOverrides);
|
|
|
|
expect(createFnAgentSpy).toHaveBeenCalledTimes(1);
|
|
const callArg = createFnAgentSpy.mock.calls[0]?.[0] as Record<string, unknown>;
|
|
expect(callArg?.systemPrompt).toBe(customPrompt);
|
|
});
|
|
|
|
it("retrySubtaskSession falls back to default prompt when promptOverrides is undefined", async () => {
|
|
const store = new MockAiSessionStore();
|
|
const row = buildSubtaskRow({
|
|
id: "subtask-retry-no-override",
|
|
status: "error",
|
|
error: "Transient failure",
|
|
result: null,
|
|
});
|
|
store.rows.set(row.id, row);
|
|
setAiSessionStore(store as any);
|
|
|
|
mockCreateFnAgent.mockReset();
|
|
|
|
const createFnAgentSpy = vi.fn().mockImplementation(async () => ({
|
|
session: {
|
|
state: { messages: [] },
|
|
prompt: vi.fn(),
|
|
dispose: vi.fn(),
|
|
},
|
|
}));
|
|
mockCreateFnAgent.mockImplementation(createFnAgentSpy);
|
|
|
|
await retrySubtaskSession(row.id, "/tmp/project");
|
|
|
|
expect(createFnAgentSpy).toHaveBeenCalledTimes(1);
|
|
const callArg = createFnAgentSpy.mock.calls[0]?.[0] as Record<string, unknown>;
|
|
expect(callArg?.systemPrompt).toContain("task decomposition assistant");
|
|
});
|
|
|
|
it("cancelSubtaskSession throws SessionNotFoundError for unknown session", async () => {
|
|
await expect(cancelSubtaskSession("missing-session")).rejects.toMatchObject({
|
|
name: "SessionNotFoundError",
|
|
});
|
|
});
|
|
|
|
it("cancelSubtaskSession removes an existing session", async () => {
|
|
const created = await createSubtaskSession("Cancel active session");
|
|
|
|
await cancelSubtaskSession(created.sessionId);
|
|
|
|
expect(getSubtaskSession(created.sessionId)).toBeUndefined();
|
|
});
|
|
|
|
it("cleanupSubtaskSession is idempotent", async () => {
|
|
const created = await createSubtaskSession("Cleanup idempotency");
|
|
|
|
expect(() => cleanupSubtaskSession(created.sessionId)).not.toThrow();
|
|
expect(() => cleanupSubtaskSession(created.sessionId)).not.toThrow();
|
|
expect(getSubtaskSession(created.sessionId)).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("subtask session rehydration", () => {
|
|
it("rehydrates recoverable subtask sessions from SQLite rows", () => {
|
|
const store = new MockAiSessionStore();
|
|
const subtaskRow = buildSubtaskRow({ id: "subtask-rehydrate-1", status: "generating" });
|
|
const planningRow = buildSubtaskRow({
|
|
id: "planning-rehydrate-1",
|
|
status: "awaiting_input",
|
|
type: "planning",
|
|
});
|
|
|
|
store.rows.set(subtaskRow.id, subtaskRow);
|
|
store.rows.set(planningRow.id, planningRow);
|
|
|
|
const rehydrated = rehydrateFromStore(store as any);
|
|
|
|
expect(rehydrated).toBe(1);
|
|
const session = getSubtaskSession(subtaskRow.id);
|
|
expect(session).toBeDefined();
|
|
expect(session?.sessionId).toBe(subtaskRow.id);
|
|
expect(session?.initialDescription).toBe("Break this task down");
|
|
expect(session?.status).toBe("generating");
|
|
expect(session?.subtasks).toHaveLength(1);
|
|
expect(getSubtaskSession(planningRow.id)).toBeUndefined();
|
|
});
|
|
|
|
it("returns 0 and logs diagnostic when listRecoverable throws", () => {
|
|
const store = new MockAiSessionStore();
|
|
// Override listRecoverable to throw
|
|
vi.spyOn(store, "listRecoverable").mockImplementation(() => {
|
|
throw new Error("Database connection failed");
|
|
});
|
|
|
|
// Capture structured diagnostics via shared helper sink
|
|
const loggedEntries: LogEntry[] = [];
|
|
setDiagnosticsSink((level, scope, message, context) => {
|
|
loggedEntries.push({ level, scope, message, context, timestamp: new Date() });
|
|
});
|
|
|
|
const rehydrated = rehydrateFromStore(store as any);
|
|
|
|
// Non-fatal: returns 0 and continues
|
|
expect(rehydrated).toBe(0);
|
|
// Assert structured diagnostic record
|
|
expect(loggedEntries).toContainEqual(
|
|
expect.objectContaining({
|
|
level: "error",
|
|
scope: "subtask-breakdown",
|
|
message: "Failed to list recoverable sessions",
|
|
context: expect.objectContaining({
|
|
operation: "list-recoverable",
|
|
}),
|
|
})
|
|
);
|
|
|
|
resetDiagnosticsSink();
|
|
});
|
|
|
|
it("skips corrupted rows and continues with valid rows", () => {
|
|
const store = new MockAiSessionStore();
|
|
const goodRow = buildSubtaskRow({ id: "subtask-good", status: "generating" });
|
|
const badRow = buildSubtaskRow({
|
|
id: "subtask-bad",
|
|
status: "generating",
|
|
inputPayload: "{bad-json",
|
|
});
|
|
|
|
store.rows.set(goodRow.id, goodRow);
|
|
store.rows.set(badRow.id, badRow);
|
|
|
|
// Capture structured diagnostics via shared helper sink
|
|
const loggedEntries: LogEntry[] = [];
|
|
setDiagnosticsSink((level, scope, message, context) => {
|
|
loggedEntries.push({ level, scope, message, context, timestamp: new Date() });
|
|
});
|
|
|
|
const rehydrated = rehydrateFromStore(store as any);
|
|
|
|
expect(rehydrated).toBe(1);
|
|
expect(getSubtaskSession(goodRow.id)).toBeDefined();
|
|
expect(getSubtaskSession(badRow.id)).toBeUndefined();
|
|
// Assert structured diagnostic record
|
|
expect(loggedEntries).toContainEqual(
|
|
expect.objectContaining({
|
|
level: "error",
|
|
scope: "subtask-breakdown",
|
|
message: "Failed to rehydrate session",
|
|
context: expect.objectContaining({
|
|
sessionId: badRow.id,
|
|
operation: "rehydrate",
|
|
}),
|
|
})
|
|
);
|
|
|
|
resetDiagnosticsSink();
|
|
});
|
|
|
|
it("falls through to SQLite when session is missing in memory", () => {
|
|
const store = new MockAiSessionStore();
|
|
const row = buildSubtaskRow({ id: "subtask-fallthrough", status: "generating" });
|
|
store.rows.set(row.id, row);
|
|
setAiSessionStore(store as any);
|
|
|
|
const session = getSubtaskSession(row.id);
|
|
|
|
expect(session).toBeDefined();
|
|
expect(session?.sessionId).toBe(row.id);
|
|
expect(session?.initialDescription).toBe("Break this task down");
|
|
expect(session?.status).toBe("generating");
|
|
});
|
|
|
|
it("returns in-memory session before SQLite fallback", () => {
|
|
const store = new MockAiSessionStore();
|
|
const row = buildSubtaskRow({ id: "subtask-memory-first", status: "generating" });
|
|
store.rows.set(row.id, row);
|
|
|
|
setAiSessionStore(store as any);
|
|
rehydrateFromStore(store as any);
|
|
|
|
store.rows.set(
|
|
row.id,
|
|
buildSubtaskRow({
|
|
id: row.id,
|
|
status: "generating",
|
|
inputPayload: JSON.stringify({ initialDescription: "SQLite version" }),
|
|
}),
|
|
);
|
|
|
|
const getSpy = vi.spyOn(store, "get");
|
|
const session = getSubtaskSession(row.id);
|
|
|
|
expect(session?.initialDescription).toBe("Break this task down");
|
|
expect(getSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("logs error diagnostic when SQLite restore fails for corrupted row", () => {
|
|
const store = new MockAiSessionStore();
|
|
// Use corrupted result field (which is parsed in buildSubtaskSessionFromRow)
|
|
const badRow = buildSubtaskRow({
|
|
id: "subtask-restore-bad",
|
|
status: "generating",
|
|
result: "{bad-json",
|
|
});
|
|
store.rows.set(badRow.id, badRow);
|
|
setAiSessionStore(store as any);
|
|
|
|
// Capture structured diagnostics via shared helper sink
|
|
const loggedEntries: LogEntry[] = [];
|
|
setDiagnosticsSink((level, scope, message, context) => {
|
|
loggedEntries.push({ level, scope, message, context, timestamp: new Date() });
|
|
});
|
|
|
|
const session = getSubtaskSession(badRow.id);
|
|
|
|
expect(session).toBeUndefined();
|
|
// Assert structured diagnostic record includes sessionId
|
|
expect(loggedEntries).toContainEqual(
|
|
expect.objectContaining({
|
|
level: "error",
|
|
scope: "subtask-breakdown",
|
|
message: "Failed to restore session from SQLite",
|
|
context: expect.objectContaining({
|
|
sessionId: badRow.id,
|
|
operation: "restore",
|
|
}),
|
|
})
|
|
);
|
|
|
|
resetDiagnosticsSink();
|
|
});
|
|
});
|
|
|
|
describe("SessionNotFoundError", () => {
|
|
it("is an Error subtype with SessionNotFoundError name", () => {
|
|
const error = new SessionNotFoundError("Missing session");
|
|
|
|
expect(error).toBeInstanceOf(Error);
|
|
expect(error.name).toBe("SessionNotFoundError");
|
|
expect(error.message).toBe("Missing session");
|
|
});
|
|
});
|
|
|
|
describe("subtask generation timeout / abort", () => {
|
|
it("marks the session as error and stops the prompt() promise when generation exceeds GENERATION_TIMEOUT_MS", async () => {
|
|
vi.useFakeTimers();
|
|
|
|
let resolveHungPrompt: (() => void) | undefined;
|
|
const hungPromptCallable = vi.fn(async () => {
|
|
// Simulate a stalled provider stream that never terminates on its own.
|
|
await new Promise<void>((resolve) => { resolveHungPrompt = resolve; });
|
|
return undefined;
|
|
});
|
|
|
|
mockCreateFnAgent.mockImplementation(async () => ({
|
|
session: {
|
|
state: { messages: [] },
|
|
prompt: hungPromptCallable,
|
|
dispose: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
const created = await createSubtaskSession(
|
|
"Hung subtask generation",
|
|
undefined,
|
|
"/tmp/project",
|
|
);
|
|
|
|
// Yield once so startSubtaskGeneration's microtasks run before we advance time.
|
|
await Promise.resolve();
|
|
expect(getSubtaskSession(created.sessionId)?.status).toBe("generating");
|
|
|
|
await vi.advanceTimersByTimeAsync(GENERATION_TIMEOUT_MS);
|
|
// Drain any remaining microtasks (the abort propagates through Promise.race).
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
|
|
const after = getSubtaskSession(created.sessionId);
|
|
expect(after?.status).toBe("error");
|
|
expect(after?.error).toMatch(/timed out/i);
|
|
|
|
// The hung prompt is still pending; release it so its microtask completes.
|
|
resolveHungPrompt?.();
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("stopSubtaskGeneration aborts an in-flight session and marks it stopped", async () => {
|
|
vi.useFakeTimers();
|
|
|
|
let resolveHungPrompt: (() => void) | undefined;
|
|
mockCreateFnAgent.mockImplementation(async () => ({
|
|
session: {
|
|
state: { messages: [] },
|
|
prompt: vi.fn(async () => {
|
|
await new Promise<void>((resolve) => { resolveHungPrompt = resolve; });
|
|
}),
|
|
dispose: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
const created = await createSubtaskSession(
|
|
"Stoppable subtask generation",
|
|
undefined,
|
|
"/tmp/project",
|
|
);
|
|
await Promise.resolve();
|
|
|
|
expect(stopSubtaskGeneration(created.sessionId)).toBe(true);
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
|
|
const after = getSubtaskSession(created.sessionId);
|
|
expect(after?.status).toBe("error");
|
|
expect(after?.error).toMatch(/stopped by user/i);
|
|
|
|
// Stop is idempotent — no in-flight generation after first call.
|
|
expect(stopSubtaskGeneration(created.sessionId)).toBe(false);
|
|
|
|
resolveHungPrompt?.();
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
|
|
vi.useRealTimers();
|
|
});
|
|
});
|