refactor(server): inject opencode runtime (#868)

This commit is contained in:
Mohamed Boudra
2026-05-10 12:57:19 +08:00
committed by GitHub
parent 4165dbe8e9
commit 9f3256a254
7 changed files with 344 additions and 302 deletions

View File

@@ -13,7 +13,6 @@ const mockState = vi.hoisted(() => {
claude: [] as ConstructorEntry[],
codex: [] as ConstructorEntry[],
copilot: [] as ConstructorEntry[],
opencode: [] as ConstructorEntry[],
pi: [] as ConstructorEntry[],
genericAcp: [] as Array<{
command: string[];
@@ -26,7 +25,6 @@ const mockState = vi.hoisted(() => {
this.constructorArgs.claude = [];
this.constructorArgs.codex = [];
this.constructorArgs.copilot = [];
this.constructorArgs.opencode = [];
this.constructorArgs.pi = [];
this.constructorArgs.genericAcp = [];
this.isCommandAvailable.mockReset();
@@ -188,54 +186,6 @@ vi.mock("./providers/copilot-acp-agent.js", () => ({
},
}));
vi.mock("./providers/opencode-agent.js", () => ({
OpenCodeAgentClient: class OpenCodeAgentClient {
readonly capabilities = {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
};
readonly provider = "opencode";
readonly runtimeSettings?: unknown;
constructor(_logger: unknown, runtimeSettings?: unknown) {
this.runtimeSettings = runtimeSettings;
mockState.constructorArgs.opencode.push({ runtimeSettings });
}
async createSession(): Promise<never> {
throw new Error("not implemented");
}
async resumeSession(): Promise<never> {
throw new Error("not implemented");
}
async listModels(): Promise<AgentModelDefinition[]> {
return mockState.runtimeModels.get(this.provider) ?? [];
}
async listModes(): Promise<[]> {
return [];
}
async isAvailable(): Promise<boolean> {
return true;
}
},
}));
vi.mock("./providers/opencode/server-manager.js", () => ({
OpenCodeServerManager: {
getInstance: vi.fn(() => ({
shutdown: vi.fn(),
})),
},
}));
vi.mock("./providers/pi-direct-agent.js", () => ({
PiDirectAgentClient: class PiDirectAgentClient {
readonly capabilities = {

View File

@@ -1,73 +1,28 @@
import { afterEach, describe, expect, test, vi } from "vitest";
vi.mock("@opencode-ai/sdk/v2/client", () => ({
createOpencodeClient: vi.fn(),
}));
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client";
import { describe, expect, test } from "vitest";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import type { AgentStreamEvent } from "../agent-sdk-types.js";
import { OpenCodeAgentClient } from "./opencode-agent.js";
import { createTestOpenCodeServerManager } from "./opencode/test-server-manager.js";
import {
createEventStream,
idleEvent,
TestOpenCodeClient,
TestOpenCodeRuntime,
} from "./opencode/test-utils/test-opencode-runtime.js";
interface MockOpenCodeClientOptions {
agents?: unknown[];
events?: unknown[];
}
function createEventStream(events: unknown[]): AsyncGenerator<never> {
return (async function* () {
for (const event of events) {
yield event as never;
}
})();
}
function mockOpenCodeClient(options: MockOpenCodeClientOptions = {}) {
const promptAsync = vi.fn().mockResolvedValue({});
const permissionReply = vi.fn().mockResolvedValue({});
const questionReply = vi.fn().mockResolvedValue({});
const questionReject = vi.fn().mockResolvedValue({});
const appAgents = vi.fn().mockResolvedValue({ data: options.agents ?? [] });
const events = options.events ?? [idleEvent()];
const runtime = new TestOpenCodeRuntime();
const openCodeClient = new TestOpenCodeClient();
openCodeClient.appAgentsResponse = { data: options.agents ?? [] };
openCodeClient.eventStream = createEventStream(options.events ?? [idleEvent()]);
runtime.enqueueClient(openCodeClient);
vi.mocked(createOpencodeClient).mockReturnValue({
session: {
create: vi.fn().mockResolvedValue({ data: { id: "session-1" } }),
promptAsync,
abort: vi.fn().mockResolvedValue({}),
update: vi.fn().mockResolvedValue({}),
},
provider: {
list: vi.fn().mockResolvedValue({ data: { connected: [], all: [] } }),
},
event: {
subscribe: vi.fn().mockResolvedValue({ stream: createEventStream(events) }),
},
command: {
list: vi.fn().mockResolvedValue({ data: [] }),
},
app: {
agents: appAgents,
},
permission: {
reply: permissionReply,
},
question: {
reply: questionReply,
reject: questionReject,
},
} as never);
return { appAgents, permissionReply, promptAsync, questionReject, questionReply };
}
function idleEvent(): unknown {
return {
type: "session.idle",
properties: { sessionID: "session-1" },
};
return { openCodeClient, runtime };
}
function toolPermissionEvent(): unknown {
@@ -108,22 +63,15 @@ function questionEvent(): unknown {
}
describe("OpenCode full-access mode", () => {
afterEach(() => {
vi.restoreAllMocks();
});
test("includes virtual full-access mode with dynamic OpenCode agents", async () => {
const serverManager = createTestOpenCodeServerManager();
mockOpenCodeClient({
const { runtime } = mockOpenCodeClient({
agents: [
{ name: "build", mode: "primary", hidden: false, description: "Build agent" },
{ name: "paseo-custom", mode: "primary", hidden: false, description: "Custom agent" },
],
});
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, {
serverManager,
});
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, { runtime });
const modes = await client.listModes({ cwd: "/tmp/project", force: false });
expect(modes.map((mode) => mode.id)).toEqual(["build", "plan", "full-access", "paseo-custom"]);
@@ -134,12 +82,9 @@ describe("OpenCode full-access mode", () => {
});
test("reports full-access but sends prompts through OpenCode build agent", async () => {
const serverManager = createTestOpenCodeServerManager();
const { promptAsync } = mockOpenCodeClient();
const { openCodeClient, runtime } = mockOpenCodeClient();
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, {
serverManager,
});
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, { runtime });
const session = await client.createSession({
provider: "opencode",
cwd: "/tmp/project",
@@ -150,22 +95,21 @@ describe("OpenCode full-access mode", () => {
await session.run("Implement the change");
expect(promptAsync).toHaveBeenCalledTimes(1);
expect(promptAsync).toHaveBeenCalledWith(expect.objectContaining({ agent: "build" }));
expect(openCodeClient.calls.sessionPromptAsync).toHaveLength(1);
expect(openCodeClient.calls.sessionPromptAsync[0]).toEqual(
expect.objectContaining({ agent: "build" }),
);
await session.close();
});
test("auto-approves tool permissions in full-access without surfacing them", async () => {
const serverManager = createTestOpenCodeServerManager();
const { permissionReply } = mockOpenCodeClient({
const { openCodeClient, runtime } = mockOpenCodeClient({
events: [toolPermissionEvent(), idleEvent()],
});
const receivedEvents: AgentStreamEvent[] = [];
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, {
serverManager,
});
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, { runtime });
const session = await client.createSession({
provider: "opencode",
cwd: "/tmp/project",
@@ -175,8 +119,8 @@ describe("OpenCode full-access mode", () => {
await session.run("Run verification");
expect(permissionReply).toHaveBeenCalledTimes(1);
expect(permissionReply).toHaveBeenCalledWith({
expect(openCodeClient.calls.permissionReply).toHaveLength(1);
expect(openCodeClient.calls.permissionReply[0]).toEqual({
requestID: "permission-1",
directory: "/tmp/project",
reply: "once",
@@ -188,15 +132,12 @@ describe("OpenCode full-access mode", () => {
});
test("keeps questions separate from full-access tool auto-approval", async () => {
const serverManager = createTestOpenCodeServerManager();
const { permissionReply, questionReply } = mockOpenCodeClient({
const { openCodeClient, runtime } = mockOpenCodeClient({
events: [questionEvent(), idleEvent()],
});
const receivedEvents: AgentStreamEvent[] = [];
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, {
serverManager,
});
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, { runtime });
const session = await client.createSession({
provider: "opencode",
cwd: "/tmp/project",
@@ -221,13 +162,13 @@ describe("OpenCode full-access mode", () => {
updatedInput: { answers: { Decision: "Proceed" } },
});
expect(questionReply).toHaveBeenCalledTimes(1);
expect(questionReply).toHaveBeenCalledWith({
expect(openCodeClient.calls.questionReply).toHaveLength(1);
expect(openCodeClient.calls.questionReply[0]).toEqual({
requestID: "question-1",
directory: "/tmp/project",
answers: [["Proceed"]],
});
expect(permissionReply).not.toHaveBeenCalled();
expect(openCodeClient.calls.permissionReply).toEqual([]);
expect(session.getPendingPermissions()).toEqual([]);
await session.close();

View File

@@ -1,25 +1,23 @@
import { afterEach, expect, test, vi } from "vitest";
vi.mock("@opencode-ai/sdk/v2/client", () => ({
createOpencodeClient: vi.fn(),
}));
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { OpenCodeAgentClient } from "./opencode-agent.js";
import { createTestOpenCodeServerManager } from "./opencode/test-server-manager.js";
import {
TestOpenCodeClient,
TestOpenCodeRuntime,
} from "./opencode/test-utils/test-opencode-runtime.js";
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
test("allows a slow provider.list call to succeed instead of failing after 10 seconds", async () => {
vi.useFakeTimers();
async function providerList(): Promise<unknown> {
return new Promise((resolve) => {
const runtime = new TestOpenCodeRuntime();
const openCodeClient = new TestOpenCodeClient();
openCodeClient.providerListImplementation = () =>
new Promise((resolve) => {
setTimeout(() => {
resolve({
data: {
@@ -40,18 +38,9 @@ test("allows a slow provider.list call to succeed instead of failing after 10 se
});
}, 15_000);
});
}
runtime.enqueueClient(openCodeClient);
vi.mocked(createOpencodeClient).mockReturnValue({
provider: {
list: providerList,
},
} as never);
const serverManager = createTestOpenCodeServerManager();
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, {
serverManager,
});
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, { runtime });
const modelsPromise = client.listModels({ cwd: "/tmp/opencode-models", force: false });
await vi.advanceTimersByTimeAsync(15_000);
@@ -63,26 +52,23 @@ test("allows a slow provider.list call to succeed instead of failing after 10 se
label: "GLM 5.1",
},
]);
expect(openCodeClient.calls.providerList).toHaveLength(1);
});
test("passes explicit refresh force through server acquisition", async () => {
vi.mocked(createOpencodeClient).mockReturnValue({
provider: {
list: async () => ({
data: {
connected: ["openai"],
all: [{ id: "openai", name: "OpenAI", models: {} }],
},
}),
const runtime = new TestOpenCodeRuntime();
const openCodeClient = new TestOpenCodeClient();
openCodeClient.providerListResponse = {
data: {
connected: ["openai"],
all: [{ id: "openai", name: "OpenAI", models: {} }],
},
} as never);
const serverManager = createTestOpenCodeServerManager();
};
runtime.enqueueClient(openCodeClient);
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, {
serverManager,
});
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, { runtime });
await client.listModels({ cwd: "/tmp/opencode-models", force: true });
expect(serverManager.acquisitions).toEqual([{ force: true, released: true }]);
expect(runtime.acquisitions).toEqual([{ force: true, releaseCount: 1 }]);
});

View File

@@ -1,14 +1,11 @@
import { afterEach, describe, expect, test, vi } from "vitest";
vi.mock("@opencode-ai/sdk/v2/client", () => ({
createOpencodeClient: vi.fn(),
}));
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client";
import { describe, expect, test } from "vitest";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { OpenCodeAgentClient } from "./opencode-agent.js";
import { createTestOpenCodeServerManager } from "./opencode/test-server-manager.js";
import {
TestOpenCodeClient,
TestOpenCodeRuntime,
} from "./opencode/test-utils/test-opencode-runtime.js";
function createDeferred<T>(): {
promise: Promise<T>;
@@ -25,34 +22,12 @@ function createDeferred<T>(): {
}
describe("OpenCodeAgentSession slash command timeout handling", () => {
afterEach(() => {
vi.restoreAllMocks();
});
test("lists only OpenCode built-in slash commands Paseo can execute", async () => {
vi.mocked(createOpencodeClient).mockReturnValue({
session: {
create: vi.fn().mockResolvedValue({ data: { id: "session-1" } }),
},
provider: {
list: vi.fn().mockResolvedValue({
data: {
connected: ["openai"],
all: [{ id: "openai", name: "OpenAI", models: {} }],
},
}),
},
command: {
list: vi.fn().mockResolvedValue({ data: [] }),
},
app: {
agents: vi.fn().mockResolvedValue({ data: [] }),
},
} as never);
const runtime = new TestOpenCodeRuntime();
const openCodeClient = createOpenCodeClientWithConnectedProvider();
runtime.enqueueClient(openCodeClient);
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, {
serverManager: createTestOpenCodeServerManager(),
});
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, { runtime });
const session = await client.createSession({ provider: "opencode", cwd: "/tmp" });
await expect(session.listCommands?.()).resolves.toEqual(
@@ -68,44 +43,11 @@ describe("OpenCodeAgentSession slash command timeout handling", () => {
});
test("executes compact through the OpenCode summarize endpoint", async () => {
const command = vi.fn();
const summarize = vi.fn().mockResolvedValue({ data: {} });
const runtime = new TestOpenCodeRuntime();
const openCodeClient = createOpenCodeClientWithConnectedProvider();
runtime.enqueueClient(openCodeClient);
vi.mocked(createOpencodeClient).mockReturnValue({
session: {
create: vi.fn().mockResolvedValue({ data: { id: "session-1" } }),
command,
summarize,
},
provider: {
list: vi.fn().mockResolvedValue({
data: {
connected: ["openai"],
all: [{ id: "openai", name: "OpenAI", models: {} }],
},
}),
},
event: {
subscribe: vi.fn().mockResolvedValue({
stream: (async function* () {
yield {
type: "session.idle",
properties: { sessionID: "session-1" },
};
})(),
}),
},
command: {
list: vi.fn().mockResolvedValue({ data: [] }),
},
app: {
agents: vi.fn().mockResolvedValue({ data: [] }),
},
} as never);
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, {
serverManager: createTestOpenCodeServerManager(),
});
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, { runtime });
const session = await client.createSession({ provider: "opencode", cwd: "/tmp" });
await expect(session.run("/compact")).resolves.toMatchObject({
@@ -114,50 +56,30 @@ describe("OpenCodeAgentSession slash command timeout handling", () => {
timeline: [],
usage: undefined,
});
expect(summarize).toHaveBeenCalledWith({ sessionID: "session-1", directory: "/tmp" });
expect(command).not.toHaveBeenCalled();
expect(openCodeClient.calls.sessionSummarize).toEqual([
{ sessionID: "session-1", directory: "/tmp" },
]);
expect(openCodeClient.calls.sessionCommand).toEqual([]);
});
test("waits for SSE completion when slash commands hit a header timeout", async () => {
const idleEventGate = createDeferred<void>();
const runtime = new TestOpenCodeRuntime();
const openCodeClient = createOpenCodeClientWithConnectedProvider();
openCodeClient.sessionCommandError = new Error("fetch failed: Headers Timeout Error");
openCodeClient.commandListResponse = {
data: [{ name: "help", description: "Show help", hints: [] }],
};
openCodeClient.eventStream = (async function* () {
await idleEventGate.promise;
yield {
type: "session.idle",
properties: { sessionID: "session-1" },
};
})();
runtime.enqueueClient(openCodeClient);
vi.mocked(createOpencodeClient).mockReturnValue({
session: {
create: vi.fn().mockResolvedValue({ data: { id: "session-1" } }),
command: vi.fn().mockRejectedValue(new Error("fetch failed: Headers Timeout Error")),
},
provider: {
list: vi.fn().mockResolvedValue({
data: {
connected: ["openai"],
all: [{ id: "openai", name: "OpenAI", models: {} }],
},
}),
},
event: {
subscribe: vi.fn().mockResolvedValue({
stream: (async function* () {
await idleEventGate.promise;
yield {
type: "session.idle",
properties: { sessionID: "session-1" },
};
})(),
}),
},
command: {
list: vi.fn().mockResolvedValue({
data: [{ name: "help", description: "Show help", hints: [] }],
}),
},
app: {
agents: vi.fn().mockResolvedValue({ data: [] }),
},
} as never);
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, {
serverManager: createTestOpenCodeServerManager(),
});
const client = new OpenCodeAgentClient(createTestLogger(), undefined, undefined, { runtime });
const session = await client.createSession({ provider: "opencode", cwd: "/tmp" });
const runPromise = session.run("/help");
@@ -172,3 +94,14 @@ describe("OpenCodeAgentSession slash command timeout handling", () => {
});
});
});
function createOpenCodeClientWithConnectedProvider(): TestOpenCodeClient {
const openCodeClient = new TestOpenCodeClient();
openCodeClient.providerListResponse = {
data: {
connected: ["openai"],
all: [{ id: "openai", name: "OpenAI", models: {} }],
},
};
return openCodeClient;
}

View File

@@ -2,7 +2,6 @@ import { readdir, readFile } from "node:fs/promises";
import { homedir } from "node:os";
import path from "node:path";
import {
createOpencodeClient,
type AssistantMessage as OpenCodeAssistantMessage,
type Event as OpenCodeEvent,
type FilePartInput as OpenCodeFilePartInput,
@@ -47,10 +46,7 @@ import { withTimeout } from "../../../utils/promise-timeout.js";
import { execCommand } from "../../../utils/spawn.js";
import { buildToolCallDisplayModel } from "../../../shared/tool-call-display.js";
import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js";
import {
OpenCodeServerManager,
type OpenCodeServerManagerLike,
} from "./opencode/server-manager.js";
import { OpenCodeServerManager } from "./opencode/server-manager.js";
import {
formatDiagnosticStatus,
formatProviderDiagnostic,
@@ -60,6 +56,11 @@ import {
} from "./diagnostic-utils.js";
import { runProviderTurn } from "./provider-runner.js";
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
import {
createSdkOpenCodeClient,
type OpenCodeRuntime,
type OpenCodeServerAcquisition,
} from "./opencode/runtime.js";
const OPENCODE_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: true,
@@ -898,14 +899,34 @@ export const __openCodeInternals = {
};
interface OpenCodeAgentClientDeps {
serverManager?: OpenCodeServerManagerLike;
runtime?: OpenCodeRuntime;
}
class ProductionOpenCodeRuntime implements OpenCodeRuntime {
constructor(private readonly serverManager: OpenCodeServerManager) {}
async acquireServer(options: { force: boolean }): Promise<OpenCodeServerAcquisition> {
return this.serverManager.acquire(options);
}
async ensureServerRunning(): Promise<{ port: number; url: string }> {
return this.serverManager.ensureRunning();
}
createClient(options: { baseUrl: string; directory: string }): OpencodeClient {
return createSdkOpenCodeClient(options);
}
async shutdown(): Promise<void> {
await this.serverManager.shutdown();
}
}
export class OpenCodeAgentClient implements AgentClient {
readonly provider = "opencode" as const;
readonly capabilities = OPENCODE_CAPABILITIES;
private readonly serverManager: OpenCodeServerManagerLike;
private readonly runtime: OpenCodeRuntime;
private readonly logger: Logger;
private readonly runtimeSettings?: ProviderRuntimeSettings;
private readonly modelContextWindows = new Map<string, number>();
@@ -920,8 +941,11 @@ export class OpenCodeAgentClient implements AgentClient {
this.logger = logger.child({ module: "agent", provider: "opencode" });
this.runtimeSettings = runtimeSettings;
this.storageRoot = storageRoot ?? resolveOpenCodeStorageRoot();
this.serverManager =
deps.serverManager ?? OpenCodeServerManager.getInstance(this.logger, runtimeSettings);
this.runtime =
deps.runtime ??
new ProductionOpenCodeRuntime(
OpenCodeServerManager.getInstance(this.logger, runtimeSettings),
);
}
async createSession(
@@ -930,9 +954,9 @@ export class OpenCodeAgentClient implements AgentClient {
options?: AgentCreateSessionOptions,
): Promise<AgentSession> {
const openCodeConfig = this.assertConfig(config);
const acquisition = await this.serverManager.acquire({ force: false });
const acquisition = await this.runtime.acquireServer({ force: false });
const { url } = acquisition.server;
const client = createOpencodeClient({
const client = this.runtime.createClient({
baseUrl: url,
directory: openCodeConfig.cwd,
});
@@ -986,9 +1010,9 @@ export class OpenCodeAgentClient implements AgentClient {
...overrides,
};
const openCodeConfig = this.assertConfig(config);
const acquisition = await this.serverManager.acquire({ force: false });
const acquisition = await this.runtime.acquireServer({ force: false });
const { url } = acquisition.server;
const client = createOpencodeClient({
const client = this.runtime.createClient({
baseUrl: url,
directory: openCodeConfig.cwd,
});
@@ -1011,9 +1035,9 @@ export class OpenCodeAgentClient implements AgentClient {
}
async listModels(options: ListModelsOptions): Promise<AgentModelDefinition[]> {
const acquisition = await this.serverManager.acquire({ force: options.force });
const acquisition = await this.runtime.acquireServer({ force: options.force });
const { url } = acquisition.server;
const client = createOpencodeClient({
const client = this.runtime.createClient({
baseUrl: url,
directory: options.cwd,
});
@@ -1074,10 +1098,10 @@ export class OpenCodeAgentClient implements AgentClient {
}
async listModes(options: ListModesOptions): Promise<AgentMode[]> {
const acquisition = await this.serverManager.acquire({ force: options.force });
const acquisition = await this.runtime.acquireServer({ force: options.force });
const { url } = acquisition.server;
const directory = options.cwd;
const client = createOpencodeClient({ baseUrl: url, directory });
const client = this.runtime.createClient({ baseUrl: url, directory });
try {
const response = await withTimeout(
@@ -1128,7 +1152,7 @@ export class OpenCodeAgentClient implements AgentClient {
let status = formatDiagnosticStatus(available);
try {
const { url } = await this.serverManager.ensureRunning();
const { url } = await this.runtime.ensureServerRunning();
serverStatus = `Running (${url})`;
} catch (error) {
serverStatus = `Unavailable (${toDiagnosticErrorMessage(error)})`;

View File

@@ -0,0 +1,24 @@
import {
createOpencodeClient,
type OpencodeClient,
type OpencodeClientConfig,
} from "@opencode-ai/sdk/v2/client";
export interface OpenCodeServerAcquisition {
server: { port: number; url: string };
release: () => void;
}
export interface OpenCodeRuntime {
acquireServer(options: { force: boolean }): Promise<OpenCodeServerAcquisition>;
ensureServerRunning(): Promise<{ port: number; url: string }>;
createClient(options: { baseUrl: string; directory: string }): OpencodeClient;
shutdown(): Promise<void>;
}
export function createSdkOpenCodeClient(options: {
baseUrl: string;
directory: string;
}): OpencodeClient {
return createOpencodeClient(options satisfies OpencodeClientConfig & { directory: string });
}

View File

@@ -0,0 +1,184 @@
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client";
import type { OpenCodeRuntime, OpenCodeServerAcquisition } from "../runtime.js";
interface OpenCodeResponse {
data?: unknown;
error?: unknown;
}
export class TestOpenCodeRuntime implements OpenCodeRuntime {
readonly acquisitions: Array<{ force: boolean; releaseCount: number }> = [];
readonly clientCreations: Array<{ baseUrl: string; directory: string }> = [];
private readonly clients: TestOpenCodeClient[] = [];
server = { port: 1234, url: "http://127.0.0.1:1234" };
enqueueClient(client: TestOpenCodeClient): void {
this.clients.push(client);
}
async acquireServer(options: { force: boolean }): Promise<OpenCodeServerAcquisition> {
const acquisition = { force: options.force, releaseCount: 0 };
this.acquisitions.push(acquisition);
return {
server: this.server,
release: () => {
acquisition.releaseCount += 1;
},
};
}
async ensureServerRunning(): Promise<{ port: number; url: string }> {
return this.server;
}
createClient(options: { baseUrl: string; directory: string }): OpencodeClient {
this.clientCreations.push(options);
const client = this.clients.shift() ?? new TestOpenCodeClient();
return client.asSdkClient();
}
async shutdown(): Promise<void> {}
}
export class TestOpenCodeClient {
readonly calls = {
appAgents: [] as unknown[],
commandList: [] as unknown[],
eventSubscribe: [] as unknown[],
permissionReply: [] as unknown[],
providerList: [] as unknown[],
questionReject: [] as unknown[],
questionReply: [] as unknown[],
sessionAbort: [] as unknown[],
sessionCommand: [] as unknown[],
sessionCreate: [] as unknown[],
sessionDelete: [] as unknown[],
sessionMessages: [] as unknown[],
sessionPromptAsync: [] as unknown[],
sessionSummarize: [] as unknown[],
sessionUpdate: [] as unknown[],
};
appAgentsResponse: OpenCodeResponse = { data: [] };
commandListResponse: OpenCodeResponse = { data: [] };
eventStream: AsyncIterable<unknown> = createEventStream([idleEvent()]);
permissionReplyResponse: OpenCodeResponse = {};
providerListResponse: OpenCodeResponse = { data: { connected: [], all: [] } };
providerListImplementation: (() => Promise<OpenCodeResponse>) | null = null;
questionRejectResponse: OpenCodeResponse = {};
questionReplyResponse: OpenCodeResponse = {};
sessionAbortResponse: OpenCodeResponse = {};
sessionCommandError: unknown = null;
sessionCommandResponse: OpenCodeResponse = {};
sessionCreateResponse: OpenCodeResponse = { data: { id: "session-1" } };
sessionDeleteResponse: OpenCodeResponse = {};
sessionMessagesResponse: OpenCodeResponse = { data: [] };
sessionPromptAsyncResponse: OpenCodeResponse = {};
sessionSummarizeResponse: OpenCodeResponse = { data: {} };
sessionUpdateResponse: OpenCodeResponse = {};
asSdkClient(): OpencodeClient {
return {
app: {
agents: async (parameters: unknown) => {
this.calls.appAgents.push(parameters);
return this.appAgentsResponse;
},
},
command: {
list: async (parameters: unknown) => {
this.calls.commandList.push(parameters);
return this.commandListResponse;
},
},
event: {
subscribe: async (parameters: unknown, options: unknown) => {
this.calls.eventSubscribe.push({ parameters, options });
return { stream: this.eventStream };
},
},
mcp: {
add: async () => ({}),
connect: async () => ({}),
},
permission: {
reply: async (parameters: unknown) => {
this.calls.permissionReply.push(parameters);
return this.permissionReplyResponse;
},
},
provider: {
list: async (parameters: unknown) => {
this.calls.providerList.push(parameters);
return this.providerListImplementation
? await this.providerListImplementation()
: this.providerListResponse;
},
},
question: {
reject: async (parameters: unknown) => {
this.calls.questionReject.push(parameters);
return this.questionRejectResponse;
},
reply: async (parameters: unknown) => {
this.calls.questionReply.push(parameters);
return this.questionReplyResponse;
},
},
session: {
abort: async (parameters: unknown) => {
this.calls.sessionAbort.push(parameters);
return this.sessionAbortResponse;
},
command: async (parameters: unknown) => {
this.calls.sessionCommand.push(parameters);
if (this.sessionCommandError) {
throw this.sessionCommandError;
}
return this.sessionCommandResponse;
},
create: async (parameters: unknown) => {
this.calls.sessionCreate.push(parameters);
return this.sessionCreateResponse;
},
delete: async (parameters: unknown) => {
this.calls.sessionDelete.push(parameters);
return this.sessionDeleteResponse;
},
messages: async (parameters: unknown) => {
this.calls.sessionMessages.push(parameters);
return this.sessionMessagesResponse;
},
promptAsync: async (parameters: unknown) => {
this.calls.sessionPromptAsync.push(parameters);
return this.sessionPromptAsyncResponse;
},
summarize: async (parameters: unknown) => {
this.calls.sessionSummarize.push(parameters);
return this.sessionSummarizeResponse;
},
update: async (parameters: unknown) => {
this.calls.sessionUpdate.push(parameters);
return this.sessionUpdateResponse;
},
},
} as unknown as OpencodeClient;
}
}
export function createEventStream(events: unknown[]): AsyncGenerator<unknown> {
return (async function* () {
for (const event of events) {
yield event;
}
})();
}
export function idleEvent(): unknown {
return {
type: "session.idle",
properties: { sessionID: "session-1" },
};
}