fix(opencode): Wait for SSE after slash command timeouts (#407)
Treat OpenCode slash-command header timeouts as recoverable transport failures so Paseo waits for the SSE terminal event instead of failing the turn immediately. Add a regression test for the timeout path to keep slash commands aligned with the existing event-stream completion flow.
This commit is contained in:
+86
@@ -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<T>(): {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
reject: (error: unknown) => void;
|
||||
} {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const promise = new Promise<T>((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<void>();
|
||||
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string>();
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user