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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 36 additions & 4 deletions examples/client-options.ts
Original file line number Diff line number Diff line change
@@ -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<void>) {
console.log(title);
try {
Expand All @@ -33,6 +55,7 @@ async function main() {
headers: {
"x-demo-source": "client-options-example",
},
fetch: echoingFetch(),
credentials: "include",
throwOnError: true,
},
Expand All @@ -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 () => {
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
21 changes: 21 additions & 0 deletions src/opencode-client-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
10 changes: 8 additions & 2 deletions src/opencode-client-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
);
}

Expand Down Expand Up @@ -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 {
Expand Down
Loading