Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed

- **Startup no longer blocks on embedding warmup or score recalculation** — the plugin loads immediately and warms up in the background.

### Fixed

- **Auto-capture no longer strands prompts in captured=2 state when capture is skipped after claiming** — early returns now release the claim for the next idle cycle.
- **Plugin now implements the opencode `dispose` hook** — all timers, jobs, the web server, and sqlite connections are cleaned up when the host disposes or reloads the plugin.
- **Warmup timeout race no longer triggers an unhandled promise rejection.**
- **Auto-capture and profile learning now wait for opencode provider state instead of racing it at startup.**
- **The forget tool now reports actual deletion failures instead of always claiming success.**

## [2.23.1] - 2026-09-07

### Fixed
Expand Down
77 changes: 49 additions & 28 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import type { UserProfileData } from "./services/user-profile/types.js";
import type { SearchResult } from "./services/sqlite/types.js";
import { getLanguageName } from "./services/language-detector.js";
import type { MemoryScope } from "./services/client.js";
import { setProviderStateInit } from "./services/ai/opencode-provider.js";

async function showToast(
ctx: PluginInput,
Expand Down Expand Up @@ -128,25 +129,35 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => {
const GLOBAL_PLUGIN_WARMUP_KEY = Symbol.for("opencode-mem0.plugin.warmedup");

if (!(globalThis as any)[GLOBAL_PLUGIN_WARMUP_KEY] && isConfigured()) {
try {
const timeoutMs = CONFIG.warmupTimeoutMs ?? 30000;
await Promise.race([
memoryClient.warmup(),
new Promise<void>((_, reject) =>
setTimeout(() => reject(new Error(`Warmup timed out after ${timeoutMs}ms`)), timeoutMs)
),
]);
(globalThis as any)[GLOBAL_PLUGIN_WARMUP_KEY] = true;
} catch (error) {
log("Plugin warmup failed", { error: String(error) });
if (error instanceof Error && error.message.includes("timed out")) {
embeddingService.embeddingAvailable = false;
embeddingService.isWarmedUp = true;
log(
"Embedding model warmup timed out — marking embeddings unavailable. Searches will use text-only fallback."
);
void (async () => {
try {
const timeoutMs = CONFIG.warmupTimeoutMs ?? 30000;
let timeoutId: ReturnType<typeof setTimeout> | undefined;
try {
await Promise.race([
memoryClient.warmup(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Prevent the first chat hook from waiting on warmup

On a cold local-embedding startup, the factory can now expose chat.message while this warmup is still pending. That hook calls searchMemories(), whose embedWithTimeout() awaits the in-progress embeddingService.warmup() before reaching signal-aware work, so its AbortController cannot interrupt model initialization. The first user prompt can therefore stall until the model load eventually finishes—potentially beyond warmupTimeoutMs—rather than using the prior bounded startup degradation path.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified and fixed in #66 (c78740b): the warmup wait inside embed() is now raced against warmupTimeoutMs and the caller's AbortSignal; it rejects AbortError-shaped so an already-running model load isn't re-triggered and the service isn't permanently disabled. searchMemories degrades to text-only for that prompt instead of stalling it. Tests: tests/warmup-bound.test.ts.

new Promise<void>((_, reject) => {
timeoutId = setTimeout(
() => reject(new Error(`Warmup timed out after ${timeoutMs}ms`)),
timeoutMs
);
}),
]);
} finally {
clearTimeout(timeoutId);
}
(globalThis as any)[GLOBAL_PLUGIN_WARMUP_KEY] = true;
} catch (error) {
log("Plugin warmup failed", { error: String(error) });
if (error instanceof Error && error.message.includes("timed out")) {
embeddingService.embeddingAvailable = false;
embeddingService.isWarmedUp = true;
log(
"Embedding model warmup timed out — marking embeddings unavailable. Searches will use text-only fallback."
);
}
}
}
})();
}

// Notify when a newer release exists (OpenCode pins plugin versions in its
Expand Down Expand Up @@ -178,9 +189,9 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => {
}
}

// 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
(async () => {
// Wire opencode state path and provider list — fire-and-forget to avoid blocking init.
// Callers await ensureProviderState() before getStatePath().
const providerStateReady = (async () => {
try {
const { setStatePath, setConnectedProviders } =
await import("./services/ai/opencode-provider.js");
Expand All @@ -196,6 +207,7 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => {
log("Failed to initialize opencode provider state", { error: String(error) });
}
})();
setProviderStateInit(providerStateReady);

if (isConfigured() && CONFIG.webServerEnabled) {
startWebServer({
Expand Down Expand Up @@ -247,12 +259,13 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => {
// Start background memory scoring recalculation
if (isConfigured() && CONFIG.memoryScoring.enabled) {
startScoringRecalculation();
// Run one-time recalculation on startup to ensure existing memories are scored
try {
recalculateAllScores(true);
} catch (error) {
log("Initial scoring recalculation failed", { error: String(error) });
}
void Promise.resolve().then(() => {
try {
recalculateAllScores(true);
Comment on lines +262 to +264

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Score recalculation still blocks startup

Promise.resolve().then runs synchronous recalculateAllScores before callers resume from plugin initialization. Large stores still block the host during startup.

Prompt for agents
Move initial score recalculation off the plugin-initialization microtask path. Schedule it on a later event-loop turn or use an asynchronous or worker-based implementation so the host can receive and use the plugin first. Integrate the scheduled job with disposal so it cannot start or continue after shutdown.
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified and fixed in #66 (f20a844): the scan now runs on a setTimeout(0) macrotask (cleared on dispose), so the host's await of the factory resolves before the shard scan executes — the DX comment was right that the microtask ran first.

Comment on lines +262 to +264

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Defer the initial score scan beyond factory resolution

recalculateAllScores() is synchronous and can scan every memory shard. Queueing it in a promise microtask does not make startup non-blocking: that microtask is already queued before the async plugin factory resolves to its awaiting caller, so it runs before the host can continue after await OpenCodeMemPlugin(...). With scoring enabled, large stores therefore still block plugin startup despite the new background-startup behavior and changelog entry.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in #66 (f20a844): setTimeout(0) macrotask instead of the microtask — the host resumes first, changelog claim now true.

} catch (error) {
log("Initial scoring recalculation failed", { error: String(error) });
}
});
}

// Start memory lifecycle job (STM/LTM decay, promotion, archiving)
Expand All @@ -271,6 +284,10 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => {
const shutdownHandler = async () => {
delete (globalThis as any)[Symbol.for("opencode-mem0.shutdown")];
try {
for (const timer of sessionIdleTimers.values()) {
clearTimeout(timer);
}
sessionIdleTimers.clear();
Comment on lines +287 to +290

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Running idle work survives disposal

Once an idle callback starts, clearTimeout cannot cancel it. It continues captures and maintenance after memoryClient.close(), allowing disposed instances to write or reopen databases.

Prompt for agents
Add lifecycle cancellation for in-flight idle processing in src/index.ts, not only pending timeout handles. Track a disposed flag or AbortController per plugin instance. Check cancellation between awaited idle stages and before every post-disposal database operation. Make shutdown wait for active idle work to settle before closing memoryClient, or abort that work and then close connections.
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partially addressed in #66 (f20a844): the event handler gains an entry checkpoint and the capture/learning pipelines check again after their first await, so no NEW work starts post-dispose. Fully cancelling in-flight work mid-await (AbortController plumbing through capture) was left out — entry checkpoints plus the bounded provider-state wait cover the wedge scenarios; a full cancellation system is the follow-up if disposal-during-capture proves hot.

stopScoringRecalculation();
stopLifecycleJob();
clearInterval(sessionCleanupTimer);
Expand Down Expand Up @@ -596,7 +613,10 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => {
if (!args.memoryId)
return JSON.stringify({ success: false, error: "memoryId required" });
const delRes = await memoryClient.deleteMemory(args.memoryId);
return JSON.stringify({ success: delRes.success, message: "Memory removed" });
return JSON.stringify({
success: delRes.success,
message: delRes.success ? "Memory removed" : delRes.error || "Memory removal failed",
});
}

try {
Expand Down Expand Up @@ -634,6 +654,7 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => {
await handleSessionCompacted(event, ctx, directory);
}
},
dispose: shutdownHandler,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Late web server survives disposal

When disposal precedes startWebServer completion, shutdownHandler sees no server to stop. The completion later installs a live server owned by the disposed plugin.

Prompt for agents
Coordinate asynchronous web-server startup with plugin disposal in src/index.ts. Track a disposed state or the startup promise. If disposal happens before startWebServer resolves, stop the returned server immediately and suppress its callbacks and toasts. Ensure shutdown also waits for or safely settles pending startup without allowing a disposed instance to become owner.
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified and fixed in #66 (f20a844): the startWebServer().then handler now checks the disposed flag — a server resolving after disposal is stopped immediately and never assigned. Regression-tested in tests/disposal-lifecycle.test.ts.

};
};

Expand Down
10 changes: 10 additions & 0 deletions src/services/ai/opencode-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,16 @@ type FetchInput = string | Request | URL;
let _statePath: string | null = null;
let _connectedProviders: string[] = [];

let providerStateInit: Promise<void> = Promise.resolve();

export function setProviderStateInit(promise: Promise<void>): void {
providerStateInit = promise;
}

export async function ensureProviderState(): Promise<void> {
await providerStateInit;
}

export function setStatePath(path: string): void {
_statePath = path;
}
Expand Down
3 changes: 3 additions & 0 deletions src/services/auto-capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,9 +131,12 @@ export async function performAutoCapture(
isCapturing = true;
let claimedPromptId: string | null = null;
try {
const { ensureProviderState } = await import("./ai/opencode-provider.js");
await ensureProviderState();
Comment on lines +134 to +135

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified and fixed in #66 (f20a844): ensureProviderState() is now bounded (10s warn-and-proceed), so a hung host bootstrap can no longer wedge the capture mutex; a disposed-plugin checkpoint also backs out before claiming a prompt.

Comment on lines +134 to +135

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Do not gate manual capture on host provider state

If ctx.client.path.get() or provider.list() stalls during host bootstrap, providerStateReady never settles because its initialization has no timeout. A configuration using only memoryModel/memoryApiUrl does not need OpenCode state, but this unconditional await leaves isCapturing true forever; every later idle cycle then returns at the mutex check and automatic capture stops for the rest of the process. Limit this wait to the OpenCode-provider path (and apply the same distinction to profile learning) or bound the state initialization.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in #66 (f20a844) — bound applied to the OpenCode-provider path only where needed (all paths, via the shared 10s race), plus the disposed guard before claiming.

const prompt = userPromptManager.getLastUncapturedPrompt(sessionID);
if (!prompt) return;
if (!userPromptManager.claimPrompt(prompt.id)) return;
claimedPromptId = prompt.id;
const maxRetries = CONFIG.autoCaptureMaxRetries ?? 3;
const existingAttempts = userPromptManager.getCaptureAttempts(prompt.id);

Expand Down
3 changes: 2 additions & 1 deletion src/services/memory-conflicts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,11 @@ const verdictViaOpencode = async (
): Promise<"yes" | "no" | null> => {
if (!CONFIG.opencodeProvider || !CONFIG.opencodeModel) return null;

const { isProviderConnected, getStatePath, generateStructuredOutput } =
const { isProviderConnected, getStatePath, generateStructuredOutput, ensureProviderState } =
await import("./ai/opencode-provider.js");

if (!isProviderConnected(CONFIG.opencodeProvider)) return null;
await ensureProviderState();

const schema = z.object({
contradicts: z.enum(["YES", "NO"]),
Expand Down
2 changes: 2 additions & 0 deletions src/services/user-memory-learning.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ export async function performUserProfileLearning(

isLearningRunning = true;
try {
const { ensureProviderState } = await import("./ai/opencode-provider.js");
await ensureProviderState();
Comment on lines +104 to +105

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified and fixed in #66 (f20a844) — the bounded ensureProviderState() covers this path (10s warn-and-proceed), plus a post-await disposed check before profile learning proceeds.

const threshold = CONFIG.userProfileAnalysisInterval;
const maxBatches = CONFIG.userProfileMaxBatchesPerIdle;

Expand Down
56 changes: 56 additions & 0 deletions tests/auto-capture.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const mockGetLanguageName = vi.fn().mockReturnValue("English");
const mockIsProviderConnected = vi.fn().mockReturnValue(true);
const mockGetStatePath = vi.fn().mockReturnValue("/some/path");
const mockGenerateStructuredOutput = vi.fn();
const mockEnsureProviderState = vi.fn().mockResolvedValue(undefined);

vi.mock("../src/services/tags.js", () => ({
getTags: (...args: any[]) => mockGetTags(...args),
Expand Down Expand Up @@ -65,6 +66,7 @@ vi.mock("../src/services/ai/opencode-provider.js", () => ({
isProviderConnected: (...args: unknown[]) => mockIsProviderConnected(...args),
getStatePath: (...args: unknown[]) => mockGetStatePath(...args),
generateStructuredOutput: (...args: unknown[]) => mockGenerateStructuredOutput(...args),
ensureProviderState: (...args: unknown[]) => mockEnsureProviderState(...args),
}));

vi.mock("../src/services/language-detector.js", () => ({
Expand Down Expand Up @@ -132,6 +134,7 @@ describe("auto-capture helpers", () => {
mockIsProviderConnected.mockReset().mockReturnValue(true);
mockGetStatePath.mockReset().mockReturnValue("/some/path");
mockGenerateStructuredOutput.mockReset();
mockEnsureProviderState.mockReset().mockResolvedValue(undefined);
});

it("acquires mutex and prevents concurrent capture calls", async () => {
Expand Down Expand Up @@ -329,6 +332,59 @@ describe("auto-capture helpers", () => {
expect(mockMemoryClient.addMemory).not.toHaveBeenCalled();
});

it("releases claim on early return after claimPrompt", async () => {
const prompt = { id: "p1", messageId: "m1", content: "test" };
let capturedState = 0;
mockUserPromptManager.getLastUncapturedPrompt.mockImplementation(() =>
capturedState === 0 ? prompt : null
);
mockUserPromptManager.claimPrompt.mockImplementation(() => {
if (capturedState !== 0) return false;
capturedState = 2;
return true;
});
mockUserPromptManager.resetPromptClaim.mockImplementation(() => {
if (capturedState === 2) capturedState = 0;
});
const ctx = {
client: {
session: { messages: () => ({ data: undefined }) },
},
} as any;

await performAutoCapture(ctx, "sess-1", "/test");
expect(mockUserPromptManager.resetPromptClaim).toHaveBeenCalledWith("p1");

await performAutoCapture(ctx, "sess-1", "/test");
expect(mockUserPromptManager.getLastUncapturedPrompt).toHaveBeenCalledTimes(2);
expect(mockUserPromptManager.claimPrompt).toHaveBeenCalledTimes(2);
expect(mockUserPromptManager.getLastUncapturedPrompt).toHaveNthReturnedWith(2, prompt);
});

it("waits for provider state before capturing", async () => {
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
mockEnsureProviderState.mockReturnValue(gate);
mockUserPromptManager.getLastUncapturedPrompt.mockReturnValue({
id: "p1",
messageId: "m1",
content: "test",
});
mockUserPromptManager.claimPrompt.mockReturnValue(true);
const messages = vi.fn().mockResolvedValue({ data: undefined });
const ctx = { client: { session: { messages } } } as any;

const pending = performAutoCapture(ctx, "sess-1", "/test");
await Promise.resolve();
expect(messages).not.toHaveBeenCalled();

release();
await expect(pending).resolves.toBeUndefined();
expect(messages).toHaveBeenCalled();
});

it("returns early when AI response has only tool calls with no text", async () => {
mockUserPromptManager.getLastUncapturedPrompt.mockReturnValue({
id: "p1",
Expand Down
2 changes: 2 additions & 0 deletions tests/chat-message-mode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ vi.mock("../src/services/logger.js", () => ({
vi.mock("../src/services/ai/opencode-provider.js", () => ({
setStatePath: vi.fn(),
setConnectedProviders: vi.fn(),
setProviderStateInit: vi.fn(),
ensureProviderState: () => Promise.resolve(),
}));

vi.mock("../src/services/language-detector.js", () => ({
Expand Down
1 change: 1 addition & 0 deletions tests/memory-conflicts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ vi.mock("../src/services/ai/opencode-provider.js", () => ({
isProviderConnected: vi.fn().mockReturnValue(false),
getStatePath: vi.fn().mockReturnValue("/tmp/state.json"),
generateStructuredOutput: vi.fn(),
ensureProviderState: () => Promise.resolve(),
}));

vi.mock("../src/services/ai/ai-provider-factory.js", () => ({
Expand Down
82 changes: 82 additions & 0 deletions tests/plugin-error-handling.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ vi.mock("../src/services/web-server.js", () => ({
vi.mock("../src/services/ai/opencode-provider.js", () => ({
setStatePath: vi.fn(),
setConnectedProviders: vi.fn(),
setProviderStateInit: vi.fn(),
ensureProviderState: () => Promise.resolve(),
}));

vi.mock("../src/services/auto-capture.js", () => ({
Expand Down Expand Up @@ -199,4 +201,84 @@ describe("OpenCodeMemPlugin error handling", () => {
expect(toastErrors.length).toBeGreaterThanOrEqual(1);
expect(toastErrors[0].data?.error).toContain("Takeover toast failed");
});

it("warmup timeout race no longer triggers an unhandled promise rejection", async () => {
vi.useFakeTimers();
const rejections: unknown[] = [];
const onUnhandled = (reason: unknown) => {
rejections.push(reason);
};
process.on("unhandledRejection", onUnhandled);
const warmupKey = Symbol.for("opencode-mem0.plugin.warmedup");
delete (globalThis as Record<symbol, unknown>)[warmupKey];
const timeoutMs = 50;
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout");
try {
const { memoryClient } = await import("../src/services/client.js");
const { CONFIG } = await import("../src/config.js");
(CONFIG as { warmupTimeoutMs: number }).warmupTimeoutMs = timeoutMs;
(memoryClient.warmup as ReturnType<typeof vi.fn>).mockImplementation(
() => new Promise<void>((resolve) => setTimeout(() => resolve(), 1))
);

const mockCtx = {
directory: "/test",
client: {
session: { prompt: vi.fn().mockResolvedValue({ success: true }) },
tui: { showToast: vi.fn().mockResolvedValue(undefined) },
path: { get: vi.fn().mockResolvedValue({ data: { state: "/test/.opencode" } }) },
provider: { list: vi.fn().mockResolvedValue({ data: { connected: [] } }) },
},
};

const pluginPromise = OpenCodeMemPlugin(mockCtx as never);
await Promise.resolve();
const timeoutCallIndex = setTimeoutSpy.mock.calls.findIndex((call) => call[1] === timeoutMs);
expect(timeoutCallIndex).toBeGreaterThanOrEqual(0);
const timeoutId = setTimeoutSpy.mock.results[timeoutCallIndex]?.value;

await vi.advanceTimersByTimeAsync(1);
await pluginPromise;
await vi.advanceTimersByTimeAsync(timeoutMs + 50);
await Promise.resolve();

expect(clearTimeoutSpy).toHaveBeenCalledWith(timeoutId);
expect(rejections).toEqual([]);
} finally {
process.off("unhandledRejection", onUnhandled);
setTimeoutSpy.mockRestore();
clearTimeoutSpy.mockRestore();
vi.useRealTimers();
(globalThis as Record<symbol, unknown>)[warmupKey] = true;
}
});

it("factory returns without awaiting warmup", async () => {
const warmupKey = Symbol.for("opencode-mem0.plugin.warmedup");
delete (globalThis as Record<symbol, unknown>)[warmupKey];
const { memoryClient } = await import("../src/services/client.js");
const { CONFIG } = await import("../src/config.js");
(CONFIG as { warmupTimeoutMs: number }).warmupTimeoutMs = 30000;
(memoryClient.warmup as ReturnType<typeof vi.fn>).mockReturnValue(new Promise(() => {}));

const mockCtx = {
directory: "/test",
client: {
session: { prompt: vi.fn().mockResolvedValue({ success: true }) },
tui: { showToast: vi.fn().mockResolvedValue(undefined) },
path: { get: vi.fn().mockResolvedValue({ data: { state: "/test/.opencode" } }) },
provider: { list: vi.fn().mockResolvedValue({ data: { connected: [] } }) },
},
};

const plugin = await Promise.race([
OpenCodeMemPlugin(mockCtx as never),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("factory blocked on warmup")), 200)
),
]);
expect(typeof plugin.event).toBe("function");
(globalThis as Record<symbol, unknown>)[warmupKey] = true;
});
});
Loading
Loading