feat(FN-2230): merge fusion/fn-2230
This commit is contained in:
@@ -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)],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user