feat(FN-2230): merge fusion/fn-2230

This commit is contained in:
gsxdsm
2026-04-22 02:16:40 -07:00
parent cc7d7d9040
commit e2b69409be
6 changed files with 520 additions and 37 deletions

View File

@@ -10,6 +10,8 @@ import {
__setCreateFnAgent,
__resetChatState,
chatStreamManager,
__getChatDiagnostics,
__setChatDiagnostics,
} from "../chat.js";
// ── Mock Setup ──────────────────────────────────────────────────────────────
@@ -991,3 +993,164 @@ describe("ChatManager.sendMessage", () => {
expect((chatManager as any).activeGenerations.has("chat-001")).toBe(false);
});
});
describe("ChatManager diagnostics", () => {
beforeEach(() => {
vi.clearAllMocks();
__resetChatState();
mockChatStore.getSession.mockReturnValue({
id: "chat-001",
agentId: "agent-001",
status: "active",
});
mockChatStore.addMessage.mockReturnValue({
id: "msg-001",
sessionId: "chat-001",
role: "assistant",
content: "",
});
mockChatStore.getMessages.mockReturnValue([]);
mockAgentStore.init.mockResolvedValue(undefined);
mockAgentStore.getAgent.mockResolvedValue({
id: "agent-001",
name: "Avery",
role: "executor",
soul: "Be calm and precise.",
});
mockAgentStore.listAgents.mockResolvedValue([]);
__setBuildAgentChatPrompt(async ({ basePrompt }: any) => basePrompt);
});
it("logs error diagnostic when broadcast callback throws", () => {
const loggedErrors: Array<{ message: string; args: unknown[] }> = [];
__setChatDiagnostics({
log: vi.fn(),
warn: vi.fn(),
error: (message: string, ...args: unknown[]) => {
loggedErrors.push({ message, args });
},
});
const throwingCallback = vi.fn(() => {
throw new Error("Broadcast callback failed");
});
chatStreamManager.subscribe("chat-001", throwingCallback);
expect(() =>
chatStreamManager.broadcast("chat-001", { type: "thinking", data: "test" })
).not.toThrow();
expect(throwingCallback).toHaveBeenCalledTimes(1);
expect(loggedErrors).toContainEqual({
message: "Error broadcasting to client for session chat-001:",
args: [expect.any(Error)],
});
});
it("logs error diagnostic when sendMessage encounters AI processing failure", async () => {
const loggedErrors: Array<{ message: string; args: unknown[] }> = [];
__setChatDiagnostics({
log: vi.fn(),
warn: vi.fn(),
error: (message: string, ...args: unknown[]) => {
loggedErrors.push({ message, args });
},
});
__setCreateFnAgent(async () => {
return {
session: {
prompt: vi.fn().mockRejectedValue(new Error("AI processing failed")),
dispose: vi.fn(),
state: { messages: [] },
},
};
});
const chatManager = createChatManager();
await chatManager.sendMessage("chat-001", "Hello");
expect(loggedErrors).toContainEqual({
message: "Error in sendMessage for session chat-001:",
args: [expect.any(Error)],
});
});
it("logs error diagnostic when dispose fails during cancellation", () => {
const loggedErrors: Array<{ message: string; args: unknown[] }> = [];
__setChatDiagnostics({
log: vi.fn(),
warn: vi.fn(),
error: (message: string, ...args: unknown[]) => {
loggedErrors.push({ message, args });
},
});
const disposeSpy = vi.fn().mockImplementation(() => {
throw new Error("Dispose failed");
});
__setCreateFnAgent(async () => {
return {
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: disposeSpy,
state: {
messages: [{ role: "assistant", content: "Done" }],
},
},
};
});
const chatManager = createChatManager();
// Set up an active generation manually
const abortController = new AbortController();
(chatManager as any).activeGenerations.set("chat-001", {
abortController,
agentResult: { session: { dispose: disposeSpy } },
});
chatManager.cancelGeneration("chat-001");
expect(loggedErrors).toContainEqual({
message: "Error disposing agent session during cancellation:",
args: [expect.any(Error)],
});
});
it("logs error diagnostic when dispose fails after successful sendMessage", async () => {
const loggedErrors: Array<{ message: string; args: unknown[] }> = [];
__setChatDiagnostics({
log: vi.fn(),
warn: vi.fn(),
error: (message: string, ...args: unknown[]) => {
loggedErrors.push({ message, args });
},
});
const disposeSpy = vi.fn().mockImplementation(() => {
throw new Error("Dispose failed");
});
__setCreateFnAgent(async () => {
return {
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: disposeSpy,
state: {
messages: [{ role: "assistant", content: "Done" }],
},
},
};
});
const chatManager = createChatManager();
await chatManager.sendMessage("chat-001", "Hello");
expect(loggedErrors).toContainEqual({
message: "Error disposing agent session:",
args: [expect.any(Error)],
});
});
});

