diff --git a/src/hooks/runtime-fallback/dispose.test.ts b/src/hooks/runtime-fallback/dispose.test.ts index e49cb0904..643b7c8fb 100644 --- a/src/hooks/runtime-fallback/dispose.test.ts +++ b/src/hooks/runtime-fallback/dispose.test.ts @@ -107,9 +107,10 @@ describe("createRuntimeFallbackHook dispose", () => { globalThis.clearTimeout = originalClearTimeout }) - test("#given runtime-fallback hook created #when dispose() is called #then cleanup interval is cleared", () => { + test("#given runtime-fallback hook handles its first event #when dispose() is called #then cleanup interval is cleared", async () => { // given const hook = createRuntimeFallbackHook(createMockContext(), { pluginConfig: {} }) + await hook.event({ event: { type: "session.created", properties: {} } }) // when hook.dispose?.() diff --git a/src/hooks/runtime-fallback/hook.init.test.ts b/src/hooks/runtime-fallback/hook.init.test.ts new file mode 100644 index 000000000..06a7e658c --- /dev/null +++ b/src/hooks/runtime-fallback/hook.init.test.ts @@ -0,0 +1,124 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test" +import type { OhMyOpenCodeConfig } from "../../config" +import type { HookDeps, RuntimeFallbackInterval, RuntimeFallbackPluginInput } from "./types" + +type RuntimeFallbackModule = typeof import("./hook") + +const loadPluginConfigMock = mock(() => ({} satisfies OhMyOpenCodeConfig)) +const createAutoRetryHelpersMock = mock((_deps: HookDeps) => { + void _deps + + return { + abortSessionRequest: async () => {}, + clearSessionFallbackTimeout: () => {}, + scheduleSessionFallbackTimeout: () => {}, + autoRetryWithFallback: async () => {}, + resolveAgentForSessionFromContext: async () => undefined, + cleanupStaleSessions: () => {}, + } +}) +const createEventHandlerMock = mock(() => async () => {}) +const createMessageUpdateHandlerMock = mock(() => async () => {}) +const createChatMessageHandlerMock = mock(() => async () => {}) + +function registerModuleMocks(): void { + mock.module("../../plugin-config", () => ({ + loadPluginConfig: loadPluginConfigMock, + })) + + mock.module("./auto-retry", () => ({ + createAutoRetryHelpers: createAutoRetryHelpersMock, + })) + + mock.module("./event-handler", () => ({ + createEventHandler: createEventHandlerMock, + })) + + mock.module("./message-update-handler", () => ({ + createMessageUpdateHandler: createMessageUpdateHandlerMock, + })) + + mock.module("./chat-message-handler", () => ({ + createChatMessageHandler: createChatMessageHandlerMock, + })) +} + +function createMockContext(): RuntimeFallbackPluginInput { + return { + client: { + session: { + abort: async () => ({}), + messages: async () => ({}), + promptAsync: async () => ({}), + }, + tui: { + showToast: async () => ({}), + }, + }, + directory: "/test", + } +} + +function createMockInterval(): RuntimeFallbackInterval { + return { + unref: () => {}, + } +} + +describe("createRuntimeFallbackHook initialization", () => { + const originalSetInterval = globalThis.setInterval + let setIntervalCalls = 0 + let createRuntimeFallbackHook: RuntimeFallbackModule["createRuntimeFallbackHook"] + + beforeEach(async () => { + mock.restore() + registerModuleMocks() + loadPluginConfigMock.mockClear() + createAutoRetryHelpersMock.mockClear() + createEventHandlerMock.mockClear() + createMessageUpdateHandlerMock.mockClear() + createChatMessageHandlerMock.mockClear() + setIntervalCalls = 0 + + globalThis.setInterval = ((callback: Parameters[0], delay?: number) => { + void callback + void delay + setIntervalCalls += 1 + return createMockInterval() as ReturnType + }) as typeof globalThis.setInterval + + const cacheBuster = `${Date.now()}-${Math.random()}` + const runtimeFallbackModule: RuntimeFallbackModule = await import(`./hook?test=${cacheBuster}`) + createRuntimeFallbackHook = runtimeFallbackModule.createRuntimeFallbackHook + }) + + afterEach(() => { + globalThis.setInterval = originalSetInterval + mock.restore() + }) + + test("#given injected pluginConfig #when the hook factory runs #then loadPluginConfig is not called", () => { + // given + const pluginConfig = {} satisfies OhMyOpenCodeConfig + + // when + createRuntimeFallbackHook(createMockContext(), { pluginConfig }) + + // then + expect(loadPluginConfigMock).not.toHaveBeenCalled() + }) + + test("#given a fresh hook #when the first event arrives #then cleanup interval starts only once", async () => { + // given + const hook = createRuntimeFallbackHook(createMockContext(), { pluginConfig: {} }) + + // when + expect(setIntervalCalls).toBe(0) + await hook.event({ event: { type: "session.created", properties: {} } }) + expect(setIntervalCalls).toBe(1) + await hook.event({ event: { type: "session.error", properties: {} } }) + + // then + expect(setIntervalCalls).toBe(1) + }) +}) diff --git a/src/hooks/runtime-fallback/hook.ts b/src/hooks/runtime-fallback/hook.ts index 2a13d507e..f3509ab0a 100644 --- a/src/hooks/runtime-fallback/hook.ts +++ b/src/hooks/runtime-fallback/hook.ts @@ -1,7 +1,5 @@ import type { HookDeps, RuntimeFallbackHook, RuntimeFallbackInterval, RuntimeFallbackOptions, RuntimeFallbackPluginInput, RuntimeFallbackTimeout } from "./types" -import { DEFAULT_CONFIG, HOOK_NAME } from "./constants" -import { log } from "../../shared/logger" -import { loadPluginConfig } from "../../plugin-config" +import { DEFAULT_CONFIG } from "./constants" import { createAutoRetryHelpers } from "./auto-retry" import { createEventHandler } from "./event-handler" import { createMessageUpdateHandler } from "./message-update-handler" @@ -24,20 +22,11 @@ export function createRuntimeFallbackHook( notify_on_fallback: options?.config?.notify_on_fallback ?? DEFAULT_CONFIG.notify_on_fallback, } - let pluginConfig = options?.pluginConfig - if (!pluginConfig) { - try { - pluginConfig = loadPluginConfig(ctx.directory, ctx) - } catch { - log(`[${HOOK_NAME}] Plugin config not available`) - } - } - const deps: HookDeps = { ctx, config, options, - pluginConfig, + pluginConfig: options?.pluginConfig, sessionStates: new Map(), sessionLastAccess: new Map(), sessionRetryInFlight: new Set(), @@ -51,10 +40,23 @@ export function createRuntimeFallbackHook( const messageUpdateHandler = createMessageUpdateHandler(deps, helpers) const chatMessageHandler = createChatMessageHandler(deps) - const cleanupInterval = setInterval(helpers.cleanupStaleSessions, 5 * 60 * 1000) - cleanupInterval.unref() + let cleanupInterval: RuntimeFallbackInterval | null = null + let intervalStarted = false + + const ensureInterval = (): void => { + if (intervalStarted) return + + intervalStarted = true + cleanupInterval = setInterval(helpers.cleanupStaleSessions, 5 * 60 * 1000) + + if (typeof cleanupInterval.unref === "function") { + cleanupInterval.unref() + } + } const eventHandler = async ({ event }: { event: { type: string; properties?: unknown } }) => { + ensureInterval() + if (event.type === "message.updated") { if (!config.enabled) return const props = event.properties as Record | undefined @@ -65,7 +67,9 @@ export function createRuntimeFallbackHook( } const dispose = () => { - clearInterval(cleanupInterval) + if (cleanupInterval) { + clearInterval(cleanupInterval) + } for (const fallbackTimeout of deps.sessionFallbackTimeouts.values()) { clearTimeout(fallbackTimeout)