diff --git a/src/index.ts b/src/index.ts index aefd558..9cf1284 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,7 @@ import { performAutoCapture } from "./services/auto-capture.js"; import { performUserProfileLearning } from "./services/user-memory-learning.js"; import { userPromptManager } from "./services/user-prompt/user-prompt-manager.js"; import { startWebServer, WebServer } from "./services/web-server.js"; +import { embeddingService } from "./services/embedding.js"; import { isConfigured, CONFIG, initConfig } from "./config.js"; import { log } from "./services/logger.js"; @@ -23,19 +24,44 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => { let webServer: WebServer | null = null; let idleTimeout: Timer | null = null; - if (!isConfigured()) { - } - const GLOBAL_PLUGIN_WARMUP_KEY = Symbol.for("opencode-mem.plugin.warmedup"); + const GLOBAL_PLUGIN_WARMUP_PROMISE_KEY = Symbol.for("opencode-mem.plugin.warmupPromise"); + const GLOBAL_PLUGIN_WARMUP_TIMEOUT_MS = 60_000; - if (!(globalThis as any)[GLOBAL_PLUGIN_WARMUP_KEY] && isConfigured()) { - try { - await memoryClient.warmup(); - (globalThis as any)[GLOBAL_PLUGIN_WARMUP_KEY] = true; - } catch (error) { - log("Plugin warmup failed", { error: String(error) }); - } - } + const startBackgroundWarmup = () => { + if (!isConfigured()) return; + + const globalState = globalThis as any; + if (globalState[GLOBAL_PLUGIN_WARMUP_KEY]) return; + if (globalState[GLOBAL_PLUGIN_WARMUP_PROMISE_KEY]) return; + + const warmupState: { promise: Promise | null } = { promise: null }; + warmupState.promise = (async () => { + try { + await Promise.race([ + memoryClient.warmup(), + new Promise((_, reject) => { + setTimeout( + () => reject(new Error("Background warmup timed out")), + GLOBAL_PLUGIN_WARMUP_TIMEOUT_MS + ); + }), + ]); + globalState[GLOBAL_PLUGIN_WARMUP_KEY] = true; + } catch (error) { + log("Plugin warmup failed", { error: String(error) }); + if (String(error).includes("Background warmup timed out")) { + embeddingService.resetWarmupState(); + } + } finally { + if (globalState[GLOBAL_PLUGIN_WARMUP_PROMISE_KEY] === warmupState.promise) { + globalState[GLOBAL_PLUGIN_WARMUP_PROMISE_KEY] = null; + } + } + })(); + + globalState[GLOBAL_PLUGIN_WARMUP_PROMISE_KEY] = warmupState.promise; + }; // Wire opencode state path and provider list — fire-and-forget to avoid blocking init // These calls can hang if opencode isn't fully bootstrapped yet @@ -57,76 +83,77 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => { })(); if (CONFIG.webServerEnabled) { - startWebServer({ - port: CONFIG.webServerPort, - host: CONFIG.webServerHost, - enabled: CONFIG.webServerEnabled, - }) - .then((server) => { - webServer = server; - const url = webServer.getUrl(); - - webServer.setOnTakeoverCallback(async () => { - if (ctx.client?.tui) { - ctx.client.tui - .showToast({ - body: { - title: "Memory Explorer", - message: "Took over web server ownership", - variant: "success", - duration: 3000, - }, - }) - .catch(() => {}); - } - }); + try { + webServer = await startWebServer({ + port: CONFIG.webServerPort, + host: CONFIG.webServerHost, + enabled: CONFIG.webServerEnabled, + }); - if (webServer.isServerOwner()) { - if (ctx.client?.tui) { - ctx.client.tui - .showToast({ - body: { - title: "Memory Explorer", - message: `Web UI started at ${url}`, - variant: "success", - duration: 5000, - }, - }) - .catch(() => {}); - } - } else { - if (ctx.client?.tui) { - ctx.client.tui - .showToast({ - body: { - title: "Memory Explorer", - message: `Web UI available at ${url}`, - variant: "info", - duration: 3000, - }, - }) - .catch(() => {}); - } + const url = webServer.getUrl(); + + webServer.setOnTakeoverCallback(async () => { + if (ctx.client?.tui) { + ctx.client.tui + .showToast({ + body: { + title: "Memory Explorer", + message: "Took over web server ownership", + variant: "success", + duration: 3000, + }, + }) + .catch(() => {}); } - }) - .catch((error) => { - log("Web server failed to start", { error: String(error) }); + }); + if (webServer.isServerOwner()) { if (ctx.client?.tui) { ctx.client.tui .showToast({ body: { - title: "Memory Explorer Error", - message: `Failed to start: ${String(error)}`, - variant: "error", + title: "Memory Explorer", + message: `Web UI started at ${url}`, + variant: "success", duration: 5000, }, }) .catch(() => {}); } - }); + } else { + if (ctx.client?.tui) { + ctx.client.tui + .showToast({ + body: { + title: "Memory Explorer", + message: `Web UI available at ${url}`, + variant: "info", + duration: 3000, + }, + }) + .catch(() => {}); + } + } + } catch (error) { + log("Web server failed to start", { error: String(error) }); + + if (ctx.client?.tui) { + ctx.client.tui + .showToast({ + body: { + title: "Memory Explorer Error", + message: `Failed to start: ${String(error)}`, + variant: "error", + duration: 5000, + }, + }) + .catch(() => {}); + } + } } + startBackgroundWarmup(); + const shutdownHandler = async () => { try { if (webServer) { @@ -275,6 +302,7 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => { const needsWarmup = !(await memoryClient.isReady()); if (needsWarmup) { + startBackgroundWarmup(); return JSON.stringify({ success: false, error: "Memory system is initializing." }); } diff --git a/src/services/embedding.ts b/src/services/embedding.ts index 128b9ce..21e1a47 100644 --- a/src/services/embedding.ts +++ b/src/services/embedding.ts @@ -31,6 +31,8 @@ function withTimeout(promise: Promise, ms: number): Promise { export class EmbeddingService { private pipe: any = null; private initPromise: Promise | null = null; + private initGeneration = 0; + private resetSignal = this.createResetSignal(); public isWarmedUp: boolean = false; private cache: Map = new Map(); private cachedModelName: string | null = null; @@ -42,26 +44,76 @@ export class EmbeddingService { return (globalThis as any)[GLOBAL_EMBEDDING_KEY]; } + private createResetSignal(): { promise: Promise; resolve: () => void } { + let resolve!: () => void; + const promise = new Promise((resolver) => { + resolve = resolver; + }); + + return { promise, resolve }; + } + async warmup(progressCallback?: (progress: any) => void): Promise { - if (this.isWarmedUp) return; - if (this.initPromise) return this.initPromise; - this.initPromise = this.initializeModel(progressCallback); - return this.initPromise; + while (!this.isWarmedUp) { + if (!this.initPromise) { + const generation = ++this.initGeneration; + const initPromise = this.initializeModel(generation, progressCallback).finally(() => { + if (this.initPromise === initPromise) { + this.initPromise = null; + } + }); + + this.initPromise = initPromise; + } + + await Promise.race([this.initPromise, this.resetSignal.promise]); + } + } + + resetWarmupState(): void { + const resetSignal = this.resetSignal; + this.resetSignal = this.createResetSignal(); + this.initGeneration += 1; + this.isWarmedUp = false; + this.pipe = null; + if (this.initPromise) { + this.initPromise = null; + } + + this.clearCache(); + resetSignal.resolve(); } - private async initializeModel(progressCallback?: (progress: any) => void): Promise { + private async initializeModel( + generation: number, + progressCallback?: (progress: any) => void + ): Promise { try { if (CONFIG.embeddingApiUrl && CONFIG.embeddingApiKey) { + if (generation !== this.initGeneration) { + return; + } + this.isWarmedUp = true; return; } + const { pipeline } = await ensureTransformersLoaded(); - this.pipe = await pipeline("feature-extraction", CONFIG.embeddingModel, { + const pipe = await pipeline("feature-extraction", CONFIG.embeddingModel, { progress_callback: progressCallback, }); + + if (generation !== this.initGeneration) { + return; + } + + this.pipe = pipe; this.isWarmedUp = true; } catch (error) { - this.initPromise = null; + if (generation !== this.initGeneration) { + return; + } + log("Failed to initialize embedding model", { error: String(error) }); throw error; } @@ -76,12 +128,9 @@ export class EmbeddingService { const cached = this.cache.get(text); if (cached) return cached; - if (!this.isWarmedUp && !this.initPromise) { + if (!this.isWarmedUp) { await this.warmup(); } - if (this.initPromise) { - await this.initPromise; - } let result: Float32Array; diff --git a/tests/embedding.test.ts b/tests/embedding.test.ts new file mode 100644 index 0000000..2b7ff27 --- /dev/null +++ b/tests/embedding.test.ts @@ -0,0 +1,252 @@ +import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; +import { CONFIG } from "../src/config.ts"; + +const originalConfig = { + storagePath: CONFIG.storagePath, + embeddingApiUrl: CONFIG.embeddingApiUrl, + embeddingApiKey: CONFIG.embeddingApiKey, + embeddingModel: CONFIG.embeddingModel, +}; + +const mockEnv = { + allowLocalModels: false, + allowRemoteModels: false, + cacheDir: "", +}; + +let pipelineCalls = 0; +let pipelineImpl: () => Promise = async () => async () => ({ data: new Float32Array([1]) }); + +mock.module("@xenova/transformers", () => ({ + env: mockEnv, + pipeline: (..._args: unknown[]) => { + pipelineCalls += 1; + return pipelineImpl(); + }, +})); + +const { EmbeddingService } = await import("../src/services/embedding.ts"); + +async function flushMicrotasks() { + await Promise.resolve(); + await Promise.resolve(); +} + +describe("EmbeddingService warmup", () => { + beforeEach(() => { + pipelineCalls = 0; + pipelineImpl = async () => async () => ({ data: new Float32Array([1]) }); + mockEnv.allowLocalModels = false; + mockEnv.allowRemoteModels = false; + mockEnv.cacheDir = ""; + CONFIG.storagePath = "/tmp/opencode-mem-test"; + CONFIG.embeddingApiUrl = ""; + CONFIG.embeddingApiKey = ""; + CONFIG.embeddingModel = "test-model"; + }); + + afterEach(() => { + CONFIG.storagePath = originalConfig.storagePath; + CONFIG.embeddingApiUrl = originalConfig.embeddingApiUrl; + CONFIG.embeddingApiKey = originalConfig.embeddingApiKey; + CONFIG.embeddingModel = originalConfig.embeddingModel; + }); + + it("starts a fresh warmup generation after reset and ignores the stale result", async () => { + let pipelineAttempt = 0; + let firstWarmupSettled = false; + let markFirstPipelineStarted!: () => void; + let markSecondPipelineStarted!: () => void; + let resolveFirstPipeline!: (value: any) => void; + let resolveSecondPipeline!: (value: any) => void; + const firstPipelineStarted = new Promise((resolve) => { + markFirstPipelineStarted = resolve; + }); + const secondPipelineStarted = new Promise((resolve) => { + markSecondPipelineStarted = resolve; + }); + + pipelineImpl = () => + new Promise((resolve) => { + pipelineAttempt += 1; + + if (pipelineAttempt === 1) { + markFirstPipelineStarted(); + resolveFirstPipeline = resolve; + return; + } + + if (pipelineAttempt === 2) { + markSecondPipelineStarted(); + resolveSecondPipeline = resolve; + return; + } + + resolve(async () => ({ data: new Float32Array([1]) })); + }); + + const service = new EmbeddingService(); + const firstWarmup = service.warmup(); + void firstWarmup.then(() => { + firstWarmupSettled = true; + }); + + await firstPipelineStarted; + service.resetWarmupState(); + + const secondWarmup = service.warmup(); + await secondPipelineStarted; + + expect(pipelineCalls).toBe(2); + + resolveFirstPipeline(async () => ({ data: new Float32Array([1]) })); + await flushMicrotasks(); + + expect(firstWarmupSettled).toBe(false); + expect(service.isWarmedUp).toBe(false); + + resolveSecondPipeline(async () => ({ data: new Float32Array([1]) })); + await Promise.all([firstWarmup, secondWarmup]); + + expect(service.isWarmedUp).toBe(true); + expect(pipelineCalls).toBe(2); + }); + + it("keeps waiting for the new generation when the stale initialization rejects", async () => { + let pipelineAttempt = 0; + let firstWarmupRejected = false; + let markFirstPipelineStarted!: () => void; + let markSecondPipelineStarted!: () => void; + let rejectFirstPipeline!: (reason?: unknown) => void; + let resolveSecondPipeline!: (value: any) => void; + const firstPipelineStarted = new Promise((resolve) => { + markFirstPipelineStarted = resolve; + }); + const secondPipelineStarted = new Promise((resolve) => { + markSecondPipelineStarted = resolve; + }); + + pipelineImpl = () => + new Promise((resolve, reject) => { + pipelineAttempt += 1; + + if (pipelineAttempt === 1) { + markFirstPipelineStarted(); + rejectFirstPipeline = reject; + return; + } + + if (pipelineAttempt === 2) { + markSecondPipelineStarted(); + resolveSecondPipeline = resolve; + return; + } + + resolve(async () => ({ data: new Float32Array([1]) })); + }); + + const service = new EmbeddingService(); + const firstWarmup = service.warmup(); + void firstWarmup.catch(() => { + firstWarmupRejected = true; + }); + + await firstPipelineStarted; + service.resetWarmupState(); + + const secondWarmup = service.warmup(); + await secondPipelineStarted; + + rejectFirstPipeline(new Error("stale boom")); + await flushMicrotasks(); + + expect(firstWarmupRejected).toBe(false); + expect(service.isWarmedUp).toBe(false); + + resolveSecondPipeline(async () => ({ data: new Float32Array([1]) })); + await Promise.all([firstWarmup, secondWarmup]); + + expect(firstWarmupRejected).toBe(false); + expect(service.isWarmedUp).toBe(true); + expect(pipelineCalls).toBe(2); + }); + + it("wakes waiters blocked on a stale hanging initialization after reset", async () => { + let pipelineAttempt = 0; + let firstWarmupResolved = false; + let markFirstPipelineStarted!: () => void; + let markSecondPipelineStarted!: () => void; + let resolveSecondPipeline!: (value: any) => void; + const firstPipelineStarted = new Promise((resolve) => { + markFirstPipelineStarted = resolve; + }); + const secondPipelineStarted = new Promise((resolve) => { + markSecondPipelineStarted = resolve; + }); + + pipelineImpl = () => { + pipelineAttempt += 1; + + if (pipelineAttempt === 1) { + markFirstPipelineStarted(); + return new Promise(() => {}); + } + + if (pipelineAttempt === 2) { + return new Promise((resolve) => { + markSecondPipelineStarted(); + resolveSecondPipeline = resolve; + }); + } + + return Promise.resolve(async () => ({ data: new Float32Array([1]) })); + }; + + const service = new EmbeddingService(); + const firstWarmup = service.warmup(); + void firstWarmup.then(() => { + firstWarmupResolved = true; + }); + + await firstPipelineStarted; + service.resetWarmupState(); + + const secondWarmup = service.warmup(); + await secondPipelineStarted; + + expect(firstWarmupResolved).toBe(false); + expect(service.isWarmedUp).toBe(false); + + resolveSecondPipeline(async () => ({ data: new Float32Array([1]) })); + await Promise.all([firstWarmup, secondWarmup]); + + expect(firstWarmupResolved).toBe(true); + expect(service.isWarmedUp).toBe(true); + expect(pipelineCalls).toBe(2); + }); + + it("allows a new warmup attempt after the previous initialization fails", async () => { + const service = new EmbeddingService(); + + pipelineImpl = async () => { + throw new Error("boom"); + }; + + let thrown: unknown; + try { + await service.warmup(); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(Error); + expect((thrown as Error).message).toBe("boom"); + expect(service.isWarmedUp).toBe(false); + + pipelineImpl = async () => async () => ({ data: new Float32Array([1]) }); + + await service.warmup(); + expect(service.isWarmedUp).toBe(true); + expect(pipelineCalls).toBe(2); + }); +}); diff --git a/tests/web-server-startup-order.test.ts b/tests/web-server-startup-order.test.ts new file mode 100644 index 0000000..c364445 --- /dev/null +++ b/tests/web-server-startup-order.test.ts @@ -0,0 +1,339 @@ +import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"; + +const GLOBAL_PLUGIN_WARMUP_KEY = Symbol.for("opencode-mem.plugin.warmedup"); +const GLOBAL_PLUGIN_WARMUP_PROMISE_KEY = Symbol.for("opencode-mem.plugin.warmupPromise"); +const originalSetTimeout = globalThis.setTimeout; + +const CONFIG = { + webServerEnabled: true, + webServerPort: 3456, + webServerHost: "127.0.0.1", + autoCaptureLanguage: "en", + autoCaptureEnabled: false, + showErrorToasts: false, + chatMessage: { + enabled: false, + injectOn: "always", + maxMemories: 10, + excludeCurrentSession: false, + }, + compaction: { + enabled: false, + memoryLimit: 10, + }, +}; + +type TestState = { + configured: boolean; + isReady: boolean; + warmupCalls: number; + resetWarmupStateCalls: number; + events: string[]; + timeoutCallbacks: Array<() => void>; + startWebServerImpl: () => Promise; + warmupImpl: () => Promise; +}; + +type MockWebServer = { + getUrl: () => string; + isServerOwner: () => boolean; + setOnTakeoverCallback: (callback: () => Promise) => void; + stop: () => Promise; +}; + +const testState: TestState = { + configured: true, + isReady: false, + warmupCalls: 0, + resetWarmupStateCalls: 0, + events: [], + timeoutCallbacks: [], + startWebServerImpl: async () => createMockWebServer(), + warmupImpl: async () => {}, +}; + +function createMockWebServer(): MockWebServer { + return { + getUrl: () => `http://${CONFIG.webServerHost}:${CONFIG.webServerPort}`, + isServerOwner: () => true, + setOnTakeoverCallback: () => {}, + stop: async () => {}, + }; +} + +function resetWarmupGlobals() { + const globalState = globalThis as Record; + delete globalState[GLOBAL_PLUGIN_WARMUP_KEY]; + delete globalState[GLOBAL_PLUGIN_WARMUP_PROMISE_KEY]; +} + +function resetTestState() { + CONFIG.webServerEnabled = true; + CONFIG.webServerPort = 3456; + CONFIG.webServerHost = "127.0.0.1"; + + testState.configured = true; + testState.isReady = false; + testState.warmupCalls = 0; + testState.resetWarmupStateCalls = 0; + testState.events = []; + testState.timeoutCallbacks = []; + testState.startWebServerImpl = async () => { + testState.events.push("server:start", "server:ready"); + return createMockWebServer(); + }; + testState.warmupImpl = async () => {}; +} + +function createPluginInput() { + return { + directory: "/tmp/opencode-mem-runtime-test", + client: { + path: { + get: async () => ({ data: {} }), + }, + provider: { + list: async () => ({ data: {} }), + }, + session: { + messages: async () => ({ data: [] }), + prompt: async () => ({}), + }, + }, + } as any; +} + +async function flushMicrotasks() { + await Promise.resolve(); + await Promise.resolve(); +} + +mock.module("@opencode-ai/plugin", () => { + const optional = () => ({ optional }); + const toolFactory = (definition: Record) => definition; + + return { + tool: Object.assign(toolFactory, { + schema: { + enum: optional, + string: optional, + number: optional, + }, + }), + }; +}); + +mock.module("../src/config.js", () => ({ + CONFIG, + initConfig: () => {}, + isConfigured: () => testState.configured, +})); + +mock.module("../src/services/client.js", () => ({ + memoryClient: { + warmup: () => { + testState.warmupCalls += 1; + testState.events.push("warmup:start"); + return testState.warmupImpl(); + }, + isReady: async () => testState.isReady, + close: () => {}, + listMemories: async () => ({ success: true, memories: [] }), + addMemory: async () => ({ success: true, id: "mem-1" }), + searchMemories: async () => ({ success: true, results: [], total: 0, timing: 0 }), + deleteMemory: async () => ({ success: true }), + searchMemoriesBySessionID: async () => ({ success: true, results: [], total: 0, timing: 0 }), + }, +})); + +mock.module("../src/services/context.js", () => ({ + formatContextForPrompt: () => "", +})); + +mock.module("../src/services/tags.js", () => ({ + getTags: () => ({ + project: { + tag: "project_tag", + displayName: "Project", + userName: "User", + userEmail: "user@example.com", + projectPath: "/tmp/opencode-mem-runtime-test", + projectName: "opencode-mem-runtime-test", + gitRepoUrl: "https://github.com/tickernelz/opencode-mem", + }, + user: { + userEmail: "user@example.com", + }, + }), +})); + +mock.module("../src/services/privacy.js", () => ({ + stripPrivateContent: (content: string) => content, + isFullyPrivate: () => false, +})); + +mock.module("../src/services/auto-capture.js", () => ({ + performAutoCapture: async () => {}, +})); + +mock.module("../src/services/user-memory-learning.js", () => ({ + performUserProfileLearning: async () => {}, +})); + +mock.module("../src/services/user-prompt/user-prompt-manager.js", () => ({ + userPromptManager: { + savePrompt: () => {}, + }, +})); + +mock.module("../src/services/web-server.js", () => ({ + startWebServer: () => testState.startWebServerImpl(), +})); + +mock.module("../src/services/embedding.js", () => ({ + embeddingService: { + resetWarmupState: () => { + testState.resetWarmupStateCalls += 1; + }, + }, +})); + +mock.module("../src/services/logger.js", () => ({ + log: () => {}, +})); + +mock.module("../src/services/language-detector.js", () => ({ + getLanguageName: () => "English", +})); + +mock.module("../src/services/ai/opencode-provider.js", () => ({ + setStatePath: () => {}, + setConnectedProviders: () => {}, +})); + +const { OpenCodeMemPlugin } = await import("../src/index.ts"); + +describe("web server startup order", () => { + let processOnSpy: ReturnType; + + beforeEach(() => { + resetTestState(); + resetWarmupGlobals(); + + globalThis.setTimeout = ((handler: TimerHandler, _timeout?: number, ...args: unknown[]) => { + testState.timeoutCallbacks.push(() => { + if (typeof handler === "function") { + handler(...args); + } + }); + return 0 as any; + }) as typeof setTimeout; + + processOnSpy = spyOn(process, "on").mockImplementation(() => process as any); + }); + + afterEach(() => { + globalThis.setTimeout = originalSetTimeout; + processOnSpy.mockRestore(); + resetWarmupGlobals(); + }); + + it("waits for web server startup to finish before background warmup begins", async () => { + let resolveServer!: (server: MockWebServer) => void; + + testState.startWebServerImpl = () => { + testState.events.push("server:start"); + return new Promise((resolve) => { + resolveServer = (server) => { + testState.events.push("server:ready"); + resolve(server); + }; + }); + }; + + const pluginPromise = OpenCodeMemPlugin(createPluginInput()); + await flushMicrotasks(); + + expect(testState.events).toEqual(["server:start"]); + expect(testState.warmupCalls).toBe(0); + + resolveServer(createMockWebServer()); + await pluginPromise; + + expect(testState.events).toEqual(["server:start", "server:ready", "warmup:start"]); + }); + + it("waits for a failed web server startup attempt before background warmup begins", async () => { + let rejectServer!: (error: Error) => void; + + testState.startWebServerImpl = () => { + testState.events.push("server:start"); + return new Promise((_resolve, reject) => { + rejectServer = (error) => { + testState.events.push("server:failed"); + reject(error); + }; + }); + }; + + const pluginPromise = OpenCodeMemPlugin(createPluginInput()); + await flushMicrotasks(); + + expect(testState.events).toEqual(["server:start"]); + expect(testState.warmupCalls).toBe(0); + + rejectServer(new Error("bind failed")); + await pluginPromise; + + expect(testState.events).toEqual(["server:start", "server:failed", "warmup:start"]); + }); + + it("keeps the memory tool non-blocking while initialization is still running", async () => { + CONFIG.webServerEnabled = false; + + testState.warmupImpl = () => new Promise(() => {}); + + const hooks = await OpenCodeMemPlugin(createPluginInput()); + expect(testState.warmupCalls).toBe(1); + + const result = await Promise.race([ + (hooks.tool as any).memory.execute({}, { sessionID: "session-1" }), + new Promise((resolve) => originalSetTimeout(() => resolve("__timeout__"), 25)), + ]); + + expect(result).not.toBe("__timeout__"); + expect(JSON.parse(String(result))).toEqual({ + success: false, + error: "Memory system is initializing.", + }); + expect(testState.warmupCalls).toBe(1); + }); + + it("allows a new warmup attempt after a timed-out warmup clears stale state", async () => { + CONFIG.webServerEnabled = false; + + let attempt = 0; + testState.warmupImpl = () => { + attempt += 1; + if (attempt === 1) { + return new Promise(() => {}); + } + return Promise.resolve(); + }; + + const hooks = await OpenCodeMemPlugin(createPluginInput()); + expect(testState.warmupCalls).toBe(1); + expect(testState.timeoutCallbacks).toHaveLength(1); + + testState.timeoutCallbacks[0]?.(); + await flushMicrotasks(); + + const result = JSON.parse( + String(await (hooks.tool as any).memory.execute({}, { sessionID: "session-1" })) + ); + + expect(result).toEqual({ success: false, error: "Memory system is initializing." }); + expect(testState.resetWarmupStateCalls).toBe(1); + expect(testState.warmupCalls).toBe(2); + }); +});