diff --git a/packages/server/src/server/agent/providers/opencode-agent.slash-command-timeout.test.ts b/packages/server/src/server/agent/providers/opencode-agent.slash-command-timeout.test.ts new file mode 100644 index 000000000..a57590f38 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode-agent.slash-command-timeout.test.ts @@ -0,0 +1,86 @@ +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"; + +function createDeferred(): { + promise: Promise; + resolve: (value: T) => void; + reject: (error: unknown) => void; +} { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +describe("OpenCodeAgentSession slash command timeout handling", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("waits for SSE completion when slash commands hit a header timeout", async () => { + const idleEventGate = createDeferred(); + + vi.mocked(createOpencodeClient).mockReturnValue({ + session: { + create: vi.fn().mockResolvedValue({ data: { id: "session-1" } }), + command: vi.fn().mockRejectedValue(new Error("fetch failed: Headers Timeout Error")), + }, + provider: { + list: vi.fn().mockResolvedValue({ + data: { + connected: ["openai"], + all: [{ id: "openai", name: "OpenAI", models: {} }], + }, + }), + }, + event: { + subscribe: vi.fn().mockResolvedValue({ + stream: (async function* () { + await idleEventGate.promise; + yield { + type: "session.idle", + properties: { sessionID: "session-1" }, + }; + })(), + }), + }, + command: { + list: vi.fn().mockResolvedValue({ + data: [{ name: "help", description: "Show help", hints: [] }], + }), + }, + app: { + agents: vi.fn().mockResolvedValue({ data: [] }), + }, + } as never); + + 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 session = await client.createSession({ provider: "opencode", cwd: "/tmp" }); + + const runPromise = session.run("/help"); + await Promise.resolve(); + idleEventGate.resolve(); + + await expect(runPromise).resolves.toMatchObject({ + sessionId: "session-1", + finalText: "", + timeline: [], + usage: undefined, + }); + }); +}); diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 3e036bd8c..4aea0eb2e 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -107,6 +107,12 @@ const OPENCODE_FATAL_RETRY_MESSAGE_TOKENS = [ "does not exist", "unsupported model", ] as const; +const OPENCODE_HEADERS_TIMEOUT_TOKENS = [ + "headers timeout", + "headers timeout error", + "headers_timeout", + "und_err_headers_timeout", +] as const; const OpencodeToolStateSchema = z .object({ @@ -306,6 +312,49 @@ function isFatalOpenCodeRetryMessage(message: string | null | undefined): boolea return OPENCODE_FATAL_RETRY_MESSAGE_TOKENS.some((token) => normalized.includes(token)); } +function isOpenCodeHeadersTimeoutFailure(error: unknown): boolean { + const diagnostics = new Set(); + const queue: unknown[] = [error]; + + while (queue.length > 0) { + const current = queue.shift(); + if (!current) { + continue; + } + + const normalized = stringifyUnknownError(current).trim().toLowerCase(); + if (normalized) { + diagnostics.add(normalized); + } + + if (typeof current === "object") { + const record = current as { + message?: unknown; + code?: unknown; + name?: unknown; + cause?: unknown; + }; + + for (const value of [record.message, record.code, record.name]) { + if (typeof value === "string") { + const diagnostic = value.trim().toLowerCase(); + if (diagnostic) { + diagnostics.add(diagnostic); + } + } + } + + if (record.cause) { + queue.push(record.cause); + } + } + } + + return [...diagnostics].some((diagnostic) => + OPENCODE_HEADERS_TIMEOUT_TOKENS.some((token) => diagnostic.includes(token)), + ); +} + function isAlreadyPresentMcpError(error: unknown): boolean { const normalized = stringifyUnknownError(error).toLowerCase(); return MCP_ALREADY_PRESENT_ERROR_TOKENS.some((token) => normalized.includes(token)); @@ -1598,6 +1647,17 @@ class OpenCodeAgentSession implements AgentSession { }) .then((response) => { if (response.error) { + if (isOpenCodeHeadersTimeoutFailure(response.error)) { + this.logger.warn( + { + err: response.error, + commandName: slashCommand.commandName, + turnId, + }, + "OpenCode slash command hit a header timeout; waiting for SSE terminal event", + ); + return; + } const errorMsg = normalizeTurnFailureError(response.error); this.finishForegroundTurn( { type: "turn_failed", provider: "opencode", error: errorMsg }, @@ -1611,6 +1671,17 @@ class OpenCodeAgentSession implements AgentSession { } }) .catch((err) => { + if (isOpenCodeHeadersTimeoutFailure(err)) { + this.logger.warn( + { + err, + commandName: slashCommand.commandName, + turnId, + }, + "OpenCode slash command hit a header timeout; waiting for SSE terminal event", + ); + return; + } this.finishForegroundTurn( { type: "turn_failed", provider: "opencode", error: normalizeTurnFailureError(err) }, turnId,