feat(FN-2225): merge fusion/fn-2225
This commit is contained in:
@@ -21,6 +21,7 @@ import {
|
||||
getRateLimitResetTime,
|
||||
__resetPlanningState,
|
||||
__setCreateFnAgent,
|
||||
__setPlanningDiagnostics,
|
||||
rehydrateFromStore,
|
||||
setAiSessionStore,
|
||||
RateLimitError,
|
||||
@@ -494,6 +495,58 @@ describe("planning module", () => {
|
||||
const callArg = createFnAgentSpy.mock.calls[0]?.[0] as Record<string, unknown>;
|
||||
expect(callArg?.systemPrompt).toContain("planning assistant");
|
||||
});
|
||||
|
||||
it("logs error diagnostic when agent initialization fails and preserves error state", async () => {
|
||||
const loggedErrors: Array<{ message: string; args: unknown[] }> = [];
|
||||
__setPlanningDiagnostics({
|
||||
log: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: (message: string, ...args: unknown[]) => {
|
||||
loggedErrors.push({ message, args });
|
||||
},
|
||||
});
|
||||
|
||||
__setCreateFnAgent(async () => {
|
||||
throw new Error("Agent creation failed");
|
||||
});
|
||||
|
||||
const sessionId = await createSessionWithAgent(
|
||||
getUniqueIp(),
|
||||
"Build auth system",
|
||||
TEST_ROOT_DIR,
|
||||
);
|
||||
|
||||
// Wait for the async initialization to complete (errors are logged in initializeAgent's catch block)
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
return loggedErrors.some(
|
||||
(e) => e.message === `Agent initialization error for session ${sessionId}:`
|
||||
);
|
||||
},
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
|
||||
// Verify the error was logged
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
const agentError = loggedErrors.find(
|
||||
(e) => e.message === `Agent initialization error for session ${sessionId}:`
|
||||
);
|
||||
expect(agentError).toBeDefined();
|
||||
},
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
|
||||
const agentError = loggedErrors.find(
|
||||
(e) => e.message === `Agent initialization error for session ${sessionId}:`
|
||||
);
|
||||
expect(agentError?.args[0]).toBeInstanceOf(Error);
|
||||
expect((agentError?.args[0] as Error).message).toBe("Agent creation failed");
|
||||
|
||||
// Verify session is in error state
|
||||
const session = getSession(sessionId);
|
||||
expect(session?.error).toContain("Agent creation failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("submitResponse", () => {
|
||||
@@ -931,18 +984,24 @@ describe("planning 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[] }> = [];
|
||||
__setPlanningDiagnostics({
|
||||
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(getSession(goodRow.id)).toBeDefined();
|
||||
expect(getSession(badRow.id)).toBeUndefined();
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
`[planning] 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)],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1206,6 +1265,64 @@ describe("planning module", () => {
|
||||
const result = parseAgentResponse(input);
|
||||
expect(result.type).toBe("complete");
|
||||
});
|
||||
|
||||
it("logs error diagnostic when no JSON candidate found before throwing", () => {
|
||||
const loggedErrors: Array<{ message: string; args: unknown[] }> = [];
|
||||
__setPlanningDiagnostics({
|
||||
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(() => parseAgentResponse(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[] }> = [];
|
||||
__setPlanningDiagnostics({
|
||||
log: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: (message: string, ...args: unknown[]) => {
|
||||
loggedErrors.push({ message, args });
|
||||
},
|
||||
});
|
||||
|
||||
// Invalid JSON that repair cannot fix (missing quotes around values, unclosed objects)
|
||||
const input = '{"type":"question","data":{"id":q-1,"question":"What is this?';
|
||||
expect(() => parseAgentResponse(input)).toThrow("Failed to parse AI response");
|
||||
|
||||
expect(loggedErrors).toContainEqual({
|
||||
message: "Failed to parse agent response (repair also failed):",
|
||||
args: [expect.stringContaining("{\"type\":\"question\"")],
|
||||
});
|
||||
});
|
||||
|
||||
it("logs error diagnostic for invalid response structure before throwing", () => {
|
||||
const loggedErrors: Array<{ message: string; args: unknown[] }> = [];
|
||||
__setPlanningDiagnostics({
|
||||
log: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: (message: string, ...args: unknown[]) => {
|
||||
loggedErrors.push({ message, args });
|
||||
},
|
||||
});
|
||||
|
||||
const input = '{"type":"unknown","data":null}';
|
||||
expect(() => parseAgentResponse(input)).toThrow("invalid response structure");
|
||||
|
||||
expect(loggedErrors).toContainEqual({
|
||||
message: "Invalid response structure from AI:",
|
||||
args: [expect.stringContaining('"type":"unknown"')],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatInterviewQA", () => {
|
||||
@@ -1367,6 +1484,49 @@ describe("planning module", () => {
|
||||
planningStreamManager.cleanupSession(sessionId);
|
||||
expect(planningStreamManager.getBufferedEvents(sessionId, 0)).toEqual([]);
|
||||
});
|
||||
|
||||
it("broadcast callback throw logs error but broadcast continues and buffer remains valid", () => {
|
||||
const sessionId = "stream-session-throw";
|
||||
const loggedErrors: Array<{ message: string; args: unknown[] }> = [];
|
||||
__setPlanningDiagnostics({
|
||||
log: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: (message: string, ...args: unknown[]) => {
|
||||
loggedErrors.push({ message, args });
|
||||
},
|
||||
});
|
||||
|
||||
let otherCallbackCalled = false;
|
||||
const failingCallback = () => {
|
||||
throw new Error("Callback failed");
|
||||
};
|
||||
const workingCallback = () => {
|
||||
otherCallbackCalled = true;
|
||||
};
|
||||
|
||||
planningStreamManager.subscribe(sessionId, failingCallback);
|
||||
planningStreamManager.subscribe(sessionId, workingCallback);
|
||||
|
||||
const eventId = planningStreamManager.broadcast(sessionId, {
|
||||
type: "thinking",
|
||||
data: "test",
|
||||
});
|
||||
|
||||
// Broadcast should continue despite callback failure
|
||||
expect(eventId).toBe(1);
|
||||
expect(otherCallbackCalled).toBe(true);
|
||||
|
||||
// Buffer should still be valid
|
||||
const buffered = planningStreamManager.getBufferedEvents(sessionId, 0);
|
||||
expect(buffered).toHaveLength(1);
|
||||
expect(buffered[0]).toMatchObject({ id: 1, event: "thinking" });
|
||||
|
||||
// Error should be logged
|
||||
expect(loggedErrors).toContainEqual({
|
||||
message: `Error broadcasting to client for session ${sessionId}:`,
|
||||
args: [expect.any(Error)],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateSubtasksFromPlanning", () => {
|
||||
|
||||
@@ -31,6 +31,63 @@ type AgentResult = any;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let createFnAgent: any;
|
||||
|
||||
/**
|
||||
* Diagnostics logger for the planning module.
|
||||
* Provides consistent [planning] prefixed output with test-injectable handlers.
|
||||
*/
|
||||
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(`[planning] ${message}`, ...args);
|
||||
},
|
||||
warn(message: string, ...args: unknown[]) {
|
||||
console.warn(`[planning] ${message}`, ...args);
|
||||
},
|
||||
error(message: string, ...args: unknown[]) {
|
||||
console.error(`[planning] ${message}`, ...args);
|
||||
},
|
||||
};
|
||||
|
||||
let _diagnostics: DiagnosticsLogger = defaultDiagnostics;
|
||||
|
||||
/**
|
||||
* Get the current diagnostics logger.
|
||||
* @internal - exposed for test hook
|
||||
*/
|
||||
export function __getPlanningDiagnostics(): DiagnosticsLogger {
|
||||
return _diagnostics;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject a diagnostics logger (test-only).
|
||||
* When a logger is injected, all planning module diagnostics route through it.
|
||||
* This allows tests to assert on diagnostics without global console spies.
|
||||
*/
|
||||
export function __setPlanningDiagnostics(diagnostics: DiagnosticsLogger | null): void {
|
||||
_diagnostics = diagnostics ?? defaultDiagnostics;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared diagnostics helper used throughout the planning module.
|
||||
* Routes all informational, warning, and error diagnostics through the current logger.
|
||||
*/
|
||||
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) {
|
||||
@@ -213,7 +270,7 @@ function cleanupInMemorySession(sessionId: string): boolean {
|
||||
try {
|
||||
session.agent.session.dispose?.();
|
||||
} catch (err) {
|
||||
console.error(`[planning] Error disposing agent for session ${sessionId}:`, err);
|
||||
diagnostics.error(`Error disposing agent for session ${sessionId}:`, err);
|
||||
}
|
||||
session.agent = undefined;
|
||||
}
|
||||
@@ -308,7 +365,7 @@ export function rehydrateFromStore(store: AiSessionStore): number {
|
||||
try {
|
||||
rows = store.listRecoverable().filter((row) => row.type === "planning");
|
||||
} catch (error) {
|
||||
console.error("[planning] Failed to list recoverable sessions:", error);
|
||||
diagnostics.error("Failed to list recoverable sessions:", error);
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -319,7 +376,7 @@ export function rehydrateFromStore(store: AiSessionStore): number {
|
||||
sessions.set(session.id, session);
|
||||
rehydrated += 1;
|
||||
} catch (error) {
|
||||
console.error(`[planning] Failed to rehydrate session ${row.id}:`, error);
|
||||
diagnostics.error(`Failed to rehydrate session ${row.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,8 +412,8 @@ function cleanupExpiredSessions(): void {
|
||||
}
|
||||
|
||||
if (cleanedSessions > 0 || cleanedRateLimits > 0) {
|
||||
console.log(
|
||||
`[planning] Cleanup: removed ${cleanedSessions} sessions, ${cleanedRateLimits} rate limit entries`
|
||||
diagnostics.log(
|
||||
`Cleanup: removed ${cleanedSessions} sessions, ${cleanedRateLimits} rate limit entries`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -429,7 +486,7 @@ export class PlanningStreamManager extends EventEmitter {
|
||||
try {
|
||||
callback(event, eventId);
|
||||
} catch (err) {
|
||||
console.error(`[planning] Error broadcasting to client for session ${sessionId}:`, err);
|
||||
diagnostics.error(`Error broadcasting to client for session ${sessionId}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -758,7 +815,7 @@ export async function createSessionWithAgent(
|
||||
|
||||
// Initialize AI agent in background - it will stream via planningStreamManager
|
||||
initializeAgent(session, rootDir, modelProvider, modelId, promptOverrides).catch((err) => {
|
||||
console.error(`[planning] Failed to initialize agent for session ${sessionId}:`, err);
|
||||
diagnostics.error(`Failed to initialize agent for session ${sessionId}:`, err);
|
||||
persistSession(session, "error", undefined, err.message || "Failed to initialize AI agent");
|
||||
planningStreamManager.broadcast(sessionId, {
|
||||
type: "error",
|
||||
@@ -787,7 +844,7 @@ async function initializeAgent(
|
||||
await continueAgentConversation(session, session.initialPlan);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "Failed to initialize AI agent";
|
||||
console.error(`[planning] 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();
|
||||
persistSession(session, "error", undefined, errorMessage);
|
||||
@@ -935,8 +992,8 @@ async function continueAgentConversation(session: Session, message: string): Pro
|
||||
|
||||
if (attempt < MAX_PARSE_RETRIES) {
|
||||
// Retry: ask the AI to reformat as clean JSON
|
||||
console.warn(
|
||||
`[planning] 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 = "";
|
||||
@@ -965,8 +1022,8 @@ async function continueAgentConversation(session: Session, message: string): Pro
|
||||
responseText = retryText;
|
||||
} catch (retryErr) {
|
||||
// Retry prompt itself failed — give up
|
||||
console.error(
|
||||
`[planning] Retry prompt failed for session ${session.id}:`,
|
||||
diagnostics.error(
|
||||
`Retry prompt failed for session ${session.id}:`,
|
||||
retryErr
|
||||
);
|
||||
break;
|
||||
@@ -978,8 +1035,8 @@ async function continueAgentConversation(session: Session, message: string): Pro
|
||||
if (!parsed) {
|
||||
// All attempts exhausted — emit actionable error
|
||||
const errorMsg = `${lastError?.message || "Failed to parse AI response"} You can try responding again or start a new planning session.`;
|
||||
console.error(
|
||||
`[planning] All parse attempts exhausted for session ${session.id}:`,
|
||||
diagnostics.error(
|
||||
`All parse attempts exhausted for session ${session.id}:`,
|
||||
errorMsg
|
||||
);
|
||||
session.error = errorMsg;
|
||||
@@ -1016,7 +1073,7 @@ async function continueAgentConversation(session: Session, message: string): Pro
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "AI processing failed";
|
||||
console.error(`[planning] 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();
|
||||
persistSession(session, "error", undefined, errorMessage);
|
||||
@@ -1170,7 +1227,7 @@ export function parseAgentResponse(text: string): PlanningResponse {
|
||||
const candidate = extractJsonCandidate(text);
|
||||
|
||||
if (!candidate) {
|
||||
console.error("[planning] 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.");
|
||||
}
|
||||
|
||||
@@ -1183,8 +1240,8 @@ export function parseAgentResponse(text: string): PlanningResponse {
|
||||
const repaired = repairJson(candidate);
|
||||
parsed = JSON.parse(repaired);
|
||||
} catch (repairErr) {
|
||||
console.error(
|
||||
"[planning] Failed to parse agent response (repair also failed):",
|
||||
diagnostics.error(
|
||||
"Failed to parse agent response (repair also failed):",
|
||||
candidate.slice(0, 500)
|
||||
);
|
||||
throw new Error(
|
||||
@@ -1210,7 +1267,7 @@ export function parseAgentResponse(text: string): PlanningResponse {
|
||||
}
|
||||
}
|
||||
|
||||
console.error("[planning] Invalid response structure from AI:", JSON.stringify(parsed).slice(0, 500));
|
||||
diagnostics.error("Invalid response structure from AI:", JSON.stringify(parsed).slice(0, 500));
|
||||
throw new Error("AI returned an invalid response structure. Please try again.");
|
||||
}
|
||||
|
||||
@@ -1362,7 +1419,7 @@ function disposeSessionAgentForRetry(session: Session): void {
|
||||
try {
|
||||
session.agent.session.dispose?.();
|
||||
} catch (error) {
|
||||
console.error(`[planning] 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;
|
||||
@@ -1458,7 +1515,7 @@ export function getSession(sessionId: string): Session | undefined {
|
||||
sessions.set(restored.id, restored);
|
||||
return restored;
|
||||
} catch (error) {
|
||||
console.error(`[planning] Failed to restore session ${sessionId} from SQLite:`, error);
|
||||
diagnostics.error(`Failed to restore session ${sessionId} from SQLite:`, error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -1561,6 +1618,9 @@ export function __resetPlanningState(): void {
|
||||
}
|
||||
_aiSessionDeletedListener = undefined;
|
||||
_aiSessionStore = undefined;
|
||||
|
||||
// Reset diagnostics logger to default
|
||||
__setPlanningDiagnostics(null);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user