Files
paseo/packages/server/src/server/agent/providers/opencode-bridge.local.e2e.test.ts
T
paseo-bot[bot]andgszep 91154ba4e8 fix(opencode): advertise agent-scoped bridge tool schemas (#7383)
Co-authored-by: gszep <contact@gszep.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-19 16:58:21 +02:00

494 lines
18 KiB
TypeScript

import { OpenCodeRuntimeClient } from "./opencode/runtime-client.js";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client";
import { expect, test } from "vitest";
import { execCommand } from "../../../utils/spawn.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import {
getRealProviderConfig,
getRealProviderRuntimeSettings,
} from "../../daemon-e2e/real-provider-test-config.js";
import type { PaseoToolCatalog } from "../tools/types.js";
import { AgentManager } from "../agent-manager.js";
import { AgentStorage } from "../agent-storage.js";
import { ProviderSnapshotManager } from "../provider-snapshot-manager.js";
import { createPaseoAgentToolManifest, createPaseoToolCatalog } from "../tools/paseo-tools.js";
import { serializePaseoToolInputParameters } from "../tools/paseo-tool-serialization.js";
import { OpenCodeAgentClient } from "./opencode-agent.js";
import { OpenCodeV2AgentClient } from "./opencode/v2/agent.js";
import { OpenCodeBridge } from "./opencode/bridge.js";
import { OpenCodeServerManager } from "./opencode/server-manager.js";
import {
drainPersistedTimeline,
readAssistantText,
requireSessionId,
} from "./opencode/test-utils/v2-local-e2e-helpers.js";
test("real OpenCode server advertises agent-scoped notification descriptions", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-opencode-manifest-real-"));
const logger = createTestLogger();
const providerSnapshotManager = new ProviderSnapshotManager({ logger });
const dependencies = {
agentManager: new AgentManager({ logger }),
agentStorage: new AgentStorage(path.join(root, "agents"), logger),
providerSnapshotManager,
logger,
};
const bridge = new OpenCodeBridge({ paseoHome: root, logger });
bridge.setManifestCatalog(createPaseoAgentToolManifest(dependencies));
const manager = new OpenCodeServerManager({
logger,
resolveHomeDir: () => root,
runtimeSettings: {
env: {
XDG_CONFIG_HOME: path.join(root, "config"),
XDG_DATA_HOME: path.join(root, "data"),
XDG_CACHE_HOME: path.join(root, "cache"),
OPENCODE_DISABLE_AUTO_UPDATE: "1",
},
},
decorateServerEnv: (env) => bridge.decorateServerEnv(env),
});
let inspection: Awaited<ReturnType<OpenCodeServerManager["acquireCurrent"]>> | undefined;
try {
await bridge.start();
inspection = await manager.acquireCurrent();
const sdk = createOpencodeClient({ baseUrl: inspection.server.url, directory: root });
const response = await sdk.tool.list({ directory: root, provider: "openai", model: "gpt-5.4" });
expect(response.error).toBeUndefined();
const agentCatalog = createPaseoToolCatalog({ ...dependencies, callerAgentId: "caller" });
for (const name of ["create_agent", "send_agent_prompt"]) {
const actual = response.data!.find((tool) => tool.id === `paseo_${name}`)!;
expect(actual).toBeDefined();
const expected = serializePaseoToolInputParameters(agentCatalog.getTool(name)!);
expect(actual.parameters).toMatchObject({
properties: {
notifyOnFinish: {
description: (expected.properties as Record<string, { description: string }>)
.notifyOnFinish.description,
},
},
});
}
} finally {
await inspection?.release();
await manager.shutdown();
await bridge.close();
await providerSnapshotManager.shutdown();
await rm(root, { recursive: true, force: true });
}
}, 120_000);
test("real OpenCode server persists provider permissions across creation and resume", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-opencode-permissions-"));
const cwd = path.join(root, "repo");
const logger = createTestLogger();
const manager = new OpenCodeServerManager({ logger, resolveHomeDir: () => root });
const client = new OpenCodeAgentClient(logger, undefined, { serverManager: manager });
let session: Awaited<ReturnType<OpenCodeAgentClient["createSession"]>> | undefined;
let inspection: Awaited<ReturnType<OpenCodeServerManager["acquireCurrent"]>> | undefined;
try {
await mkdir(cwd);
session = await client.createSession({
provider: "opencode",
cwd,
providerOptions: { permission: { external_directory: "allow" } },
});
inspection = await manager.acquireCurrent();
const sdk = createOpencodeClient({ baseUrl: inspection.server.url, directory: cwd });
async function readPermission(sessionId: string) {
const response = await sdk.session.get({ sessionID: sessionId, directory: cwd });
if (response.error) throw new Error(JSON.stringify(response.error));
return response.data?.permission;
}
const handle = session.describePersistence()!;
expect(await readPermission(handle.sessionId)).toEqual([
{ permission: "external_directory", pattern: "*", action: "allow" },
]);
await session.close();
// OpenCode appends session-update rules and the last matching rule wins, so a resume
// with a different policy leaves both entries in order.
session = await client.resumeSession(handle, {
providerOptions: { permission: { external_directory: "deny" } },
});
expect(await readPermission(handle.sessionId)).toEqual([
{ permission: "external_directory", pattern: "*", action: "allow" },
{ permission: "external_directory", pattern: "*", action: "deny" },
]);
} finally {
await inspection?.release();
await session?.close();
await manager.shutdown();
await rm(root, { recursive: true, force: true });
}
}, 120_000);
test("real OpenCode server shares one process while shell.env stays session-scoped", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-opencode-real-"));
const firstCwd = path.join(root, "first");
const secondCwd = path.join(root, "second");
const logger = createTestLogger();
const bridge = new OpenCodeBridge({ paseoHome: root, logger });
await bridge.start();
const firstTools = createCallerCatalog("real-agent-one");
const secondTools = createCallerCatalog("real-agent-two");
bridge.setManifestCatalog(firstTools);
const manager = new OpenCodeServerManager({
logger,
resolveHomeDir: () => root,
decorateServerEnv: (env) => bridge.decorateServerEnv(env),
});
const client = new OpenCodeAgentClient(logger, undefined, {
serverManager: manager,
bridge,
});
let first: Awaited<ReturnType<OpenCodeAgentClient["createSession"]>> | undefined;
let second: Awaited<ReturnType<OpenCodeAgentClient["createSession"]>> | undefined;
let inspection: Awaited<ReturnType<OpenCodeServerManager["acquireCurrent"]>> | undefined;
try {
await Promise.all([
mkdir(firstCwd, { recursive: true }),
mkdir(secondCwd, { recursive: true }),
]);
first = await client.createSession(
{
provider: "opencode",
cwd: firstCwd,
model: process.env.OPENCODE_TEST_MODEL ?? "opencode/big-pickle",
modeId: "build",
},
{
agentId: "real-agent-one",
env: { PASEO_AGENT_ID: "real-agent-one", PASEO_AGENT_CWD: firstCwd },
paseoTools: firstTools,
},
{ persistSession: false },
);
second = await client.createSession(
{ provider: "opencode", cwd: secondCwd },
{
agentId: "real-agent-two",
env: { PASEO_AGENT_ID: "real-agent-two", PASEO_AGENT_CWD: secondCwd },
paseoTools: secondTools,
},
{ persistSession: false },
);
inspection = await manager.acquireCurrent();
const sdk = createOpencodeClient({ baseUrl: inspection.server.url, directory: root });
const [firstShell, secondShell] = await Promise.all([
sdk.session.shell({
sessionID: requireSessionId(first),
directory: firstCwd,
agent: "build",
command: 'printf "%s|%s" "$PASEO_AGENT_ID" "$PASEO_AGENT_CWD"',
}),
sdk.session.shell({
sessionID: requireSessionId(second),
directory: secondCwd,
agent: "build",
command: 'printf "%s|%s" "$PASEO_AGENT_ID" "$PASEO_AGENT_CWD"',
}),
]);
expect(firstShell.error).toBeUndefined();
expect(secondShell.error).toBeUndefined();
expect(JSON.stringify(firstShell.data)).toContain(`real-agent-one|${firstCwd}`);
expect(JSON.stringify(secondShell.data)).toContain(`real-agent-two|${secondCwd}`);
expect(inspection.server.url).toMatch(/^http:\/\/127\.0\.0\.1:/);
// Keep the prompt assertions independent of asynchronous events from the direct shell API.
await first.close();
first = await client.createSession(
{
provider: "opencode",
cwd: firstCwd,
model: process.env.OPENCODE_TEST_MODEL ?? "opencode/big-pickle",
modeId: "build",
},
{
agentId: "real-agent-one",
env: { PASEO_AGENT_ID: "real-agent-one", PASEO_AGENT_CWD: firstCwd },
paseoTools: firstTools,
},
{ persistSession: false },
);
const agentResult = await first.run(
[
"Use the bash tool to run: env | grep -E '^(PASEO_AGENT_ID|PASEO_AGENT_CWD)='",
"Then report both values in your response:",
"AGENT=real-agent-one",
`CWD=${firstCwd}`,
].join("\n"),
);
expect(agentResult.timeline).toEqual(
expect.arrayContaining([
expect.objectContaining({ type: "tool_call", name: "bash", status: "completed" }),
]),
);
const envReport = readAssistantText(agentResult.timeline);
expect(envReport).toContain("real-agent-one");
expect(envReport).toContain(firstCwd);
const callerResult = await first.run(
[
"Use the paseo_report_caller_agent_id tool to read your Paseo caller agent ID.",
"Then report that ID in your response.",
].join("\n"),
);
expect(callerResult.timeline).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: "tool_call",
name: "paseo_report_caller_agent_id",
status: "completed",
}),
]),
);
expect(readAssistantText(callerResult.timeline)).toContain("real-agent-one");
} finally {
await inspection?.release();
await first?.close();
await second?.close();
await manager.shutdown();
await bridge.close();
await rm(root, { recursive: true, force: true });
}
}, 240_000);
test("real OpenCode server asks before running a Paseo tool with an ask rule", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-opencode-ask-"));
const cwd = path.join(root, "repo");
const logger = createTestLogger();
const bridge = new OpenCodeBridge({ paseoHome: root, logger });
await bridge.start();
const catalog = createCallerCatalog("ask-rule-agent");
bridge.setManifestCatalog(catalog);
const realSettings = getRealProviderRuntimeSettings("opencode");
const runtimeSettings = {
...realSettings,
env: {
...realSettings.env,
OPENCODE_CONFIG_CONTENT: JSON.stringify({ permission: { "paseo_*": "ask" } }),
},
};
const manager = new OpenCodeServerManager({
logger,
runtimeSettings,
resolveHomeDir: () => root,
decorateServerEnv: (env) => bridge.decorateServerEnv(env),
});
const client = new OpenCodeAgentClient(logger, runtimeSettings, {
serverManager: manager,
bridge,
});
let session: Awaited<ReturnType<OpenCodeAgentClient["createSession"]>> | undefined;
try {
await mkdir(cwd);
session = await client.createSession(
{ ...getRealProviderConfig("opencode"), cwd },
{ agentId: "ask-rule-agent", paseoTools: catalog },
{ persistSession: false },
);
const asked: string[] = [];
const completedTools: string[] = [];
const activeSession = session;
session.subscribe((event) => {
if (event.type === "permission_requested") {
asked.push(event.request.name);
void activeSession.respondToPermission(event.request.id, { behavior: "allow" });
}
if (
event.type === "timeline" &&
event.item.type === "tool_call" &&
event.item.status === "completed"
) {
completedTools.push(event.item.name);
}
});
await session.run(
"Call the paseo_report_caller_agent_id tool once, then reply with its exact result. Do not use any other tools.",
);
expect(completedTools).toContain("paseo_report_caller_agent_id");
expect(asked).toContain("paseo_report_caller_agent_id");
} finally {
await session?.close();
await manager.shutdown();
await bridge.close();
await rm(root, { recursive: true, force: true });
}
}, 180_000);
function createCallerCatalog(callerAgentId: string): PaseoToolCatalog {
const tool = {
name: "report_caller_agent_id",
title: "Report Paseo caller agent ID",
description: "Returns the caller agent ID assigned by Paseo.",
inputSchema: {},
async handler() {
return { content: [{ type: "text", text: callerAgentId }] };
},
};
const tools = new Map([[tool.name, tool]]);
return {
tools,
getTool: (name) => tools.get(name),
async executeTool(name, input, context) {
const definition = tools.get(name);
if (!definition) throw new Error(`Unknown tool: ${name}`);
return definition.handler(input, context ?? {});
},
};
}
test("v2 native tool bridge preserves caller identity", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-opencode-v2-"));
const logger = createTestLogger();
const bridge = new OpenCodeBridge({ paseoHome: root, logger });
const catalog = createCallerCatalog("paseo-v2-caller");
bridge.setManifestCatalog(catalog);
await bridge.start();
const client = new OpenCodeV2AgentClient({ logger, bridge });
let session: Awaited<ReturnType<typeof client.createSession>> | undefined;
try {
session = await client.createSession(
{
provider: "opencode",
cwd: root,
model: process.env.OPENCODE_TEST_MODEL ?? "openai/gpt-6-astra",
featureValues: { auto_accept: true },
},
{ agentId: "paseo-v2-caller", paseoTools: catalog, env: { PASEO_TEST_SCOPE: "V2_ENV_OK" } },
{ persistSession: false },
);
const result = await session.run(
"Call the paseo_report_caller_agent_id tool once, then reply with its exact result. Do not use any other tools.",
);
expect(readAssistantText(result.timeline)).toContain("paseo-v2-caller");
const environment = await session.run(
"Use the shell tool to run printf '%s' \"$PASEO_TEST_SCOPE\", then reply with its output.",
);
expect(environment.finalText).toContain("V2_ENV_OK");
expect(result.timeline).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: "tool_call",
name: "paseo_report_caller_agent_id",
status: "completed",
}),
]),
);
} finally {
await session?.interrupt();
await session?.close();
await client.shutdown();
await bridge.close();
await rm(root, { recursive: true, force: true });
}
}, 120_000);
test.each([
{
major: 1,
package: "opencode-ai@1.14.46",
windowsPackage: "opencode-windows",
expectedTimeline: [],
expectedInitialTimeline: undefined,
expectedNotices: undefined,
},
{
major: 2,
package: "@opencode/cli@2.0.10",
windowsPackage: "@opencode/cli-windows",
expectedTimeline: [
{ type: "notification", level: "info", message: "This chat uses OpenCode v2." },
],
expectedInitialTimeline: [
expect.objectContaining({
item: { type: "notification", level: "info", message: "This chat uses OpenCode v2." },
}),
],
expectedNotices: [expect.objectContaining({ major: 2 })],
},
])(
"versioned runtime v$major discovers models and preserves a native session handle",
async ({
package: cliPackage,
windowsPackage,
expectedTimeline,
expectedInitialTimeline,
expectedNotices,
}) => {
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-opencode-versioned-"));
const client = new OpenCodeRuntimeClient(createTestLogger(), {
command: {
mode: "replace",
argv: [
process.platform === "win32"
? path.join(
root,
"node_modules",
`${windowsPackage}-${process.arch}`,
"bin",
"opencode.exe",
)
: path.join(root, "node_modules", ".bin", "opencode"),
],
},
env: {
XDG_CONFIG_HOME: path.join(root, "config"),
XDG_DATA_HOME: path.join(root, "data"),
XDG_CACHE_HOME: path.join(root, "cache"),
XDG_STATE_HOME: path.join(root, "state"),
},
});
let original: Awaited<ReturnType<typeof client.createSession>> | undefined;
let resumed: Awaited<ReturnType<typeof client.resumeSession>> | undefined;
try {
await execCommand(
"npm",
["install", "--prefix", root, "--no-audit", "--no-fund", cliPackage],
{
cwd: root,
timeout: 180_000,
},
);
const catalog = await client.fetchCatalog({ scope: "workspace", cwd: root, force: true });
expect(catalog.models.length).toBeGreaterThan(0);
expect(catalog.modes.map((mode) => mode.id)).toContain("build");
original = await client.createSession(
{ provider: "opencode", cwd: root, modeId: "build" },
undefined,
{ persistSession: false },
);
const handle = await original.describePersistence();
expect(original.initialTimeline).toEqual(expectedInitialTimeline);
expect(handle.metadata?.openCodeRuntimeNotices).toEqual(expectedNotices);
resumed = await client.resumeSession(handle, { cwd: root });
expect((await resumed.describePersistence()).nativeHandle).toBe(handle.nativeHandle);
expect(
(await drainPersistedTimeline(resumed)).map((event) =>
event.type === "timeline" ? event.item : event,
),
).toEqual(expectedTimeline);
} finally {
await resumed?.close();
await original?.close();
await client.shutdown();
// Windows can retain a transient executable lock after provider shutdown.
await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
},
240_000,
);