Stabilize OpenCode provider unit tests
This commit is contained in:
@@ -113,6 +113,8 @@ Vitest picks up tests by suffix. The suffix tells the runner which category it b
|
|||||||
|
|
||||||
App-level Playwright browser E2E lives in `packages/app/e2e/*.spec.ts` and runs via `npm run test:e2e --workspace=@getpaseo/app` (separate from Vitest E2E). App Playwright specs that hit real providers use `*.real.spec.ts` and run through `npm run test:e2e:real --workspace=@getpaseo/app`; the default app E2E project ignores that suffix so CI does not need provider credentials.
|
App-level Playwright browser E2E lives in `packages/app/e2e/*.spec.ts` and runs via `npm run test:e2e --workspace=@getpaseo/app` (separate from Vitest E2E). App Playwright specs that hit real providers use `*.real.spec.ts` and run through `npm run test:e2e:real --workspace=@getpaseo/app`; the default app E2E project ignores that suffix so CI does not need provider credentials.
|
||||||
|
|
||||||
|
Live provider smoke tests belong in `*.real.e2e.test.ts`, not `*.test.ts`, even when guarded by environment variables. Default unit suites must use deterministic provider adapters/fakes so missing credits, auth outages, and upstream model drift do not block normal CI.
|
||||||
|
|
||||||
### Test setup
|
### Test setup
|
||||||
|
|
||||||
- Server: `packages/server/src/test-utils/vitest-setup.ts` loads `.env.test`, sets `PASEO_SUPERVISED=0`, and disables Git/SSH prompts. Add new global env shims here, not in individual tests.
|
- Server: `packages/server/src/test-utils/vitest-setup.ts` loads `.env.test`, sets `PASEO_SUPERVISED=0`, and disables Git/SSH prompts. Add new global env shims here, not in individual tests.
|
||||||
|
|||||||
@@ -1156,7 +1156,7 @@ export class AgentManager {
|
|||||||
async setAgentMode(agentId: string, modeId: string): Promise<void> {
|
async setAgentMode(agentId: string, modeId: string): Promise<void> {
|
||||||
const agent = this.requireSessionAgent(agentId);
|
const agent = this.requireSessionAgent(agentId);
|
||||||
await agent.session.setMode(modeId);
|
await agent.session.setMode(modeId);
|
||||||
const currentMode = await agent.session.getCurrentMode();
|
const currentMode = (await agent.session.getCurrentMode()) ?? modeId;
|
||||||
agent.config.modeId = currentMode ?? undefined;
|
agent.config.modeId = currentMode ?? undefined;
|
||||||
agent.currentModeId = currentMode;
|
agent.currentModeId = currentMode;
|
||||||
// Update runtimeInfo to reflect the new mode
|
// Update runtimeInfo to reflect the new mode
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { afterAll, describe, expect, test, vi } from "vitest";
|
import { describe, expect, test, vi } from "vitest";
|
||||||
import { existsSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
import { mkdtempSync, realpathSync, rmSync } from "node:fs";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { execFileSync } from "node:child_process";
|
|
||||||
|
|
||||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||||
import type { Event as OpenCodeEvent } from "@opencode-ai/sdk/v2/client";
|
import type { Event as OpenCodeEvent } from "@opencode-ai/sdk/v2/client";
|
||||||
@@ -11,7 +10,6 @@ import {
|
|||||||
OpenCodeAgentClient,
|
OpenCodeAgentClient,
|
||||||
translateOpenCodeEvent,
|
translateOpenCodeEvent,
|
||||||
} from "./opencode-agent.js";
|
} from "./opencode-agent.js";
|
||||||
import { OpenCodeServerManager } from "./opencode/server-manager.js";
|
|
||||||
import { streamSession } from "./test-utils/session-stream-adapter.js";
|
import { streamSession } from "./test-utils/session-stream-adapter.js";
|
||||||
import {
|
import {
|
||||||
TestOpenCodeClient,
|
TestOpenCodeClient,
|
||||||
@@ -34,7 +32,7 @@ function tmpCwd(): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const TEST_MODEL = "openrouter/~openai/gpt-mini-latest";
|
const TEST_MODEL = "opencode/big-pickle";
|
||||||
|
|
||||||
interface TurnResult {
|
interface TurnResult {
|
||||||
events: AgentStreamEvent[];
|
events: AgentStreamEvent[];
|
||||||
@@ -82,20 +80,39 @@ async function collectTurnEvents(iterator: AsyncGenerator<AgentStreamEvent>): Pr
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isBinaryInstalled(binary: string): boolean {
|
function assistantTurnEvents({
|
||||||
try {
|
sessionId = "session-1",
|
||||||
const out = execFileSync("which", [binary], { encoding: "utf8" }).trim();
|
text = "Hello from OpenCode",
|
||||||
return out.length > 0;
|
}: {
|
||||||
} catch {
|
sessionId?: string;
|
||||||
return false;
|
text?: string;
|
||||||
}
|
} = {}): unknown[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: "message.updated",
|
||||||
|
properties: {
|
||||||
|
info: {
|
||||||
|
id: "msg_assistant",
|
||||||
|
sessionID: sessionId,
|
||||||
|
role: "assistant",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "message.part.delta",
|
||||||
|
properties: {
|
||||||
|
sessionID: sessionId,
|
||||||
|
messageID: "msg_assistant",
|
||||||
|
partID: "prt_text",
|
||||||
|
field: "text",
|
||||||
|
delta: text,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ type: "session.idle", properties: { sessionID: sessionId } },
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasOpenCode = isBinaryInstalled("opencode");
|
describe("OpenCodeAgentClient adapter smoke tests", () => {
|
||||||
const hasOpenRouterKey = Boolean(process.env.OPENROUTER_API_KEY);
|
|
||||||
const canRunLiveOpenCodeTurns = hasOpenCode && hasOpenRouterKey;
|
|
||||||
|
|
||||||
(canRunLiveOpenCodeTurns ? describe : describe.skip)("OpenCodeAgentClient smoke tests", () => {
|
|
||||||
const logger = createTestLogger();
|
const logger = createTestLogger();
|
||||||
const buildConfig = (cwd: string): AgentSessionConfig => ({
|
const buildConfig = (cwd: string): AgentSessionConfig => ({
|
||||||
provider: "opencode",
|
provider: "opencode",
|
||||||
@@ -103,16 +120,13 @@ const canRunLiveOpenCodeTurns = hasOpenCode && hasOpenRouterKey;
|
|||||||
model: TEST_MODEL,
|
model: TEST_MODEL,
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await OpenCodeServerManager.getInstance(logger).shutdown();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("creates a session with valid id and provider", async () => {
|
test("creates a session with valid id and provider", async () => {
|
||||||
const cwd = tmpCwd();
|
const cwd = tmpCwd();
|
||||||
const client = new OpenCodeAgentClient(logger);
|
const runtime = new TestOpenCodeRuntime();
|
||||||
|
runtime.enqueueClient(new TestOpenCodeClient());
|
||||||
|
const client = new OpenCodeAgentClient(logger, undefined, { runtime });
|
||||||
const session = await client.createSession(buildConfig(cwd));
|
const session = await client.createSession(buildConfig(cwd));
|
||||||
|
|
||||||
// HARD ASSERT: Session has required fields
|
|
||||||
expect(typeof session.id).toBe("string");
|
expect(typeof session.id).toBe("string");
|
||||||
expect(session.id.length).toBeGreaterThan(0);
|
expect(session.id.length).toBeGreaterThan(0);
|
||||||
expect(session.provider).toBe("opencode");
|
expect(session.provider).toBe("opencode");
|
||||||
@@ -123,44 +137,68 @@ const canRunLiveOpenCodeTurns = hasOpenCode && hasOpenRouterKey;
|
|||||||
|
|
||||||
test("single turn completes with streaming deltas", async () => {
|
test("single turn completes with streaming deltas", async () => {
|
||||||
const cwd = tmpCwd();
|
const cwd = tmpCwd();
|
||||||
const client = new OpenCodeAgentClient(logger);
|
const runtime = new TestOpenCodeRuntime();
|
||||||
|
const openCodeClient = new TestOpenCodeClient();
|
||||||
|
openCodeClient.sessionPromptAsyncEvents = assistantTurnEvents();
|
||||||
|
runtime.enqueueClient(openCodeClient);
|
||||||
|
const client = new OpenCodeAgentClient(logger, undefined, { runtime });
|
||||||
const session = await client.createSession(buildConfig(cwd));
|
const session = await client.createSession(buildConfig(cwd));
|
||||||
|
|
||||||
const iterator = streamSession(session, "Say hello");
|
const iterator = streamSession(session, "Say hello");
|
||||||
const turn = await collectTurnEvents(iterator);
|
const turn = await collectTurnEvents(iterator);
|
||||||
|
|
||||||
// HARD ASSERT: Turn completed successfully
|
|
||||||
expect(turn.turnCompleted).toBe(true);
|
expect(turn.turnCompleted).toBe(true);
|
||||||
expect(turn.turnFailed).toBe(false);
|
expect(turn.turnFailed).toBe(false);
|
||||||
|
|
||||||
// HARD ASSERT: Got at least one assistant message
|
|
||||||
expect(turn.assistantMessages.length).toBeGreaterThan(0);
|
expect(turn.assistantMessages.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
// HARD ASSERT: Each delta is non-empty
|
|
||||||
for (const msg of turn.assistantMessages) {
|
for (const msg of turn.assistantMessages) {
|
||||||
expect(msg.text.length).toBeGreaterThan(0);
|
expect(msg.text.length).toBeGreaterThan(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// HARD ASSERT: Concatenated deltas form non-empty response
|
|
||||||
const fullResponse = turn.assistantMessages.map((m) => m.text).join("");
|
const fullResponse = turn.assistantMessages.map((m) => m.text).join("");
|
||||||
expect(fullResponse.length).toBeGreaterThan(0);
|
expect(fullResponse).toBe("Hello from OpenCode");
|
||||||
|
expect(openCodeClient.calls.sessionPromptAsync).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
sessionID: "session-1",
|
||||||
|
directory: cwd,
|
||||||
|
model: { providerID: "opencode", modelID: "big-pickle" },
|
||||||
|
agent: "build",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
await session.close();
|
await session.close();
|
||||||
rmSync(cwd, { recursive: true, force: true });
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
}, 120_000);
|
}, 120_000);
|
||||||
|
|
||||||
test("listModels returns models with required fields", async () => {
|
test("listModels returns models with required fields", async () => {
|
||||||
const client = new OpenCodeAgentClient(logger);
|
const runtime = new TestOpenCodeRuntime();
|
||||||
const models = await client.listModels({ cwd: os.homedir(), force: false });
|
const openCodeClient = new TestOpenCodeClient();
|
||||||
|
openCodeClient.providerListResponse = {
|
||||||
|
data: {
|
||||||
|
connected: ["opencode"],
|
||||||
|
all: [
|
||||||
|
{
|
||||||
|
id: "opencode",
|
||||||
|
name: "OpenCode",
|
||||||
|
source: "api",
|
||||||
|
models: {
|
||||||
|
"big-pickle": {
|
||||||
|
name: "Big Pickle",
|
||||||
|
limit: {
|
||||||
|
context: 200_000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
runtime.enqueueClient(openCodeClient);
|
||||||
|
const client = new OpenCodeAgentClient(logger, undefined, { runtime });
|
||||||
|
const cwd = os.homedir();
|
||||||
|
const models = await client.listModels({ cwd, force: false });
|
||||||
|
|
||||||
// HARD ASSERT: Returns an array
|
|
||||||
expect(Array.isArray(models)).toBe(true);
|
expect(Array.isArray(models)).toBe(true);
|
||||||
|
expect(models).toHaveLength(1);
|
||||||
|
|
||||||
// HARD ASSERT: At least one model is returned (OpenCode has connected providers)
|
|
||||||
expect(models.length).toBeGreaterThan(0);
|
|
||||||
expect(models.some((model) => model.id === TEST_MODEL)).toBe(true);
|
|
||||||
|
|
||||||
// HARD ASSERT: Each model has required fields with correct types
|
|
||||||
for (const model of models) {
|
for (const model of models) {
|
||||||
expect(model.provider).toBe("opencode");
|
expect(model.provider).toBe("opencode");
|
||||||
expect(typeof model.id).toBe("string");
|
expect(typeof model.id).toBe("string");
|
||||||
@@ -174,17 +212,25 @@ const canRunLiveOpenCodeTurns = hasOpenCode && hasOpenRouterKey;
|
|||||||
providerId: expect.any(String),
|
providerId: expect.any(String),
|
||||||
modelId: expect.any(String),
|
modelId: expect.any(String),
|
||||||
});
|
});
|
||||||
// contextWindowMaxTokens is upstream-provided and may be absent for some
|
expect(typeof model.metadata?.contextWindowMaxTokens).toBe("number");
|
||||||
// OpenCode-routed providers; assert the type only when present.
|
|
||||||
if (model.metadata?.contextWindowMaxTokens !== undefined) {
|
|
||||||
expect(typeof model.metadata.contextWindowMaxTokens).toBe("number");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
expect(models[0]).toMatchObject({
|
||||||
|
id: TEST_MODEL,
|
||||||
|
label: "Big Pickle",
|
||||||
|
metadata: {
|
||||||
|
providerId: "opencode",
|
||||||
|
modelId: "big-pickle",
|
||||||
|
contextWindowMaxTokens: 200_000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(openCodeClient.calls.providerList).toEqual([{ directory: cwd }]);
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
|
|
||||||
test("available modes include build and plan", async () => {
|
test("available modes include build and plan", async () => {
|
||||||
const cwd = tmpCwd();
|
const cwd = tmpCwd();
|
||||||
const client = new OpenCodeAgentClient(logger);
|
const runtime = new TestOpenCodeRuntime();
|
||||||
|
runtime.enqueueClient(new TestOpenCodeClient());
|
||||||
|
const client = new OpenCodeAgentClient(logger, undefined, { runtime });
|
||||||
const session = await client.createSession(buildConfig(cwd));
|
const session = await client.createSession(buildConfig(cwd));
|
||||||
|
|
||||||
const modes = await session.getAvailableModes();
|
const modes = await session.getAvailableModes();
|
||||||
@@ -198,19 +244,23 @@ const canRunLiveOpenCodeTurns = hasOpenCode && hasOpenRouterKey;
|
|||||||
|
|
||||||
test("custom agents defined in opencode.json appear in available modes", async () => {
|
test("custom agents defined in opencode.json appear in available modes", async () => {
|
||||||
const cwd = tmpCwd();
|
const cwd = tmpCwd();
|
||||||
writeFileSync(
|
const runtime = new TestOpenCodeRuntime();
|
||||||
path.join(cwd, "opencode.json"),
|
const openCodeClient = new TestOpenCodeClient();
|
||||||
JSON.stringify({
|
openCodeClient.appAgentsResponse = {
|
||||||
agent: {
|
data: [
|
||||||
"paseo-test-custom": {
|
{
|
||||||
description: "Custom agent defined for Paseo integration test",
|
name: "paseo-test-custom",
|
||||||
mode: "primary",
|
description: "Custom agent defined for Paseo integration test",
|
||||||
},
|
mode: "primary",
|
||||||
},
|
},
|
||||||
}),
|
{ name: "compaction", mode: "subagent" },
|
||||||
);
|
{ name: "summary", mode: "subagent" },
|
||||||
|
{ name: "title", mode: "subagent" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
runtime.enqueueClient(openCodeClient);
|
||||||
|
|
||||||
const client = new OpenCodeAgentClient(logger);
|
const client = new OpenCodeAgentClient(logger, undefined, { runtime });
|
||||||
const session = await client.createSession(buildConfig(cwd));
|
const session = await client.createSession(buildConfig(cwd));
|
||||||
|
|
||||||
const modes = await session.getAvailableModes();
|
const modes = await session.getAvailableModes();
|
||||||
@@ -232,10 +282,46 @@ const canRunLiveOpenCodeTurns = hasOpenCode && hasOpenRouterKey;
|
|||||||
rmSync(cwd, { recursive: true, force: true });
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
|
|
||||||
test("plan mode blocks edits while build mode can write files", async () => {
|
test("plan and build modes are sent to OpenCode as distinct runtime agents", async () => {
|
||||||
const cwd = tmpCwd();
|
const cwd = tmpCwd();
|
||||||
const planFile = path.join(cwd, "plan-mode-output.txt");
|
const runtime = new TestOpenCodeRuntime();
|
||||||
const client = new OpenCodeAgentClient(logger);
|
const planOpenCodeClient = new TestOpenCodeClient();
|
||||||
|
planOpenCodeClient.sessionPromptAsyncEvents = assistantTurnEvents({ text: "Plan response" });
|
||||||
|
const buildOpenCodeClient = new TestOpenCodeClient();
|
||||||
|
buildOpenCodeClient.sessionPromptAsyncEvents = [
|
||||||
|
{
|
||||||
|
type: "message.updated",
|
||||||
|
properties: {
|
||||||
|
info: {
|
||||||
|
id: "msg_assistant",
|
||||||
|
sessionID: "session-1",
|
||||||
|
role: "assistant",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "message.part.updated",
|
||||||
|
properties: {
|
||||||
|
part: {
|
||||||
|
id: "prt_tool",
|
||||||
|
sessionID: "session-1",
|
||||||
|
messageID: "msg_assistant",
|
||||||
|
type: "tool",
|
||||||
|
tool: "write",
|
||||||
|
callID: "call_write",
|
||||||
|
state: {
|
||||||
|
status: "completed",
|
||||||
|
input: { filePath: "build-mode-output.txt", content: "hello" },
|
||||||
|
output: "created build-mode-output.txt",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...assistantTurnEvents({ text: "Build response" }),
|
||||||
|
];
|
||||||
|
runtime.enqueueClient(planOpenCodeClient);
|
||||||
|
runtime.enqueueClient(buildOpenCodeClient);
|
||||||
|
const client = new OpenCodeAgentClient(logger, undefined, { runtime });
|
||||||
|
|
||||||
const planSession = await client.createSession({
|
const planSession = await client.createSession({
|
||||||
...buildConfig(cwd),
|
...buildConfig(cwd),
|
||||||
@@ -251,8 +337,14 @@ const canRunLiveOpenCodeTurns = hasOpenCode && hasOpenRouterKey;
|
|||||||
|
|
||||||
expect(planTurn.turnCompleted).toBe(true);
|
expect(planTurn.turnCompleted).toBe(true);
|
||||||
expect(planTurn.turnFailed).toBe(false);
|
expect(planTurn.turnFailed).toBe(false);
|
||||||
expect(existsSync(planFile)).toBe(false);
|
|
||||||
expect(planTurn.toolCalls).toHaveLength(0);
|
expect(planTurn.toolCalls).toHaveLength(0);
|
||||||
|
expect(planOpenCodeClient.calls.sessionPromptAsync).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
sessionID: "session-1",
|
||||||
|
directory: cwd,
|
||||||
|
agent: "plan",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
const planResponse = planTurn.assistantMessages
|
const planResponse = planTurn.assistantMessages
|
||||||
.map((message) => message.text)
|
.map((message) => message.text)
|
||||||
@@ -277,6 +369,13 @@ const canRunLiveOpenCodeTurns = hasOpenCode && hasOpenRouterKey;
|
|||||||
expect(buildTurn.turnCompleted).toBe(true);
|
expect(buildTurn.turnCompleted).toBe(true);
|
||||||
expect(buildTurn.turnFailed).toBe(false);
|
expect(buildTurn.turnFailed).toBe(false);
|
||||||
expect(buildTurn.toolCalls.some((toolCall) => toolCall.status === "completed")).toBe(true);
|
expect(buildTurn.toolCalls.some((toolCall) => toolCall.status === "completed")).toBe(true);
|
||||||
|
expect(buildOpenCodeClient.calls.sessionPromptAsync).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
sessionID: "session-1",
|
||||||
|
directory: cwd,
|
||||||
|
agent: "build",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
const buildResponse = buildTurn.assistantMessages
|
const buildResponse = buildTurn.assistantMessages
|
||||||
.map((message) => message.text)
|
.map((message) => message.text)
|
||||||
|
|||||||
Reference in New Issue
Block a user