import fs from "node:fs"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { importFreshModule } from "../../test/helpers/import-fresh.js"; import { emitDiagnosticEvent, onDiagnosticEvent, resetDiagnosticEventsForTest, setDiagnosticsEnabledForProcess, } from "../infra/diagnostic-events.js"; import { diagnosticSessionStates, getDiagnosticSessionStateCountForTest, getDiagnosticSessionState, pruneDiagnosticSessionStates, resetDiagnosticSessionStateForTest, } from "./diagnostic-session-state.js"; import { getDiagnosticStabilitySnapshot } from "./diagnostic-stability.js"; import { logSessionStateChange, resetDiagnosticStateForTest, resolveStuckSessionWarnMs, startDiagnosticHeartbeat, } from "./diagnostic.js"; function createEmitMemorySampleMock() { return vi.fn(() => ({ rssBytes: 100, heapTotalBytes: 80, heapUsedBytes: 40, externalBytes: 10, arrayBuffersBytes: 5, })); } describe("diagnostic session state pruning", () => { beforeEach(() => { vi.useFakeTimers(); resetDiagnosticSessionStateForTest(); }); afterEach(() => { resetDiagnosticSessionStateForTest(); vi.useRealTimers(); }); it("evicts stale idle session states", () => { getDiagnosticSessionState({ sessionId: "stale-1" }); expect(getDiagnosticSessionStateCountForTest()).toBe(1); vi.advanceTimersByTime(31 * 60 * 1000); getDiagnosticSessionState({ sessionId: "fresh-1" }); expect(getDiagnosticSessionStateCountForTest()).toBe(1); }); it("caps tracked session states to a bounded max", () => { const now = Date.now(); for (let i = 0; i < 2001; i += 1) { diagnosticSessionStates.set(`session-${i}`, { sessionId: `session-${i}`, lastActivity: now + i, state: "idle", queueDepth: 1, }); } pruneDiagnosticSessionStates(now + 2002, true); expect(getDiagnosticSessionStateCountForTest()).toBe(2000); }); it("reuses keyed session state when later looked up by sessionId", () => { const keyed = getDiagnosticSessionState({ sessionId: "s1", sessionKey: "agent:main:demo-channel:channel:c1", }); const bySessionId = getDiagnosticSessionState({ sessionId: "s1" }); expect(bySessionId).toBe(keyed); expect(bySessionId.sessionKey).toBe("agent:main:demo-channel:channel:c1"); expect(getDiagnosticSessionStateCountForTest()).toBe(1); }); }); describe("logger import side effects", () => { afterEach(() => { vi.restoreAllMocks(); vi.useRealTimers(); }); it("does not mkdir at import time", async () => { vi.useRealTimers(); const mkdirSpy = vi.spyOn(fs, "mkdirSync"); await importFreshModule( import.meta.url, "./logger.js?scope=diagnostic-mkdir", ); expect(mkdirSpy).not.toHaveBeenCalled(); }); }); describe("stuck session diagnostics threshold", () => { beforeEach(() => { vi.useFakeTimers(); resetDiagnosticStateForTest(); resetDiagnosticEventsForTest(); }); afterEach(() => { resetDiagnosticEventsForTest(); resetDiagnosticStateForTest(); vi.useRealTimers(); }); it("uses the configured diagnostics.stuckSessionWarnMs threshold", () => { const events: Array<{ type: string }> = []; const unsubscribe = onDiagnosticEvent((event) => { events.push({ type: event.type }); }); try { startDiagnosticHeartbeat({ diagnostics: { enabled: true, stuckSessionWarnMs: 30_000, }, }); logSessionStateChange({ sessionId: "s1", sessionKey: "main", state: "processing" }); vi.advanceTimersByTime(61_000); } finally { unsubscribe(); } expect(events.filter((event) => event.type === "session.stuck")).toHaveLength(1); }); it("starts and stops the stability recorder with the heartbeat lifecycle", () => { startDiagnosticHeartbeat({ diagnostics: { enabled: true, }, }); logSessionStateChange({ sessionId: "s1", sessionKey: "main", state: "processing" }); expect(getDiagnosticStabilitySnapshot({ limit: 10 }).events).toContainEqual( expect.objectContaining({ type: "session.state", outcome: "processing", }), ); const [event] = getDiagnosticStabilitySnapshot({ limit: 10 }).events; expect(event).not.toHaveProperty("sessionId"); expect(event).not.toHaveProperty("sessionKey"); resetDiagnosticStateForTest(); emitDiagnosticEvent({ type: "webhook.received", channel: "telegram" }); expect(getDiagnosticStabilitySnapshot({ limit: 10 }).events).toEqual([]); }); it("does not track session state when diagnostics are disabled", () => { const events: string[] = []; const unsubscribe = onDiagnosticEvent((event) => events.push(event.type)); try { setDiagnosticsEnabledForProcess(false); logSessionStateChange({ sessionId: "s1", sessionKey: "main", state: "processing" }); } finally { unsubscribe(); } expect(events).toEqual([]); expect(getDiagnosticSessionStateCountForTest()).toBe(0); }); it("checks memory pressure every tick without recording idle samples", () => { const emitMemorySample = createEmitMemorySampleMock(); startDiagnosticHeartbeat( { diagnostics: { enabled: true, }, }, { emitMemorySample }, ); vi.advanceTimersByTime(30_000); expect(emitMemorySample).toHaveBeenLastCalledWith({ emitSample: false }); logSessionStateChange({ sessionId: "s1", sessionKey: "main", state: "processing" }); vi.advanceTimersByTime(30_000); expect(emitMemorySample).toHaveBeenLastCalledWith({ emitSample: true }); }); it("does not start the heartbeat when diagnostics are disabled by config", () => { const emitMemorySample = createEmitMemorySampleMock(); startDiagnosticHeartbeat( { diagnostics: { enabled: false, }, }, { emitMemorySample }, ); vi.advanceTimersByTime(30_000); expect(emitMemorySample).not.toHaveBeenCalled(); }); it("falls back to default threshold when config is absent", () => { const events: Array<{ type: string }> = []; const unsubscribe = onDiagnosticEvent((event) => { events.push({ type: event.type }); }); try { startDiagnosticHeartbeat(); logSessionStateChange({ sessionId: "s2", sessionKey: "main", state: "processing" }); vi.advanceTimersByTime(31_000); } finally { unsubscribe(); } expect(events.filter((event) => event.type === "session.stuck")).toHaveLength(0); }); it("uses default threshold for invalid values", () => { expect(resolveStuckSessionWarnMs({ diagnostics: { stuckSessionWarnMs: -1 } })).toBe(120_000); expect(resolveStuckSessionWarnMs({ diagnostics: { stuckSessionWarnMs: 0 } })).toBe(120_000); expect(resolveStuckSessionWarnMs()).toBe(120_000); }); });