From d39414064ee136a5ea7940c1fc8e979cfb7f45d4 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 9 Apr 2026 00:25:30 +0700 Subject: [PATCH] Fix opencode terminal states and model refresh --- ...code-agent.error-handling.real.e2e.test.ts | 188 ++++++++++++++++++ ...opencode-agent.list-models-timeout.test.ts | 69 +++++++ .../server/agent/providers/opencode-agent.ts | 72 ++++++- ...ncode-initial-prompt-wait.real.e2e.test.ts | 113 +++++++++++ .../opencode-invalid-model.real.e2e.test.ts | 61 ++++++ 5 files changed, 494 insertions(+), 9 deletions(-) create mode 100644 packages/server/src/server/agent/providers/opencode-agent.error-handling.real.e2e.test.ts create mode 100644 packages/server/src/server/agent/providers/opencode-agent.list-models-timeout.test.ts create mode 100644 packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts create mode 100644 packages/server/src/server/daemon-e2e/opencode-invalid-model.real.e2e.test.ts diff --git a/packages/server/src/server/agent/providers/opencode-agent.error-handling.real.e2e.test.ts b/packages/server/src/server/agent/providers/opencode-agent.error-handling.real.e2e.test.ts new file mode 100644 index 000000000..d59ea9831 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode-agent.error-handling.real.e2e.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, test } from "vitest"; +import pino from "pino"; + +import type { AgentStreamEvent } from "../agent-sdk-types.js"; +import { isCommandAvailableSync } from "../../../utils/executable.js"; +import { OpenCodeAgentClient } from "./opencode-agent.js"; +import { streamSession } from "./test-utils/session-stream-adapter.js"; + +function isTerminalEvent(event: AgentStreamEvent): boolean { + return ( + event.type === "turn_completed" || + event.type === "turn_failed" || + event.type === "turn_canceled" + ); +} + +/** + * Real e2e tests for OpenCode error handling. + * + * Validates that OpenCode surfaces errors properly instead of hanging forever + * when models are invalid, auth fails, or provider API calls fail. + */ +describe("opencode agent error handling (real)", () => { + test.runIf(isCommandAvailableSync("opencode"))( + "surfaces error for the exact opencode/ path used by paseo run", + async () => { + const client = new OpenCodeAgentClient(pino({ level: "silent" })); + const session = await client.createSession({ + provider: "opencode", + cwd: process.cwd(), + modeId: "build", + }); + + try { + await session.setModel("opencode/adklasldkdas"); + + const events: AgentStreamEvent[] = []; + for await (const event of streamSession(session, "hello")) { + events.push(event); + if (isTerminalEvent(event)) break; + } + + const terminal = events.find(isTerminalEvent); + expect(terminal).toBeDefined(); + expect(terminal!.type).toBe("turn_failed"); + } finally { + await session.close().catch(() => undefined); + } + }, + 45_000, + ); + + test.runIf(isCommandAvailableSync("opencode"))( + "surfaces error for unknown provider model (fast path)", + async () => { + const client = new OpenCodeAgentClient(pino({ level: "silent" })); + const session = await client.createSession({ + provider: "opencode", + cwd: process.cwd(), + modeId: "build", + }); + + try { + await session.setModel("bogus-provider/totally-fake-model-12345"); + + const events: AgentStreamEvent[] = []; + for await (const event of streamSession(session, "Say hello")) { + events.push(event); + if (isTerminalEvent(event)) break; + } + + const terminal = events.find(isTerminalEvent); + expect(terminal).toBeDefined(); + expect(terminal!.type).toBe("turn_failed"); + } finally { + await session.close().catch(() => undefined); + } + }, + 30_000, + ); + + test.runIf(isCommandAvailableSync("opencode"))( + "sequential sessions: second session works after first errors", + async () => { + const client = new OpenCodeAgentClient(pino({ level: "silent" })); + + // Session 1: bogus model, will error quickly + const s1 = await client.createSession({ + provider: "opencode", + cwd: process.cwd(), + modeId: "build", + }); + await s1.setModel("bogus-provider/fake-model-12345"); + for await (const event of streamSession(s1, "Say hello")) { + if (isTerminalEvent(event)) break; + } + await s1.close(); + + // Session 2: different bogus model, should also work + const s2 = await client.createSession({ + provider: "opencode", + cwd: process.cwd(), + modeId: "build", + }); + await s2.setModel("bogus-provider/fake-model-67890"); + + const events: AgentStreamEvent[] = []; + for await (const event of streamSession(s2, "Say hello")) { + events.push(event); + if (isTerminalEvent(event)) break; + } + await s2.close().catch(() => undefined); + + const terminal = events.find(isTerminalEvent); + expect(terminal).toBeDefined(); + expect(terminal!.type).toBe("turn_failed"); + }, + 30_000, + ); + + test.runIf(isCommandAvailableSync("opencode"))( + "surfaces error for known provider with nonexistent model (retry path)", + async () => { + // When the provider is recognized (anthropic) but the model doesn't exist, + // OpenCode retries before surfacing the error. This must not hang. + const client = new OpenCodeAgentClient(pino({ level: "silent" })); + const session = await client.createSession({ + provider: "opencode", + cwd: process.cwd(), + modeId: "build", + }); + + try { + await session.setModel("anthropic/claude-nonexistent-99"); + + const events: AgentStreamEvent[] = []; + const start = Date.now(); + for await (const event of streamSession(session, "Say hello")) { + events.push(event); + if (isTerminalEvent(event)) break; + } + const elapsed = Date.now() - start; + + const terminal = events.find(isTerminalEvent); + expect(terminal).toBeDefined(); + expect(elapsed).toBeLessThan(30_000); + console.log( + `[nonexistent model] elapsed=${elapsed}ms terminal=${terminal!.type}`, + ); + } finally { + await session.close().catch(() => undefined); + } + }, + 45_000, + ); + + test.runIf(isCommandAvailableSync("opencode"))( + "surfaces fatal retry status from zai/glm-5.1 instead of hanging forever", + async () => { + const client = new OpenCodeAgentClient(pino({ level: "silent" })); + const session = await client.createSession({ + provider: "opencode", + cwd: process.cwd(), + modeId: "build", + }); + + try { + await session.setModel("zai/glm-5.1"); + + const events: AgentStreamEvent[] = []; + for await (const event of streamSession(session, "Say hello")) { + events.push(event); + if (isTerminalEvent(event)) break; + } + + const terminal = events.find(isTerminalEvent); + expect(terminal).toBeDefined(); + expect(terminal!.type).toBe("turn_failed"); + expect( + (terminal!.type === "turn_failed" ? terminal!.error : "").toLowerCase(), + ).toMatch(/insufficient balance|resource package|recharge/); + } finally { + await session.close().catch(() => undefined); + } + }, + 45_000, + ); +}); 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 new file mode 100644 index 000000000..0e0a7f362 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode-agent.list-models-timeout.test.ts @@ -0,0 +1,69 @@ +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 { createTestLogger } from "../../../test-utils/test-logger.js"; +import { OpenCodeAgentClient, OpenCodeServerManager } from "./opencode-agent.js"; + +describe("OpenCodeAgentClient.listModels timeout", () => { + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + test("allows a slow provider.list call to succeed instead of failing after 10 seconds", async () => { + vi.useFakeTimers(); + + const providerList = vi.fn( + () => + new Promise((resolve) => { + setTimeout(() => { + resolve({ + data: { + connected: ["zai"], + all: [ + { + id: "zai", + name: "Z.AI", + models: { + "glm-5.1": { + name: "GLM 5.1", + limit: { context: 128_000 }, + }, + }, + }, + ], + }, + }); + }, 15_000); + }), + ); + + vi.mocked(createOpencodeClient).mockReturnValue({ + provider: { + list: providerList, + }, + } as never); + + vi.spyOn(OpenCodeServerManager, "getInstance").mockReturnValue({ + ensureRunning: vi.fn().mockResolvedValue({ port: 1234, url: "http://127.0.0.1:1234" }), + } as never); + + const client = new OpenCodeAgentClient(createTestLogger()); + const modelsPromise = client.listModels(); + + await vi.advanceTimersByTimeAsync(15_000); + + await expect(modelsPromise).resolves.toMatchObject([ + { + provider: "opencode", + id: "zai/glm-5.1", + label: "GLM 5.1", + }, + ]); + }); +}); diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 9ac9eba51..126003918 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -93,6 +93,19 @@ type OpenCodeMcpConfig = }; const MCP_ALREADY_PRESENT_ERROR_TOKENS = ["already", "exists", "connected"] as const; +const OPENCODE_PROVIDER_LIST_TIMEOUT_MS = 30_000; +const OPENCODE_FATAL_RETRY_MESSAGE_TOKENS = [ + "insufficient balance", + "no resource package", + "please recharge", + "invalid api key", + "unauthorized", + "authentication", + "model not found", + "unknown model", + "does not exist", + "unsupported model", +] as const; const OpencodeToolStateSchema = z .object({ @@ -217,6 +230,14 @@ function normalizeTurnFailureError(error: unknown): string { return normalized.length > 0 ? normalized : "Unknown error"; } +function isFatalOpenCodeRetryMessage(message: string | null | undefined): boolean { + const normalized = typeof message === "string" ? message.trim().toLowerCase() : ""; + if (!normalized) { + return false; + } + return OPENCODE_FATAL_RETRY_MESSAGE_TOKENS.some((token) => normalized.includes(token)); +} + function isAlreadyPresentMcpError(error: unknown): boolean { const normalized = stringifyUnknownError(error).toLowerCase(); return MCP_ALREADY_PRESENT_ERROR_TOKENS.some((token) => normalized.includes(token)); @@ -726,16 +747,17 @@ export class OpenCodeAgentClient implements AgentClient { directory: options?.cwd ?? process.cwd(), }); - // Set a timeout for the API call to fail fast if OpenCode isn't responding + // Background model discovery can be legitimately slow while OpenCode refreshes + // provider state, so allow longer than turn execution paths. const timeoutPromise = new Promise((_, reject) => { setTimeout( () => reject( new Error( - "OpenCode provider.list timed out after 10s - server may not be authenticated or connected to any providers", + `OpenCode provider.list timed out after ${OPENCODE_PROVIDER_LIST_TIMEOUT_MS / 1000}s - server may not be authenticated or connected to any providers`, ), ), - 10_000, + OPENCODE_PROVIDER_LIST_TIMEOUT_MS, ); }); @@ -1215,6 +1237,14 @@ export function translateOpenCodeEvent( provider: "opencode", usage: undefined, }); + } else if (status.type === "retry" && isFatalOpenCodeRetryMessage(status.message)) { + state.streamedPartKeys.clear(); + state.partTypes.clear(); + events.push({ + type: "turn_failed", + provider: "opencode", + error: normalizeTurnFailureError(status.message), + }); } // "retry" and "busy" are transient — no terminal event. break; @@ -1452,7 +1482,7 @@ class OpenCodeAgentSession implements AgentSession { ); }); } else { - const promptResponse = await this.client.session.promptAsync({ + void this.client.session.promptAsync({ sessionID: this.sessionId, directory: this.config.cwd, parts, @@ -1468,14 +1498,27 @@ class OpenCodeAgentSession implements AgentSession { ...(model ? { model } : {}), ...(effectiveMode ? { agent: effectiveMode } : {}), ...(effectiveVariant ? { variant: effectiveVariant } : {}), - }); - - if (promptResponse.error) { + }).then((promptResponse) => { + if (promptResponse.error) { + this.finishForegroundTurn( + { + type: "turn_failed", + provider: "opencode", + error: normalizeTurnFailureError(promptResponse.error), + }, + turnId, + ); + } + }).catch((error) => { this.finishForegroundTurn( - { type: "turn_failed", provider: "opencode", error: normalizeTurnFailureError(promptResponse.error) }, + { + type: "turn_failed", + provider: "opencode", + error: normalizeTurnFailureError(error), + }, turnId, ); - } + }); } return { turnId }; @@ -1529,6 +1572,17 @@ class OpenCodeAgentSession implements AgentSession { this.notifySubscribers(e, turnId); } } + + if (!turnAbortController.signal.aborted && this.activeForegroundTurnId === turnId) { + this.finishForegroundTurn( + { + type: "turn_failed", + provider: "opencode", + error: "OpenCode event stream ended before the turn reached a terminal state", + }, + turnId, + ); + } } catch (error) { if (!turnAbortController.signal.aborted && this.activeForegroundTurnId === turnId) { this.finishForegroundTurn( diff --git a/packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts new file mode 100644 index 000000000..d8213d660 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, test } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import pino from "pino"; + +import { OpenCodeAgentClient } from "../agent/providers/opencode-agent.js"; +import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; +import { DaemonClient } from "../test-utils/daemon-client.js"; +import { isProviderAvailable } from "./agent-configs.js"; + +function tmpCwd(): string { + return mkdtempSync(path.join(tmpdir(), "daemon-real-opencode-init-prompt-")); +} + +async function createHarness(): Promise<{ + client: DaemonClient; + daemon: Awaited>; +}> { + const logger = pino({ level: "silent" }); + const daemon = await createTestPaseoDaemon({ + agentClients: { opencode: new OpenCodeAgentClient(logger) }, + logger, + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + await client.connect(); + await client.fetchAgents({ subscribe: { subscriptionId: "opencode-init-prompt" } }); + return { client, daemon }; +} + +describe("daemon E2E (real opencode) - initial prompt wait", () => { + test.runIf(isProviderAvailable("opencode"))( + "waitForFinish does not resolve before an initial prompt using opencode/big-pickle actually completes", + async () => { + const cwd = tmpCwd(); + const { client, daemon } = await createHarness(); + + try { + const models = await client.listProviderModels("opencode"); + expect(models.models.some((model) => model.id === "zai/glm-5.1")).toBe(true); + + const agent = await client.createAgent({ + provider: "opencode", + cwd, + title: "OpenCode initial prompt wait regression", + model: "opencode/big-pickle", + initialPrompt: "Reply with exactly: BIG_PICKLE_OK", + }); + + const finish = await client.waitForFinish(agent.id, 60_000); + expect(finish.status).toBe("idle"); + expect(finish.lastMessage).toContain("BIG_PICKLE_OK"); + + const snapshot = await client.fetchAgent(agent.id); + expect(snapshot.agent?.status).toBe("idle"); + + const timeline = await client.fetchAgentTimeline(agent.id, { + direction: "tail", + limit: 0, + projection: "projected", + }); + const assistantMessages = timeline.entries.filter( + (entry) => entry.item.type === "assistant_message", + ); + + expect(assistantMessages.length).toBeGreaterThan(0); + expect( + assistantMessages.some((entry) => entry.item.text.includes("BIG_PICKLE_OK")), + ).toBe(true); + } finally { + await client.close().catch(() => undefined); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } + }, + 90_000, + ); + + test.runIf(isProviderAvailable("opencode"))( + "waitForFinish surfaces a terminal error when zai/glm-5.1 enters a fatal retry loop", + async () => { + const cwd = tmpCwd(); + const { client, daemon } = await createHarness(); + + try { + const models = await client.listProviderModels("opencode"); + expect(models.models.some((model) => model.id === "zai/glm-5.1")).toBe(true); + + const agent = await client.createAgent({ + provider: "opencode", + cwd, + title: "OpenCode zai fatal retry regression", + model: "zai/glm-5.1", + initialPrompt: "Reply with exactly: GLM_51_OK", + }); + + const finish = await client.waitForFinish(agent.id, 60_000); + expect(finish.status).toBe("error"); + expect((finish.error ?? "").toLowerCase()).toMatch( + /insufficient balance|resource package|recharge/, + ); + + const snapshot = await client.fetchAgent(agent.id); + expect(snapshot.agent?.status).toBe("error"); + } finally { + await client.close().catch(() => undefined); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } + }, + 90_000, + ); +}); diff --git a/packages/server/src/server/daemon-e2e/opencode-invalid-model.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/opencode-invalid-model.real.e2e.test.ts new file mode 100644 index 000000000..903665674 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/opencode-invalid-model.real.e2e.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import pino from "pino"; + +import { OpenCodeAgentClient } from "../agent/providers/opencode-agent.js"; +import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; +import { DaemonClient } from "../test-utils/daemon-client.js"; +import { isProviderAvailable } from "./agent-configs.js"; + +function tmpCwd(): string { + return mkdtempSync(path.join(tmpdir(), "daemon-real-opencode-invalid-model-")); +} + +async function createHarness(): Promise<{ + client: DaemonClient; + daemon: Awaited>; +}> { + const logger = pino({ level: "silent" }); + const daemon = await createTestPaseoDaemon({ + agentClients: { opencode: new OpenCodeAgentClient(logger) }, + logger, + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + await client.connect(); + await client.fetchAgents({ subscribe: { subscriptionId: "opencode-invalid-model" } }); + return { client, daemon }; +} + +describe("daemon E2E (real opencode) - invalid model handling", () => { + test.runIf(isProviderAvailable("opencode"))( + "initial prompt with a nonexistent OpenCode model fails instead of hanging forever", + async () => { + const cwd = tmpCwd(); + const { client, daemon } = await createHarness(); + + try { + const agent = await client.createAgent({ + provider: "opencode", + cwd, + title: "OpenCode invalid model regression", + model: "opencode/adklasldkdas", + initialPrompt: "hello", + }); + + const finish = await client.waitForFinish(agent.id, 30_000); + expect(finish.status).toBe("error"); + expect(finish.error).toBeTruthy(); + + const snapshot = await client.fetchAgent(agent.id); + expect(snapshot.agent?.status).toBe("error"); + } finally { + await client.close().catch(() => undefined); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } + }, + 60_000, + ); +});