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

This commit is contained in:
Fusion
2026-04-22 01:32:14 -07:00
committed by gsxdsm
parent a5800392f1
commit 898a5f9afc
5 changed files with 269 additions and 36 deletions

View File

@@ -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", () => {

View File

@@ -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);
}
/**