From c3ddb046ac23bd7a5b08cbea39b903a6d5ead25c Mon Sep 17 00:00:00 2001 From: Waishnav Date: Tue, 11 Aug 2026 19:39:07 +0530 Subject: [PATCH 1/6] feat: pool the OpenCode server runtime --- src/local-agent-opencode.test.ts | 81 +++++++++++ src/local-agent-opencode.ts | 238 +++++++++++++++++++++++++++++++ 2 files changed, 319 insertions(+) create mode 100644 src/local-agent-opencode.test.ts create mode 100644 src/local-agent-opencode.ts diff --git a/src/local-agent-opencode.test.ts b/src/local-agent-opencode.test.ts new file mode 100644 index 000000000..af361fbbb --- /dev/null +++ b/src/local-agent-opencode.test.ts @@ -0,0 +1,81 @@ +import assert from "node:assert/strict"; +import { + OpencodeLocalAgentDriver, + type OpencodeClientLike, + type OpencodeFactory, +} from "./local-agent-opencode.js"; +import { LocalAgentRuntimePool } from "./local-agent-runtime-pool.js"; + +let sessionNumber = 0; +const createInputs: unknown[] = []; +const promptInputs: unknown[] = []; +const client: OpencodeClientLike = { + session: { + async create(input) { + createInputs.push(input); + sessionNumber += 1; + return { id: `session_${sessionNumber}` }; + }, + async prompt(input) { + promptInputs.push(input); + return input; + }, + async wait() {}, + async messages(input) { + const sessionId = (input as { sessionID: string }).sessionID; + return { + data: [{ + info: { role: "assistant" }, + parts: [{ type: "text", text: `response:${sessionId}` }], + }], + }; + }, + }, +}; +let factoryCalls = 0; +let closeCalls = 0; +const factory: OpencodeFactory = async () => { + factoryCalls += 1; + return { + client, + server: { close: () => { closeCalls += 1; } }, + }; +}; +const driver = new OpencodeLocalAgentDriver(factory); +const pool = new LocalAgentRuntimePool(); + +const first = await pool.run(driver, { + agentId: "agt_one", + provider: "opencode", + workspace: "/tmp/project", + }, { + prompt: "first", + workspace: "/tmp/project", + model: "anthropic/sonnet", + thinking: "high", + }); +const second = await pool.run(driver, { + agentId: "agt_two", + provider: "opencode", + workspace: "/tmp/project", +}, { + prompt: "second", + workspace: "/tmp/project", +}); + +assert.equal(factoryCalls, 1, "OpenCode agents share one server runtime"); +assert.equal(first.providerSessionId, "session_1"); +assert.equal(second.providerSessionId, "session_2"); +assert.equal(second.finalResponse, "response:session_2"); +assert.deepEqual(createInputs[0], { + location: { directory: "/tmp/project" }, + model: { providerID: "anthropic", modelID: "sonnet", variant: "high" }, +}); +assert.deepEqual(promptInputs[0], { + sessionID: "session_1", + prompt: { text: "first" }, +}); + +await pool.close(); +await pool.close(); +assert.equal(closeCalls, 1, "shared OpenCode server closes once"); diff --git a/src/local-agent-opencode.ts b/src/local-agent-opencode.ts new file mode 100644 index 000000000..13bf0375b --- /dev/null +++ b/src/local-agent-opencode.ts @@ -0,0 +1,238 @@ +import type { + LocalAgentDriver, + LocalAgentRunInput, + LocalAgentRunResult, + LocalAgentRuntime, + LocalAgentRuntimeContext, +} from "./local-agent-runtime.js"; + +export interface OpencodeClientLike { + session: { + create(parameters?: unknown, options?: unknown): Promise; + switchModel?(parameters?: unknown, options?: unknown): Promise; + prompt(parameters?: unknown, options?: unknown): Promise; + wait?(parameters?: unknown, options?: unknown): Promise; + messages?(parameters?: unknown, options?: unknown): Promise; + }; +} + +export interface OpencodeServerLike { + close(): void; +} + +export type OpencodeFactory = () => Promise<{ + client: OpencodeClientLike; + server: OpencodeServerLike; +}>; + +export class OpencodeRuntime implements LocalAgentRuntime { + readonly provider = "opencode" as const; + private readonly models = new Map(); + private alive = true; + private closed = false; + + constructor( + private readonly client: OpencodeClientLike, + private readonly server: OpencodeServerLike, + ) {} + + async run(input: LocalAgentRunInput): Promise { + if (!this.alive) throw new Error("OpenCode runtime is not running."); + const model = input.model + ? parseOpencodeModel(input.model, input.thinking) + : input.thinking && input.providerSessionId + ? updateOpencodeModelVariant(this.models.get(input.providerSessionId), input.thinking) + : undefined; + const sessionId = input.providerSessionId ?? await createOpencodeSession(this.client, input, model); + if (model) { + if (input.providerSessionId && this.client.session.switchModel) { + await this.client.session.switchModel({ sessionID: sessionId, model }, { throwOnError: true }); + } + this.models.set(sessionId, model); + } + const promptResult = await promptOpencodeSession(this.client, sessionId, input); + await waitForOpencodeSession(this.client, sessionId); + const messages = await readOpencodeMessages(this.client, sessionId); + const finalResponse = requireFinalResponse( + extractOpenCodeFinalResponse(messages) || extractOpenCodeFinalResponse(promptResult), + ); + return { + provider: this.provider, + providerSessionId: sessionId, + finalResponse, + items: [promptResult, messages], + }; + } + + async releaseSession(_providerSessionId: string): Promise { + // OpenCode keeps durable sessions independently of this process. + } + + isAlive(): boolean { + return this.alive && !this.closed; + } + + async close(): Promise { + if (this.closed) return; + this.closed = true; + this.alive = false; + this.server.close(); + } +} + +export class OpencodeLocalAgentDriver implements LocalAgentDriver { + readonly provider = "opencode" as const; + readonly idleTimeoutMs = 5 * 60_000; + + constructor(private readonly factory: OpencodeFactory = defaultOpencodeFactory) {} + + runtimeKey(_context: LocalAgentRuntimeContext): string { + return "opencode:default"; + } + + async createRuntime(_context: LocalAgentRuntimeContext): Promise { + const { client, server } = await this.factory(); + return new OpencodeRuntime(client, server); + } +} + +async function defaultOpencodeFactory(): Promise<{ + client: OpencodeClientLike; + server: OpencodeServerLike; +}> { + const { createOpencode } = await import("@opencode-ai/sdk/v2"); + return createOpencode(); +} + +interface OpencodeModelRef { + providerID: string; + modelID: string; + variant?: string; +} + +async function createOpencodeSession( + client: OpencodeClientLike, + input: LocalAgentRunInput, + model?: OpencodeModelRef, +): Promise { + const result = await client.session.create({ + location: { directory: input.workspace }, + ...(model ? { model } : {}), + }, { throwOnError: true }); + const id = readNestedString(result, ["id"]) + ?? readNestedString(result, ["data", "id"]) + ?? readNestedString(result, ["session", "id"]) + ?? readNestedString(result, ["data", "session", "id"]); + if (!id) throw new Error("OpenCode did not return a session id."); + return id; +} + +async function promptOpencodeSession( + client: OpencodeClientLike, + sessionId: string, + input: LocalAgentRunInput, +): Promise { + return client.session.prompt({ + sessionID: sessionId, + prompt: { text: input.prompt }, + }, { throwOnError: true }); +} + +async function waitForOpencodeSession(client: OpencodeClientLike, sessionId: string): Promise { + if (!client.session.wait) return; + await client.session.wait({ sessionID: sessionId }, { throwOnError: true }); +} + +async function readOpencodeMessages(client: OpencodeClientLike, sessionId: string): Promise { + if (!client.session.messages) return undefined; + return client.session.messages({ sessionID: sessionId, order: "asc", limit: 100 }, { throwOnError: true }); +} + +function parseOpencodeModel(model: string, variant?: string): OpencodeModelRef { + const separator = model.indexOf("/"); + const reference = separator === -1 + ? { providerID: "opencode", modelID: model } + : { providerID: model.slice(0, separator), modelID: model.slice(separator + 1) }; + return variant ? { ...reference, variant } : reference; +} + +function updateOpencodeModelVariant( + model: OpencodeModelRef | undefined, + variant: string, +): OpencodeModelRef | undefined { + return model ? { ...model, variant } : undefined; +} + +export function extractOpenCodeFinalResponse(value: unknown): string { + const root = unwrapProviderPayload(value); + const messages = Array.isArray(root) ? root : readArray(root, "messages"); + if (messages) return extractLastOpenCodeAssistantMessageText(messages); + return extractOpenCodeAssistantMessageText(root); +} + +function extractLastOpenCodeAssistantMessageText(messages: unknown[]): string { + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = asRecord(messages[index]); + if (!message) continue; + const info = asRecord(message.info); + const role = typeof info?.role === "string" ? info.role : message.role; + const type = typeof message.type === "string" ? message.type : undefined; + if (role !== "assistant" && type !== "assistant") continue; + const text = extractOpenCodeAssistantMessageText(message); + if (text) return text; + } + return ""; +} + +function extractOpenCodeAssistantMessageText(value: unknown): string { + const message = asRecord(value); + if (!message) return ""; + for (const key of ["content", "parts"] as const) { + const parts = readArray(message, key); + if (!parts) continue; + const text = parts + .map((part) => { + const record = asRecord(part); + return record?.type === "text" && typeof record.text === "string" ? record.text : ""; + }) + .filter(Boolean) + .join(""); + if (text.trim()) return text.trim(); + } + const info = asRecord(message.info) ?? message; + return stringifyStructuredMessage(info.structured); +} + +function stringifyStructuredMessage(value: unknown): string { + if (value === undefined || value === null) return ""; + if (typeof value === "string") return value.trim(); + return JSON.stringify(value); +} + +function unwrapProviderPayload(value: unknown): unknown { + const record = asRecord(value); + return record ? record.data ?? record.result ?? value : value; +} + +function readArray(value: unknown, key: string): unknown[] | undefined { + const result = asRecord(value)?.[key]; + return Array.isArray(result) ? result : undefined; +} + +function readNestedString(value: unknown, path: string[]): string | undefined { + let current: unknown = value; + for (const key of path) current = asRecord(current)?.[key]; + return typeof current === "string" ? current : undefined; +} + +function asRecord(value: unknown): Record | undefined { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? value as Record + : undefined; +} + +function requireFinalResponse(response: string): string { + const trimmed = response.trim(); + if (!trimmed) throw new Error("OpenCode did not return a final assistant response."); + return trimmed; +} From 86d0c36cc0eff979eceb4554cf7b35945c100e5e Mon Sep 17 00:00:00 2001 From: Waishnav Date: Wed, 12 Aug 2026 00:31:49 +0530 Subject: [PATCH 2/6] refactor: register native OpenCode runtime --- src/local-agent-adapters.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/local-agent-adapters.ts b/src/local-agent-adapters.ts index b918ac18e..806a9bb18 100644 --- a/src/local-agent-adapters.ts +++ b/src/local-agent-adapters.ts @@ -18,6 +18,7 @@ import { } from "./local-agent-runtime.js"; import { LOCAL_AGENT_PROVIDERS } from "./local-agent-profiles.js"; import { CodexLocalAgentDriver } from "./local-agent-codex.js"; +import { OpencodeLocalAgentDriver } from "./local-agent-opencode.js"; export interface LocalAgentAdapter { readonly provider: LocalAgentProvider; @@ -55,7 +56,11 @@ export function createLocalAgentAdapter(provider: LocalAgentProvider): LocalAgen export function createLocalAgentDrivers(): LocalAgentDriver[] { return LOCAL_AGENT_PROVIDERS.map((provider) => - provider === "codex" ? new CodexLocalAgentDriver() : new LegacyLocalAgentDriver(provider)); + provider === "codex" + ? new CodexLocalAgentDriver() + : provider === "opencode" + ? new OpencodeLocalAgentDriver() + : new LegacyLocalAgentDriver(provider)); } class LegacyLocalAgentDriver implements LocalAgentDriver { From 51d7ea66a684add6cec62207762add321409507f Mon Sep 17 00:00:00 2001 From: Waishnav Date: Wed, 12 Aug 2026 00:31:52 +0530 Subject: [PATCH 3/6] fix: align OpenCode runtime with SDK v2 --- src/local-agent-opencode.test.ts | 46 +++++++++--- src/local-agent-opencode.ts | 121 ++++++++++++++++--------------- 2 files changed, 100 insertions(+), 67 deletions(-) diff --git a/src/local-agent-opencode.test.ts b/src/local-agent-opencode.test.ts index af361fbbb..3995660bc 100644 --- a/src/local-agent-opencode.test.ts +++ b/src/local-agent-opencode.test.ts @@ -9,29 +9,36 @@ import { LocalAgentRuntimePool } from "./local-agent-runtime-pool.js"; let sessionNumber = 0; const createInputs: unknown[] = []; const promptInputs: unknown[] = []; -const client: OpencodeClientLike = { - session: { - async create(input) { +const switchInputs: unknown[] = []; +const client = { + v2: { + session: { + async create(input: unknown) { createInputs.push(input); sessionNumber += 1; - return { id: `session_${sessionNumber}` }; + return { data: { data: { id: `session_${sessionNumber}` } } }; }, - async prompt(input) { + async prompt(input: unknown) { promptInputs.push(input); return input; }, async wait() {}, - async messages(input) { + 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 switchModel(input: unknown) { switchInputs.push(input); }, + }, }, -}; +} as unknown as OpencodeClientLike; let factoryCalls = 0; let closeCalls = 0; const factory: OpencodeFactory = async () => { @@ -69,13 +76,32 @@ assert.equal(second.providerSessionId, "session_2"); assert.equal(second.finalResponse, "response:session_2"); assert.deepEqual(createInputs[0], { location: { directory: "/tmp/project" }, - model: { providerID: "anthropic", modelID: "sonnet", variant: "high" }, + model: { providerID: "anthropic", id: "sonnet", variant: "high" }, }); assert.deepEqual(promptInputs[0], { sessionID: "session_1", prompt: { text: "first" }, }); +let callbackSessionId: string | undefined; +await pool.run(driver, { + agentId: "agt_one", + provider: "opencode", + workspace: "/tmp/project", +}, { + prompt: "thinking override", + workspace: "/tmp/project", + providerSessionId: first.providerSessionId ?? undefined, + thinking: "low", +}, { + onSessionId: (id) => { callbackSessionId = id; }, +}); +assert.equal(callbackSessionId, first.providerSessionId); +assert.deepEqual(switchInputs[0], { + sessionID: "session_1", + model: { providerID: "anthropic", id: "sonnet", variant: "low" }, +}); + await pool.close(); await pool.close(); assert.equal(closeCalls, 1, "shared OpenCode server closes once"); diff --git a/src/local-agent-opencode.ts b/src/local-agent-opencode.ts index 13bf0375b..5b6743791 100644 --- a/src/local-agent-opencode.ts +++ b/src/local-agent-opencode.ts @@ -1,20 +1,20 @@ +import type { + ModelRef, + OpencodeClient, + PromptInput, + SessionMessagesResponse, + SessionV2Info, +} from "@opencode-ai/sdk/v2"; import type { LocalAgentDriver, + LocalAgentRunCallbacks, LocalAgentRunInput, LocalAgentRunResult, LocalAgentRuntime, LocalAgentRuntimeContext, } from "./local-agent-runtime.js"; -export interface OpencodeClientLike { - session: { - create(parameters?: unknown, options?: unknown): Promise; - switchModel?(parameters?: unknown, options?: unknown): Promise; - prompt(parameters?: unknown, options?: unknown): Promise; - wait?(parameters?: unknown, options?: unknown): Promise; - messages?(parameters?: unknown, options?: unknown): Promise; - }; -} +export type OpencodeClientLike = Pick; export interface OpencodeServerLike { close(): void; @@ -27,7 +27,6 @@ export type OpencodeFactory = () => Promise<{ export class OpencodeRuntime implements LocalAgentRuntime { readonly provider = "opencode" as const; - private readonly models = new Map(); private alive = true; private closed = false; @@ -36,19 +35,16 @@ export class OpencodeRuntime implements LocalAgentRuntime { private readonly server: OpencodeServerLike, ) {} - async run(input: LocalAgentRunInput): Promise { + async run(input: LocalAgentRunInput, callbacks?: LocalAgentRunCallbacks): Promise { if (!this.alive) throw new Error("OpenCode runtime is not running."); - const model = input.model - ? parseOpencodeModel(input.model, input.thinking) - : input.thinking && input.providerSessionId - ? updateOpencodeModelVariant(this.models.get(input.providerSessionId), input.thinking) - : undefined; - const sessionId = input.providerSessionId ?? await createOpencodeSession(this.client, input, model); - if (model) { - if (input.providerSessionId && this.client.session.switchModel) { - await this.client.session.switchModel({ sessionID: sessionId, model }, { throwOnError: true }); - } - this.models.set(sessionId, model); + const resumed = Boolean(input.providerSessionId); + const initialModel = input.model ? parseOpencodeModel(input.model, input.thinking) : undefined; + const sessionId = input.providerSessionId ?? await createOpencodeSession(this.client, input, initialModel); + await callbacks?.onSessionId?.(sessionId); + + const model = initialModel ?? (input.thinking ? await modelWithThinking(this.client, sessionId, input.thinking) : 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); @@ -96,35 +92,32 @@ export class OpencodeLocalAgentDriver implements LocalAgentDriver { } } -async function defaultOpencodeFactory(): Promise<{ - client: OpencodeClientLike; - server: OpencodeServerLike; -}> { +async function defaultOpencodeFactory(): Promise<{ client: OpencodeClientLike; server: OpencodeServerLike }> { const { createOpencode } = await import("@opencode-ai/sdk/v2"); return createOpencode(); } -interface OpencodeModelRef { - providerID: string; - modelID: string; - variant?: string; -} - async function createOpencodeSession( client: OpencodeClientLike, input: LocalAgentRunInput, - model?: OpencodeModelRef, + model?: ModelRef, ): Promise { - const result = await client.session.create({ + const result = await client.v2.session.create({ location: { directory: input.workspace }, ...(model ? { model } : {}), }, { throwOnError: true }); - const id = readNestedString(result, ["id"]) - ?? readNestedString(result, ["data", "id"]) - ?? readNestedString(result, ["session", "id"]) - ?? readNestedString(result, ["data", "session", "id"]); - if (!id) throw new Error("OpenCode did not return a session id."); - return id; + return requireSessionId(result.data.data); +} + +async function modelWithThinking( + client: OpencodeClientLike, + sessionId: string, + thinking: string, +): Promise { + const result = await client.v2.session.get({ sessionID: sessionId }, { throwOnError: true }); + const model = result.data.data.model; + if (!model) throw new Error("OpenCode did not return the current session model for a thinking override."); + return { ...model, variant: thinking }; } async function promptOpencodeSession( @@ -132,35 +125,36 @@ async function promptOpencodeSession( sessionId: string, input: LocalAgentRunInput, ): Promise { - return client.session.prompt({ + const prompt: PromptInput = { text: input.prompt }; + return client.v2.session.prompt({ sessionID: sessionId, - prompt: { text: input.prompt }, + prompt, }, { throwOnError: true }); } async function waitForOpencodeSession(client: OpencodeClientLike, sessionId: string): Promise { - if (!client.session.wait) return; - await client.session.wait({ sessionID: sessionId }, { throwOnError: true }); + await client.v2.session.wait({ sessionID: sessionId }, { throwOnError: true }); } -async function readOpencodeMessages(client: OpencodeClientLike, sessionId: string): Promise { - if (!client.session.messages) return undefined; - return client.session.messages({ sessionID: sessionId, order: "asc", limit: 100 }, { throwOnError: true }); +async function readOpencodeMessages( + client: OpencodeClientLike, + sessionId: string, +): Promise { + const result = await client.v2.session.messages({ sessionID: sessionId, order: "asc", limit: 100 }, { throwOnError: true }); + return result.data; } -function parseOpencodeModel(model: string, variant?: string): OpencodeModelRef { +function parseOpencodeModel(model: string, variant?: string): ModelRef { const separator = model.indexOf("/"); const reference = separator === -1 - ? { providerID: "opencode", modelID: model } - : { providerID: model.slice(0, separator), modelID: model.slice(separator + 1) }; + ? { providerID: "opencode", id: model } + : { providerID: model.slice(0, separator), id: model.slice(separator + 1) }; return variant ? { ...reference, variant } : reference; } -function updateOpencodeModelVariant( - model: OpencodeModelRef | undefined, - variant: string, -): OpencodeModelRef | undefined { - return model ? { ...model, variant } : undefined; +function requireSessionId(session: SessionV2Info): string { + if (!session.id) throw new Error("OpenCode did not return a session id."); + return session.id; } export function extractOpenCodeFinalResponse(value: unknown): string { @@ -210,8 +204,21 @@ function stringifyStructuredMessage(value: unknown): string { } function unwrapProviderPayload(value: unknown): unknown { - const record = asRecord(value); - return record ? record.data ?? record.result ?? value : value; + let current = value; + for (let depth = 0; depth < 3; depth += 1) { + const record = asRecord(current); + if (!record) return current; + if (record.data !== undefined) { + current = record.data; + continue; + } + if (record.result !== undefined) { + current = record.result; + continue; + } + return current; + } + return current; } function readArray(value: unknown, key: string): unknown[] | undefined { From 1f1a79535bb3d21507ddf63ea37164ce82e3c6bc Mon Sep 17 00:00:00 2001 From: Waishnav Date: Wed, 12 Aug 2026 09:13:54 +0530 Subject: [PATCH 4/6] fix: enforce OpenCode authority and health invalidation --- src/local-agent-opencode.test.ts | 63 +++++++++++++++++ src/local-agent-opencode.ts | 115 ++++++++++++++++++++++++------- 2 files changed, 155 insertions(+), 23 deletions(-) diff --git a/src/local-agent-opencode.test.ts b/src/local-agent-opencode.test.ts index 3995660bc..49e4afd0d 100644 --- a/src/local-agent-opencode.test.ts +++ b/src/local-agent-opencode.test.ts @@ -1,6 +1,8 @@ import assert from "node:assert/strict"; import { OpencodeLocalAgentDriver, + opencodeAgentFor, + opencodePermissionFor, type OpencodeClientLike, type OpencodeFactory, } from "./local-agent-opencode.js"; @@ -10,6 +12,8 @@ let sessionNumber = 0; const createInputs: unknown[] = []; const promptInputs: unknown[] = []; const switchInputs: unknown[] = []; +const agentInputs: unknown[] = []; +let healthAvailable = true; const client = { v2: { session: { @@ -35,8 +39,13 @@ const client = { 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; @@ -76,6 +85,7 @@ assert.equal(second.providerSessionId, "session_2"); assert.equal(second.finalResponse, "response:session_2"); assert.deepEqual(createInputs[0], { location: { directory: "/tmp/project" }, + agent: "devspace_allowed", model: { providerID: "anthropic", id: "sonnet", variant: "high" }, }); assert.deepEqual(promptInputs[0], { @@ -101,6 +111,59 @@ assert.deepEqual(switchInputs[0], { sessionID: "session_1", model: { providerID: "anthropic", id: "sonnet", variant: "low" }, }); +assert.deepEqual(agentInputs[0], { sessionID: "session_1", agent: "devspace_allowed" }); +assert.equal(opencodeAgentFor("read_only"), "devspace_read_only"); +assert.equal(opencodeAgentFor("full_access"), "devspace_full_access"); +assert.deepEqual(opencodePermissionFor("allowed"), { + read: "allow", + edit: "allow", + glob: "allow", + grep: "allow", + list: "allow", + bash: "allow", + external_directory: "deny", +}); +const readOnlyPermissions = opencodePermissionFor("read_only"); +assert.equal(typeof readOnlyPermissions === "object" ? readOnlyPermissions.bash : undefined, "deny"); + +let recoveringFactoryCalls = 0; +const recoveringDriver = new OpencodeLocalAgentDriver(async () => { + recoveringFactoryCalls += 1; + healthAvailable = true; + return { client, server: { close: () => undefined } }; +}); +const recoveringPool = new LocalAgentRuntimePool(); +await recoveringPool.run(recoveringDriver, { + agentId: "agt_dead", + provider: "opencode", + workspace: "/tmp/project", +}, { + prompt: "initial", + workspace: "/tmp/project", +}); +healthAvailable = false; +await assert.rejects( + recoveringPool.run(recoveringDriver, { + agentId: "agt_dead", + provider: "opencode", + workspace: "/tmp/project", + }, { + prompt: "dead runtime", + workspace: "/tmp/project", + }), + /health check failed/, +); +assert.equal(recoveringPool.size, 0, "a failed health check removes the dead runtime immediately"); +await recoveringPool.run(recoveringDriver, { + agentId: "agt_dead", + provider: "opencode", + workspace: "/tmp/project", +}, { + prompt: "recreated", + workspace: "/tmp/project", +}); +assert.equal(recoveringFactoryCalls, 2, "the next turn creates a fresh OpenCode server"); +await recoveringPool.close(); await pool.close(); await pool.close(); diff --git a/src/local-agent-opencode.ts b/src/local-agent-opencode.ts index 5b6743791..6846cf172 100644 --- a/src/local-agent-opencode.ts +++ b/src/local-agent-opencode.ts @@ -2,6 +2,7 @@ import type { ModelRef, OpencodeClient, PromptInput, + PermissionConfig, SessionMessagesResponse, SessionV2Info, } from "@opencode-ai/sdk/v2"; @@ -20,7 +21,7 @@ export interface OpencodeServerLike { close(): void; } -export type OpencodeFactory = () => Promise<{ +export type OpencodeFactory = (context?: LocalAgentRuntimeContext) => Promise<{ client: OpencodeClientLike; server: OpencodeServerLike; }>; @@ -37,27 +38,37 @@ export class OpencodeRuntime implements LocalAgentRuntime { async run(input: LocalAgentRunInput, callbacks?: LocalAgentRunCallbacks): Promise { if (!this.alive) throw new Error("OpenCode runtime is not running."); - const resumed = Boolean(input.providerSessionId); - const initialModel = input.model ? parseOpencodeModel(input.model, input.thinking) : undefined; - const sessionId = input.providerSessionId ?? await createOpencodeSession(this.client, input, initialModel); - await callbacks?.onSessionId?.(sessionId); - - const model = initialModel ?? (input.thinking ? await modelWithThinking(this.client, sessionId, input.thinking) : undefined); - if (model && (resumed || !initialModel)) { - await this.client.v2.session.switchModel({ sessionID: sessionId, model }, { throwOnError: true }); + try { + await assertOpencodeHealthy(this.client); + const resumed = Boolean(input.providerSessionId); + const initialModel = input.model ? parseOpencodeModel(input.model, input.thinking) : undefined; + const sessionId = input.providerSessionId ?? await createOpencodeSession(this.client, input, initialModel); + await callbacks?.onSessionId?.(sessionId); + await this.client.v2.session.switchAgent({ + sessionID: sessionId, + agent: opencodeAgentFor(input.writeMode), + }, { throwOnError: true }); + + const model = initialModel ?? (input.thinking ? await modelWithThinking(this.client, sessionId, input.thinking) : 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); + const messages = await readOpencodeMessages(this.client, sessionId); + const finalResponse = requireFinalResponse( + extractOpenCodeFinalResponse(messages) || extractOpenCodeFinalResponse(promptResult), + ); + return { + provider: this.provider, + providerSessionId: sessionId, + finalResponse, + items: [promptResult, messages], + }; + } catch (error) { + if (isOpenCodeTransportFailure(error)) this.alive = false; + throw error; } - const promptResult = await promptOpencodeSession(this.client, sessionId, input); - await waitForOpencodeSession(this.client, sessionId); - const messages = await readOpencodeMessages(this.client, sessionId); - const finalResponse = requireFinalResponse( - extractOpenCodeFinalResponse(messages) || extractOpenCodeFinalResponse(promptResult), - ); - return { - provider: this.provider, - providerSessionId: sessionId, - finalResponse, - items: [promptResult, messages], - }; } async releaseSession(_providerSessionId: string): Promise { @@ -87,14 +98,20 @@ export class OpencodeLocalAgentDriver implements LocalAgentDriver { } async createRuntime(_context: LocalAgentRuntimeContext): Promise { - const { client, server } = await this.factory(); + const { client, server } = await this.factory(_context); return new OpencodeRuntime(client, server); } } async function defaultOpencodeFactory(): Promise<{ client: OpencodeClientLike; server: OpencodeServerLike }> { const { createOpencode } = await import("@opencode-ai/sdk/v2"); - return createOpencode(); + return createOpencode({ config: { + agent: { + devspace_read_only: { permission: opencodePermissionFor("read_only") }, + devspace_allowed: { permission: opencodePermissionFor("allowed") }, + devspace_full_access: { permission: opencodePermissionFor("full_access") }, + }, + } }); } async function createOpencodeSession( @@ -104,11 +121,59 @@ async function createOpencodeSession( ): Promise { const result = await client.v2.session.create({ location: { directory: input.workspace }, + agent: opencodeAgentFor(input.writeMode), ...(model ? { model } : {}), }, { throwOnError: true }); return requireSessionId(result.data.data); } +export function opencodeAgentFor(writeMode: LocalAgentRunInput["writeMode"]): string { + switch (writeMode) { + case "read_only": return "devspace_read_only"; + case "full_access": return "devspace_full_access"; + case "allowed": + case undefined: return "devspace_allowed"; + } +} + +export function opencodePermissionFor(writeMode: LocalAgentRunInput["writeMode"]): PermissionConfig { + const allowed = writeMode !== "read_only"; + const unrestricted = writeMode === "full_access"; + return { + read: "allow", + edit: allowed ? "allow" : "deny", + glob: "allow", + grep: "allow", + list: "allow", + bash: allowed ? "allow" : "deny", + external_directory: unrestricted ? "allow" : "deny", + }; +} + +async function assertOpencodeHealthy(client: OpencodeClientLike): Promise { + const health = client.v2.health; + if (!health) return; + try { + await health.get({ throwOnError: true }); + } catch (error) { + throw new OpencodeHealthError(errorMessage(error)); + } +} + +function isOpenCodeTransportFailure(error: unknown): boolean { + if (error instanceof OpencodeHealthError) return true; + if (!(error instanceof Error)) return true; + const message = error.message.toLowerCase(); + return message.includes("fetch") || message.includes("econn") || message.includes("socket") || message.includes("health") || message.includes("network") || message.includes("server"); +} + +class OpencodeHealthError extends Error { + constructor(message: string) { + super(`OpenCode server health check failed: ${message}`); + this.name = "OpencodeHealthError"; + } +} + async function modelWithThinking( client: OpencodeClientLike, sessionId: string, @@ -243,3 +308,7 @@ function requireFinalResponse(response: string): string { if (!trimmed) throw new Error("OpenCode did not return a final assistant response."); return trimmed; } + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} From 66d6e7150344e40c23319af653541aa0da3e4585 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 17 Aug 2026 18:53:44 +0530 Subject: [PATCH 5/6] fix: make OpenCode runtime policy explicit --- src/local-agent-opencode.test.ts | 48 ++++++++++++++++++++++++++++++++ src/local-agent-opencode.ts | 37 ++++++++++++++++++++---- 2 files changed, 79 insertions(+), 6 deletions(-) diff --git a/src/local-agent-opencode.test.ts b/src/local-agent-opencode.test.ts index 49e4afd0d..80d7f9c8d 100644 --- a/src/local-agent-opencode.test.ts +++ b/src/local-agent-opencode.test.ts @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import { + opencodeAgentConfig, OpencodeLocalAgentDriver, opencodeAgentFor, opencodePermissionFor, @@ -121,10 +122,57 @@ assert.deepEqual(opencodePermissionFor("allowed"), { grep: "allow", list: "allow", bash: "allow", + task: "deny", external_directory: "deny", }); const readOnlyPermissions = opencodePermissionFor("read_only"); assert.equal(typeof readOnlyPermissions === "object" ? readOnlyPermissions.bash : undefined, "deny"); +for (const writeMode of ["read_only", "allowed", "full_access"] as const) { + const config = opencodeAgentConfig(writeMode); + assert.equal(config.mode, "primary"); + assert.equal(typeof config.permission === "object" ? config.permission.task : undefined, "deny"); +} + +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" }] }] } }; + }, + }, + health: { async get() { return { data: { healthy: true } }; } }, + }, +} as unknown as OpencodeClientLike; +const applicationErrorPool = new LocalAgentRuntimePool(); +const applicationErrorDriver = new OpencodeLocalAgentDriver(async () => ({ + client: applicationErrorClient, + server: { close: () => undefined }, +})); +await assert.rejects( + applicationErrorPool.run(applicationErrorDriver, { + agentId: "agt_app_error", + provider: "opencode", + workspace: "/tmp/project", + }, { prompt: "bad input", workspace: "/tmp/project" }), + /server rejected invalid input/, +); +assert.equal(applicationErrorPool.size, 1, "ordinary provider errors must not evict a healthy server runtime"); +const recoveredApplicationTurn = await applicationErrorPool.run(applicationErrorDriver, { + agentId: "agt_app_error", + provider: "opencode", + workspace: "/tmp/project", +}, { prompt: "valid input", workspace: "/tmp/project" }); +assert.equal(recoveredApplicationTurn.finalResponse, "ok"); +await applicationErrorPool.close(); let recoveringFactoryCalls = 0; const recoveringDriver = new OpencodeLocalAgentDriver(async () => { diff --git a/src/local-agent-opencode.ts b/src/local-agent-opencode.ts index 6846cf172..ce2a4e763 100644 --- a/src/local-agent-opencode.ts +++ b/src/local-agent-opencode.ts @@ -107,13 +107,23 @@ async function defaultOpencodeFactory(): Promise<{ client: OpencodeClientLike; s const { createOpencode } = await import("@opencode-ai/sdk/v2"); return createOpencode({ config: { agent: { - devspace_read_only: { permission: opencodePermissionFor("read_only") }, - devspace_allowed: { permission: opencodePermissionFor("allowed") }, - devspace_full_access: { permission: opencodePermissionFor("full_access") }, + devspace_read_only: opencodeAgentConfig("read_only"), + devspace_allowed: opencodeAgentConfig("allowed"), + devspace_full_access: opencodeAgentConfig("full_access"), }, } }); } +export function opencodeAgentConfig(writeMode: LocalAgentRunInput["writeMode"]): { + mode: "primary"; + permission: PermissionConfig; +} { + return { + mode: "primary", + permission: opencodePermissionFor(writeMode), + }; +} + async function createOpencodeSession( client: OpencodeClientLike, input: LocalAgentRunInput, @@ -146,6 +156,7 @@ export function opencodePermissionFor(writeMode: LocalAgentRunInput["writeMode"] grep: "allow", list: "allow", bash: allowed ? "allow" : "deny", + task: "deny", external_directory: unrestricted ? "allow" : "deny", }; } @@ -162,9 +173,23 @@ async function assertOpencodeHealthy(client: OpencodeClientLike): Promise function isOpenCodeTransportFailure(error: unknown): boolean { if (error instanceof OpencodeHealthError) return true; - if (!(error instanceof Error)) return true; - const message = error.message.toLowerCase(); - return message.includes("fetch") || message.includes("econn") || message.includes("socket") || message.includes("health") || message.includes("network") || message.includes("server"); + const code = transportErrorCode(error); + return code === "ECONNREFUSED" + || code === "ECONNRESET" + || code === "EPIPE" + || code === "ENETDOWN" + || code === "ENETUNREACH" + || code === "ETIMEDOUT"; +} + +function transportErrorCode(error: unknown): string | undefined { + if (!error || typeof error !== "object") return undefined; + const code = (error as NodeJS.ErrnoException).code; + if (typeof code === "string") return code; + const cause = (error as Error & { cause?: unknown }).cause; + return cause && typeof cause === "object" && typeof (cause as NodeJS.ErrnoException).code === "string" + ? (cause as NodeJS.ErrnoException).code + : undefined; } class OpencodeHealthError extends Error { From 0df2b2bf307ff69feccdcfabdf15b398dddfa847 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Mon, 17 Aug 2026 18:53:44 +0530 Subject: [PATCH 6/6] test: run OpenCode provider coverage --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 8123663f9..da4b7b630 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "dev": "node scripts/dev-server.mjs", "postinstall": "node scripts/fix-node-pty-permissions.mjs", "start": "node dist/cli.js serve", - "test": "tsx src/config.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-daemon-lifecycle.test.ts && tsx src/local-agent-daemon-protocol.test.ts && tsx src/local-agent-daemon.test.ts && tsx src/local-agent-codex.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/local-agent-manager.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", + "test": "tsx src/config.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-daemon-lifecycle.test.ts && tsx src/local-agent-daemon-protocol.test.ts && tsx src/local-agent-daemon.test.ts && tsx src/local-agent-codex.test.ts && tsx src/local-agent-opencode.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/local-agent-manager.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "keywords": [],