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",
|
"does not exist",
|
||||||
"unsupported model",
|
"unsupported model",
|
||||||
] as const;
|
] as const;
|
||||||
|
const OPENCODE_HEADERS_TIMEOUT_TOKENS = [
|
||||||
|
"headers timeout",
|
||||||
|
"headers timeout error",
|
||||||
|
"headers_timeout",
|
||||||
|
"und_err_headers_timeout",
|
||||||
|
] as const;
|
||||||
|
|
||||||
const OpencodeToolStateSchema = z
|
const OpencodeToolStateSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -306,6 +312,49 @@ function isFatalOpenCodeRetryMessage(message: string | null | undefined): boolea
|
|||||||
return OPENCODE_FATAL_RETRY_MESSAGE_TOKENS.some((token) => normalized.includes(token));
|
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 {
|
function isAlreadyPresentMcpError(error: unknown): boolean {
|
||||||
const normalized = stringifyUnknownError(error).toLowerCase();
|
const normalized = stringifyUnknownError(error).toLowerCase();
|
||||||
return MCP_ALREADY_PRESENT_ERROR_TOKENS.some((token) => normalized.includes(token));
|
return MCP_ALREADY_PRESENT_ERROR_TOKENS.some((token) => normalized.includes(token));
|
||||||
@@ -1598,6 +1647,17 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
})
|
})
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
if (response.error) {
|
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);
|
const errorMsg = normalizeTurnFailureError(response.error);
|
||||||
this.finishForegroundTurn(
|
this.finishForegroundTurn(
|
||||||
{ type: "turn_failed", provider: "opencode", error: errorMsg },
|
{ type: "turn_failed", provider: "opencode", error: errorMsg },
|
||||||
@@ -1611,6 +1671,17 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.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(
|
this.finishForegroundTurn(
|
||||||
{ type: "turn_failed", provider: "opencode", error: normalizeTurnFailureError(err) },
|
{ type: "turn_failed", provider: "opencode", error: normalizeTurnFailureError(err) },
|
||||||
turnId,
|
turnId,
|
||||||
|
|||||||
Reference in New Issue
Block a user