From 3e4fa100f0a6d0131386cba7ec9005bdffcab977 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Fri, 21 Aug 2026 00:49:57 +0530 Subject: [PATCH] fix(opencode): wait for session completion --- src/local-agent-opencode.test.ts | 67 +++++++++++++++++++++++++ src/local-agent-opencode.ts | 84 ++++++++++++++++++++++++++++++-- 2 files changed, 148 insertions(+), 3 deletions(-) diff --git a/src/local-agent-opencode.test.ts b/src/local-agent-opencode.test.ts index 7798d1d0..cf5e24fd 100644 --- a/src/local-agent-opencode.test.ts +++ b/src/local-agent-opencode.test.ts @@ -119,6 +119,73 @@ assert.deepEqual(switchInputs[0], { model: { providerID: "anthropic", id: "sonnet", variant: "low" }, }); assert.deepEqual(agentInputs[0], { sessionID: "session_1", agent: "devspace_allowed" }); + +let readinessActiveCalls = 0; +let readinessMessageCalls = 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() { + readinessMessageCalls += 1; + 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"); +assert.equal(readinessActiveCalls >= 3, true); +assert.equal(readinessMessageCalls >= 3, true); +await readinessPool.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 b2f86faa..f5514ab9 100644 --- a/src/local-agent-opencode.ts +++ b/src/local-agent-opencode.ts @@ -20,6 +20,9 @@ import type { LocalAgentRuntimeContext, } from "./local-agent-runtime.js"; +const OPENCODE_SESSION_POLL_INTERVAL_MS = 250; +const OPENCODE_SESSION_POLL_TIMEOUT_MS = 5 * 60_000; + export type OpencodeClientLike = Pick; export interface OpencodeServerLike { @@ -71,7 +74,7 @@ export class OpencodeRuntime implements LocalAgentRuntime { await this.client.v2.session.switchModel({ sessionID: sessionId, model }, { throwOnError: true }); } const promptResult = await promptOpencodeSession(this.client, sessionId, input); - await waitForOpencodeSession(this.client, sessionId); + await waitForOpencodeSession(this.client, sessionId, promptResult); const messages = await readOpencodeMessages(this.client, sessionId); const finalResponse = requireFinalResponse( extractOpenCodeFinalResponse(messages) || extractOpenCodeFinalResponse(promptResult), @@ -266,8 +269,44 @@ async function promptOpencodeSession( }, { throwOnError: true }); } -async function waitForOpencodeSession(client: OpencodeClientLike, sessionId: string): Promise { - await client.v2.session.wait({ sessionID: sessionId }, { 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); + 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( @@ -278,6 +317,45 @@ async function readOpencodeMessages( return result.data; } +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 { const separator = model.indexOf("/"); const reference = separator === -1