feat(FN-2245): enforce shared diagnostics in planning interview flows

- Migrate mission, milestone-slice, and subtask breakdown flows to shared ai-session-diagnostics logging
- Add a planning-flow guardrail test that fails on raw console.* diagnostics across planning modules
- Update mission and subtask breakdown tests to use structured diagnostics sink hooks instead of console spies
- Keep backward-compatible diagnostics test hooks and reset injected sinks to avoid cross-test leakage
This commit is contained in:
Fusion
2026-04-22 09:39:34 -07:00
committed by gsxdsm
parent 16f692ab2a
commit 9fa1ddb006
6 changed files with 424 additions and 225 deletions

View File

@@ -24,6 +24,11 @@ import {
extractJsonCandidate,
repairJson,
} from "./mission-interview.js";
import {
createSessionDiagnostics,
resetDiagnosticsSink,
nonfatal,
} from "./ai-session-diagnostics.js";
// Re-export JSON parsing utilities from mission-interview for external consumers
export {
@@ -32,6 +37,13 @@ export {
repairJson,
} from "./mission-interview.js";
/**
* Shared diagnostics helper for the milestone-slice-interview module.
* Uses the shared ai-session-diagnostics helper for consistent scoped logging.
* @see ai-session-diagnostics.ts for the shared contract
*/
const diagnostics = createSessionDiagnostics("milestone-slice-interview");
/**
* Parse a target interview response (milestone or slice) from the AI agent.
* Validates the response structure and extracts the typed data.
@@ -40,7 +52,7 @@ function parseTargetInterviewResponseImpl(text: string): TargetInterviewResponse
const candidate = extractJsonCandidate(text);
if (!candidate) {
console.error("[milestone-slice-interview] No JSON candidate found in agent response:", text.slice(0, 500));
diagnostics.error("No JSON candidate found in agent response", { inputSnippet: text.slice(0, 500), operation: "parse-json" });
throw new Error("AI returned no valid JSON. Please try again.");
}
@@ -52,7 +64,7 @@ function parseTargetInterviewResponseImpl(text: string): TargetInterviewResponse
const repaired = repairJson(candidate);
parsed = JSON.parse(repaired);
} catch (repairErr) {
console.error("[milestone-slice-interview] Failed to parse agent response:", candidate.slice(0, 500));
diagnostics.error("Failed to parse agent response (repair also failed)", { inputSnippet: candidate.slice(0, 500), operation: "parse-json-repair" });
throw new Error(
`Failed to parse AI response: ${repairErr instanceof Error ? repairErr.message : "Unknown error"}. Please try again.`
);
@@ -75,7 +87,7 @@ function parseTargetInterviewResponseImpl(text: string): TargetInterviewResponse
}
}
console.error("[milestone-slice-interview] Invalid response structure:", JSON.stringify(parsed).slice(0, 500));
diagnostics.error("Invalid response structure from AI", { parsedSnippet: JSON.stringify(parsed).slice(0, 500), operation: "parse-validate" });
throw new Error("AI returned an invalid response structure. Please try again.");
}
@@ -461,7 +473,7 @@ export function rehydrateFromStore(store: AiSessionStore): number {
(row) => row.type === "milestone_interview" || row.type === "slice_interview"
);
} catch (error) {
console.error("[milestone-slice-interview] Failed to list recoverable sessions:", error);
diagnostics.errorFromException("Failed to list recoverable sessions", error, { operation: "list-recoverable" });
return 0;
}
@@ -472,7 +484,7 @@ export function rehydrateFromStore(store: AiSessionStore): number {
sessions.set(session.id, session);
rehydrated += 1;
} catch (error) {
console.error(`[milestone-slice-interview] Failed to rehydrate session ${row.id}:`, error);
diagnostics.errorFromException("Failed to rehydrate session", error, { sessionId: row.id, operation: "rehydrate" });
}
}
@@ -541,11 +553,12 @@ export class MilestoneSliceInterviewStreamManager extends EventEmitter {
if (!callbacks) return eventId;
for (const callback of callbacks) {
try {
callback(event, eventId);
} catch (err) {
console.error(`[milestone-slice-interview] Error broadcasting to client for session ${sessionId}:`, err);
}
nonfatal(
() => callback(event, eventId),
diagnostics,
"Error broadcasting to client",
{ sessionId, operation: "broadcast" }
);
}
return eventId;
@@ -657,11 +670,12 @@ function disposeAgentForRetry(session: TargetInterviewSession): void {
return;
}
try {
session.agent.session.dispose?.();
} catch (error) {
console.error(`[milestone-slice-interview] Error disposing agent for retry in session ${session.id}:`, error);
}
nonfatal(
() => session.agent.session.dispose?.(),
diagnostics,
"Error disposing agent for retry",
{ sessionId: session.id, operation: "dispose-retry" }
);
session.agent = undefined;
}
@@ -770,7 +784,7 @@ async function initializeAgent(session: TargetInterviewSession, rootDir: string)
);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "Failed to initialize AI agent";
console.error(`[milestone-slice-interview] Agent initialization error for session ${session.id}:`, err);
diagnostics.errorFromException("Agent initialization error for session", err, { sessionId: session.id, operation: "initialize-agent" });
session.error = errorMessage;
session.updatedAt = new Date();
persistSession(session, "error", errorMessage);
@@ -828,8 +842,9 @@ async function continueAgentConversation(session: TargetInterviewSession, messag
lastError = err instanceof Error ? err : new Error(String(err));
if (attempt < MAX_PARSE_RETRIES) {
console.warn(
`[milestone-slice-interview] Parse attempt ${attempt + 1} failed for session ${session.id}, requesting reformat`
diagnostics.warn(
"Parse attempt failed, requesting reformat",
{ sessionId: session.id, attempt: attempt + 1, operation: "parse-retry" }
);
try {
session.thinkingOutput = "";
@@ -857,7 +872,7 @@ async function continueAgentConversation(session: TargetInterviewSession, messag
}
responseText = retryText;
} catch (retryErr) {
console.error(`[milestone-slice-interview] Retry prompt failed for session ${session.id}:`, retryErr);
diagnostics.errorFromException("Retry prompt failed for session", retryErr, { sessionId: session.id, operation: "retry-prompt" });
break;
}
}
@@ -866,7 +881,10 @@ async function continueAgentConversation(session: TargetInterviewSession, messag
if (!parsed) {
const errorMsg = `${lastError?.message || "Failed to parse AI response"} You can try responding again or start a new session.`;
console.error(`[milestone-slice-interview] All parse attempts exhausted for session ${session.id}:`, errorMsg);
diagnostics.error(
"All parse attempts exhausted for session",
{ sessionId: session.id, message: errorMsg, operation: "parse-exhausted" }
);
session.error = errorMsg;
session.updatedAt = new Date();
persistSession(session, "error", errorMsg);
@@ -901,7 +919,7 @@ async function continueAgentConversation(session: TargetInterviewSession, messag
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "AI processing failed";
console.error(`[milestone-slice-interview] Agent conversation error for session ${session.id}:`, err);
diagnostics.errorFromException("Agent conversation error for session", err, { sessionId: session.id, operation: "conversation" });
session.error = errorMessage;
session.updatedAt = new Date();
persistSession(session, "error", errorMessage);
@@ -955,7 +973,7 @@ export async function createTargetInterviewSession(
// Initialize AI agent in background
initializeAgent(session, rootDir).catch((err) => {
console.error(`[milestone-slice-interview] Failed to initialize agent for session ${sessionId}:`, err);
diagnostics.errorFromException("Failed to initialize agent for session", err, { sessionId, operation: "initialize-agent" });
persistSession(session, "error", err.message || "Failed to initialize AI agent");
milestoneSliceInterviewStreamManager.broadcast(sessionId, {
type: "error",
@@ -1104,7 +1122,7 @@ export function getTargetInterviewSession(sessionId: string): TargetInterviewSes
sessions.set(restored.id, restored);
return restored;
} catch (error) {
console.error(`[milestone-slice-interview] Failed to restore session ${sessionId} from SQLite:`, error);
diagnostics.errorFromException("Failed to restore session from SQLite", error, { sessionId, operation: "restore" });
return undefined;
}
}
@@ -1267,4 +1285,7 @@ export function __resetMilestoneSliceInterviewState(): void {
}
_aiSessionDeletedListener = undefined;
_aiSessionStore = undefined;
// Reset diagnostics sink to default
resetDiagnosticsSink();
}

View File

@@ -28,9 +28,12 @@ import {
RateLimitError,
SessionNotFoundError,
submitMissionInterviewResponse,
__getMissionInterviewDiagnostics,
__setMissionInterviewDiagnostics,
} from "./mission-interview.js";
import {
setDiagnosticsSink,
resetDiagnosticsSink,
} from "./ai-session-diagnostics.js";
import type { LogEntry } from "./ai-session-diagnostics.js";
import { EventEmitter } from "node:events";
import type { AiSessionRow } from "./ai-session-store.js";
@@ -257,13 +260,10 @@ describe("mission-interview module", () => {
store.rows.set(goodRow.id, goodRow);
store.rows.set(badRow.id, badRow);
const loggedErrors: Array<{ message: string; args: unknown[] }> = [];
__setMissionInterviewDiagnostics({
log: vi.fn(),
warn: vi.fn(),
error: (message: string, ...args: unknown[]) => {
loggedErrors.push({ message, args });
},
// Capture structured diagnostics via shared helper sink
const loggedEntries: LogEntry[] = [];
setDiagnosticsSink((level, scope, message, context) => {
loggedEntries.push({ level, scope, message, context, timestamp: new Date() });
});
const rehydrated = rehydrateFromStore(store as any);
@@ -271,10 +271,20 @@ describe("mission-interview module", () => {
expect(rehydrated).toBe(1);
expect(getMissionInterviewSession(goodRow.id)).toBeDefined();
expect(getMissionInterviewSession(badRow.id)).toBeUndefined();
expect(loggedErrors).toContainEqual({
message: `Failed to rehydrate session ${badRow.id}:`,
args: [expect.any(Error)],
});
// Assert structured diagnostic record
expect(loggedEntries).toContainEqual(
expect.objectContaining({
level: "error",
scope: "mission-interview",
message: "Failed to rehydrate session",
context: expect.objectContaining({
sessionId: badRow.id,
operation: "rehydrate",
}),
})
);
resetDiagnosticsSink();
});
it("falls through to SQLite when in-memory session is missing", () => {
@@ -531,13 +541,10 @@ describe("mission-interview module", () => {
});
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 });
},
// Capture structured diagnostics via shared helper sink
const loggedEntries: LogEntry[] = [];
setDiagnosticsSink((level, scope, message, context) => {
loggedEntries.push({ level, scope, message, context, timestamp: new Date() });
});
const throwingCallback = vi.fn(() => {
@@ -551,10 +558,20 @@ describe("mission-interview module", () => {
).not.toThrow();
expect(throwingCallback).toHaveBeenCalledTimes(1);
expect(loggedErrors).toContainEqual({
message: "Error broadcasting to client for session session-error-callback:",
args: [expect.any(Error)],
});
// Assert structured diagnostic record
expect(loggedEntries).toContainEqual(
expect.objectContaining({
level: "error",
scope: "mission-interview",
message: "Error broadcasting to client",
context: expect.objectContaining({
sessionId: "session-error-callback",
operation: "broadcast",
}),
})
);
resetDiagnosticsSink();
});
});
@@ -595,61 +612,79 @@ describe("mission-interview module", () => {
});
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 });
},
// Capture structured diagnostics via shared helper sink
const loggedEntries: LogEntry[] = [];
setDiagnosticsSink((level, scope, message, context) => {
loggedEntries.push({ level, scope, message, context, timestamp: new Date() });
});
const 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")],
});
expect(loggedEntries).toContainEqual(
expect.objectContaining({
level: "error",
scope: "mission-interview",
message: "No JSON candidate found in agent response",
context: expect.objectContaining({
inputSnippet: expect.stringContaining("I'm not sure"),
operation: "parse-json",
}),
})
);
resetDiagnosticsSink();
});
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 });
},
// Capture structured diagnostics via shared helper sink
const loggedEntries: LogEntry[] = [];
setDiagnosticsSink((level, scope, message, context) => {
loggedEntries.push({ level, scope, message, context, timestamp: new Date() });
});
// 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"')],
});
expect(loggedEntries).toContainEqual(
expect.objectContaining({
level: "error",
scope: "mission-interview",
message: "Failed to parse agent response (repair also failed)",
context: expect.objectContaining({
inputSnippet: expect.stringContaining('{"type":"question"'),
operation: "parse-json-repair",
}),
})
);
resetDiagnosticsSink();
});
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 });
},
// Capture structured diagnostics via shared helper sink
const loggedEntries: LogEntry[] = [];
setDiagnosticsSink((level, scope, message, context) => {
loggedEntries.push({ level, scope, message, context, timestamp: new Date() });
});
const input = '{"type":"unknown","data":null}';
expect(() => parseMissionAgentResponse(input)).toThrow("invalid response structure");
expect(loggedErrors).toContainEqual({
message: "Invalid response structure:",
args: [expect.stringContaining('"type":"unknown"')],
});
expect(loggedEntries).toContainEqual(
expect.objectContaining({
level: "error",
scope: "mission-interview",
message: "Invalid response structure from AI",
context: expect.objectContaining({
parsedSnippet: expect.stringContaining('"type":"unknown"'),
operation: "parse-validate",
}),
})
);
resetDiagnosticsSink();
});
});

View File

@@ -21,6 +21,11 @@ import { randomUUID } from "node:crypto";
import { EventEmitter } from "node:events";
import type { AiSessionStore, AiSessionRow } from "./ai-session-store.js";
import { SessionEventBuffer, type SessionBufferedEvent } from "./sse-buffer.js";
import {
createSessionDiagnostics,
resetDiagnosticsSink,
nonfatal,
} from "./ai-session-diagnostics.js";
// Dynamic import for @fusion/engine to avoid resolution issues in test environment
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -29,65 +34,36 @@ type AgentResult = 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).
* Shared diagnostics helper for the mission-interview module.
* Uses the shared ai-session-diagnostics helper for consistent scoped logging.
* @see ai-session-diagnostics.ts for the shared contract
*/
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;
const diagnostics = createSessionDiagnostics("mission-interview");
/**
* Get the current diagnostics logger.
* Get the current diagnostics logger (for backward compatibility).
* @internal - exposed for test hook
*/
export function __getMissionInterviewDiagnostics(): DiagnosticsLogger {
return _diagnostics;
export function __getMissionInterviewDiagnostics() {
return diagnostics;
}
/**
* Inject a diagnostics logger (test-only).
* When a logger is injected, all mission-interview module diagnostics route through it.
* Inject a diagnostics sink (test-only).
* Delegates to the shared ai-session-diagnostics sink.
* When a sink 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;
export function __setMissionInterviewDiagnostics(_logger: unknown): void {
// For backward compatibility, we keep this function but it now delegates
// to the shared helper's sink mechanism. The actual sink injection
// should use setDiagnosticsSink() from ai-session-diagnostics.
if (_logger === null) {
resetDiagnosticsSink();
}
}
/**
* 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 {
@@ -422,7 +398,7 @@ export function rehydrateFromStore(store: AiSessionStore): number {
try {
rows = store.listRecoverable().filter((row) => row.type === "mission_interview");
} catch (error) {
diagnostics.error("Failed to list recoverable sessions:", error);
diagnostics.errorFromException("Failed to list recoverable sessions", error, { operation: "list-recoverable" });
return 0;
}
@@ -433,7 +409,7 @@ export function rehydrateFromStore(store: AiSessionStore): number {
sessions.set(session.id, session);
rehydrated += 1;
} catch (error) {
diagnostics.error(`Failed to rehydrate session ${row.id}:`, error);
diagnostics.errorFromException("Failed to rehydrate session", error, { sessionId: row.id, operation: "rehydrate" });
}
}
@@ -502,11 +478,12 @@ export class MissionInterviewStreamManager extends EventEmitter {
if (!callbacks) return eventId;
for (const callback of callbacks) {
try {
callback(event, eventId);
} catch (err) {
diagnostics.error(`Error broadcasting to client for session ${sessionId}:`, err);
}
nonfatal(
() => callback(event, eventId),
diagnostics,
"Error broadcasting to client",
{ sessionId, operation: "broadcast" }
);
}
return eventId;
@@ -676,7 +653,7 @@ export function parseMissionAgentResponse(text: string): MissionInterviewRespons
const candidate = extractJsonCandidate(text);
if (!candidate) {
diagnostics.error("No JSON candidate found in agent response:", text.slice(0, 500));
diagnostics.error("No JSON candidate found in agent response", { inputSnippet: text.slice(0, 500), operation: "parse-json" });
throw new Error("AI returned no valid JSON. Please try again.");
}
@@ -688,7 +665,7 @@ export function parseMissionAgentResponse(text: string): MissionInterviewRespons
const repaired = repairJson(candidate);
parsed = JSON.parse(repaired);
} catch (repairErr) {
diagnostics.error("Failed to parse agent response:", candidate.slice(0, 500));
diagnostics.error("Failed to parse agent response (repair also failed)", { inputSnippet: candidate.slice(0, 500), operation: "parse-json-repair" });
throw new Error(
`Failed to parse AI response: ${repairErr instanceof Error ? repairErr.message : "Unknown error"}. Please try again.`
);
@@ -713,7 +690,7 @@ export function parseMissionAgentResponse(text: string): MissionInterviewRespons
}
}
diagnostics.error("Invalid response structure:", JSON.stringify(parsed).slice(0, 500));
diagnostics.error("Invalid response structure from AI", { parsedSnippet: JSON.stringify(parsed).slice(0, 500), operation: "parse-validate" });
throw new Error("AI returned an invalid response structure. Please try again.");
}
@@ -768,11 +745,12 @@ function disposeMissionAgentForRetry(session: MissionInterviewSession): void {
return;
}
try {
session.agent.session.dispose?.();
} catch (error) {
diagnostics.error(`Error disposing agent for retry in session ${session.id}:`, error);
}
nonfatal(
() => session.agent.session.dispose?.(),
diagnostics,
"Error disposing agent for retry",
{ sessionId: session.id, operation: "dispose-retry" }
);
session.agent = undefined;
}
@@ -798,7 +776,7 @@ async function initializeAgent(
);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "Failed to initialize AI agent";
diagnostics.error(`Agent initialization error for session ${session.id}:`, err);
diagnostics.errorFromException("Agent initialization error for session", err, { sessionId: session.id, operation: "initialize-agent" });
session.error = errorMessage;
session.updatedAt = new Date();
persistMissionSession(session, "error", errorMessage);
@@ -952,7 +930,8 @@ async function continueAgentConversation(session: MissionInterviewSession, messa
if (attempt < MAX_PARSE_RETRIES) {
diagnostics.warn(
`Parse attempt ${attempt + 1} failed for session ${session.id}, requesting reformat`
"Parse attempt failed, requesting reformat",
{ sessionId: session.id, attempt: attempt + 1, operation: "parse-retry" }
);
try {
session.thinkingOutput = "";
@@ -980,7 +959,7 @@ async function continueAgentConversation(session: MissionInterviewSession, messa
}
responseText = retryText;
} catch (retryErr) {
diagnostics.error(`Retry prompt failed for session ${session.id}:`, retryErr);
diagnostics.errorFromException("Retry prompt failed for session", retryErr, { sessionId: session.id, operation: "retry-prompt" });
break;
}
}
@@ -989,7 +968,10 @@ 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.`;
diagnostics.error(`All parse attempts exhausted for session ${session.id}:`, errorMsg);
diagnostics.error(
"All parse attempts exhausted for session",
{ sessionId: session.id, message: errorMsg, operation: "parse-exhausted" }
);
session.error = errorMsg;
session.updatedAt = new Date();
persistMissionSession(session, "error", errorMsg);
@@ -1024,7 +1006,7 @@ async function continueAgentConversation(session: MissionInterviewSession, messa
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "AI processing failed";
diagnostics.error(`Agent conversation error for session ${session.id}:`, err);
diagnostics.errorFromException("Agent conversation error for session", err, { sessionId: session.id, operation: "conversation" });
session.error = errorMessage;
session.updatedAt = new Date();
persistMissionSession(session, "error", errorMessage);
@@ -1078,7 +1060,7 @@ export async function createMissionInterviewSession(
// Initialize AI agent in background
initializeAgent(session, rootDir, promptOverrides).catch((err) => {
diagnostics.error(`Failed to initialize agent for session ${sessionId}:`, err);
diagnostics.errorFromException("Failed to initialize agent for session", err, { sessionId, operation: "initialize-agent" });
persistMissionSession(session, "error", err.message || "Failed to initialize AI agent");
missionInterviewStreamManager.broadcast(sessionId, {
type: "error",
@@ -1219,7 +1201,7 @@ export function getMissionInterviewSession(sessionId: string): MissionInterviewS
sessions.set(restored.id, restored);
return restored;
} catch (error) {
diagnostics.error(`Failed to restore session ${sessionId} from SQLite:`, error);
diagnostics.errorFromException("Failed to restore session from SQLite", error, { sessionId, operation: "restore" });
return undefined;
}
}
@@ -1250,8 +1232,8 @@ export function __resetMissionInterviewState(): void {
_aiSessionDeletedListener = undefined;
_aiSessionStore = undefined;
// Reset diagnostics logger to default
__setMissionInterviewDiagnostics(null);
// Reset diagnostics sink to default
resetDiagnosticsSink();
}
// ── Custom Errors ───────────────────────────────────────────────────────────

View File

@@ -0,0 +1,136 @@
/**
* Planning-Flow Diagnostics Guardrail Test
*
* This test enforces that AI-session planning flow modules use the shared
* ai-session-diagnostics helper instead of raw console.* calls for diagnostics.
*
* Guardrail: These modules must NOT contain direct console.log( / console.warn( / console.error(
* calls in AI-session flow code. Raw console diagnostics indicate incomplete migration
* to the shared helper or accidental reintroduction.
*
* @see ai-session-diagnostics.ts for the shared diagnostics contract
*/
// @vitest-environment node
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
/**
* List of planning-flow modules that must use the shared diagnostics helper.
* These modules handle AI-session flows and must not use raw console.* diagnostics.
*/
const PLANNING_FLOW_MODULES = [
"planning.ts",
"mission-interview.ts",
"milestone-slice-interview.ts",
"subtask-breakdown.ts",
] as const;
type PlanningFlowModule = (typeof PLANNING_FLOW_MODULES)[number];
/**
* Patterns that indicate raw console diagnostics in AI-session failure paths.
* These should be caught by the guardrail.
* Note: Patterns must have global flag (g) for use with matchAll.
*/
const RAW_CONSOLE_PATTERNS: readonly RegExp[] = [
/console\.log\(/g,
/console\.warn\(/g,
/console\.error\(/g,
];
type RawConsolePattern = (typeof RAW_CONSOLE_PATTERNS)[number];
/**
* Read the source content of a planning-flow module.
* Throws if the file cannot be read.
*/
function readModuleSource(moduleName: PlanningFlowModule): string {
const modulePath = resolve(import.meta.dirname, moduleName);
return readFileSync(modulePath, "utf-8");
}
/**
* Find all raw console calls in a module's source.
* Returns an array of { pattern, match } objects for each found violation.
*/
function findRawConsoleCalls(
source: string,
patterns: readonly RegExp[]
): Array<{ pattern: RegExp; match: RegExpMatchArray }> {
const violations: Array<{ pattern: RegExp; match: RegExpMatchArray }> = [];
for (const pattern of patterns) {
const matches = source.matchAll(pattern);
for (const match of matches) {
violations.push({ pattern, match });
}
}
return violations;
}
describe("Planning-Flow Diagnostics Guardrail", () => {
/**
* Test that each planning-flow module uses the shared diagnostics helper
* instead of raw console.* calls.
*
* This guardrail prevents:
* - Incomplete migrations where raw console.* remains
* - Accidental reintroduction of raw console diagnostics
* - Inconsistent diagnostics across planning flow modules
*/
describe("AI-session failure paths must use shared diagnostics helper", () => {
for (const moduleName of PLANNING_FLOW_MODULES) {
const moduleShortName = moduleName.replace(".ts", "");
it(`${moduleShortName} does not contain raw console.* diagnostics`, () => {
const source = readModuleSource(moduleName);
const violations = findRawConsoleCalls(source, RAW_CONSOLE_PATTERNS);
if (violations.length > 0) {
const violationDetails = violations
.map(({ pattern, match }) => {
// Calculate line number from match index
const beforeMatch = source.slice(0, match.index);
const lineNumber = beforeMatch.split("\n").length;
return ` Line ${lineNumber}: ${match[0]}`;
})
.join("\n");
expect.fail(
`${moduleName} contains raw console.* diagnostics in AI-session flow code.\n` +
`Expected: Use createSessionDiagnostics() + diagnostics.error() from ai-session-diagnostics.js\n` +
`Found ${violations.length} violation(s):\n` +
`${violationDetails}\n\n` +
`Migration guide:\n` +
` 1. Import: import { createSessionDiagnostics } from "./ai-session-diagnostics.js";\n` +
` 2. Create: const diagnostics = createSessionDiagnostics("${moduleShortName}");\n` +
` 3. Replace: console.error("msg:", err) -> diagnostics.errorFromException("msg", err, { sessionId, operation });\n` +
` 4. Replace: console.error("msg") -> diagnostics.error("msg", { sessionId, operation });`
);
}
});
}
});
/**
* Verify that the shared diagnostics helper exists and exports expected APIs.
* This ensures the guardrail itself has a valid target to enforce against.
*/
describe("shared diagnostics helper contract", () => {
it("ai-session-diagnostics exports required APIs", async () => {
const helperPath = resolve(import.meta.dirname, "ai-session-diagnostics.ts");
const helperSource = readFileSync(helperPath, "utf-8");
// Verify the helper exports the core APIs
expect(helperSource).toContain("createSessionDiagnostics");
expect(helperSource).toContain("setDiagnosticsSink");
expect(helperSource).toContain("resetDiagnosticsSink");
expect(helperSource).toContain("nonfatal");
expect(helperSource).toContain("errorFromException");
});
});
});

View File

@@ -15,6 +15,11 @@ vi.mock("@fusion/engine", () => ({
import type { AiSessionRow } from "./ai-session-store.js";
// @ts-expect-error Vite raw loader import for source-level utility tests
import subtaskBreakdownSource from "./subtask-breakdown.ts?raw";
import {
setDiagnosticsSink,
resetDiagnosticsSink,
} from "./ai-session-diagnostics.js";
import type { LogEntry } from "./ai-session-diagnostics.js";
import {
__resetSubtaskBreakdownState,
cancelSubtaskSession,
@@ -27,8 +32,6 @@ import {
InvalidSessionStateError,
setAiSessionStore,
SubtaskStreamManager,
__getSubtaskBreakdownDiagnostics,
__setSubtaskBreakdownDiagnostics,
} from "./subtask-breakdown.js";
const UUID_REGEX =
@@ -755,6 +758,38 @@ describe("subtask session rehydration", () => {
expect(getSubtaskSession(planningRow.id)).toBeUndefined();
});
it("returns 0 and logs diagnostic when listRecoverable throws", () => {
const store = new MockAiSessionStore();
// Override listRecoverable to throw
vi.spyOn(store, "listRecoverable").mockImplementation(() => {
throw new Error("Database connection failed");
});
// Capture structured diagnostics via shared helper sink
const loggedEntries: LogEntry[] = [];
setDiagnosticsSink((level, scope, message, context) => {
loggedEntries.push({ level, scope, message, context, timestamp: new Date() });
});
const rehydrated = rehydrateFromStore(store as any);
// Non-fatal: returns 0 and continues
expect(rehydrated).toBe(0);
// Assert structured diagnostic record
expect(loggedEntries).toContainEqual(
expect.objectContaining({
level: "error",
scope: "subtask-breakdown",
message: "Failed to list recoverable sessions",
context: expect.objectContaining({
operation: "list-recoverable",
}),
})
);
resetDiagnosticsSink();
});
it("skips corrupted rows and continues with valid rows", () => {
const store = new MockAiSessionStore();
const goodRow = buildSubtaskRow({ id: "subtask-good", status: "generating" });
@@ -767,13 +802,10 @@ describe("subtask session rehydration", () => {
store.rows.set(goodRow.id, goodRow);
store.rows.set(badRow.id, badRow);
const loggedErrors: Array<{ message: string; args: unknown[] }> = [];
__setSubtaskBreakdownDiagnostics({
log: vi.fn(),
warn: vi.fn(),
error: (message: string, ...args: unknown[]) => {
loggedErrors.push({ message, args });
},
// Capture structured diagnostics via shared helper sink
const loggedEntries: LogEntry[] = [];
setDiagnosticsSink((level, scope, message, context) => {
loggedEntries.push({ level, scope, message, context, timestamp: new Date() });
});
const rehydrated = rehydrateFromStore(store as any);
@@ -781,10 +813,20 @@ describe("subtask session rehydration", () => {
expect(rehydrated).toBe(1);
expect(getSubtaskSession(goodRow.id)).toBeDefined();
expect(getSubtaskSession(badRow.id)).toBeUndefined();
expect(loggedErrors).toContainEqual({
message: `Failed to rehydrate session ${badRow.id}:`,
args: [expect.any(Error)],
});
// Assert structured diagnostic record
expect(loggedEntries).toContainEqual(
expect.objectContaining({
level: "error",
scope: "subtask-breakdown",
message: "Failed to rehydrate session",
context: expect.objectContaining({
sessionId: badRow.id,
operation: "rehydrate",
}),
})
);
resetDiagnosticsSink();
});
it("falls through to SQLite when session is missing in memory", () => {
@@ -836,22 +878,29 @@ describe("subtask session rehydration", () => {
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 });
},
// Capture structured diagnostics via shared helper sink
const loggedEntries: LogEntry[] = [];
setDiagnosticsSink((level, scope, message, context) => {
loggedEntries.push({ level, scope, message, context, timestamp: new Date() });
});
const session = getSubtaskSession(badRow.id);
expect(session).toBeUndefined();
expect(loggedErrors).toContainEqual({
message: `Failed to restore session ${badRow.id} from SQLite:`,
args: [expect.any(Error)],
});
// Assert structured diagnostic record includes sessionId
expect(loggedEntries).toContainEqual(
expect.objectContaining({
level: "error",
scope: "subtask-breakdown",
message: "Failed to restore session from SQLite",
context: expect.objectContaining({
sessionId: badRow.id,
operation: "restore",
}),
})
);
resetDiagnosticsSink();
});
});

View File

@@ -4,71 +4,47 @@ import { randomUUID } from "node:crypto";
import { EventEmitter } from "node:events";
import type { AiSessionStore, AiSessionRow } from "./ai-session-store.js";
import { SessionEventBuffer, type SessionBufferedEvent } from "./sse-buffer.js";
import {
createSessionDiagnostics,
resetDiagnosticsSink,
} from "./ai-session-diagnostics.js";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
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).
* Shared diagnostics helper for the subtask-breakdown module.
* Uses the shared ai-session-diagnostics helper for consistent scoped logging.
* @see ai-session-diagnostics.ts for the shared contract
*/
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;
const diagnostics = createSessionDiagnostics("subtask-breakdown");
/**
* Get the current diagnostics logger.
* Get the current diagnostics logger (for backward compatibility).
* @internal - exposed for test hook
*/
export function __getSubtaskBreakdownDiagnostics(): DiagnosticsLogger {
return _diagnostics;
export function __getSubtaskBreakdownDiagnostics() {
return diagnostics;
}
/**
* Inject a diagnostics logger (test-only).
* When a logger is injected, all subtask-breakdown module diagnostics route through it.
* Inject a diagnostics sink (test-only).
* Delegates to the shared ai-session-diagnostics sink.
* When a sink 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;
export function __setSubtaskBreakdownDiagnostics(_logger: unknown): void {
// For backward compatibility, we keep this function but it now delegates
// to the shared helper's sink mechanism. The actual sink injection
// should use setDiagnosticsSink() from ai-session-diagnostics.
// This function is kept for backward compatibility with existing tests.
if (_logger === null) {
resetDiagnosticsSink();
}
}
/**
* 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 {
@@ -220,7 +196,7 @@ export function rehydrateFromStore(store: AiSessionStore): number {
try {
rows = store.listRecoverable().filter((row) => row.type === "subtask");
} catch (error) {
diagnostics.error("Failed to list recoverable sessions:", error);
diagnostics.errorFromException("Failed to list recoverable sessions", error, { operation: "list-recoverable" });
return 0;
}
@@ -231,7 +207,7 @@ export function rehydrateFromStore(store: AiSessionStore): number {
sessions.set(session.sessionId, session);
rehydrated += 1;
} catch (error) {
diagnostics.error(`Failed to rehydrate session ${row.id}:`, error);
diagnostics.errorFromException("Failed to rehydrate session", error, { sessionId: row.id, operation: "rehydrate" });
}
}
@@ -634,7 +610,7 @@ export function getSubtaskSession(sessionId: string): SubtaskSession | undefined
sessions.set(restored.sessionId, restored);
return toPublicSubtaskSession(restored);
} catch (error) {
diagnostics.error(`Failed to restore session ${sessionId} from SQLite:`, error);
diagnostics.errorFromException("Failed to restore session from SQLite", error, { sessionId, operation: "restore" });
return undefined;
}
}
@@ -665,8 +641,8 @@ export function __resetSubtaskBreakdownState(): void {
_aiSessionDeletedListener = undefined;
_aiSessionStore = undefined;
// Reset diagnostics logger to default
__setSubtaskBreakdownDiagnostics(null);
// Reset diagnostics sink to default
resetDiagnosticsSink();
}
export class SessionNotFoundError extends Error {