Fix opencode terminal states and model refresh
This commit is contained in:
+188
@@ -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/<bad-model> 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,
|
||||
);
|
||||
});
|
||||
@@ -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",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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<never>((_, 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(
|
||||
|
||||
@@ -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<ReturnType<typeof createTestPaseoDaemon>>;
|
||||
}> {
|
||||
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,
|
||||
);
|
||||
});
|
||||
@@ -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<ReturnType<typeof createTestPaseoDaemon>>;
|
||||
}> {
|
||||
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,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user