diff --git a/src/index.ts b/src/index.ts index 950a2fe5..24cc4715 100644 --- a/src/index.ts +++ b/src/index.ts @@ -311,6 +311,7 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => { // Aborted on plugin dispose: queued-but-not-started auto-capture jobs are // skipped; in-flight work keeps its original (uncancellable) semantics. const pluginLifetime = new AbortController(); + let cleanedUp = false; const GLOBAL_PLUGIN_WARMUP_KEY = Symbol.for("opencode-mem.plugin.warmedup"); @@ -328,7 +329,11 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => { await configureOpencodeHostTransport(ctx); - (async () => { + // Re-reads the provider directory into the connectivity snapshot. Errors + // are logged and the previous snapshot retained. The host registers + // user-config providers after plugin setup (provider.updated/model.updated), + // so this runs again on those events and on connectivity-gate misses. + const refreshConnectedProviders = async (): Promise => { try { const providerResult = await ctx.client.provider.list(); if (providerResult.data?.connected) { @@ -344,9 +349,46 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => { }); } } catch (error) { - log("Failed to initialize opencode provider state", { error: String(error) }); + log("Failed to refresh opencode provider state", { error: String(error) }); } - })(); + }; + + // Coalesce refresh triggers into one in-flight pass plus trailing drain. + // Event handlers and ensureProviderConnected (gate miss) share this path so + // concurrent list() calls collapse; callers can await the same promise. + let refreshInFlight: Promise | undefined; + let refreshQueued = false; + const runCoalescedRefresh = (): Promise => { + refreshQueued = true; + if (refreshInFlight) return refreshInFlight; + + refreshInFlight = (async () => { + while (refreshQueued && !cleanedUp) { + refreshQueued = false; + await refreshConnectedProviders(); + } + })().finally(() => { + refreshInFlight = undefined; + if (refreshQueued && !cleanedUp) { + void runCoalescedRefresh(); + } + }); + return refreshInFlight; + }; + + void runCoalescedRefresh(); + + // Gate miss awaits the same coalesced refresh; cleared on dispose so no + // refresh fires afterwards. Only clear our own refresher identity. + const { setConnectedProvidersRefresher, clearConnectedProvidersRefresher } = + await loadOpencodeProvider(); + const providerRefresher = async () => { + await runCoalescedRefresh(); + }; + setConnectedProvidersRefresher(providerRefresher); + const clearProviderRefreshWiring = () => { + clearConnectedProvidersRefresher(providerRefresher); + }; let tursoReadyForWeb = !isConfigured(); if (CONFIG.webServerEnabled && isConfigured()) { @@ -471,10 +513,10 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => { }); } - let cleanedUp = false; const cleanupPlugin = async () => { if (cleanedUp) return; cleanedUp = true; + clearProviderRefreshWiring(); pluginLifetime.abort(); for (const timer of idleTimers.values()) clearTimeout(timer); idleTimers.clear(); @@ -1076,6 +1118,13 @@ export const OpenCodeMemPlugin: Plugin = async (ctx: PluginInput) => { event: async (input: { event: { type: string; properties?: any } }) => { const event = input.event; + // Late host provider registration: user-config providers can appear + // (or disappear) after plugin setup; refresh the connectivity snapshot. + if (event.type === "provider.updated" || event.type === "model.updated") { + if (!cleanedUp) runCoalescedRefresh(); + return; + } + // Client-side step watchdog for internal structured-output sessions (#278). // OpenCode's agent.steps soft-cap does not hard-stop json_schema loops when // forced StructuredOutput keeps failing (e.g. opencode-claude-auth). diff --git a/src/services/ai/opencode-provider.ts b/src/services/ai/opencode-provider.ts index d777d94f..296dd253 100644 --- a/src/services/ai/opencode-provider.ts +++ b/src/services/ai/opencode-provider.ts @@ -88,6 +88,9 @@ let _v2Client: OpencodeClient | undefined; let _v2BaseUrl: string | undefined; let _hostFetch: typeof fetch | undefined; let _useSdkTransport = false; +// Registered by the plugin init so a gate miss can force one provider-directory +// refresh before failing (late host provider registration). +let _connectedProvidersRefresher: (() => Promise) | undefined; export function setHostFetch(customFetch: typeof fetch): void { _hostFetch = customFetch; @@ -105,6 +108,34 @@ export function isProviderConnected(providerName: string): boolean { return _connectedProviders.has(providerName); } +/** Register (or clear) the refresh callback used by ensureProviderConnected. */ +export function setConnectedProvidersRefresher(refresher?: () => Promise): void { + _connectedProvidersRefresher = refresher; +} + +/** Clear the refresh callback only if it is still the one being disposed. */ +export function clearConnectedProvidersRefresher(refresher: () => Promise): void { + if (_connectedProvidersRefresher === refresher) _connectedProvidersRefresher = undefined; +} + +/** + * Refresh-on-miss for the connectivity gate: when a provider id is missing + * from the snapshot and a refresher is registered, await one refresh and + * re-check. Returns the re-checked connectivity; never refreshes twice for + * the same call and never throws (refresh errors are logged by the refresher). + */ +export async function ensureProviderConnected(providerName: string): Promise { + if (_connectedProviders.has(providerName)) return true; + const refresher = _connectedProvidersRefresher; + if (!refresher) return false; + try { + await refresher(); + } catch { + // The refresher logs; a failed refresh keeps the previous set. + } + return _connectedProviders.has(providerName); +} + export function setV2Client(client: OpencodeClient): void { _v2Client = client; // Native v2 adapters pass a session-capable client without a server URL. diff --git a/src/services/ai/profile-llm-client.ts b/src/services/ai/profile-llm-client.ts index 300a6ba1..e4131533 100644 --- a/src/services/ai/profile-llm-client.ts +++ b/src/services/ai/profile-llm-client.ts @@ -18,9 +18,11 @@ export async function getOpenCodeClient(): Promise { return _cachedClient; } - const { isProviderConnected, getV2Client } = await loadOpencodeProvider(); + const { ensureProviderConnected, getV2Client } = await loadOpencodeProvider(); - if (!isProviderConnected(provider)) { + // One refresh-on-miss before failing: user-config providers register + // seconds after plugin setup, so the init snapshot may lag the host. + if (!(await ensureProviderConnected(provider))) { throw new Error( `opencode provider '${provider}' is not connected. Check your opencode provider configuration.` ); diff --git a/src/services/auto-capture.ts b/src/services/auto-capture.ts index 2e090cc8..c0dce2d7 100644 --- a/src/services/auto-capture.ts +++ b/src/services/auto-capture.ts @@ -529,7 +529,7 @@ async function generateSummary( log("opencodeProvider takes precedence over memoryModel for auto-capture"); } - const { isProviderConnected, getV2Client, generateStructuredOutput } = + const { ensureProviderConnected, getV2Client, generateStructuredOutput } = await loadOpencodeProvider(); // "inherit" resolves to the model opencode used for the captured prompt @@ -547,7 +547,9 @@ async function generateSummary( modelID = prompt.modelId; } - if (!isProviderConnected(providerID)) { + // One refresh-on-miss before failing: user-config providers register + // seconds after plugin setup, so the init snapshot may lag the host. + if (!(await ensureProviderConnected(providerID))) { throw new Error( `opencode provider '${providerID}' is not connected. Check your opencode provider configuration.` ); diff --git a/src/v2/legacy-client.ts b/src/v2/legacy-client.ts index 4cf35ce0..587d8d5e 100644 --- a/src/v2/legacy-client.ts +++ b/src/v2/legacy-client.ts @@ -283,16 +283,25 @@ export async function eventBelongsToLocation(ctx: Context, raw: any): Promise { llmEnteredResolve = res; }); const llmOrder = []; mock.module(${JSON.stringify(u("src/services/ai/opencode-provider-loader.js"))}, () => ({ loadOpencodeProvider: async () => ({ - isProviderConnected: () => true, + ensureProviderConnected: async () => true, getV2Client: () => ({}), generateStructuredOutput: async ({ userPrompt }) => { const sid = userPrompt.includes("Fix login bug") ? "sess-A" : "sess-B"; @@ -425,7 +425,7 @@ mock.module(${JSON.stringify(u("src/services/tags.js"))}, () => ({ })); mock.module(${JSON.stringify(u("src/services/ai/opencode-provider-loader.js"))}, () => ({ loadOpencodeProvider: async () => ({ - isProviderConnected: () => true, + ensureProviderConnected: async () => true, getV2Client: () => ({}), generateStructuredOutput: async ({ userPrompt }) => { const sid = userPrompt.includes("Do work") ? "sess-X" : "?"; @@ -501,7 +501,7 @@ mock.module(${JSON.stringify(u("src/services/tags.js"))}, () => ({ })); mock.module(${JSON.stringify(u("src/services/ai/opencode-provider-loader.js"))}, () => ({ loadOpencodeProvider: async () => ({ - isProviderConnected: () => true, + ensureProviderConnected: async () => true, getV2Client: () => ({}), generateStructuredOutput: async ({ userPrompt }) => { if (userPrompt.includes("Will fail")) throw new Error("llm exploded"); @@ -607,7 +607,7 @@ let releaseA; const gateA = new Promise((res) => { releaseA = res; }); mock.module(${JSON.stringify(u("src/services/ai/opencode-provider-loader.js"))}, () => ({ loadOpencodeProvider: async () => ({ - isProviderConnected: () => true, + ensureProviderConnected: async () => true, getV2Client: () => ({}), generateStructuredOutput: async ({ userPrompt }) => { const sid = userPrompt.includes("In flight") ? "sess-A" : "sess-B"; diff --git a/tests/auto-capture.test.ts b/tests/auto-capture.test.ts index 80c5e364..d229b1a2 100644 --- a/tests/auto-capture.test.ts +++ b/tests/auto-capture.test.ts @@ -157,7 +157,7 @@ mock.module(${JSON.stringify(languageUrl)}, () => ({ })); mock.module(${JSON.stringify(opencodeProviderLoaderUrl)}, () => ({ loadOpencodeProvider: async () => ({ - isProviderConnected: () => true, + ensureProviderConnected: async () => true, getV2Client: () => ({}), generateStructuredOutput: async ({ userPrompt }) => { summaryPrompts.push(userPrompt); @@ -286,7 +286,7 @@ mock.module(${JSON.stringify(languageUrl)}, () => ({ })); mock.module(${JSON.stringify(opencodeProviderLoaderUrl)}, () => ({ loadOpencodeProvider: async () => ({ - isProviderConnected: () => true, + ensureProviderConnected: async () => true, getV2Client: () => ({}), generateStructuredOutput: async () => { throw new Error( diff --git a/tests/fixtures/v2-provider-refresh.mjs b/tests/fixtures/v2-provider-refresh.mjs new file mode 100644 index 00000000..e5459937 --- /dev/null +++ b/tests/fixtures/v2-provider-refresh.mjs @@ -0,0 +1,340 @@ +/** + * Provider-connectivity refresh scenarios (late provider registration). + * + * Root cause being regression-guarded: at plugin setup the provider + * directory often lists only the built-in broker ('opencode'); user-config + * providers (e.g. 'newapi') register a few seconds later and the host emits + * provider.updated / model.updated. The plugin must refresh its + * connectivity snapshot on those events (and on gate misses) instead of + * trusting the init-time snapshot forever. + * + * Runs the real plugin (src/index.js), the real v2 adapter/legacy-client + * event path, and the real opencode-provider module state. Only peripheral + * services are mocked. Spawned by tests/v2-provider-refresh.test.ts with + * HOME/XDG redirected to an isolated sandbox; scenario via argv[2]. + */ +import { mock } from "bun:test"; + +const scenario = process.argv[2] ?? "bootstrap"; +const moduleUrl = (path) => new URL("../../src/" + path + ".js", import.meta.url).href; + +// Directory fixture state, per scenario: +// - bootstrap/burst/dispose/miss: call #1 sees only the broker, later calls +// also include newapi (registered ~2s after setup, like the real host). +// - capture-error: newapi never appears (miss refresh must still fail). +// - refresh-error: newapi is present from call #1, but every refresh +// (call >= 2) throws — the previous set must be retained. +const newapiFromCall = + scenario === "refresh-error" ? 1 : scenario === "capture-error" ? Infinity : 2; +const failFromCall = scenario === "refresh-error" ? 2 : 0; +const listDelayMs = scenario === "burst" ? 25 : 0; + +let listCalls = 0; +const logs = []; +const toasts = []; +let failedAttempts = 0; +let released = false; +let claimed = false; + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function listProviders() { + listCalls += 1; + if (failFromCall && listCalls >= failFromCall) { + throw new Error("provider directory unavailable"); + } + if (listDelayMs) await sleep(listDelayMs); + const data = [ + { id: "opencode", activation: "enabled", name: "OpenCode", package: "opencode-pkg" }, + ]; + if (listCalls >= newapiFromCall) { + data.push({ id: "newapi", activation: "enabled", name: "NewAPI", package: "newapi-pkg" }); + } + return { location: { directory: "/workspace/project" }, data }; +} + +async function poll(predicate, ms = 4000) { + const deadline = Date.now() + ms; + while (Date.now() < deadline) { + if (predicate()) return true; + await sleep(10); + } + return predicate(); +} + +mock.module(moduleUrl("config"), () => ({ + CONFIG: { + autoCaptureEnabled: false, + autoCaptureProviderStatus: { ready: true, mode: "opencode", issues: [] }, + autoCaptureMaxRetries: 1, + autoCaptureLanguage: "en", + opencodeProvider: "newapi", + opencodeModel: "test-model", + showAutoCaptureToasts: false, + showErrorToasts: true, + chatMessage: { enabled: false }, + compaction: { enabled: false }, + webServerEnabled: false, + autoUpdate: { enabled: false }, + storagePath: process.env.HOME, + }, + initConfig() {}, + isConfigured: () => true, +})); + +mock.module(moduleUrl("services/client"), () => ({ + memoryClient: { + warmup: async () => {}, + close: async () => {}, + listMemories: async () => ({ success: true, memories: [] }), + addMemory: async () => ({ success: true, id: "mem-unexpected" }), + }, +})); + +mock.module(moduleUrl("services/tags"), () => ({ + getTags: () => ({ + project: { tag: "fixture-project", projectPath: "/workspace/project" }, + user: { userEmail: "fixture-user" }, + }), +})); + +mock.module(moduleUrl("services/context"), () => ({ + formatContextForPrompt: async () => "", +})); + +const promptRows = [ + { + id: "prompt-1", + sessionId: "session-1", + messageId: "msg-1", + projectPath: "/workspace/project", + content: "Implement the provider gate fix", + createdAt: 1, + captured: false, + capture_attempts: 0, + }, +]; + +mock.module(moduleUrl("services/user-prompt/user-prompt-manager"), () => ({ + userPromptManager: { + savePrompt: async () => {}, + setPromptModel: async () => {}, + getUncapturedPromptsForSession: async () => (claimed ? [] : promptRows), + claimPrompt: async () => { + if (claimed) return false; + claimed = true; + return true; + }, + recordFailedAttempt: async () => { + failedAttempts += 1; + }, + releaseClaim: async () => { + released = true; + claimed = false; + return true; + }, + linkMemoryToPrompt: async () => {}, + markAsCaptured: async () => {}, + deletePrompt: async () => {}, + }, +})); + +mock.module(moduleUrl("services/logger"), () => ({ + log: (...args) => + logs.push(args.map((a) => (typeof a === "string" ? a : JSON.stringify(a))).join(" ")), +})); + +mock.module(moduleUrl("services/web-server"), () => ({ + startWebServer: async () => null, + WebServer: class {}, +})); + +mock.module(moduleUrl("services/auto-update"), () => ({ startAutoUpdate() {} })); + +const { OpenCodeMemPlugin } = await import(moduleUrl("index")); +const { registerV2Adapter } = await import(moduleUrl("v2/adapter")); +const { createLegacyClient } = await import(moduleUrl("v2/legacy-client")); +const opencodeProvider = await import(moduleUrl("services/ai/opencode-provider")); + +const fakeCtx = { + location: { directory: "/workspace/project", project: { directory: "/workspace/project" } }, + provider: { list: listProviders }, + model: { + list: async () => { + throw new Error("model.list must not be called for provider connectivity"); + }, + }, +}; +const client = createLegacyClient(fakeCtx); +const plugin = await OpenCodeMemPlugin({ directory: "/workspace/project", client }); + +/** Real adapter wiring; events stay gated until init has settled. */ +function makeAdapter(events) { + let releaseGate; + const gate = new Promise((resolve) => (releaseGate = resolve)); + let resolveDrained; + const drained = new Promise((resolve) => (resolveDrained = resolve)); + const ctx = { + location: { + directory: "/workspace/project", + project: { id: "project", directory: "/workspace/project", canonical: "project" }, + }, + tool: { transform: async () => {} }, + session: { hook: async () => {} }, + event: { + async *subscribe({ signal }) { + await gate; + for (const event of events) yield event; + resolveDrained(); + await new Promise((resolve) => + signal.addEventListener("abort", () => resolve(), { once: true }) + ); + }, + }, + }; + return { ctx, releaseGate, drained }; +} + +const output = {}; + +if (scenario === "bootstrap") { + const adapter = makeAdapter([ + { type: "provider.updated", location: { directory: "/workspace/project" }, data: {} }, + ]); + const cleanup = await registerV2Adapter(adapter.ctx, plugin); + const initSettled = await poll(() => opencodeProvider.isProviderConnected("opencode")); + output.initSettled = initSettled; + output.initListCalls = listCalls; + output.afterInitOnlyOpencode = + initSettled && !opencodeProvider.isProviderConnected("newapi") && listCalls === 1; + adapter.releaseGate(); + await adapter.drained; + output.afterEventNewapi = await poll(() => opencodeProvider.isProviderConnected("newapi")); + output.eventListCalls = listCalls; + await cleanup(); + await plugin.dispose(); +} else if (scenario === "miss") { + const initSettled = await poll(() => opencodeProvider.isProviderConnected("opencode")); + output.initSettled = initSettled; + output.callsAfterInit = listCalls; + output.missNewapi = (await opencodeProvider.ensureProviderConnected("newapi")) === true; + output.callsAfterMiss = listCalls; + output.missGhost = (await opencodeProvider.ensureProviderConnected("ghost")) === false; + output.finalCalls = listCalls; + await plugin.dispose(); +} else if (scenario === "capture-error") { + const initSettled = await poll(() => opencodeProvider.isProviderConnected("opencode")); + const { performAutoCapture } = await import(moduleUrl("services/auto-capture")); + await performAutoCapture( + { + client: { + session: { + messages: async () => ({ + data: [ + { + info: { id: "msg-1", role: "user" }, + parts: [{ type: "text", text: "Implement the provider gate fix" }], + }, + { + info: { id: "asst-1", role: "assistant" }, + parts: [{ type: "text", text: "Implemented it" }], + }, + ], + }), + }, + tui: { + showToast: async (toast) => { + toasts.push(toast); + return {}; + }, + }, + }, + }, + "session-1", + "/workspace/project" + ); + output.initSettled = initSettled; + output.toastMessage = toasts[0]?.body?.message ?? null; + output.failedAttempts = failedAttempts; + output.released = released; + output.refreshCalls = listCalls - 1; + await plugin.dispose(); +} else if (scenario === "refresh-error") { + const adapter = makeAdapter([ + { type: "provider.updated", location: { directory: "/workspace/project" }, data: {} }, + ]); + const cleanup = await registerV2Adapter(adapter.ctx, plugin); + output.initSettled = await poll( + () => + opencodeProvider.isProviderConnected("opencode") && + opencodeProvider.isProviderConnected("newapi") + ); + adapter.releaseGate(); + await adapter.drained; + output.refreshAttempted = await poll(() => listCalls >= 2); + await sleep(50); + output.newapiStillConnected = opencodeProvider.isProviderConnected("newapi"); + output.errorLogged = logs.some((l) => l.includes("Failed to refresh opencode provider state")); + output.listCalls = listCalls; + await cleanup(); + await plugin.dispose(); +} else if (scenario === "burst") { + const events = []; + for (let i = 0; i < 12; i++) { + // Location-bearing (and one payload-envelope) events so the V2 adapter's + // eventBelongsToLocation filter forwards the whole burst into legacy.event. + events.push( + i % 2 + ? { + type: "model.updated", + location: { directory: "/workspace/project" }, + data: {}, + } + : { + type: "provider.updated", + location: { directory: "/workspace/project" }, + data: {}, + } + ); + } + events[11] = { + payload: { + type: "provider.updated", + location: { directory: "/workspace/project" }, + data: {}, + }, + }; + const adapter = makeAdapter(events); + const cleanup = await registerV2Adapter(adapter.ctx, plugin); + output.initSettled = await poll( + () => opencodeProvider.isProviderConnected("opencode") && listCalls === 1 + ); + adapter.releaseGate(); + await adapter.drained; + output.refreshed = await poll(() => opencodeProvider.isProviderConnected("newapi")); + await sleep(120); + output.listCalls = listCalls; + await cleanup(); + await plugin.dispose(); +} else if (scenario === "dispose") { + const adapter = makeAdapter([]); + const cleanup = await registerV2Adapter(adapter.ctx, plugin); + output.initSettled = await poll(() => opencodeProvider.isProviderConnected("opencode")); + output.callsBeforeDispose = listCalls; + // Unblock the (empty) event stream so the adapter watcher can finish. + adapter.releaseGate(); + await adapter.drained; + await cleanup(); + await plugin.event({ event: { type: "provider.updated", properties: {} } }); + await sleep(120); + output.callsAfterDisposeEvent = listCalls; + output.ghostFalseAfterDispose = + (await opencodeProvider.ensureProviderConnected("ghost")) === false; + output.opencodeStillConnected = opencodeProvider.isProviderConnected("opencode"); + await plugin.dispose(); +} else { + console.error("unknown scenario: " + scenario); + process.exit(64); +} + +console.log(JSON.stringify(output)); diff --git a/tests/opencode-provider.test.ts b/tests/opencode-provider.test.ts index ad85686b..449cf2c7 100644 --- a/tests/opencode-provider.test.ts +++ b/tests/opencode-provider.test.ts @@ -1,7 +1,9 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import { z } from "zod"; import { + clearConnectedProvidersRefresher, createV2Client, + ensureProviderConnected, generateStructuredOutput, getV2Client, isInternalStructuredSession, @@ -10,6 +12,7 @@ import { resetHostFetch, resetInternalStructuredSessions, setConnectedProviders, + setConnectedProvidersRefresher, setHostFetch, setStructuredOutputTimeoutMsForTests, setV2Client, @@ -77,6 +80,7 @@ function installFetchMock(responder: (call: FetchCall) => { status?: number; bod describe("connected providers state", () => { afterEach(() => { setConnectedProviders([]); + setConnectedProvidersRefresher(undefined); }); it("setConnectedProviders + isProviderConnected reflect known providers", () => { @@ -92,6 +96,45 @@ describe("connected providers state", () => { expect(isProviderConnected("anthropic")).toBe(false); expect(isProviderConnected("openai")).toBe(true); }); + + it("ensureProviderConnected refreshes once on miss and skips on hit", async () => { + let refreshCalls = 0; + setConnectedProviders(["opencode"]); + setConnectedProvidersRefresher(async () => { + refreshCalls += 1; + setConnectedProviders(["opencode", "newapi"]); + }); + + expect(await ensureProviderConnected("opencode")).toBe(true); + expect(refreshCalls).toBe(0); + + expect(await ensureProviderConnected("newapi")).toBe(true); + expect(refreshCalls).toBe(1); + expect(await ensureProviderConnected("ghost")).toBe(false); + expect(refreshCalls).toBe(2); + }); + + it("clearConnectedProvidersRefresher only clears matching identity", async () => { + let calls = 0; + const refresher = async () => { + calls += 1; + setConnectedProviders(["opencode", "newapi"]); + }; + const other = async () => { + calls += 10; + }; + setConnectedProviders(["opencode"]); + setConnectedProvidersRefresher(refresher); + + clearConnectedProvidersRefresher(other); + expect(await ensureProviderConnected("newapi")).toBe(true); + expect(calls).toBe(1); + + clearConnectedProvidersRefresher(refresher); + setConnectedProviders(["opencode"]); + expect(await ensureProviderConnected("newapi")).toBe(false); + expect(calls).toBe(1); + }); }); describe("v2 client cache", () => { diff --git a/tests/v2-legacy-client.test.ts b/tests/v2-legacy-client.test.ts index 54d8b18e..2821d115 100644 --- a/tests/v2-legacy-client.test.ts +++ b/tests/v2-legacy-client.test.ts @@ -176,6 +176,27 @@ describe("OpenCode v2 legacy client bridge", () => { ).toBe(true); }); + it("forwards process-scoped provider/model inventory events without location", async () => { + const ctx = createContext(); + expect(await eventBelongsToLocation(ctx, { type: "provider.updated", data: {} })).toBe(true); + expect(await eventBelongsToLocation(ctx, { type: "model.updated", data: {} })).toBe(true); + expect( + await eventBelongsToLocation(ctx, { + payload: { type: "provider.updated", data: {} }, + }) + ).toBe(true); + // Still location-scoped when the host does attach a directory. + expect( + await eventBelongsToLocation(ctx, { + type: "provider.updated", + location: { directory: "/workspace/other" }, + data: {}, + }) + ).toBe(false); + // Unrelated events without location/session stay filtered out. + expect(await eventBelongsToLocation(ctx, { type: "session.idle", data: {} })).toBe(false); + }); + it("normalizes legacy tool results", () => { expect(legacyToolResult("ok")).toEqual({ content: "ok" }); expect(legacyToolResult({ output: "done", metadata: { count: 1 } })).toEqual({ diff --git a/tests/v2-provider-refresh.test.ts b/tests/v2-provider-refresh.test.ts new file mode 100644 index 00000000..b4837062 --- /dev/null +++ b/tests/v2-provider-refresh.test.ts @@ -0,0 +1,123 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +const fixturePath = fileURLToPath(new URL("./fixtures/v2-provider-refresh.mjs", import.meta.url)); + +/** + * Spawns the fixture in an isolated process (real src/index.js plugin, real + * v2 adapter → toLegacyEvent → legacy.event path, real opencode-provider + * module state) with HOME/XDG redirected under the OS temp sandbox so no + * real auth, memory DB, or log file is touched. + */ +function runScenario(scenario: string) { + const dir = mkdtempSync(join(tmpdir(), "opencode-mem-provider-refresh-")); + tempDirs.push(dir); + const result = Bun.spawnSync({ + cmd: [process.execPath, fixturePath, scenario], + stdout: "pipe", + stderr: "pipe", + env: { + ...process.env, + HOME: dir, + XDG_CONFIG_HOME: join(dir, ".config"), + XDG_DATA_HOME: join(dir, ".local", "share"), + XDG_STATE_HOME: join(dir, ".local", "state"), + XDG_CACHE_HOME: join(dir, ".cache"), + OPENCODE_MEM_LOG_FILE: join(dir, "opencode-mem.log"), + }, + }); + const stdout = Buffer.from(result.stdout).toString("utf8").trim(); + return { + exitCode: result.exitCode, + stderr: Buffer.from(result.stderr).toString("utf8").trim(), + parsed: stdout ? JSON.parse(stdout) : null, + }; +} + +describe("provider connectivity snapshot refresh (late provider registration)", () => { + it("bootstraps with only 'opencode', then refreshes through the real event path", () => { + const result = runScenario("bootstrap"); + + expect([result.exitCode, result.stderr]).toEqual([0, ""]); + expect(result.parsed?.initSettled).toBe(true); + expect(result.parsed?.afterInitOnlyOpencode).toBe(true); + // After provider.updated is delivered via ctx.event.subscribe → adapter + // → toLegacyEvent → legacy.event, the snapshot includes newapi. + expect(result.parsed?.afterEventNewapi).toBe(true); + expect(result.parsed?.eventListCalls).toBeGreaterThanOrEqual(2); + }, 30_000); + + it("refresh-on-miss: one refresh resolves a late provider; ghosts stay false", () => { + const result = runScenario("miss"); + + expect([result.exitCode, result.stderr]).toEqual([0, ""]); + expect(result.parsed?.initSettled).toBe(true); + // fixture encodes missGhost = (ensure("ghost") === false); true means the + // ghost really stayed disconnected after its refresh. + expect(result.parsed?.missGhost).toBe(true); + expect(result.parsed?.missNewapi).toBe(true); + // one refresh per miss: call #2 for newapi, call #3 for the ghost + expect(result.parsed?.callsAfterMiss).toBe(2); + expect(result.parsed?.finalCalls).toBe(3); + }, 30_000); + + it("capture path throws the existing 'not connected' error after a failed miss refresh", () => { + const result = runScenario("capture-error"); + + expect([result.exitCode, result.stderr]).toEqual([0, ""]); + // The toast is the 100-char-truncated "Summary generation failed: + // "; the underlying error text must be unchanged. + expect(result.parsed?.toastMessage).toContain("Summary generation failed:"); + expect(result.parsed?.toastMessage).toContain( + "opencode provider 'newapi' is not connected. Check your opencode provider" + ); + expect(result.parsed?.failedAttempts).toBe(1); + expect(result.parsed?.released).toBe(true); + expect(result.parsed?.refreshCalls).toBe(1); + }, 30_000); + + it("keeps the previous set when a refresh errors; the event handler never throws", () => { + const result = runScenario("refresh-error"); + + expect([result.exitCode, result.stderr]).toEqual([0, ""]); + expect(result.parsed?.initSettled).toBe(true); + expect(result.parsed?.refreshAttempted).toBe(true); + expect(result.parsed?.newapiStillConnected).toBe(true); + expect(result.parsed?.errorLogged).toBe(true); + expect(result.parsed?.listCalls).toBeGreaterThanOrEqual(2); + }, 30_000); + + it("coalesces a burst of provider/model events into a bounded number of refreshes", () => { + const result = runScenario("burst"); + + expect([result.exitCode, result.stderr]).toEqual([0, ""]); + expect(result.parsed?.refreshed).toBe(true); + // 12 location-bearing provider/model events must coalesce: init list plus + // at most a small number of trailing drains (not one list per event). + expect(result.parsed?.listCalls).toBeLessThanOrEqual(4); + expect(result.parsed?.listCalls).toBeGreaterThanOrEqual(2); + }, 30_000); + + it("no refresh after dispose", () => { + const result = runScenario("dispose"); + + expect([result.exitCode, result.stderr]).toEqual([0, ""]); + expect(result.parsed?.callsBeforeDispose).toBe(1); + expect(result.parsed?.callsAfterDisposeEvent).toBe(1); + // ensureProviderConnected without a registered refresher must stay a + // pure set lookup and not throw after dispose. + expect(result.parsed?.ghostFalseAfterDispose).toBe(true); + expect(result.parsed?.opencodeStillConnected).toBe(true); + }, 30_000); +});