feat(FN-2242): add shared AI-session diagnostics helper

- Add a reusable scoped diagnostics contract for AI session flows with typed levels and structured context
- Add sink injection hooks to capture and reset diagnostics output in tests without console monkey-patching
- Add nonfatal and nonfatalAsync wrappers that log failures via errorFromException and continue execution
- Add comprehensive Vitest coverage for scope isolation, sink behavior, error serialization, and non-fatal semantics
This commit is contained in:
Fusion
2026-04-22 08:23:36 -07:00
committed by gsxdsm
parent 5e62881d44
commit 744f9aa8e5
2 changed files with 1180 additions and 0 deletions

View File

@@ -0,0 +1,732 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import {
createSessionDiagnostics,
setDiagnosticsSink,
resetDiagnosticsSink,
getDiagnosticsSink,
nonfatal,
nonfatalAsync,
} from "./ai-session-diagnostics.js";
import type { DiagnosticsSink, LogEntry, DiagnosticsLevel } from "./ai-session-diagnostics.js";
describe("ai-session-diagnostics", () => {
// Track captured log entries in memory
let logged: LogEntry[];
// Helper to create a capture sink
function createCaptureSink(): DiagnosticsSink {
return (level: DiagnosticsLevel, scope: string, message: string, context) => {
logged.push({
level,
scope,
message,
context,
timestamp: new Date(),
});
};
}
beforeEach(() => {
logged = [];
setDiagnosticsSink(createCaptureSink());
});
afterEach(() => {
resetDiagnosticsSink();
});
describe("createSessionDiagnostics", () => {
it("creates diagnostics with the correct scope", () => {
const diagnostics = createSessionDiagnostics("planning");
expect(diagnostics.scope).toBe("planning");
});
it("logs info level with scope prefix", () => {
const diagnostics = createSessionDiagnostics("planning");
diagnostics.info("Session created", { sessionId: "abc-123" });
expect(logged).toHaveLength(1);
expect(logged[0]).toMatchObject({
level: "info",
scope: "planning",
message: "Session created",
});
});
it("logs warn level with scope prefix", () => {
const diagnostics = createSessionDiagnostics("planning");
diagnostics.warn("Rate limit approaching", { ip: "1.2.3.4", count: 4 });
expect(logged).toHaveLength(1);
expect(logged[0]).toMatchObject({
level: "warn",
scope: "planning",
message: "Rate limit approaching",
});
});
it("logs error level with scope prefix", () => {
const diagnostics = createSessionDiagnostics("planning");
diagnostics.error("Agent initialization failed", { sessionId: "abc" });
expect(logged).toHaveLength(1);
expect(logged[0]).toMatchObject({
level: "error",
scope: "planning",
message: "Agent initialization failed",
});
});
it("forwards structured context to the sink", () => {
const diagnostics = createSessionDiagnostics("planning");
diagnostics.info("Cleanup complete", {
sessionId: "abc-123",
ip: "192.168.1.1",
operation: "dispose",
customField: "value",
});
expect(logged[0].context).toMatchObject({
sessionId: "abc-123",
ip: "192.168.1.1",
operation: "dispose",
customField: "value",
});
});
it("adds _emittedAt timestamp to context", () => {
const diagnostics = createSessionDiagnostics("planning");
diagnostics.info("Test message");
expect(logged[0].context._emittedAt).toBeDefined();
expect(typeof logged[0].context._emittedAt).toBe("string");
// Should be a valid ISO timestamp
expect(new Date(logged[0].context._emittedAt as string)).not.toBeNaN();
});
it("adds _diagnosticsId to context for traceability", () => {
const diagnostics = createSessionDiagnostics("planning");
diagnostics.info("Test message");
expect(logged[0].context._diagnosticsId).toBeDefined();
expect(typeof logged[0].context._diagnosticsId).toBe("string");
});
it("each log entry gets a unique _diagnosticsId", () => {
const diagnostics = createSessionDiagnostics("planning");
diagnostics.info("First");
diagnostics.info("Second");
diagnostics.info("Third");
const ids = logged.map((entry) => entry.context._diagnosticsId);
expect(new Set(ids).size).toBe(3);
});
it("allows different scopes to be used independently", () => {
const planningDiag = createSessionDiagnostics("planning");
const missionDiag = createSessionDiagnostics("mission-interview");
planningDiag.info("Planning message");
missionDiag.info("Mission message");
expect(logged).toHaveLength(2);
expect(logged[0].scope).toBe("planning");
expect(logged[1].scope).toBe("mission-interview");
});
it("handles empty context gracefully", () => {
const diagnostics = createSessionDiagnostics("planning");
diagnostics.info("Test message");
expect(logged[0].context).toBeDefined();
expect(logged[0].context.sessionId).toBeUndefined();
});
it("handles undefined context argument", () => {
const diagnostics = createSessionDiagnostics("planning");
// @ts-expect-error - intentionally passing undefined to test behavior
diagnostics.info("Test message", undefined);
expect(logged[0].context).toBeDefined();
});
it("handles null context fields gracefully", () => {
const diagnostics = createSessionDiagnostics("planning");
diagnostics.info("Test message", {
sessionId: null,
ip: null,
customField: null,
});
expect(logged[0].context.sessionId).toBeNull();
expect(logged[0].context.ip).toBeNull();
});
it("does not throw when sink throws", () => {
setDiagnosticsSink(() => {
throw new Error("Sink error");
});
const diagnostics = createSessionDiagnostics("planning");
expect(() => {
diagnostics.info("Test");
diagnostics.warn("Test");
diagnostics.error("Test");
}).not.toThrow();
});
});
describe("errorFromException", () => {
it("serializes Error objects correctly", () => {
const diagnostics = createSessionDiagnostics("planning");
const error = new Error("Something went wrong");
diagnostics.errorFromException("Operation failed", error);
expect(logged[0].context.error).toMatchObject({
message: "Something went wrong",
stack: expect.stringContaining("Error: Something went wrong"),
});
});
it("serializes string errors", () => {
const diagnostics = createSessionDiagnostics("planning");
diagnostics.errorFromException("Operation failed", "Simple error string");
expect(logged[0].context.error).toBe("Simple error string");
});
it("serializes objects without message property", () => {
const diagnostics = createSessionDiagnostics("planning");
const error = { code: "ERR_FAILED", details: { foo: "bar" } };
diagnostics.errorFromException("Operation failed", error);
expect(logged[0].context.error).toMatchObject({
message: expect.any(String),
stack: undefined,
});
});
it("merges additional context with error context", () => {
const diagnostics = createSessionDiagnostics("planning");
const error = new Error("Cleanup failed");
diagnostics.errorFromException(
"Error disposing agent",
error,
{ sessionId: "abc", operation: "dispose" }
);
expect(logged[0].context).toMatchObject({
error: expect.objectContaining({ message: "Cleanup failed" }),
sessionId: "abc",
operation: "dispose",
});
});
it("captures stack trace for Error objects", () => {
const diagnostics = createSessionDiagnostics("planning");
const error = new Error("Test error");
const originalStack = error.stack;
diagnostics.errorFromException("Test", error);
expect(logged[0].context.error).toHaveProperty("stack");
expect((logged[0].context.error as { stack?: string }).stack).toBe(originalStack);
});
it("handles primitive values as errors", () => {
const diagnostics = createSessionDiagnostics("planning");
// Primitives are serialized to strings via serializeError
diagnostics.errorFromException("Test", 42);
expect(logged[0].context.error).toBe("42");
diagnostics.errorFromException("Test", true);
expect(logged[1].context.error).toBe("true");
});
});
describe("setDiagnosticsSink / resetDiagnosticsSink", () => {
it("captures logs through injected sink", () => {
const captured: LogEntry[] = [];
setDiagnosticsSink((level, scope, message, context) => {
captured.push({ level, scope, message, context, timestamp: new Date() });
});
const diagnostics = createSessionDiagnostics("test");
diagnostics.info("Hello");
expect(captured).toHaveLength(1);
expect(captured[0].message).toBe("Hello");
});
it("resetDiagnosticsSink restores default console behavior", () => {
const captured: LogEntry[] = [];
setDiagnosticsSink((level, scope, message, context) => {
captured.push({ level, scope, message, context, timestamp: new Date() });
});
const diagnostics = createSessionDiagnostics("test");
diagnostics.info("Captured");
resetDiagnosticsSink();
// After reset, logs should go to console (captured should not grow)
diagnostics.info("Should go to console");
expect(captured).toHaveLength(1); // Only the first log
});
it("resetting to null sets the default sink", () => {
// Set a custom sink first
const captured: LogEntry[] = [];
setDiagnosticsSink((level, scope, message, context) => {
captured.push({ level, scope, message, context, timestamp: new Date() });
});
// Reset to null - should restore default console behavior
setDiagnosticsSink(null);
// After reset to null, logs should go to console (not to captured array)
const consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const diagnostics = createSessionDiagnostics("test");
diagnostics.info("Test after reset");
// The captured array should be unchanged (default sink is used)
expect(captured).toHaveLength(0);
// Console should have been called with the default sink
expect(consoleLogSpy).toHaveBeenCalled();
consoleLogSpy.mockRestore();
});
it("resetting to undefined sets the default sink", () => {
setDiagnosticsSink(undefined);
const diagnostics = createSessionDiagnostics("test");
expect(() => diagnostics.info("Test")).not.toThrow();
});
it("getDiagnosticsSink returns current sink", () => {
const customSink = vi.fn();
setDiagnosticsSink(customSink);
expect(getDiagnosticsSink()).toBe(customSink);
});
});
describe("nonfatal", () => {
it("returns operation result on success", () => {
const diagnostics = createSessionDiagnostics("planning");
const result = nonfatal(
() => ({ value: 42 }),
diagnostics,
"Should not log",
{}
);
expect(result).toEqual({ value: 42 });
});
it("returns undefined and logs error on failure", () => {
const diagnostics = createSessionDiagnostics("planning");
const error = new Error("Cleanup failed");
const result = nonfatal(
() => { throw error; },
diagnostics,
"Cleanup failed",
{ sessionId: "abc" }
);
expect(result).toBeUndefined();
expect(logged).toHaveLength(1);
expect(logged[0]).toMatchObject({
level: "error",
scope: "planning",
message: "Cleanup failed",
context: expect.objectContaining({
sessionId: "abc",
error: expect.objectContaining({ message: "Cleanup failed" }),
}),
});
});
it("does not throw when operation throws", () => {
const diagnostics = createSessionDiagnostics("planning");
expect(() => {
nonfatal(
() => { throw new Error("Test error"); },
diagnostics,
"Test",
{}
);
}).not.toThrow();
});
it("logs error with serialized exception context", () => {
const diagnostics = createSessionDiagnostics("planning");
const error = new Error("Broadcast failed");
nonfatal(
() => { throw error; },
diagnostics,
"Error broadcasting",
{ operation: "broadcast", sessionId: "xyz" }
);
expect(logged[0].context.error).toMatchObject({
message: "Broadcast failed",
});
expect(logged[0].context.operation).toBe("broadcast");
expect(logged[0].context.sessionId).toBe("xyz");
});
it("returns undefined for sync void functions", () => {
const diagnostics = createSessionDiagnostics("planning");
const result = nonfatal(
() => {
// void function
},
diagnostics,
"Should not log",
{}
);
expect(result).toBeUndefined();
expect(logged).toHaveLength(0);
});
it("does not log for successful operations", () => {
const diagnostics = createSessionDiagnostics("planning");
nonfatal(() => 42, diagnostics, "Should not appear", {});
expect(logged).toHaveLength(0);
});
it("handles non-Error throws", () => {
const diagnostics = createSessionDiagnostics("planning");
const result = nonfatal(
() => { throw "string error"; },
diagnostics,
"String thrown",
{}
);
expect(result).toBeUndefined();
expect(logged[0].context.error).toBe("string error");
});
it("handles object throws", () => {
const diagnostics = createSessionDiagnostics("planning");
const errorObj = { code: "ERR_CODE", info: "details" };
const result = nonfatal(
() => { throw errorObj; },
diagnostics,
"Object thrown",
{}
);
expect(result).toBeUndefined();
// Objects without message property get serialized with key-value summary
expect(logged[0].context.error).toMatchObject({
message: expect.stringContaining("code: \"ERR_CODE\""),
});
});
});
describe("nonfatalAsync", () => {
it("returns operation result on success", async () => {
const diagnostics = createSessionDiagnostics("planning");
const result = await nonfatalAsync(
async () => ({ value: 42 }),
diagnostics,
"Should not log",
{}
);
expect(result).toEqual({ value: 42 });
});
it("returns undefined and logs error on rejection", async () => {
const diagnostics = createSessionDiagnostics("planning");
const error = new Error("Async failed");
const result = await nonfatalAsync(
async () => { throw error; },
diagnostics,
"Async operation failed",
{ operation: "fetch" }
);
expect(result).toBeUndefined();
expect(logged).toHaveLength(1);
expect(logged[0]).toMatchObject({
level: "error",
scope: "planning",
message: "Async operation failed",
context: expect.objectContaining({
operation: "fetch",
error: expect.objectContaining({ message: "Async failed" }),
}),
});
});
it("does not throw when operation rejects", async () => {
const diagnostics = createSessionDiagnostics("planning");
await expect(
nonfatalAsync(
async () => { throw new Error("Test error"); },
diagnostics,
"Test",
{}
)
).resolves.toBeUndefined(); // Should not throw, returns undefined
});
it("does not log for successful operations", async () => {
const diagnostics = createSessionDiagnostics("planning");
await nonfatalAsync(async () => 42, diagnostics, "Should not appear", {});
expect(logged).toHaveLength(0);
});
it("handles rejected promises with non-Error values", async () => {
const diagnostics = createSessionDiagnostics("planning");
const result = await nonfatalAsync(
async () => { throw "rejected string"; },
diagnostics,
"Promise rejected",
{}
);
expect(result).toBeUndefined();
expect(logged[0].context.error).toBe("rejected string");
});
});
describe("default sink behavior", () => {
it("logs info to console.log with prefix", () => {
const consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {});
resetDiagnosticsSink(); // Ensure default sink is active
const diagnostics = createSessionDiagnostics("planning");
diagnostics.info("Test message");
expect(consoleLogSpy).toHaveBeenCalledWith(
"[planning]",
"Test message",
expect.objectContaining({ _emittedAt: expect.any(String) })
);
consoleLogSpy.mockRestore();
});
it("logs warn to console.warn with prefix", () => {
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
resetDiagnosticsSink();
const diagnostics = createSessionDiagnostics("planning");
diagnostics.warn("Warning message");
expect(consoleWarnSpy).toHaveBeenCalledWith(
"[planning]",
"Warning message",
expect.objectContaining({ _emittedAt: expect.any(String) })
);
consoleWarnSpy.mockRestore();
});
it("logs error to console.error with prefix", () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
resetDiagnosticsSink();
const diagnostics = createSessionDiagnostics("planning");
diagnostics.error("Error message");
expect(consoleErrorSpy).toHaveBeenCalledWith(
"[planning]",
"Error message",
expect.objectContaining({ _emittedAt: expect.any(String) })
);
consoleErrorSpy.mockRestore();
});
it("default sink does not throw even if console methods throw", () => {
vi.spyOn(console, "log").mockImplementation(() => {
throw new Error("Console mocked error");
});
resetDiagnosticsSink();
const diagnostics = createSessionDiagnostics("planning");
expect(() => diagnostics.info("Test")).not.toThrow();
vi.mocked(console.log).mockRestore();
});
});
describe("integration patterns", () => {
it("captures cleanup pattern correctly", () => {
const diagnostics = createSessionDiagnostics("planning");
const session = { id: "session-123", agent: { dispose: () => {} } };
nonfatal(
() => session.agent.dispose(),
diagnostics,
"Error disposing agent",
{ sessionId: session.id }
);
expect(logged).toHaveLength(0); // Success path
});
it("captures cleanup failure correctly", () => {
const diagnostics = createSessionDiagnostics("planning");
const session = { id: "session-123", agent: { dispose: () => { throw new Error("Already disposed"); } } };
nonfatal(
() => session.agent.dispose(),
diagnostics,
"Error disposing agent",
{ sessionId: session.id }
);
expect(logged).toHaveLength(1);
expect(logged[0]).toMatchObject({
level: "error",
message: "Error disposing agent",
context: expect.objectContaining({
sessionId: "session-123",
error: expect.objectContaining({ message: "Already disposed" }),
}),
});
});
it("captures rehydration pattern correctly", async () => {
const diagnostics = createSessionDiagnostics("planning");
const result = await nonfatalAsync(
async () => {
// Simulate async rehydration
return { rehydrated: 5 };
},
diagnostics,
"Rehydration failed",
{ operation: "rehydrate" }
);
expect(result).toEqual({ rehydrated: 5 });
expect(logged).toHaveLength(0);
});
it("captures rehydration failure correctly", async () => {
const diagnostics = createSessionDiagnostics("planning");
const result = await nonfatalAsync(
async () => {
throw new Error("Database unavailable");
},
diagnostics,
"Rehydration failed",
{ operation: "rehydrate", sessionId: "xyz" }
);
expect(result).toBeUndefined();
expect(logged).toHaveLength(1);
expect(logged[0]).toMatchObject({
level: "error",
message: "Rehydration failed",
context: expect.objectContaining({
operation: "rehydrate",
sessionId: "xyz",
}),
});
});
it("handles multiple scopes in same test", () => {
const planning = createSessionDiagnostics("planning");
const mission = createSessionDiagnostics("mission-interview");
const subtask = createSessionDiagnostics("subtask-breakdown");
planning.info("Planning event", { sessionId: "p1" });
mission.info("Mission event", { sessionId: "m1" });
subtask.info("Subtask event", { sessionId: "s1" });
expect(logged).toHaveLength(3);
expect(logged[0].scope).toBe("planning");
expect(logged[1].scope).toBe("mission-interview");
expect(logged[2].scope).toBe("subtask-breakdown");
});
it("isolates diagnostics between test hooks", () => {
// Create first capture array and sink
const firstLogged: LogEntry[] = [];
function firstSink(level: any, scope: any, message: any, context: any) {
firstLogged.push({ level, scope, message, context, timestamp: new Date() });
}
setDiagnosticsSink(firstSink);
const diag1 = createSessionDiagnostics("scope1");
diag1.info("First scope");
expect(firstLogged).toHaveLength(1);
expect(firstLogged[0].scope).toBe("scope1");
// Reset and create new sink
resetDiagnosticsSink();
const secondLogged: LogEntry[] = [];
function secondSink(level: any, scope: any, message: any, context: any) {
secondLogged.push({ level, scope, message, context, timestamp: new Date() });
}
setDiagnosticsSink(secondSink);
const diag2 = createSessionDiagnostics("scope2");
diag2.info("Second scope");
// firstLogged should not capture second scope events
expect(firstLogged).toHaveLength(1); // Only first scope
expect(secondLogged).toHaveLength(1); // Only second scope
expect(secondLogged[0].scope).toBe("scope2");
});
});
describe("type safety", () => {
it("accepts partial context without required fields", () => {
const diagnostics = createSessionDiagnostics("planning");
// Only provide optional fields
diagnostics.info("Test", { customField: "value" });
diagnostics.warn("Test", { ip: "1.2.3.4" });
diagnostics.error("Test", { sessionId: "abc" });
expect(logged).toHaveLength(3);
});
it("does not require context argument", () => {
const diagnostics = createSessionDiagnostics("planning");
// No context provided
diagnostics.info("Simple message");
diagnostics.warn("Warning message");
diagnostics.error("Error message");
expect(logged).toHaveLength(3);
expect(logged[0].message).toBe("Simple message");
expect(logged[1].message).toBe("Warning message");
expect(logged[2].message).toBe("Error message");
});
});
});