View File

@@ -35,6 +35,66 @@ let createFnAgent: any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let buildAgentChatPromptFn: any;
/**
* Diagnostics logger for the chat module.
* Provides consistent [chat] prefixed output with test-injectable handlers.
* Mirrors the pattern established in planning.ts (FN-2225).
*/
interface DiagnosticsLogger {
log(message: string, ...args: unknown[]): void;
warn(message: string, ...args: unknown[]): void;
error(message: string, ...args: unknown[]): void;
}
const defaultDiagnostics: DiagnosticsLogger = {
log(message: string, ...args: unknown[]) {
console.log(`[chat] ${message}`, ...args);
},
warn(message: string, ...args: unknown[]) {
console.warn(`[chat] ${message}`, ...args);
},
error(message: string, ...args: unknown[]) {
console.error(`[chat] ${message}`, ...args);
},
};
let _diagnostics: DiagnosticsLogger = defaultDiagnostics;
/**
* Get the current diagnostics logger.
* @internal - exposed for test hook
*/
export function __getChatDiagnostics(): DiagnosticsLogger {
return _diagnostics;
}
/**
* Inject a diagnostics logger (test-only).
* When a logger is injected, all chat module diagnostics route through it.
* This allows tests to assert on diagnostics without global console spies.
* @internal - exposed for test hook
*/
export function __setChatDiagnostics(diagnostics: DiagnosticsLogger | null): void {
_diagnostics = diagnostics ?? defaultDiagnostics;
}
/**
* Shared diagnostics helper used throughout the chat module.
* Routes all informational, warning, and error diagnostics through the current logger.
* Mirrors the pattern from planning.ts (FN-2225).
*/
const diagnostics: DiagnosticsLogger = {
log(message: string, ...args: unknown[]) {
_diagnostics.log(message, ...args);
},
warn(message: string, ...args: unknown[]) {
_diagnostics.warn(message, ...args);
},
error(message: string, ...args: unknown[]) {
_diagnostics.error(message, ...args);
},
};
// Initialize the import (this runs in actual server, mocked in tests)
async function initEngine() {
if (!createFnAgent || !buildAgentChatPromptFn) {
@@ -263,7 +323,7 @@ export class ChatStreamManager extends EventEmitter {
try {
callback(event, eventId);
} catch (err) {
console.error(`[chat] Error broadcasting to client for session ${sessionId}:`, err);
diagnostics.error(`Error broadcasting to client for session ${sessionId}:`, err);
}
}
@@ -395,7 +455,7 @@ export class ChatManager {
return await this.agentStore.listAgents();
} catch (agentListError) {
const message = agentListError instanceof Error ? agentListError.message : String(agentListError);
console.warn(`[chat] Failed to list agents for mention parsing: ${message}`);
diagnostics.warn(`Failed to list agents for mention parsing: ${message}`);
return [];
}
}
@@ -605,7 +665,7 @@ export class ChatManager {
agent = await this.agentStore.getAgent(session.agentId);
} catch (agentLoadError) {
const message = agentLoadError instanceof Error ? agentLoadError.message : String(agentLoadError);
console.warn(`[chat] Failed to load agent context for ${session.agentId}: ${message}`);
diagnostics.warn(`Failed to load agent context for ${session.agentId}: ${message}`);
}
}
@@ -620,7 +680,7 @@ export class ChatManager {
});
} catch (promptBuildError) {
const message = promptBuildError instanceof Error ? promptBuildError.message : String(promptBuildError);
console.warn(`[chat] Failed to build enriched system prompt for ${agent.id}: ${message}`);
diagnostics.warn(`Failed to build enriched system prompt for ${agent.id}: ${message}`);
}
}
@@ -771,7 +831,7 @@ export class ChatManager {
}
const errorMessage = err instanceof Error ? err.message : "AI processing failed";
console.error(`[chat] Error in sendMessage for session ${sessionId}:`, err);
diagnostics.error(`Error in sendMessage for session ${sessionId}:`, err);
if (accumulatedText || accumulatedThinking || toolCallsAccum.length > 0) {
try {
@@ -785,7 +845,7 @@ export class ChatManager {
},
});
} catch (persistErr) {
console.error(`[chat] Failed to persist partial response for session ${sessionId}:`, persistErr);
diagnostics.error(`Failed to persist partial response for session ${sessionId}:`, persistErr);
}
}
@@ -801,7 +861,7 @@ export class ChatManager {
try {
agentResult.session.dispose?.();
} catch (err) {
console.error(`[chat] Error disposing agent session:`, err);
diagnostics.error(`Error disposing agent session:`, err);
}
}
}
@@ -819,7 +879,7 @@ export class ChatManager {
try {
entry.agentResult.session.dispose?.();
} catch (err) {
console.error(`[chat] Error disposing agent session during cancellation:`, err);
diagnostics.error(`Error disposing agent session during cancellation:`, err);
}
}
@@ -856,4 +916,7 @@ export function __resetChatState(): void {
rateLimits.clear();
engineReady = undefined;
buildAgentChatPromptFn = undefined;
// Reset diagnostics logger to default
__setChatDiagnostics(null);
}

