paperclip/packages/adapters/opencode-local/src/server/execute.test.ts

211 lines
7.4 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
vi.mock("@paperclipai/adapter-utils/execution-target", async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>;
return { ...actual, runAdapterExecutionTargetProcess: vi.fn() };
});
import { ensureRemoteOpenCodeModelConfiguredAndAvailable, execute } from "./execute.js";
import { runAdapterExecutionTargetProcess } from "@paperclipai/adapter-utils/execution-target";
const runProcessMock = vi.mocked(runAdapterExecutionTargetProcess);
async function createSkillDir(root: string, name: string): Promise<string> {
const skillDir = path.join(root, name);
await fs.mkdir(skillDir, { recursive: true });
await fs.writeFile(path.join(skillDir, "SKILL.md"), `# ${name}\n`, "utf8");
return skillDir;
}
function probeResult(overrides: Record<string, unknown>) {
return {
exitCode: 0,
signal: null,
timedOut: false,
stdout: "",
stderr: "",
pid: 123,
startedAt: new Date().toISOString(),
...overrides,
} as never;
}
describe("OpenCode local skill injection", () => {
it("injects runtime skills into the configured child HOME", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-configured-home-"));
const processHome = path.join(root, "process-home");
const configuredHome = path.join(root, "configured-home");
const workspace = path.join(root, "workspace");
const commandPath = path.join(root, "opencode");
const skillSource = await createSkillDir(path.join(root, "runtime-skills"), "paperclip");
await fs.mkdir(workspace, { recursive: true });
await fs.writeFile(commandPath, "#!/bin/sh\nexit 0\n", "utf8");
await fs.chmod(commandPath, 0o755);
const previousHome = process.env.HOME;
process.env.HOME = processHome;
runProcessMock.mockReset();
runProcessMock.mockResolvedValueOnce(probeResult({
stdout: JSON.stringify({
type: "text",
sessionID: "session-configured-home",
part: { text: "done" },
}),
}));
try {
const result = await execute({
runId: "run-configured-home",
agent: {
id: "agent-1",
companyId: "company-1",
name: "OpenCode Coder",
adapterType: "opencode_local",
adapterConfig: {},
},
runtime: {
sessionId: null,
sessionParams: null,
sessionDisplayId: null,
taskKey: null,
},
config: {
command: commandPath,
cwd: workspace,
model: "openai/gpt-5",
env: {
HOME: configuredHome,
OPENCODE_ALLOW_ALL_MODELS: "1",
},
paperclipRuntimeSkills: [{
key: "paperclipai/paperclip/paperclip",
runtimeName: "paperclip",
source: skillSource,
}],
promptTemplate: "Follow the paperclip heartbeat.",
},
context: {},
authToken: "run-jwt-token",
onLog: async () => {},
});
expect(result.exitCode).toBe(0);
const installedSkill = path.join(configuredHome, ".claude", "skills", "paperclip");
expect((await fs.lstat(installedSkill)).isSymbolicLink()).toBe(true);
expect(await fs.realpath(installedSkill)).toBe(await fs.realpath(skillSource));
await expect(fs.lstat(path.join(processHome, ".claude", "skills", "paperclip"))).rejects.toThrow();
} finally {
if (previousHome === undefined) delete process.env.HOME;
else process.env.HOME = previousHome;
await fs.rm(root, { recursive: true, force: true });
}
});
});
describe("ensureRemoteOpenCodeModelConfiguredAndAvailable", () => {
afterEach(() => {
delete process.env.OPENCODE_ALLOW_ALL_MODELS;
});
// The remote/sandbox execution path must honour OPENCODE_ALLOW_ALL_MODELS just
// like the local path: gateway-routed models (e.g. anthropic/<gateway>/<model>
// via Bifrost) never appear in `opencode models`, so the availability probe
// must be skipped. The early return happens before the executionTarget is ever
// touched, so a bogus target proves the probe was not run.
const bogusTarget = {} as never;
it("skips the remote availability probe when OPENCODE_ALLOW_ALL_MODELS is set in the run env", async () => {
await expect(
ensureRemoteOpenCodeModelConfiguredAndAvailable({
runId: "run-1",
executionTarget: bogusTarget,
command: "opencode",
model: "anthropic/tensorix/deepseek/deepseek-chat-v3.1",
cwd: "/tmp",
env: { OPENCODE_ALLOW_ALL_MODELS: "true" },
timeoutSec: 30,
graceSec: 5,
}),
).resolves.toBeUndefined();
});
it("honours OPENCODE_ALLOW_ALL_MODELS from the process env", async () => {
process.env.OPENCODE_ALLOW_ALL_MODELS = "1";
await expect(
ensureRemoteOpenCodeModelConfiguredAndAvailable({
runId: "run-2",
executionTarget: bogusTarget,
command: "opencode",
model: "anthropic/tensorix/deepseek/deepseek-chat-v3.1",
cwd: "/tmp",
env: {},
timeoutSec: 30,
graceSec: 5,
}),
).resolves.toBeUndefined();
});
it("still enforces provider/model format even when the bypass flag is set", async () => {
await expect(
ensureRemoteOpenCodeModelConfiguredAndAvailable({
runId: "run-3",
executionTarget: bogusTarget,
command: "opencode",
model: "",
cwd: "/tmp",
env: { OPENCODE_ALLOW_ALL_MODELS: "true" },
timeoutSec: 30,
graceSec: 5,
}),
).rejects.toThrow();
});
});
describe("ensureRemoteOpenCodeModelConfiguredAndAvailable — probe is non-fatal when it cannot run", () => {
const target = { kind: "remote", transport: "ssh" } as never;
const base = {
runId: "run-probe",
executionTarget: target,
command: "opencode",
cwd: "/tmp",
env: {} as Record<string, string>,
timeoutSec: 30,
graceSec: 5,
};
beforeEach(() => {
runProcessMock.mockReset();
});
it("proceeds when the remote probe exits non-zero (e.g. a transient `Unexpected error`)", async () => {
runProcessMock.mockResolvedValueOnce(probeResult({ exitCode: 1, stderr: "Unexpected error" }));
await expect(
ensureRemoteOpenCodeModelConfiguredAndAvailable({ ...base, model: "openai/gpt-5" }),
).resolves.toBeUndefined();
});
it("proceeds when the remote probe times out", async () => {
runProcessMock.mockResolvedValueOnce(probeResult({ timedOut: true, exitCode: null }));
await expect(
ensureRemoteOpenCodeModelConfiguredAndAvailable({ ...base, model: "openai/gpt-5" }),
).resolves.toBeUndefined();
});
it("proceeds when the remote probe returns no models", async () => {
runProcessMock.mockResolvedValueOnce(probeResult({ exitCode: 0, stdout: "" }));
await expect(
ensureRemoteOpenCodeModelConfiguredAndAvailable({ ...base, model: "openai/gpt-5" }),
).resolves.toBeUndefined();
});
it("still rejects when the probe succeeds but the configured model is absent (guard retained)", async () => {
runProcessMock.mockResolvedValueOnce(probeResult({ exitCode: 0, stdout: "openai/gpt-4.1\n" }));
await expect(
ensureRemoteOpenCodeModelConfiguredAndAvailable({ ...base, model: "openai/gpt-5" }),
).rejects.toThrow("Configured OpenCode model is unavailable on the remote execution target");
});
});