diff --git a/packages/server/src/server/agent/provider-registry.test.ts b/packages/server/src/server/agent/provider-registry.test.ts index 49df98ac5..e07411d9f 100644 --- a/packages/server/src/server/agent/provider-registry.test.ts +++ b/packages/server/src/server/agent/provider-registry.test.ts @@ -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 { - throw new Error("not implemented"); - } - - async resumeSession(): Promise { - throw new Error("not implemented"); - } - - async listModels(): Promise { - return mockState.runtimeModels.get(this.provider) ?? []; - } - - async listModes(): Promise<[]> { - return []; - } - - async isAvailable(): Promise { - 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 = { diff --git a/packages/server/src/server/agent/providers/opencode-agent.full-access.test.ts b/packages/server/src/server/agent/providers/opencode-agent.full-access.test.ts index e2a320e9c..26a0df5b4 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.full-access.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.full-access.test.ts @@ -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 { - 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(); diff --git a/packages/server/src/server/agent/providers/opencode-agent.list-models-timeout.test.ts b/packages/server/src/server/agent/providers/opencode-agent.list-models-timeout.test.ts index a8bc747c7..9da08a9c5 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.list-models-timeout.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.list-models-timeout.test.ts @@ -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 { - 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 }]); }); diff --git a/packages/server/src/server/agent/providers/opencode-agent.slash-command-timeout.test.ts b/packages/server/src/server/agent/providers/opencode-agent.slash-command-timeout.test.ts index 155b8ed1b..be93b53f2 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.slash-command-timeout.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.slash-command-timeout.test.ts @@ -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(): { promise: Promise; @@ -25,34 +22,12 @@ function createDeferred(): { } 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(); + 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; +} diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index e19a63508..22aa27978 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -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 { + 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 { + 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(); @@ -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 { 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 { - 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 { - 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)})`; diff --git a/packages/server/src/server/agent/providers/opencode/runtime.ts b/packages/server/src/server/agent/providers/opencode/runtime.ts new file mode 100644 index 000000000..ff30f72f7 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/runtime.ts @@ -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; + ensureServerRunning(): Promise<{ port: number; url: string }>; + createClient(options: { baseUrl: string; directory: string }): OpencodeClient; + shutdown(): Promise; +} + +export function createSdkOpenCodeClient(options: { + baseUrl: string; + directory: string; +}): OpencodeClient { + return createOpencodeClient(options satisfies OpencodeClientConfig & { directory: string }); +} diff --git a/packages/server/src/server/agent/providers/opencode/test-utils/test-opencode-runtime.ts b/packages/server/src/server/agent/providers/opencode/test-utils/test-opencode-runtime.ts new file mode 100644 index 000000000..c410ffa03 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/test-utils/test-opencode-runtime.ts @@ -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 { + 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 {} +} + +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 = createEventStream([idleEvent()]); + permissionReplyResponse: OpenCodeResponse = {}; + providerListResponse: OpenCodeResponse = { data: { connected: [], all: [] } }; + providerListImplementation: (() => Promise) | 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 { + return (async function* () { + for (const event of events) { + yield event; + } + })(); +} + +export function idleEvent(): unknown { + return { + type: "session.idle", + properties: { sessionID: "session-1" }, + }; +}