diff --git a/CHANGELOG.md b/CHANGELOG.md index b9049c6..24aaf34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.0.5] - 2026-06-11 + +### Fixed + +- **Singleton client manager recovery after dispose** - `OpencodeClientManager.dispose()` now releases the singleton slot when the disposed manager is the singleton, so a later `createOpencode()` builds a fresh client manager. Previously the singleton kept pointing at the disposed instance, and every subsequent provider in the same process failed with `Client manager has been disposed`. +- **client-options example** - Step 2 of `examples/client-options.ts` now genuinely demonstrates the preconfigured-client pattern by passing an isolated manager via `clientManager: OpencodeClientManager.createInstance({ client })`. Previously the preconfigured client was handed to the singleton that step 1 had already initialized, so it was ignored and step 2's requests flowed through step 1's client while the demo reported success. The example now also echoes each outgoing request's `x-demo-source` header so the output proves which client served it, drops a spurious `await` on the synchronous v2 `createOpencodeClient()`, and allows overriding the example model via `OPENCODE_MODEL`. + +### Changed + +- **Clearer stale-options warnings** - The client manager's "already initialized" warnings now point at the escape hatches that actually work (`OpencodeClientManager.createInstance()` with the `clientManager` provider setting, or `OpencodeClientManager.resetInstance()`) instead of the previous generic advice. + ## [3.0.4] - 2026-06-11 ### Fixed diff --git a/examples/client-options.ts b/examples/client-options.ts index e5f49f4..167b3eb 100644 --- a/examples/client-options.ts +++ b/examples/client-options.ts @@ -1,14 +1,36 @@ import { generateText } from "ai"; -import { createOpencode } from "../dist/index.js"; +import { createOpencode, OpencodeClientManager } from "../dist/index.js"; import { createOpencodeClient } from "@opencode-ai/sdk/v2"; -const MODEL = "openai/gpt-5.3-codex-spark"; +const MODEL = process.env.OPENCODE_MODEL ?? "openai/gpt-5.3-codex-spark"; const BASE_URL = "http://127.0.0.1:4096"; function formatError(error: unknown): string { return error instanceof Error ? error.message : String(error); } +// Wraps fetch to echo each outgoing request's x-demo-source header, so the +// output proves which client a request actually flowed through. +function echoingFetch(): typeof fetch { + return (input, init) => { + const headers = + input instanceof Request ? input.headers : new Headers(init?.headers); + const url = + input instanceof Request + ? input.url + : input instanceof URL + ? input.href + : String(input); + const method = + (input instanceof Request ? input.method : init?.method) ?? "GET"; + const source = headers.get("x-demo-source") ?? "(none)"; + console.log( + ` [http] ${method} ${new URL(url).pathname} x-demo-source: ${source}`, + ); + return fetch(input, init); + }; +} + async function runStep(title: string, fn: () => Promise) { console.log(title); try { @@ -33,6 +55,7 @@ async function main() { headers: { "x-demo-source": "client-options-example", }, + fetch: echoingFetch(), credentials: "include", throwOnError: true, }, @@ -46,16 +69,25 @@ async function main() { console.log(` Response: ${text}`); }); - const preconfiguredClient = await createOpencodeClient({ + // createOpencodeClient() is synchronous in @opencode-ai/sdk/v2. + const preconfiguredClient = createOpencodeClient({ baseUrl: BASE_URL, headers: { "x-demo-source": "preconfigured-client-example", }, + fetch: echoingFetch(), throwOnError: true, }); + // createOpencode({ client }) hands the client to the process-wide singleton + // client manager. Step 1 already initialized that singleton, so the client + // would be ignored (with a warning) and requests would keep flowing through + // step 1's client. An isolated manager guarantees this provider really uses + // the preconfigured client. const providerWithPreconfiguredClient = createOpencode({ - client: preconfiguredClient, + clientManager: OpencodeClientManager.createInstance({ + client: preconfiguredClient, + }), }); await runStep("2) Provider with preconfigured SDK client", async () => { diff --git a/package-lock.json b/package-lock.json index 94e938d..cae8914 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ai-sdk-provider-opencode-sdk", - "version": "3.0.4", + "version": "3.0.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ai-sdk-provider-opencode-sdk", - "version": "3.0.4", + "version": "3.0.5", "license": "MIT", "dependencies": { "@ai-sdk/provider": "^3.0.8", diff --git a/package.json b/package.json index 8e4d138..abc3994 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ai-sdk-provider-opencode-sdk", - "version": "3.0.4", + "version": "3.0.5", "description": "AI SDK v6 provider for OpenCode via @opencode-ai/sdk", "keywords": [ "ai-sdk", diff --git a/src/opencode-client-manager.test.ts b/src/opencode-client-manager.test.ts index 7e448d3..7bfdcd7 100644 --- a/src/opencode-client-manager.test.ts +++ b/src/opencode-client-manager.test.ts @@ -509,6 +509,27 @@ describe("opencode-client-manager", () => { // Attempting to get client should throw await expect(instance.getClient()).rejects.toThrow("disposed"); }); + + it("should allow a fresh singleton to be created after dispose", async () => { + const clientA = { + session: { create: vi.fn(), prompt: vi.fn(), abort: vi.fn() }, + event: { subscribe: vi.fn() }, + } as unknown as OpencodeClient; + + const clientB = { + session: { create: vi.fn(), prompt: vi.fn(), abort: vi.fn() }, + event: { subscribe: vi.fn() }, + } as unknown as OpencodeClient; + + const first = OpencodeClientManager.getInstance({ client: clientA }); + expect(await first.getClient()).toBe(clientA); + + await first.dispose(); + + const second = OpencodeClientManager.getInstance({ client: clientB }); + expect(second).not.toBe(first); + expect(await second.getClient()).toBe(clientB); + }); }); describe("createClientManager", () => { diff --git a/src/opencode-client-manager.ts b/src/opencode-client-manager.ts index 5ca3fc7..7c05455 100644 --- a/src/opencode-client-manager.ts +++ b/src/opencode-client-manager.ts @@ -133,13 +133,13 @@ export class OpencodeClientManager { filteredOptions.client !== this.client ) { this.logger.warn( - "Client manager already initialized; provided preconfigured client was ignored because a client is already active. New options from createOpencode() are ignored after initialization. Use separate client manager instances or call dispose() first.", + "Client manager already initialized; provided preconfigured client was ignored because a client is already active — requests will keep flowing through the existing client. To use this client, pass an isolated manager via createOpencode({ clientManager: OpencodeClientManager.createInstance({ client }) }), or call OpencodeClientManager.resetInstance() before creating the provider.", ); return; } this.logger.warn( - "Client manager already initialized; new options from createOpencode() were ignored. Use separate client manager instances or call dispose() first.", + "Client manager already initialized; new options from createOpencode() were ignored. Use OpencodeClientManager.createInstance() with the clientManager provider setting for isolated configuration, or call OpencodeClientManager.resetInstance() first.", ); } @@ -326,6 +326,12 @@ export class OpencodeClientManager { this.isDisposed = true; this.unregisterCleanupHandlers(); + // Release the singleton slot so a later getInstance() builds a fresh + // manager instead of returning this disposed one. + if (OpencodeClientManager.instance === this) { + OpencodeClientManager.instance = null; + } + if (this.server) { this.logger.debug?.("Stopping managed OpenCode server"); try {