View File

@@ -0,0 +1,448 @@
/**
* Shared AI-Session Diagnostics Helper
*
* Provides a reusable diagnostics contract for planning-like AI session flows.
* Modules such as `planning.ts`, `mission-interview.ts`, `milestone-slice-interview.ts`,
* and `subtask-breakdown.ts` can use this helper to converge on one consistent
* diagnostics pattern.
*
* ## Design Goals
*
* 1. **Scoped Structured Logging** — All log methods accept a structured context
* payload (typed object) in addition to a message. This enables precise test
* assertions without relying on string matching against `console.*` output.
*
* 2. **Log-and-Continue Semantics** — Non-fatal paths (cleanup, broadcast,
* rehydration) must not throw. The `nonfatal()` helper wraps potentially
* failing operations, logs diagnostics on failure, and continues execution.
*
* 3. **Test Injection Hooks** — A module-level logger can be swapped via
* `setDiagnosticsSink()`. Tests can capture diagnostics in-memory and assert
* on level, message fragment, scope, and context fields. No global
* `console.*` monkey-patching required.
*
* ## Usage
*
* ```ts
* import { createSessionDiagnostics, setDiagnosticsSink, resetDiagnosticsSink } from "./ai-session-diagnostics.js";
*
* const diagnostics = createSessionDiagnostics("planning");
*
* // Structured logging with context
* diagnostics.info("Session created", { sessionId: "abc" });
* diagnostics.warn("Rate limit approaching", { ip: "1.2.3.4", count: 4 });
* diagnostics.error("Agent initialization failed", { error: err });
*
* // Non-fatal wrapper for best-effort operations
* nonfatal(
* () => riskyOperation(),
* diagnostics,
* "Cleanup failed",
* { operation: "dispose", sessionId: "abc" }
* );
* ```
*
* ## Test Hooks
*
* ```ts
* import { setDiagnosticsSink, resetDiagnosticsSink } from "./ai-session-diagnostics.js";
*
* const logged: LogEntry[] = [];
* setDiagnosticsSink({
* log: (level, scope, message, context) => logged.push({ level, scope, message, context }),
* });
*
* // ... run test ...
*
* expect(logged).toContainEqual(expect.objectContaining({
* level: "error",
* scope: "planning",
* message: "Agent initialization failed",
* context: expect.objectContaining({ sessionId: "abc" }),
* }));
*
* resetDiagnosticsSink();
* ```
*
* ## Contract Guarantees
*
* - All methods are non-throwing (safe to call in catch blocks)
* - Default runtime uses `console.log/warn/error` with scope prefix
* - Structured context is forwarded to the sink unchanged
* - `nonfatal()` never rethrows — callers always continue
*/
import { randomUUID } from "node:crypto";
// ── Types ───────────────────────────────────────────────────────────────────
/**
* Log level for diagnostics entries.
*/
export type DiagnosticsLevel = "info" | "warn" | "error";
/**
* Structured context payload attached to a log entry.
* Consumers can attach any serializable fields relevant to the operation.
*/
export interface DiagnosticsContext {
/** The session or operation identifier when applicable */
sessionId?: string;
/** The client IP address for rate-limiting diagnostics */
ip?: string;
/** Error object when logging failures */
error?: unknown;
/** Operation name for cleanup/broadcast/rehydration failures */
operation?: string;
/** Any additional context fields */
[key: string]: unknown;
}
/**
* Log entry captured by a custom diagnostics sink.
* This is the canonical shape for test assertions.
*/
export interface LogEntry {
/** Log level */
level: DiagnosticsLevel;
/** Scope tag (e.g., "planning", "mission-interview") */
scope: string;
/** Human-readable message fragment */
message: string;
/** Structured context payload */
context: DiagnosticsContext;
/** Timestamp when the entry was recorded */
timestamp: Date;
}
/**
* A custom diagnostics sink for intercepting log output.
* Used by tests to capture diagnostics in-memory without global console spies.
*/
export interface DiagnosticsSink {
/**
* Called for every log event.
* @param level - Log level (info/warn/error)
* @param scope - Scope tag (e.g., "planning", "mission-interview")
* @param message - Human-readable message fragment
* @param context - Structured context payload
*/
(level: DiagnosticsLevel, scope: string, message: string, context: DiagnosticsContext): void;
}
/**
* Scoped diagnostics interface returned by `createSessionDiagnostics()`.
* Provides typed `info`, `warn`, and `error` methods with context payload support.
*/
export interface SessionDiagnostics {
/** The scope tag associated with this diagnostics instance */
readonly scope: string;
/**
* Log an informational diagnostic.
* @param message - Human-readable message fragment
* @param context - Structured context payload (partial, merged with defaults)
*/
info(message: string, context?: Partial<DiagnosticsContext>): void;
/**
* Log a warning diagnostic.
* @param message - Human-readable message fragment
* @param context - Structured context payload (partial, merged with defaults)
*/
warn(message: string, context?: Partial<DiagnosticsContext>): void;
/**
* Log an error diagnostic.
* @param message - Human-readable message fragment
* @param context - Structured context payload (partial, merged with defaults)
*/
error(message: string, context?: Partial<DiagnosticsContext>): void;
/**
* Log an error from a caught exception.
* Extracts the error message and stacks for structured context.
* @param message - Human-readable message fragment
* @param error - The caught error
* @param context - Additional structured context
*/
errorFromException(
message: string,
error: unknown,
context?: Partial<DiagnosticsContext>
): void;
}
// ── Module-Level Sink ───────────────────────────────────────────────────────
/**
* The current diagnostics sink.
* Defaults to `defaultSink` which outputs to console with scope prefix.
*/
let _sink: DiagnosticsSink = defaultSink;
/**
* Default sink: outputs to console with scope prefix.
* All methods are non-throwing (safe to call even if console is mocked).
*/
function defaultSink(level: DiagnosticsLevel, scope: string, message: string, context: DiagnosticsContext): void {
const prefix = `[${scope}]`;
const logArgs = [prefix, message, context];
try {
switch (level) {
case "info":
console.log(...logArgs);
break;
case "warn":
console.warn(...logArgs);
break;
case "error":
console.error(...logArgs);
break;
}
} catch {
// Intentionally swallow — diagnostics must never throw
}
}
/**
* Set a custom diagnostics sink (test hook).
*
* When a sink is injected, all diagnostics from all scopes route through it.
* This allows tests to capture diagnostics without global `console.*` spies.
*
* Pass `null` or `undefined` to reset to the default console sink.
*
* @param sink - Custom sink function, or null/undefined to reset
*
* @example
* ```ts
* const logged: LogEntry[] = [];
* setDiagnosticsSink((level, scope, message, context) => {
* logged.push({ level, scope, message, context, timestamp: new Date() });
* });
*
* // ... tests ...
*
* resetDiagnosticsSink();
* ```
*/
export function setDiagnosticsSink(sink: DiagnosticsSink | null | undefined): void {
_sink = sink ?? defaultSink;
}
/**
* Get the current diagnostics sink.
* @internal - exposed for advanced test scenarios
*/
export function getDiagnosticsSink(): DiagnosticsSink {
return _sink;
}
/**
* Reset the diagnostics sink to the default console sink.
* Call this in test teardown to restore default behavior.
*/
export function resetDiagnosticsSink(): void {
_sink = defaultSink;
}
// ── Session Diagnostics Factory ─────────────────────────────────────────────
/**
* Create a scoped diagnostics instance.
*
* The returned `SessionDiagnostics` has typed `info`, `warn`, and `error`
* methods that forward structured context to the module-level sink.
*
* @param scope - Short scope tag (e.g., "planning", "mission-interview")
* @returns A scoped diagnostics instance
*
* @example
* ```ts
* const diagnostics = createSessionDiagnostics("planning");
* diagnostics.info("Session created", { sessionId: "abc" });
* diagnostics.error("Agent init failed", { error: err });
* ```
*/
export function createSessionDiagnostics(scope: string): SessionDiagnostics {
return {
scope,
info(message, context = {}) {
emit("info", scope, message, context);
},
warn(message, context = {}) {
emit("warn", scope, message, context);
},
error(message, context = {}) {
emit("error", scope, message, context);
},
errorFromException(message, error, context = {}) {
const errorContext: DiagnosticsContext = {
...context,
error: serializeError(error),
};
emit("error", scope, message, errorContext);
},
};
}
function emit(
level: DiagnosticsLevel,
scope: string,
message: string,
context: Partial<DiagnosticsContext>
): void {
const fullContext: DiagnosticsContext = {
...context,
_emittedAt: new Date().toISOString(),
_diagnosticsId: randomUUID(),
};
try {
_sink(level, scope, message, fullContext);
} catch {
// Intentionally swallow — diagnostics must never throw
}
}
// ── Non-Fatal Helper ───────────────────────────────────────────────────────
/**
* Execute an operation and log a diagnostic if it fails.
*
* This is the standard pattern for best-effort paths (cleanup, broadcast,
* rehydration) where failures should be surfaced as diagnostics but must NOT
* propagate as exceptions.
*
* The wrapped function's return value is returned on success.
* On failure, `undefined` is returned after logging.
*
* @param operation - The potentially-failing operation
* @param logger - The scoped diagnostics instance to use
* @param message - Human-readable message for the failure diagnostic
* @param context - Structured context for the failure diagnostic
* @returns The operation's return value, or `undefined` on failure
*
* @example
* ```ts
* // Best-effort cleanup — never throws
* const disposed = nonfatal(
* () => agent.session.dispose(),
* diagnostics,
* "Error disposing agent",
* { sessionId }
* );
* // disposed is the return value, or undefined if failed
* ```
*
* @example
* ```ts
* // Best-effort broadcast — continue even if subscriber throws
* nonfatal(
* () => callback(event),
* diagnostics,
* "Error broadcasting to client",
* { sessionId }
* );
* // Execution continues regardless of callback failure
* ```
*/
export function nonfatal<T>(
operation: () => T,
logger: SessionDiagnostics,
message: string,
context: Partial<DiagnosticsContext>
): T | undefined {
try {
return operation();
} catch (error) {
logger.errorFromException(message, error, context);
return undefined;
}
}
/**
* Execute an async operation and log a diagnostic if it fails.
*
* Same semantics as `nonfatal()` but for `async` operations.
* Returns `undefined` on failure after logging.
*
* @param operation - The potentially-failing async operation
* @param logger - The scoped diagnostics instance to use
* @param message - Human-readable message for the failure diagnostic
* @param context - Structured context for the failure diagnostic
* @returns The operation's resolved value, or `undefined` on failure
*
* @example
* ```ts
* const result = await nonfatalAsync(
* () => riskyAsyncOperation(),
* diagnostics,
* "Rehydration failed",
* { sessionId }
* );
* // result is the resolved value, or undefined if failed/threw
* ```
*/
export async function nonfatalAsync<T>(
operation: () => Promise<T>,
logger: SessionDiagnostics,
message: string,
context: Partial<DiagnosticsContext>
): Promise<T | undefined> {
try {
return await operation();
} catch (error) {
logger.errorFromException(message, error, context);
return undefined;
}
}
// ── Utilities ───────────────────────────────────────────────────────────────
/**
* Serialize an error for structured context.
* Handles Error objects, strings, and unknown values.
*/
function serializeError(error: unknown): { message: string; stack?: string } | string {
if (error instanceof Error) {
return {
message: error.message,
stack: error.stack,
};
}
if (typeof error === "string") {
return error;
}
if (error && typeof error === "object") {
try {
const obj = error as Record<string, unknown>;
const message = obj.message;
// If the object has a meaningful message property, use it
if (typeof message === "string" && message !== "[object Object]") {
return {
message,
stack: typeof obj.stack === "string" ? obj.stack : undefined,
};
}
// For objects without a message property, try to extract useful info
// by serializing the object (but only for non-default representations)
const keys = Object.keys(obj).filter(k => k !== "stack");
if (keys.length > 0) {
// Return a summary of the object's key-value pairs
const summary = keys
.slice(0, 5) // Limit to 5 keys to avoid verbosity
.map(k => `${k}: ${JSON.stringify(obj[k])}`)
.join(", ");
return {
message: `{${summary}}`,
stack: typeof obj.stack === "string" ? obj.stack : undefined,
};
}
} catch {
// Fallback if object inspection fails
}
}
return String(error);
}