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 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
|
const OpencodeToolStateSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -217,6 +230,14 @@ function normalizeTurnFailureError(error: unknown): string {
|
|||||||
return normalized.length > 0 ? normalized : "Unknown error";
|
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 {
|
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));
|
||||||
@@ -726,16 +747,17 @@ export class OpenCodeAgentClient implements AgentClient {
|
|||||||
directory: options?.cwd ?? process.cwd(),
|
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) => {
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||||
setTimeout(
|
setTimeout(
|
||||||
() =>
|
() =>
|
||||||
reject(
|
reject(
|
||||||
new Error(
|
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",
|
provider: "opencode",
|
||||||
usage: undefined,
|
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.
|
// "retry" and "busy" are transient — no terminal event.
|
||||||
break;
|
break;
|
||||||
@@ -1452,7 +1482,7 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const promptResponse = await this.client.session.promptAsync({
|
void this.client.session.promptAsync({
|
||||||
sessionID: this.sessionId,
|
sessionID: this.sessionId,
|
||||||
directory: this.config.cwd,
|
directory: this.config.cwd,
|
||||||
parts,
|
parts,
|
||||||
@@ -1468,14 +1498,27 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
...(model ? { model } : {}),
|
...(model ? { model } : {}),
|
||||||
...(effectiveMode ? { agent: effectiveMode } : {}),
|
...(effectiveMode ? { agent: effectiveMode } : {}),
|
||||||
...(effectiveVariant ? { variant: effectiveVariant } : {}),
|
...(effectiveVariant ? { variant: effectiveVariant } : {}),
|
||||||
});
|
}).then((promptResponse) => {
|
||||||
|
if (promptResponse.error) {
|
||||||
if (promptResponse.error) {
|
this.finishForegroundTurn(
|
||||||
|
{
|
||||||
|
type: "turn_failed",
|
||||||
|
provider: "opencode",
|
||||||
|
error: normalizeTurnFailureError(promptResponse.error),
|
||||||
|
},
|
||||||
|
turnId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}).catch((error) => {
|
||||||
this.finishForegroundTurn(
|
this.finishForegroundTurn(
|
||||||
{ type: "turn_failed", provider: "opencode", error: normalizeTurnFailureError(promptResponse.error) },
|
{
|
||||||
|
type: "turn_failed",
|
||||||
|
provider: "opencode",
|
||||||
|
error: normalizeTurnFailureError(error),
|
||||||
|
},
|
||||||
turnId,
|
turnId,
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return { turnId };
|
return { turnId };
|
||||||
@@ -1529,6 +1572,17 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
this.notifySubscribers(e, turnId);
|
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) {
|
} catch (error) {
|
||||||
if (!turnAbortController.signal.aborted && this.activeForegroundTurnId === turnId) {
|
if (!turnAbortController.signal.aborted && this.activeForegroundTurnId === turnId) {
|
||||||
this.finishForegroundTurn(
|
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