View File

@@ -28,6 +28,8 @@ import {
RateLimitError,
SessionNotFoundError,
submitMissionInterviewResponse,
__getMissionInterviewDiagnostics,
__setMissionInterviewDiagnostics,
} from "./mission-interview.js";
import { EventEmitter } from "node:events";
import type { AiSessionRow } from "./ai-session-store.js";
@@ -255,18 +257,24 @@ describe("mission-interview module", () => {
store.rows.set(goodRow.id, goodRow);
store.rows.set(badRow.id, badRow);
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
const loggedErrors: Array<{ message: string; args: unknown[] }> = [];
__setMissionInterviewDiagnostics({
log: vi.fn(),
warn: vi.fn(),
error: (message: string, ...args: unknown[]) => {
loggedErrors.push({ message, args });
},
});
const rehydrated = rehydrateFromStore(store as any);
expect(rehydrated).toBe(1);
expect(getMissionInterviewSession(goodRow.id)).toBeDefined();
expect(getMissionInterviewSession(badRow.id)).toBeUndefined();
expect(errorSpy).toHaveBeenCalledWith(
`[mission-interview] Failed to rehydrate session ${badRow.id}:`,
expect.any(Error),
);
errorSpy.mockRestore();
expect(loggedErrors).toContainEqual({
message: `Failed to rehydrate session ${badRow.id}:`,
args: [expect.any(Error)],
});
});
it("falls through to SQLite when in-memory session is missing", () => {
@@ -521,6 +529,33 @@ describe("mission-interview module", () => {
missionInterviewStreamManager.cleanupSession(sessionId);
expect(missionInterviewStreamManager.getBufferedEvents(sessionId, 0)).toEqual([]);
});
it("logs error diagnostic when broadcast callback throws", () => {
const loggedErrors: Array<{ message: string; args: unknown[] }> = [];
__setMissionInterviewDiagnostics({
log: vi.fn(),
warn: vi.fn(),
error: (message: string, ...args: unknown[]) => {
loggedErrors.push({ message, args });
},
});
const throwingCallback = vi.fn(() => {
throw new Error("Callback failed");
});
missionInterviewStreamManager.subscribe("session-error-callback", throwingCallback);
// Should not throw, but should log the error
expect(() =>
missionInterviewStreamManager.broadcast("session-error-callback", { type: "thinking", data: "test" })
).not.toThrow();
expect(throwingCallback).toHaveBeenCalledTimes(1);
expect(loggedErrors).toContainEqual({
message: "Error broadcasting to client for session session-error-callback:",
args: [expect.any(Error)],
});
});
});
describe("response parsing", () => {
@@ -558,6 +593,64 @@ describe("mission-interview module", () => {
parseMissionAgentResponse(JSON.stringify({ type: "unknown", data: null })),
).toThrow("invalid response structure");
});
it("logs error diagnostic when no JSON candidate found before throwing", () => {
const loggedErrors: Array<{ message: string; args: unknown[] }> = [];
__setMissionInterviewDiagnostics({
log: vi.fn(),
warn: vi.fn(),
error: (message: string, ...args: unknown[]) => {
loggedErrors.push({ message, args });
},
});
const input = "I'm not sure what to ask about this project.";
expect(() => parseMissionAgentResponse(input)).toThrow("no valid JSON");
expect(loggedErrors).toContainEqual({
message: "No JSON candidate found in agent response:",
args: [expect.stringContaining("I'm not sure")],
});
});
it("logs error diagnostic when repair also fails before throwing", () => {
const loggedErrors: Array<{ message: string; args: unknown[] }> = [];
__setMissionInterviewDiagnostics({
log: vi.fn(),
warn: vi.fn(),
error: (message: string, ...args: unknown[]) => {
loggedErrors.push({ message, args });
},
});
// Invalid JSON that repair cannot fix
const input = '{"type":"question","data":{"id":q-1,"question":"What is this?"}';
expect(() => parseMissionAgentResponse(input)).toThrow("Failed to parse AI response");
expect(loggedErrors).toContainEqual({
message: "Failed to parse agent response:",
args: [expect.stringContaining('{"type":"question"')],
});
});
it("logs error diagnostic for invalid response structure before throwing", () => {
const loggedErrors: Array<{ message: string; args: unknown[] }> = [];
__setMissionInterviewDiagnostics({
log: vi.fn(),
warn: vi.fn(),
error: (message: string, ...args: unknown[]) => {
loggedErrors.push({ message, args });
},
});
const input = '{"type":"unknown","data":null}';
expect(() => parseMissionAgentResponse(input)).toThrow("invalid response structure");
expect(loggedErrors).toContainEqual({
message: "Invalid response structure:",
args: [expect.stringContaining('"type":"unknown"')],
});
});
});
describe("custom errors", () => {

View File

@@ -28,6 +28,66 @@ type AgentResult = any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let createFnAgent: any;
/**
* Diagnostics logger for the mission-interview module.
* Provides consistent [mission-interview] prefixed output with test-injectable handlers.
* Mirrors the pattern established in planning.ts (FN-2225).
*/
interface DiagnosticsLogger {
log(message: string, ...args: unknown[]): void;
warn(message: string, ...args: unknown[]): void;
error(message: string, ...args: unknown[]): void;
}
const defaultDiagnostics: DiagnosticsLogger = {
log(message: string, ...args: unknown[]) {
console.log(`[mission-interview] ${message}`, ...args);
},
warn(message: string, ...args: unknown[]) {
console.warn(`[mission-interview] ${message}`, ...args);
},
error(message: string, ...args: unknown[]) {
console.error(`[mission-interview] ${message}`, ...args);
},
};
let _diagnostics: DiagnosticsLogger = defaultDiagnostics;
/**
* Get the current diagnostics logger.
* @internal - exposed for test hook
*/
export function __getMissionInterviewDiagnostics(): DiagnosticsLogger {
return _diagnostics;
}
/**
* Inject a diagnostics logger (test-only).
* When a logger is injected, all mission-interview module diagnostics route through it.
* This allows tests to assert on diagnostics without global console spies.
* @internal - exposed for test hook
*/
export function __setMissionInterviewDiagnostics(diagnostics: DiagnosticsLogger | null): void {
_diagnostics = diagnostics ?? defaultDiagnostics;
}
/**
* Shared diagnostics helper used throughout the mission-interview module.
* Routes all informational, warning, and error diagnostics through the current logger.
* Mirrors the pattern from planning.ts (FN-2225).
*/
const diagnostics: DiagnosticsLogger = {
log(message: string, ...args: unknown[]) {
_diagnostics.log(message, ...args);
},
warn(message: string, ...args: unknown[]) {
_diagnostics.warn(message, ...args);
},
error(message: string, ...args: unknown[]) {
_diagnostics.error(message, ...args);
},
};
async function initEngine() {
if (!createFnAgent) {
try {
@@ -362,7 +422,7 @@ export function rehydrateFromStore(store: AiSessionStore): number {
try {
rows = store.listRecoverable().filter((row) => row.type === "mission_interview");
} catch (error) {
console.error("[mission-interview] Failed to list recoverable sessions:", error);
diagnostics.error("Failed to list recoverable sessions:", error);
return 0;
}
@@ -373,7 +433,7 @@ export function rehydrateFromStore(store: AiSessionStore): number {
sessions.set(session.id, session);
rehydrated += 1;
} catch (error) {
console.error(`[mission-interview] Failed to rehydrate session ${row.id}:`, error);
diagnostics.error(`Failed to rehydrate session ${row.id}:`, error);
}
}
@@ -445,7 +505,7 @@ export class MissionInterviewStreamManager extends EventEmitter {
try {
callback(event, eventId);
} catch (err) {
console.error(`[mission-interview] Error broadcasting to client for session ${sessionId}:`, err);
diagnostics.error(`Error broadcasting to client for session ${sessionId}:`, err);
}
}
@@ -616,7 +676,7 @@ export function parseMissionAgentResponse(text: string): MissionInterviewRespons
const candidate = extractJsonCandidate(text);
if (!candidate) {
console.error("[mission-interview] No JSON candidate found in agent response:", text.slice(0, 500));
diagnostics.error("No JSON candidate found in agent response:", text.slice(0, 500));
throw new Error("AI returned no valid JSON. Please try again.");
}
@@ -628,7 +688,7 @@ export function parseMissionAgentResponse(text: string): MissionInterviewRespons
const repaired = repairJson(candidate);
parsed = JSON.parse(repaired);
} catch (repairErr) {
console.error("[mission-interview] Failed to parse agent response:", candidate.slice(0, 500));
diagnostics.error("Failed to parse agent response:", candidate.slice(0, 500));
throw new Error(
`Failed to parse AI response: ${repairErr instanceof Error ? repairErr.message : "Unknown error"}. Please try again.`
);
@@ -653,7 +713,7 @@ export function parseMissionAgentResponse(text: string): MissionInterviewRespons
}
}
console.error("[mission-interview] Invalid response structure:", JSON.stringify(parsed).slice(0, 500));
diagnostics.error("Invalid response structure:", JSON.stringify(parsed).slice(0, 500));
throw new Error("AI returned an invalid response structure. Please try again.");
}
@@ -711,7 +771,7 @@ function disposeMissionAgentForRetry(session: MissionInterviewSession): void {
try {
session.agent.session.dispose?.();
} catch (error) {
console.error(`[mission-interview] Error disposing agent for retry in session ${session.id}:`, error);
diagnostics.error(`Error disposing agent for retry in session ${session.id}:`, error);
}
session.agent = undefined;
@@ -738,7 +798,7 @@ async function initializeAgent(
);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "Failed to initialize AI agent";
console.error(`[mission-interview] Agent initialization error for session ${session.id}:`, err);
diagnostics.error(`Agent initialization error for session ${session.id}:`, err);
session.error = errorMessage;
session.updatedAt = new Date();
persistMissionSession(session, "error", errorMessage);
@@ -891,8 +951,8 @@ async function continueAgentConversation(session: MissionInterviewSession, messa
lastError = err instanceof Error ? err : new Error(String(err));
if (attempt < MAX_PARSE_RETRIES) {
console.warn(
`[mission-interview] Parse attempt ${attempt + 1} failed for session ${session.id}, requesting reformat`
diagnostics.warn(
`Parse attempt ${attempt + 1} failed for session ${session.id}, requesting reformat`
);
try {
session.thinkingOutput = "";
@@ -920,7 +980,7 @@ async function continueAgentConversation(session: MissionInterviewSession, messa
}
responseText = retryText;
} catch (retryErr) {
console.error(`[mission-interview] Retry prompt failed for session ${session.id}:`, retryErr);
diagnostics.error(`Retry prompt failed for session ${session.id}:`, retryErr);
break;
}
}
@@ -929,7 +989,7 @@ async function continueAgentConversation(session: MissionInterviewSession, messa
if (!parsed) {
const errorMsg = `${lastError?.message || "Failed to parse AI response"} You can try responding again or start a new session.`;
console.error(`[mission-interview] All parse attempts exhausted for session ${session.id}:`, errorMsg);
diagnostics.error(`All parse attempts exhausted for session ${session.id}:`, errorMsg);
session.error = errorMsg;
session.updatedAt = new Date();
persistMissionSession(session, "error", errorMsg);
@@ -964,7 +1024,7 @@ async function continueAgentConversation(session: MissionInterviewSession, messa
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "AI processing failed";
console.error(`[mission-interview] Agent conversation error for session ${session.id}:`, err);
diagnostics.error(`Agent conversation error for session ${session.id}:`, err);
session.error = errorMessage;
session.updatedAt = new Date();
persistMissionSession(session, "error", errorMessage);
@@ -1018,7 +1078,7 @@ export async function createMissionInterviewSession(
// Initialize AI agent in background
initializeAgent(session, rootDir, promptOverrides).catch((err) => {
console.error(`[mission-interview] Failed to initialize agent for session ${sessionId}:`, err);
diagnostics.error(`Failed to initialize agent for session ${sessionId}:`, err);
persistMissionSession(session, "error", err.message || "Failed to initialize AI agent");
missionInterviewStreamManager.broadcast(sessionId, {
type: "error",
@@ -1159,7 +1219,7 @@ export function getMissionInterviewSession(sessionId: string): MissionInterviewS
sessions.set(restored.id, restored);
return restored;
} catch (error) {
console.error(`[mission-interview] Failed to restore session ${sessionId} from SQLite:`, error);
diagnostics.error(`Failed to restore session ${sessionId} from SQLite:`, error);
return undefined;
}
}
@@ -1189,6 +1249,9 @@ export function __resetMissionInterviewState(): void {
}
_aiSessionDeletedListener = undefined;
_aiSessionStore = undefined;
// Reset diagnostics logger to default
__setMissionInterviewDiagnostics(null);
}
// ── Custom Errors ───────────────────────────────────────────────────────────

View File

@@ -27,6 +27,8 @@ import {
InvalidSessionStateError,
setAiSessionStore,
SubtaskStreamManager,
__getSubtaskBreakdownDiagnostics,
__setSubtaskBreakdownDiagnostics,
} from "./subtask-breakdown.js";
const UUID_REGEX =
@@ -765,17 +767,24 @@ describe("subtask session rehydration", () => {
store.rows.set(goodRow.id, goodRow);
store.rows.set(badRow.id, badRow);
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
const loggedErrors: Array<{ message: string; args: unknown[] }> = [];
__setSubtaskBreakdownDiagnostics({
log: vi.fn(),
warn: vi.fn(),
error: (message: string, ...args: unknown[]) => {
loggedErrors.push({ message, args });
},
});
const rehydrated = rehydrateFromStore(store as any);
expect(rehydrated).toBe(1);
expect(getSubtaskSession(goodRow.id)).toBeDefined();
expect(getSubtaskSession(badRow.id)).toBeUndefined();
expect(errorSpy).toHaveBeenCalledWith(
`[subtask-breakdown] Failed to rehydrate session ${badRow.id}:`,
expect.any(Error),
);
expect(loggedErrors).toContainEqual({
message: `Failed to rehydrate session ${badRow.id}:`,
args: [expect.any(Error)],
});
});
it("falls through to SQLite when session is missing in memory", () => {
@@ -815,6 +824,35 @@ describe("subtask session rehydration", () => {
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);
const loggedErrors: Array<{ message: string; args: unknown[] }> = [];
__setSubtaskBreakdownDiagnostics({
log: vi.fn(),
warn: vi.fn(),
error: (message: string, ...args: unknown[]) => {
loggedErrors.push({ message, args });
},
});
const session = getSubtaskSession(badRow.id);
expect(session).toBeUndefined();
expect(loggedErrors).toContainEqual({
message: `Failed to restore session ${badRow.id} from SQLite:`,
args: [expect.any(Error)],
});
});
});
describe("SessionNotFoundError", () => {

View File

@@ -9,6 +9,66 @@ import { SessionEventBuffer, type SessionBufferedEvent } from "./sse-buffer.js";
let createFnAgent: any;
const engineModule = "@fusion/engine";
/**
* Diagnostics logger for the subtask-breakdown module.
* Provides consistent [subtask-breakdown] prefixed output with test-injectable handlers.
* Mirrors the pattern established in planning.ts (FN-2225).
*/
interface DiagnosticsLogger {
log(message: string, ...args: unknown[]): void;
warn(message: string, ...args: unknown[]): void;
error(message: string, ...args: unknown[]): void;
}
const defaultDiagnostics: DiagnosticsLogger = {
log(message: string, ...args: unknown[]) {
console.log(`[subtask-breakdown] ${message}`, ...args);
},
warn(message: string, ...args: unknown[]) {
console.warn(`[subtask-breakdown] ${message}`, ...args);
},
error(message: string, ...args: unknown[]) {
console.error(`[subtask-breakdown] ${message}`, ...args);
},
};
let _diagnostics: DiagnosticsLogger = defaultDiagnostics;
/**
* Get the current diagnostics logger.
* @internal - exposed for test hook
*/
export function __getSubtaskBreakdownDiagnostics(): DiagnosticsLogger {
return _diagnostics;
}
/**
* Inject a diagnostics logger (test-only).
* When a logger is injected, all subtask-breakdown module diagnostics route through it.
* This allows tests to assert on diagnostics without global console spies.
* @internal - exposed for test hook
*/
export function __setSubtaskBreakdownDiagnostics(diagnostics: DiagnosticsLogger | null): void {
_diagnostics = diagnostics ?? defaultDiagnostics;
}
/**
* Shared diagnostics helper used throughout the subtask-breakdown module.
* Routes all informational, warning, and error diagnostics through the current logger.
* Mirrors the pattern from planning.ts (FN-2225).
*/
const diagnostics: DiagnosticsLogger = {
log(message: string, ...args: unknown[]) {
_diagnostics.log(message, ...args);
},
warn(message: string, ...args: unknown[]) {
_diagnostics.warn(message, ...args);
},
error(message: string, ...args: unknown[]) {
_diagnostics.error(message, ...args);
},
};
async function initEngine() {
if (!createFnAgent) {
try {
@@ -160,7 +220,7 @@ export function rehydrateFromStore(store: AiSessionStore): number {
try {
rows = store.listRecoverable().filter((row) => row.type === "subtask");
} catch (error) {
console.error("[subtask-breakdown] Failed to list recoverable sessions:", error);
diagnostics.error("Failed to list recoverable sessions:", error);
return 0;
}
@@ -171,7 +231,7 @@ export function rehydrateFromStore(store: AiSessionStore): number {
sessions.set(session.sessionId, session);
rehydrated += 1;
} catch (error) {
console.error(`[subtask-breakdown] Failed to rehydrate session ${row.id}:`, error);
diagnostics.error(`Failed to rehydrate session ${row.id}:`, error);
}
}
@@ -574,7 +634,7 @@ export function getSubtaskSession(sessionId: string): SubtaskSession | undefined
sessions.set(restored.sessionId, restored);
return toPublicSubtaskSession(restored);
} catch (error) {
console.error(`[subtask-breakdown] Failed to restore session ${sessionId} from SQLite:`, error);
diagnostics.error(`Failed to restore session ${sessionId} from SQLite:`, error);
return undefined;
}
}
@@ -604,6 +664,9 @@ export function __resetSubtaskBreakdownState(): void {
}
_aiSessionDeletedListener = undefined;
_aiSessionStore = undefined;
// Reset diagnostics logger to default
__setSubtaskBreakdownDiagnostics(null);
}
export class SessionNotFoundError extends Error {