From e7b705479a8ed609bc79feba7563e3db94a7afa5 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Fri, 11 Sep 2026 02:21:31 +0530 Subject: [PATCH 1/4] fix(agents): use stable OpenCode session API --- src/local-agent-opencode.ts | 231 +++++++++--------------------------- 1 file changed, 53 insertions(+), 178 deletions(-) diff --git a/src/local-agent-opencode.ts b/src/local-agent-opencode.ts index 3d40f5d6..e41f5db6 100644 --- a/src/local-agent-opencode.ts +++ b/src/local-agent-opencode.ts @@ -1,14 +1,11 @@ import { createRequire } from "node:module"; import { createServer as createNetServer } from "node:net"; import type { - ModelRef, OpencodeClient, - PromptInput, PermissionConfig, - SessionMessagesResponse, - SessionV2Info, } from "@opencode-ai/sdk/v2"; import { + AgentProviderExecutionError, AgentProviderProtocolError, AgentProviderUnavailableError, captureAgentProviderResult, @@ -23,15 +20,18 @@ import type { } from "./local-agent-runtime.js"; import { terminateProcessTree } from "./process-platform.js"; -const OPENCODE_SESSION_POLL_INTERVAL_MS = 250; -const OPENCODE_SESSION_POLL_TIMEOUT_MS = 5 * 60_000; const OPENCODE_SERVER_HOSTNAME = "127.0.0.1"; const OPENCODE_SERVER_START_TIMEOUT_MS = 5_000; const OPENCODE_SERVER_START_ATTEMPTS = 3; const require = createRequire(import.meta.url); const spawn = require("cross-spawn") as typeof import("node:child_process").spawn; -export type OpencodeClientLike = Pick; +interface OpencodeModelRef { + providerID: string; + modelID: string; +} + +export type OpencodeClientLike = Pick; export interface OpencodeServerLike { close(): void; @@ -71,31 +71,16 @@ export class OpencodeRuntime implements LocalAgentRuntime { } try { await assertOpencodeHealthy(this.client); - const resumed = Boolean(input.providerSessionId); - const initialModel = input.model ? parseOpencodeModel(input.model, input.effort) : undefined; - const sessionId = input.providerSessionId ?? await createOpencodeSession(this.client, input, initialModel); + const sessionId = input.providerSessionId ?? await createOpencodeSession(this.client, input); await callbacks?.onSessionId?.(sessionId); - await this.client.v2.session.switchAgent({ - sessionID: sessionId, - agent: opencodeAgentFor(input.writeMode), - }, { throwOnError: true }); - - const model = initialModel ?? (input.effort ? await modelWithEffort(this.client, sessionId, input.effort) : undefined); - if (model && (resumed || !initialModel)) { - await this.client.v2.session.switchModel({ sessionID: sessionId, model }, { throwOnError: true }); - } const promptResult = await promptOpencodeSession(this.client, sessionId, input); - await waitForOpencodeSession(this.client, sessionId, promptResult); - const promptId = extractOpenCodePromptId(promptResult); - const messages = await readOpencodeMessages(this.client, sessionId, promptId); - const finalResponse = requireFinalResponse( - extractOpenCodeFinalResponse(messages) || extractOpenCodeFinalResponse(promptResult), - ); + assertOpenCodePromptSucceeded(promptResult); + const finalResponse = requireFinalResponse(extractOpenCodeFinalResponse(promptResult)); return { provider: this.provider, providerSessionId: sessionId, finalResponse, - items: [promptResult, messages], + items: [promptResult], }; } catch (error) { if (isOpenCodeTransportFailure(error)) { @@ -300,14 +285,11 @@ export function opencodeAgentConfig(writeMode: LocalAgentRunInput["writeMode"]): async function createOpencodeSession( client: OpencodeClientLike, input: LocalAgentRunInput, - model?: ModelRef, ): Promise { - const result = await client.v2.session.create({ - location: { directory: input.workspaceRoot }, - agent: opencodeAgentFor(input.writeMode), - ...(model ? { model } : {}), + const result = await client.session.create({ + directory: input.workspaceRoot, }, { throwOnError: true }); - return requireSessionId(result.data.data); + return requireSessionId(result.data); } export function opencodeAgentFor(writeMode: LocalAgentRunInput["writeMode"]): string { @@ -335,10 +317,8 @@ export function opencodePermissionFor(writeMode: LocalAgentRunInput["writeMode"] } async function assertOpencodeHealthy(client: OpencodeClientLike): Promise { - const health = client.v2.health; - if (!health) return; try { - await health.get({ throwOnError: true }); + await client.global.health({ throwOnError: true }); } catch (error) { throw new OpencodeHealthError(errorMessage(error)); } @@ -372,160 +352,32 @@ class OpencodeHealthError extends Error { } } -async function modelWithEffort( - client: OpencodeClientLike, - sessionId: string, - effort: string, -): Promise { - const result = await client.v2.session.get({ sessionID: sessionId }, { throwOnError: true }); - const model = result.data.data.model; - if (!model) { - throw new AgentProviderProtocolError({ - code: "PROVIDER_PROTOCOL_ERROR", - provider: "opencode", - operation: "resolve_model", - retryable: false, - message: "OpenCode did not return the current session model for an effort override.", - }); - } - return { ...model, variant: effort }; -} - async function promptOpencodeSession( client: OpencodeClientLike, sessionId: string, input: LocalAgentRunInput, ): Promise { - const prompt: PromptInput = { text: input.prompt }; - return client.v2.session.prompt({ + const model = input.model ? parseOpencodeModel(input.model) : undefined; + return client.session.prompt({ sessionID: sessionId, - prompt, + directory: input.workspaceRoot, + parts: [{ type: "text", text: input.prompt }], + agent: opencodeAgentFor(input.writeMode), + ...(model ? { model } : {}), + ...(input.effort ? { variant: input.effort } : {}), }, { throwOnError: true }); } -async function waitForOpencodeSession( - client: OpencodeClientLike, - sessionId: string, - promptResult: unknown, -): Promise { - // OpenCode 1.18 accepts the prompt before its foreground drain is ready. - // Its wait endpoint rejects that state and can keep rejecting after the - // session has completed, so use the v2 active-session lifecycle instead. - const active = typeof client.v2.session.active === "function" - ? client.v2.session.active.bind(client.v2.session) - : undefined; - if (!active) { - await client.v2.session.wait({ sessionID: sessionId }, { throwOnError: true }); - return; - } - - const promptId = extractOpenCodePromptId(promptResult); - const deadline = Date.now() + OPENCODE_SESSION_POLL_TIMEOUT_MS; - let observedActive = false; - while (true) { - const messages = await readOpencodeMessages(client, sessionId, promptId); - const activity = await active({ throwOnError: true }); - const running = isOpenCodeSessionActive(activity, sessionId); - if (running) observedActive = true; - - const completed = hasCompletedOpenCodeTurn(messages, promptId); - if (completed && (promptId !== undefined || (observedActive && !running))) return; - if (Date.now() >= deadline) { - throw new AgentProviderProtocolError({ - code: "PROVIDER_PROTOCOL_ERROR", - provider: "opencode", - operation: "wait_for_session", - retryable: false, - message: "OpenCode did not finish the session before the provider timeout.", - }); - } - await delay(OPENCODE_SESSION_POLL_INTERVAL_MS); - } -} - -async function readOpencodeMessages( - client: OpencodeClientLike, - sessionId: string, - promptId?: string, -): Promise { - const messages: SessionMessagesResponse["data"] = []; - const seenCursors = new Set(); - let cursor: string | undefined; - - while (true) { - const result = await client.v2.session.messages({ - sessionID: sessionId, - limit: 100, - ...(cursor ? { cursor } : { order: "asc" }), - }, { throwOnError: true }); - const page = result.data; - messages.push(...page.data); - - // A prompt-specific read can stop as soon as the submitted turn is - // complete. Reads without a prompt id still walk the full history because - // they are used to extract the final response after the wait fallback. - if (promptId !== undefined && hasCompletedOpenCodeTurn({ data: messages }, promptId)) { - break; - } - - const nextCursor = page.cursor?.next; - if (!nextCursor || seenCursors.has(nextCursor)) break; - seenCursors.add(nextCursor); - cursor = nextCursor; - } - - return { data: messages, cursor: {} }; -} - -function extractOpenCodePromptId(value: unknown): string | undefined { - const id = asRecord(unwrapProviderPayload(value))?.id; - return typeof id === "string" ? id : undefined; -} - -function isOpenCodeSessionActive(value: unknown, sessionId: string): boolean { - const activeSessions = asRecord(unwrapProviderPayload(value)); - return activeSessions?.[sessionId] !== undefined; -} - -function hasCompletedOpenCodeTurn(value: unknown, promptId?: string): boolean { - const root = unwrapProviderPayload(value); - const messages = Array.isArray(root) ? root : readArray(root, "messages"); - if (!messages) return false; - - let promptSeen = promptId === undefined; - for (const message of messages) { - const record = asRecord(message); - if (!record) continue; - const info = asRecord(record.info) ?? record; - const role = typeof info.role === "string" ? info.role : record.type; - if (promptId !== undefined && info.id === promptId && role === "user") { - promptSeen = true; - continue; - } - if (!promptSeen || role !== "assistant") continue; - - const time = asRecord(info.time) ?? asRecord(record.time); - if (typeof info.finish === "string" || typeof record.finish === "string") return true; - if (typeof time?.completed === "number") return true; - if (info.error !== undefined || record.error !== undefined) return true; - } - return false; -} - -function delay(milliseconds: number): Promise { - return new Promise((resolve) => setTimeout(resolve, milliseconds)); -} - -function parseOpencodeModel(model: string, variant?: string): ModelRef { +function parseOpencodeModel(model: string): OpencodeModelRef { const separator = model.indexOf("/"); - const reference = separator === -1 - ? { providerID: "opencode", id: model } - : { providerID: model.slice(0, separator), id: model.slice(separator + 1) }; - return variant ? { ...reference, variant } : reference; + return separator === -1 + ? { providerID: "opencode", modelID: model } + : { providerID: model.slice(0, separator), modelID: model.slice(separator + 1) }; } -function requireSessionId(session: SessionV2Info): string { - if (!session.id) { +function requireSessionId(session: unknown): string { + const id = asRecord(session)?.id; + if (typeof id !== "string" || !id) { throw new AgentProviderProtocolError({ code: "PROVIDER_PROTOCOL_ERROR", provider: "opencode", @@ -534,7 +386,30 @@ function requireSessionId(session: SessionV2Info): string { message: "OpenCode did not return a session id.", }); } - return session.id; + return id; +} + +function assertOpenCodePromptSucceeded(value: unknown): void { + const result = asRecord(unwrapProviderPayload(value)); + const info = asRecord(result?.info); + const error = asRecord(info?.error); + if (!error) return; + const data = asRecord(error.data); + const message = typeof data?.message === "string" + ? data.message + : typeof error.message === "string" + ? error.message + : typeof error.name === "string" + ? `OpenCode returned ${error.name}.` + : "OpenCode returned an assistant error."; + throw new AgentProviderExecutionError({ + code: "PROVIDER_EXECUTION_ERROR", + provider: "opencode", + operation: "prompt", + retryable: data?.isRetryable === true, + cause: error, + message, + }); } export function extractOpenCodeFinalResponse(value: unknown): string { From b79a06d7b64bc130646d0033c1c116236fd702f7 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Fri, 11 Sep 2026 02:22:50 +0530 Subject: [PATCH 2/4] test(agents): cover stable OpenCode prompt flow --- src/local-agent-opencode.test.ts | 221 +++++++------------------------ 1 file changed, 49 insertions(+), 172 deletions(-) diff --git a/src/local-agent-opencode.test.ts b/src/local-agent-opencode.test.ts index c7b4bbac..4023e2b9 100644 --- a/src/local-agent-opencode.test.ts +++ b/src/local-agent-opencode.test.ts @@ -15,41 +15,30 @@ import { LocalAgentRuntimePool } from "./local-agent-runtime-pool.js"; let sessionNumber = 0; const createInputs: unknown[] = []; const promptInputs: unknown[] = []; -const switchInputs: unknown[] = []; -const agentInputs: unknown[] = []; let healthAvailable = true; const client = { - v2: { - session: { + global: { + async health() { + if (!healthAvailable) throw new Error("server unavailable"); + return { data: { healthy: true } }; + }, + }, + session: { async create(input: unknown) { createInputs.push(input); sessionNumber += 1; - return { data: { data: { id: `session_${sessionNumber}` } } }; + return { data: { id: `session_${sessionNumber}` } }; }, async prompt(input: unknown) { promptInputs.push(input); - return input; - }, - async wait() {}, - async messages(input: unknown) { const sessionId = (input as { sessionID: string }).sessionID; return { - data: { data: [{ + data: { info: { role: "assistant" }, parts: [{ type: "text", text: `response:${sessionId}` }], - }] }, + }, }; }, - async get() { - return { data: { data: { model: { providerID: "anthropic", id: "sonnet" } } } }; - }, - async switchAgent(input: unknown) { agentInputs.push(input); }, - async switchModel(input: unknown) { switchInputs.push(input); }, - }, - health: { async get() { - if (!healthAvailable) throw new Error("server unavailable"); - return { data: { healthy: true } }; - } }, }, } as unknown as OpencodeClientLike; let factoryCalls = 0; @@ -175,13 +164,15 @@ assert.equal(firstRecord.providerSessionId, "session_1"); assert.equal(secondRecord.providerSessionId, "session_2"); assert.equal(secondRecord.finalResponse, "response:session_2"); assert.deepEqual(createInputs[0], { - location: { directory: "/tmp/project" }, - agent: "devspace_allowed", - model: { providerID: "anthropic", id: "sonnet", variant: "high" }, + directory: "/tmp/project", }); assert.deepEqual(promptInputs[0], { sessionID: "session_1", - prompt: { text: "first" }, + directory: "/tmp/project", + parts: [{ type: "text", text: "first" }], + agent: "devspace_allowed", + model: { providerID: "anthropic", modelID: "sonnet" }, + variant: "high", }); let callbackSessionId: string | undefined; @@ -198,141 +189,13 @@ await pool.run(driver, { onSessionId: (id) => { callbackSessionId = id; }, }); assert.equal(callbackSessionId, firstRecord.providerSessionId); -assert.deepEqual(switchInputs[0], { +assert.deepEqual(promptInputs[2], { sessionID: "session_1", - model: { providerID: "anthropic", id: "sonnet", variant: "low" }, + directory: "/tmp/project", + parts: [{ type: "text", text: "effort override" }], + agent: "devspace_allowed", + variant: "low", }); -assert.deepEqual(agentInputs[0], { sessionID: "session_1", agent: "devspace_allowed" }); - -let readinessActiveCalls = 0; -let readinessWaitCalls = 0; -const readinessRaceClient = { - v2: { - session: { - async create() { - return { data: { data: { id: "session_readiness" } } }; - }, - async switchAgent() {}, - async prompt() { - return { data: { data: { id: "prompt_readiness" } } }; - }, - async wait() { - readinessWaitCalls += 1; - throw new Error("Session wait is not available yet"); - }, - async active() { - readinessActiveCalls += 1; - return { - data: { - data: readinessActiveCalls < 3 - ? { session_readiness: { type: "running" } } - : {}, - }, - }; - }, - async messages() { - const data = readinessActiveCalls >= 3 - ? [ - { type: "user", id: "prompt_readiness" }, - { - type: "assistant", - id: "assistant_readiness", - time: { created: 1, completed: 2 }, - finish: "stop", - content: [{ type: "text", id: "part_readiness", text: "ready response" }], - }, - ] - : [{ type: "user", id: "prompt_readiness" }]; - return { data: { data } }; - }, - }, - health: { async get() { return { data: { healthy: true } }; } }, - }, -} as unknown as OpencodeClientLike; -const readinessPool = new LocalAgentRuntimePool(); -const readinessDriver = new OpencodeLocalAgentDriver(async () => ({ - client: readinessRaceClient, - server: { close: () => undefined }, -})); -const readinessResult = await readinessPool.run(readinessDriver, { - agentId: "agt_readiness", - provider: "opencode", - workspaceRoot: "/tmp/project", -}, { prompt: "readiness", workspaceRoot: "/tmp/project" }); -assert.equal(readinessResult.isOk(), true, "OpenCode should wait for the active session to finish"); -if (readinessResult.isOk()) { - assert.equal(readinessResult.value.finalResponse, "ready response"); -} -assert.equal(readinessWaitCalls, 0, "OpenCode should not rely on the unavailable wait endpoint"); -await readinessPool.close(); - -const longSessionRequests: Array<{ cursor?: string; order?: string }> = []; -const longSessionClient = { - v2: { - session: { - async create() { - return { data: { data: { id: "session_long" } } }; - }, - async switchAgent() {}, - async prompt() { - return { data: { data: { id: "prompt_long" } } }; - }, - async active() { - return { data: { data: {} } }; - }, - async messages(input: unknown) { - const request = input as { cursor?: string; order?: string }; - longSessionRequests.push({ cursor: request.cursor, order: request.order }); - if (!request.cursor) { - return { - data: { - data: Array.from({ length: 100 }, (_, index) => ({ - type: "assistant", - id: `old-assistant-${index}`, - finish: "stop", - content: [{ type: "text", text: `old response ${index}` }], - })), - cursor: { next: "long-session-next" }, - }, - }; - } - return { - data: { - data: [ - { type: "user", id: "prompt_long" }, - { - type: "assistant", - id: "assistant_long", - finish: "stop", - content: [{ type: "text", text: "long response" }], - }, - ], - cursor: {}, - }, - }; - }, - }, - }, -} as unknown as OpencodeClientLike; -const longSessionPool = new LocalAgentRuntimePool(); -const longSessionDriver = new OpencodeLocalAgentDriver(async () => ({ - client: longSessionClient, - server: { close: () => undefined }, -})); -const longSessionResult = await longSessionPool.run(longSessionDriver, { - agentId: "agt_long_session", - provider: "opencode", - workspaceRoot: "/tmp/project", -}, { prompt: "long session", workspaceRoot: "/tmp/project" }); -assert.equal(longSessionResult.isOk(), true, "OpenCode should find completions past the first message page"); -if (longSessionResult.isOk()) { - assert.equal(longSessionResult.value.finalResponse, "long response"); -} -assert.ok( - longSessionRequests.some((request) => request.cursor === "long-session-next"), - "OpenCode should follow the continuation cursor", -); -await longSessionPool.close(); assert.equal(opencodeAgentFor("read_only"), "devspace_read_only"); assert.equal(opencodeAgentFor("full_access"), "devspace_full_access"); @@ -356,21 +219,34 @@ for (const writeMode of ["read_only", "allowed", "full_access"] as const) { let promptFailureCount = 0; const applicationErrorClient = { - v2: { - session: { - async create() { return { data: { data: { id: "session_app_error" } } }; }, - async switchAgent() {}, - async prompt() { - promptFailureCount += 1; - if (promptFailureCount === 1) throw new Error("server rejected invalid input"); - return {}; - }, - async wait() {}, - async messages() { - return { data: { data: [{ info: { role: "assistant" }, parts: [{ type: "text", text: "ok" }] }] } }; - }, + global: { + async health() { return { data: { healthy: true } }; }, + }, + session: { + async create() { return { data: { id: "session_app_error" } }; }, + async prompt() { + promptFailureCount += 1; + if (promptFailureCount === 1) { + return { + data: { + info: { + role: "assistant", + error: { + name: "ProviderAuthError", + data: { providerID: "example", message: "Provider credentials are unavailable." }, + }, + }, + parts: [], + }, + }; + } + return { + data: { + info: { role: "assistant" }, + parts: [{ type: "text", text: "ok" }], + }, + }; }, - health: { async get() { return { data: { healthy: true } }; } }, }, } as unknown as OpencodeClientLike; const applicationErrorPool = new LocalAgentRuntimePool(); @@ -387,6 +263,7 @@ assert.equal(applicationFailure.isErr(), true); if (applicationFailure.isErr()) { assert.equal(applicationFailure.error.code, "PROVIDER_EXECUTION_ERROR"); assert.equal(applicationFailure.error.retryable, false); + assert.equal(applicationFailure.error.message, "Provider credentials are unavailable."); } assert.equal(applicationErrorPool.size, 1, "ordinary provider errors must not evict a healthy server runtime"); const recoveredApplicationTurn = await applicationErrorPool.run(applicationErrorDriver, { From 0e2014b52068c9a2cfc77315c5f80409e4e48b83 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Fri, 11 Sep 2026 02:23:06 +0530 Subject: [PATCH 3/4] chore(deps): pin OpenCode SDK version --- package.json | 2 +- pnpm-lock.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 202834dd..3357476a 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ "@modelcontextprotocol/node": "^2.0.0", "@modelcontextprotocol/sdk": "^1.29.0", "@modelcontextprotocol/server": "^2.0.0", - "@opencode-ai/sdk": "^1.17.13", + "@opencode-ai/sdk": "1.17.13", "@pierre/diffs": "^1.3.6", "better-result": "^2.10.0", "better-sqlite3": "^12.10.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7045eecd..5fbd601c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,7 +36,7 @@ importers: specifier: ^2.0.0 version: 2.0.0 '@opencode-ai/sdk': - specifier: ^1.17.13 + specifier: 1.17.13 version: 1.17.13 '@pierre/diffs': specifier: ^1.3.6 From 677abf3a4435efb3a8dd5ec8b5b4624069433f53 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Fri, 11 Sep 2026 02:51:18 +0530 Subject: [PATCH 4/4] fix(agents): bound OpenCode prompt execution --- src/local-agent-opencode.test.ts | 27 ++++++++++++++++++++++++ src/local-agent-opencode.ts | 36 ++++++++++++++++++++++++++++++-- 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/src/local-agent-opencode.test.ts b/src/local-agent-opencode.test.ts index 4023e2b9..69c35800 100644 --- a/src/local-agent-opencode.test.ts +++ b/src/local-agent-opencode.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import { opencodeAgentConfig, OpencodeLocalAgentDriver, + OpencodeRuntime, opencodeAgentFor, opencodePermissionFor, type OpencodeClientLike, @@ -197,6 +198,32 @@ assert.deepEqual(promptInputs[2], { variant: "low", }); +const timeoutClient = { + global: { + async health() { return { data: { healthy: true } }; }, + }, + session: { + async create() { return { data: { id: "session_timeout" } }; }, + async prompt(_input: unknown, options?: { signal?: AbortSignal }) { + return new Promise((_resolve, reject) => { + options?.signal?.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true }); + }); + }, + }, +} as unknown as OpencodeClientLike; +const timeoutRuntime = new OpencodeRuntime(timeoutClient, { close: () => undefined }, 5); +const timedOutPrompt = await timeoutRuntime.run({ + prompt: "never finishes", + workspaceRoot: "/tmp/project", +}); +assert.equal(timedOutPrompt.isErr(), true); +if (timedOutPrompt.isErr()) { + assert.equal(timedOutPrompt.error.code, "PROVIDER_PROTOCOL_ERROR"); + assert.equal(timedOutPrompt.error.retryable, true); + assert.match(timedOutPrompt.error.message, /provider timeout/); +} +await timeoutRuntime.close(); + assert.equal(opencodeAgentFor("read_only"), "devspace_read_only"); assert.equal(opencodeAgentFor("full_access"), "devspace_full_access"); assert.deepEqual(opencodePermissionFor("allowed"), { diff --git a/src/local-agent-opencode.ts b/src/local-agent-opencode.ts index e41f5db6..d9980aba 100644 --- a/src/local-agent-opencode.ts +++ b/src/local-agent-opencode.ts @@ -23,6 +23,7 @@ import { terminateProcessTree } from "./process-platform.js"; const OPENCODE_SERVER_HOSTNAME = "127.0.0.1"; const OPENCODE_SERVER_START_TIMEOUT_MS = 5_000; const OPENCODE_SERVER_START_ATTEMPTS = 3; +const OPENCODE_PROMPT_TIMEOUT_MS = 5 * 60_000; const require = createRequire(import.meta.url); const spawn = require("cross-spawn") as typeof import("node:child_process").spawn; @@ -49,10 +50,12 @@ export class OpencodeRuntime implements LocalAgentRuntime { readonly provider = "opencode" as const; private alive = true; private closed = false; + private readonly promptControllers = new Set(); constructor( private readonly client: OpencodeClientLike, private readonly server: OpencodeServerLike, + private readonly promptTimeoutMs = OPENCODE_PROMPT_TIMEOUT_MS, ) {} async run(input: LocalAgentRunInput, callbacks?: LocalAgentRunCallbacks) { @@ -73,7 +76,7 @@ export class OpencodeRuntime implements LocalAgentRuntime { await assertOpencodeHealthy(this.client); const sessionId = input.providerSessionId ?? await createOpencodeSession(this.client, input); await callbacks?.onSessionId?.(sessionId); - const promptResult = await promptOpencodeSession(this.client, sessionId, input); + const promptResult = await this.prompt(sessionId, input); assertOpenCodePromptSucceeded(promptResult); const finalResponse = requireFinalResponse(extractOpenCodeFinalResponse(promptResult)); return { @@ -112,8 +115,36 @@ export class OpencodeRuntime implements LocalAgentRuntime { if (this.closed) return; this.closed = true; this.alive = false; + for (const controller of this.promptControllers) controller.abort(); + this.promptControllers.clear(); this.server.close(); } + + private async prompt(sessionId: string, input: LocalAgentRunInput): Promise { + const controller = new AbortController(); + this.promptControllers.add(controller); + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + controller.abort(); + }, this.promptTimeoutMs); + try { + return await promptOpencodeSession(this.client, sessionId, input, controller.signal); + } catch (error) { + if (!timedOut) throw error; + throw new AgentProviderProtocolError({ + code: "PROVIDER_PROTOCOL_ERROR", + provider: "opencode", + operation: "prompt", + retryable: true, + cause: error, + message: "OpenCode did not finish the prompt before the provider timeout.", + }); + } finally { + clearTimeout(timer); + this.promptControllers.delete(controller); + } + } } export class OpencodeLocalAgentDriver implements LocalAgentDriver { @@ -356,6 +387,7 @@ async function promptOpencodeSession( client: OpencodeClientLike, sessionId: string, input: LocalAgentRunInput, + signal: AbortSignal, ): Promise { const model = input.model ? parseOpencodeModel(input.model) : undefined; return client.session.prompt({ @@ -365,7 +397,7 @@ async function promptOpencodeSession( agent: opencodeAgentFor(input.writeMode), ...(model ? { model } : {}), ...(input.effort ? { variant: input.effort } : {}), - }, { throwOnError: true }); + }, { throwOnError: true, signal }); } function parseOpencodeModel(model: string): OpencodeModelRef {