feat: add opencode local adapter support
This commit is contained in:
391
packages/adapters/opencode-local/src/server/execute.ts
Normal file
391
packages/adapters/opencode-local/src/server/execute.ts
Normal file
@@ -0,0 +1,391 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils";
|
||||
import {
|
||||
asString,
|
||||
asNumber,
|
||||
asStringArray,
|
||||
parseObject,
|
||||
buildPaperclipEnv,
|
||||
redactEnvForLogs,
|
||||
ensureAbsoluteDirectory,
|
||||
ensureCommandResolvable,
|
||||
ensurePathInEnv,
|
||||
renderTemplate,
|
||||
runChildProcess,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import { DEFAULT_OPENCODE_LOCAL_MODEL } from "../index.js";
|
||||
import { parseOpenCodeJsonl, isOpenCodeUnknownSessionError } from "./parse.js";
|
||||
|
||||
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PAPERCLIP_SKILLS_CANDIDATES = [
|
||||
path.resolve(__moduleDir, "../../skills"),
|
||||
path.resolve(__moduleDir, "../../../../../skills"),
|
||||
];
|
||||
|
||||
function firstNonEmptyLine(text: string): string {
|
||||
return (
|
||||
text
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find(Boolean) ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
function hasNonEmptyEnvValue(env: Record<string, string>, key: string): boolean {
|
||||
const raw = env[key];
|
||||
return typeof raw === "string" && raw.trim().length > 0;
|
||||
}
|
||||
|
||||
function resolveOpenCodeBillingType(env: Record<string, string>): "api" | "subscription" {
|
||||
return hasNonEmptyEnvValue(env, "OPENAI_API_KEY") ? "api" : "subscription";
|
||||
}
|
||||
|
||||
function resolveProviderFromModel(model: string): string | null {
|
||||
const trimmed = model.trim();
|
||||
if (!trimmed) return null;
|
||||
const slash = trimmed.indexOf("/");
|
||||
if (slash <= 0) return null;
|
||||
return trimmed.slice(0, slash).toLowerCase();
|
||||
}
|
||||
|
||||
function claudeSkillsHome(): string {
|
||||
return path.join(os.homedir(), ".claude", "skills");
|
||||
}
|
||||
|
||||
async function resolvePaperclipSkillsDir(): Promise<string | null> {
|
||||
for (const candidate of PAPERCLIP_SKILLS_CANDIDATES) {
|
||||
const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false);
|
||||
if (isDir) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function ensureOpenCodeSkillsInjected(onLog: AdapterExecutionContext["onLog"]) {
|
||||
const skillsDir = await resolvePaperclipSkillsDir();
|
||||
if (!skillsDir) return;
|
||||
|
||||
const skillsHome = claudeSkillsHome();
|
||||
await fs.mkdir(skillsHome, { recursive: true });
|
||||
const entries = await fs.readdir(skillsDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const source = path.join(skillsDir, entry.name);
|
||||
const target = path.join(skillsHome, entry.name);
|
||||
const existing = await fs.lstat(target).catch(() => null);
|
||||
if (existing) continue;
|
||||
|
||||
try {
|
||||
await fs.symlink(source, target);
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[paperclip] Injected OpenCode skill "${entry.name}" into ${skillsHome}\n`,
|
||||
);
|
||||
} catch (err) {
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[paperclip] Failed to inject OpenCode skill "${entry.name}" into ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
|
||||
const { runId, agent, runtime, config, context, onLog, onMeta, authToken } = ctx;
|
||||
|
||||
const promptTemplate = asString(
|
||||
config.promptTemplate,
|
||||
"You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.",
|
||||
);
|
||||
const command = asString(config.command, "opencode");
|
||||
const model = asString(config.model, DEFAULT_OPENCODE_LOCAL_MODEL);
|
||||
const variant = asString(config.variant, asString(config.effort, ""));
|
||||
|
||||
const workspaceContext = parseObject(context.paperclipWorkspace);
|
||||
const workspaceCwd = asString(workspaceContext.cwd, "");
|
||||
const workspaceSource = asString(workspaceContext.source, "");
|
||||
const workspaceId = asString(workspaceContext.workspaceId, "");
|
||||
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "");
|
||||
const workspaceRepoRef = asString(workspaceContext.repoRef, "");
|
||||
const workspaceHints = Array.isArray(context.paperclipWorkspaces)
|
||||
? context.paperclipWorkspaces.filter(
|
||||
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
||||
)
|
||||
: [];
|
||||
const configuredCwd = asString(config.cwd, "");
|
||||
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
|
||||
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
|
||||
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
|
||||
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
||||
await ensureOpenCodeSkillsInjected(onLog);
|
||||
|
||||
const envConfig = parseObject(config.env);
|
||||
const hasExplicitApiKey =
|
||||
typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0;
|
||||
const env: Record<string, string> = { ...buildPaperclipEnv(agent) };
|
||||
env.PAPERCLIP_RUN_ID = runId;
|
||||
const wakeTaskId =
|
||||
(typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) ||
|
||||
(typeof context.issueId === "string" && context.issueId.trim().length > 0 && context.issueId.trim()) ||
|
||||
null;
|
||||
const wakeReason =
|
||||
typeof context.wakeReason === "string" && context.wakeReason.trim().length > 0
|
||||
? context.wakeReason.trim()
|
||||
: null;
|
||||
const wakeCommentId =
|
||||
(typeof context.wakeCommentId === "string" && context.wakeCommentId.trim().length > 0 && context.wakeCommentId.trim()) ||
|
||||
(typeof context.commentId === "string" && context.commentId.trim().length > 0 && context.commentId.trim()) ||
|
||||
null;
|
||||
const approvalId =
|
||||
typeof context.approvalId === "string" && context.approvalId.trim().length > 0
|
||||
? context.approvalId.trim()
|
||||
: null;
|
||||
const approvalStatus =
|
||||
typeof context.approvalStatus === "string" && context.approvalStatus.trim().length > 0
|
||||
? context.approvalStatus.trim()
|
||||
: null;
|
||||
const linkedIssueIds = Array.isArray(context.issueIds)
|
||||
? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
|
||||
: [];
|
||||
if (wakeTaskId) {
|
||||
env.PAPERCLIP_TASK_ID = wakeTaskId;
|
||||
}
|
||||
if (wakeReason) {
|
||||
env.PAPERCLIP_WAKE_REASON = wakeReason;
|
||||
}
|
||||
if (wakeCommentId) {
|
||||
env.PAPERCLIP_WAKE_COMMENT_ID = wakeCommentId;
|
||||
}
|
||||
if (approvalId) {
|
||||
env.PAPERCLIP_APPROVAL_ID = approvalId;
|
||||
}
|
||||
if (approvalStatus) {
|
||||
env.PAPERCLIP_APPROVAL_STATUS = approvalStatus;
|
||||
}
|
||||
if (linkedIssueIds.length > 0) {
|
||||
env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
|
||||
}
|
||||
if (effectiveWorkspaceCwd) {
|
||||
env.PAPERCLIP_WORKSPACE_CWD = effectiveWorkspaceCwd;
|
||||
}
|
||||
if (workspaceSource) {
|
||||
env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource;
|
||||
}
|
||||
if (workspaceId) {
|
||||
env.PAPERCLIP_WORKSPACE_ID = workspaceId;
|
||||
}
|
||||
if (workspaceRepoUrl) {
|
||||
env.PAPERCLIP_WORKSPACE_REPO_URL = workspaceRepoUrl;
|
||||
}
|
||||
if (workspaceRepoRef) {
|
||||
env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef;
|
||||
}
|
||||
if (workspaceHints.length > 0) {
|
||||
env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
|
||||
}
|
||||
for (const [k, v] of Object.entries(envConfig)) {
|
||||
if (typeof v === "string") env[k] = v;
|
||||
}
|
||||
if (!hasExplicitApiKey && authToken) {
|
||||
env.PAPERCLIP_API_KEY = authToken;
|
||||
}
|
||||
const billingType = resolveOpenCodeBillingType(env);
|
||||
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
|
||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
||||
|
||||
const timeoutSec = asNumber(config.timeoutSec, 0);
|
||||
const graceSec = asNumber(config.graceSec, 20);
|
||||
const extraArgs = (() => {
|
||||
const fromExtraArgs = asStringArray(config.extraArgs);
|
||||
if (fromExtraArgs.length > 0) return fromExtraArgs;
|
||||
return asStringArray(config.args);
|
||||
})();
|
||||
|
||||
const runtimeSessionParams = parseObject(runtime.sessionParams);
|
||||
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
|
||||
const runtimeSessionCwd = asString(runtimeSessionParams.cwd, "");
|
||||
const canResumeSession =
|
||||
runtimeSessionId.length > 0 &&
|
||||
(runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(cwd));
|
||||
const sessionId = canResumeSession ? runtimeSessionId : null;
|
||||
if (runtimeSessionId && !canResumeSession) {
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[paperclip] OpenCode session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`,
|
||||
);
|
||||
}
|
||||
|
||||
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
|
||||
const instructionsDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : "";
|
||||
let instructionsPrefix = "";
|
||||
if (instructionsFilePath) {
|
||||
try {
|
||||
const instructionsContents = await fs.readFile(instructionsFilePath, "utf8");
|
||||
instructionsPrefix =
|
||||
`${instructionsContents}\n\n` +
|
||||
`The above agent instructions were loaded from ${instructionsFilePath}. ` +
|
||||
`Resolve any relative file references from ${instructionsDir}.\n\n`;
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[paperclip] Loaded agent instructions file: ${instructionsFilePath}\n`,
|
||||
);
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[paperclip] Warning: could not read agent instructions file "${instructionsFilePath}": ${reason}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const commandNotes = (() => {
|
||||
if (!instructionsFilePath) return [] as string[];
|
||||
if (instructionsPrefix.length > 0) {
|
||||
return [
|
||||
`Loaded agent instructions from ${instructionsFilePath}`,
|
||||
`Prepended instructions + path directive to prompt (relative references from ${instructionsDir}).`,
|
||||
];
|
||||
}
|
||||
return [
|
||||
`Configured instructionsFilePath ${instructionsFilePath}, but file could not be read; continuing without injected instructions.`,
|
||||
];
|
||||
})();
|
||||
|
||||
const renderedPrompt = renderTemplate(promptTemplate, {
|
||||
agentId: agent.id,
|
||||
companyId: agent.companyId,
|
||||
runId,
|
||||
company: { id: agent.companyId },
|
||||
agent,
|
||||
run: { id: runId, source: "on_demand" },
|
||||
context,
|
||||
});
|
||||
const prompt = `${instructionsPrefix}${renderedPrompt}`;
|
||||
|
||||
const buildArgs = (resumeSessionId: string | null) => {
|
||||
const args = ["run", "--format", "json"];
|
||||
if (resumeSessionId) args.push("--session", resumeSessionId);
|
||||
if (model) args.push("--model", model);
|
||||
if (variant) args.push("--variant", variant);
|
||||
if (extraArgs.length > 0) args.push(...extraArgs);
|
||||
args.push(prompt);
|
||||
return args;
|
||||
};
|
||||
|
||||
const runAttempt = async (resumeSessionId: string | null) => {
|
||||
const args = buildArgs(resumeSessionId);
|
||||
if (onMeta) {
|
||||
await onMeta({
|
||||
adapterType: "opencode_local",
|
||||
command,
|
||||
cwd,
|
||||
commandNotes,
|
||||
commandArgs: args.map((value, idx) => {
|
||||
if (idx === args.length - 1) return `<prompt ${prompt.length} chars>`;
|
||||
return value;
|
||||
}),
|
||||
env: redactEnvForLogs(env),
|
||||
prompt,
|
||||
context,
|
||||
});
|
||||
}
|
||||
|
||||
const proc = await runChildProcess(runId, command, args, {
|
||||
cwd,
|
||||
env,
|
||||
timeoutSec,
|
||||
graceSec,
|
||||
onLog,
|
||||
});
|
||||
|
||||
return {
|
||||
proc,
|
||||
parsed: parseOpenCodeJsonl(proc.stdout),
|
||||
};
|
||||
};
|
||||
|
||||
const providerFromModel = resolveProviderFromModel(model);
|
||||
|
||||
const toResult = (
|
||||
attempt: {
|
||||
proc: {
|
||||
exitCode: number | null;
|
||||
signal: string | null;
|
||||
timedOut: boolean;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
};
|
||||
parsed: ReturnType<typeof parseOpenCodeJsonl>;
|
||||
},
|
||||
clearSessionOnMissingSession = false,
|
||||
): AdapterExecutionResult => {
|
||||
if (attempt.proc.timedOut) {
|
||||
return {
|
||||
exitCode: attempt.proc.exitCode,
|
||||
signal: attempt.proc.signal,
|
||||
timedOut: true,
|
||||
errorMessage: `Timed out after ${timeoutSec}s`,
|
||||
clearSession: clearSessionOnMissingSession,
|
||||
};
|
||||
}
|
||||
|
||||
const resolvedSessionId = attempt.parsed.sessionId ?? runtimeSessionId ?? runtime.sessionId ?? null;
|
||||
const resolvedSessionParams = resolvedSessionId
|
||||
? ({
|
||||
sessionId: resolvedSessionId,
|
||||
cwd,
|
||||
...(workspaceId ? { workspaceId } : {}),
|
||||
...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}),
|
||||
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
|
||||
} as Record<string, unknown>)
|
||||
: null;
|
||||
const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : "";
|
||||
const stderrLine = firstNonEmptyLine(attempt.proc.stderr);
|
||||
const fallbackErrorMessage =
|
||||
parsedError ||
|
||||
stderrLine ||
|
||||
`OpenCode exited with code ${attempt.proc.exitCode ?? -1}`;
|
||||
|
||||
return {
|
||||
exitCode: attempt.proc.exitCode,
|
||||
signal: attempt.proc.signal,
|
||||
timedOut: false,
|
||||
errorMessage:
|
||||
(attempt.proc.exitCode ?? 0) === 0
|
||||
? null
|
||||
: fallbackErrorMessage,
|
||||
usage: attempt.parsed.usage,
|
||||
sessionId: resolvedSessionId,
|
||||
sessionParams: resolvedSessionParams,
|
||||
sessionDisplayId: resolvedSessionId,
|
||||
provider: providerFromModel,
|
||||
model,
|
||||
billingType,
|
||||
costUsd: attempt.parsed.costUsd,
|
||||
resultJson: {
|
||||
stdout: attempt.proc.stdout,
|
||||
stderr: attempt.proc.stderr,
|
||||
},
|
||||
summary: attempt.parsed.summary,
|
||||
clearSession: Boolean(clearSessionOnMissingSession && !resolvedSessionId),
|
||||
};
|
||||
};
|
||||
|
||||
const initial = await runAttempt(sessionId);
|
||||
if (
|
||||
sessionId &&
|
||||
!initial.proc.timedOut &&
|
||||
(initial.proc.exitCode ?? 0) !== 0 &&
|
||||
isOpenCodeUnknownSessionError(initial.proc.stdout, initial.proc.stderr)
|
||||
) {
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[paperclip] OpenCode resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
|
||||
);
|
||||
const retry = await runAttempt(null);
|
||||
return toResult(retry, true);
|
||||
}
|
||||
|
||||
return toResult(initial);
|
||||
}
|
||||
64
packages/adapters/opencode-local/src/server/index.ts
Normal file
64
packages/adapters/opencode-local/src/server/index.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
export { execute } from "./execute.js";
|
||||
export { testEnvironment } from "./test.js";
|
||||
export { parseOpenCodeJsonl, isOpenCodeUnknownSessionError } from "./parse.js";
|
||||
import type { AdapterSessionCodec } from "@paperclipai/adapter-utils";
|
||||
|
||||
function readNonEmptyString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
export const sessionCodec: AdapterSessionCodec = {
|
||||
deserialize(raw: unknown) {
|
||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
|
||||
const record = raw as Record<string, unknown>;
|
||||
const sessionId =
|
||||
readNonEmptyString(record.sessionId) ??
|
||||
readNonEmptyString(record.session_id) ??
|
||||
readNonEmptyString(record.sessionID);
|
||||
if (!sessionId) return null;
|
||||
const cwd =
|
||||
readNonEmptyString(record.cwd) ??
|
||||
readNonEmptyString(record.workdir) ??
|
||||
readNonEmptyString(record.folder);
|
||||
const workspaceId = readNonEmptyString(record.workspaceId) ?? readNonEmptyString(record.workspace_id);
|
||||
const repoUrl = readNonEmptyString(record.repoUrl) ?? readNonEmptyString(record.repo_url);
|
||||
const repoRef = readNonEmptyString(record.repoRef) ?? readNonEmptyString(record.repo_ref);
|
||||
return {
|
||||
sessionId,
|
||||
...(cwd ? { cwd } : {}),
|
||||
...(workspaceId ? { workspaceId } : {}),
|
||||
...(repoUrl ? { repoUrl } : {}),
|
||||
...(repoRef ? { repoRef } : {}),
|
||||
};
|
||||
},
|
||||
serialize(params: Record<string, unknown> | null) {
|
||||
if (!params) return null;
|
||||
const sessionId =
|
||||
readNonEmptyString(params.sessionId) ??
|
||||
readNonEmptyString(params.session_id) ??
|
||||
readNonEmptyString(params.sessionID);
|
||||
if (!sessionId) return null;
|
||||
const cwd =
|
||||
readNonEmptyString(params.cwd) ??
|
||||
readNonEmptyString(params.workdir) ??
|
||||
readNonEmptyString(params.folder);
|
||||
const workspaceId = readNonEmptyString(params.workspaceId) ?? readNonEmptyString(params.workspace_id);
|
||||
const repoUrl = readNonEmptyString(params.repoUrl) ?? readNonEmptyString(params.repo_url);
|
||||
const repoRef = readNonEmptyString(params.repoRef) ?? readNonEmptyString(params.repo_ref);
|
||||
return {
|
||||
sessionId,
|
||||
...(cwd ? { cwd } : {}),
|
||||
...(workspaceId ? { workspaceId } : {}),
|
||||
...(repoUrl ? { repoUrl } : {}),
|
||||
...(repoRef ? { repoRef } : {}),
|
||||
};
|
||||
},
|
||||
getDisplayId(params: Record<string, unknown> | null) {
|
||||
if (!params) return null;
|
||||
return (
|
||||
readNonEmptyString(params.sessionId) ??
|
||||
readNonEmptyString(params.session_id) ??
|
||||
readNonEmptyString(params.sessionID)
|
||||
);
|
||||
},
|
||||
};
|
||||
82
packages/adapters/opencode-local/src/server/parse.ts
Normal file
82
packages/adapters/opencode-local/src/server/parse.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { asString, asNumber, parseObject, parseJson } from "@paperclipai/adapter-utils/server-utils";
|
||||
|
||||
function asErrorText(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
const rec = parseObject(value);
|
||||
const message = asString(rec.message, "") || asString(rec.error, "") || asString(rec.code, "");
|
||||
if (message) return message;
|
||||
try {
|
||||
return JSON.stringify(rec);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function parseOpenCodeJsonl(stdout: string) {
|
||||
let sessionId: string | null = null;
|
||||
const messages: string[] = [];
|
||||
let errorMessage: string | null = null;
|
||||
let totalCostUsd = 0;
|
||||
const usage = {
|
||||
inputTokens: 0,
|
||||
cachedInputTokens: 0,
|
||||
outputTokens: 0,
|
||||
};
|
||||
|
||||
for (const rawLine of stdout.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) continue;
|
||||
|
||||
const event = parseJson(line);
|
||||
if (!event) continue;
|
||||
|
||||
const foundSession = asString(event.sessionID, "").trim();
|
||||
if (foundSession) sessionId = foundSession;
|
||||
|
||||
const type = asString(event.type, "");
|
||||
|
||||
if (type === "text") {
|
||||
const part = parseObject(event.part);
|
||||
const text = asString(part.text, "").trim();
|
||||
if (text) messages.push(text);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "step_finish") {
|
||||
const part = parseObject(event.part);
|
||||
const tokens = parseObject(part.tokens);
|
||||
const cache = parseObject(tokens.cache);
|
||||
usage.inputTokens += asNumber(tokens.input, 0);
|
||||
usage.cachedInputTokens += asNumber(cache.read, 0);
|
||||
usage.outputTokens += asNumber(tokens.output, 0);
|
||||
totalCostUsd += asNumber(part.cost, 0);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "error") {
|
||||
const part = parseObject(event.part);
|
||||
const msg = asErrorText(event.message ?? part.message ?? event.error ?? part.error).trim();
|
||||
if (msg) errorMessage = msg;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
summary: messages.join("\n\n").trim(),
|
||||
usage,
|
||||
costUsd: totalCostUsd > 0 ? totalCostUsd : null,
|
||||
errorMessage,
|
||||
};
|
||||
}
|
||||
|
||||
export function isOpenCodeUnknownSessionError(stdout: string, stderr: string): boolean {
|
||||
const haystack = `${stdout}\n${stderr}`
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
return /unknown\s+session|session\s+.*\s+not\s+found|resource\s+not\s+found:.*[\\/]session[\\/].*\.json|notfounderror/i.test(
|
||||
haystack,
|
||||
);
|
||||
}
|
||||
210
packages/adapters/opencode-local/src/server/test.ts
Normal file
210
packages/adapters/opencode-local/src/server/test.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import type {
|
||||
AdapterEnvironmentCheck,
|
||||
AdapterEnvironmentTestContext,
|
||||
AdapterEnvironmentTestResult,
|
||||
} from "@paperclipai/adapter-utils";
|
||||
import {
|
||||
asString,
|
||||
asStringArray,
|
||||
parseObject,
|
||||
ensureAbsoluteDirectory,
|
||||
ensureCommandResolvable,
|
||||
ensurePathInEnv,
|
||||
runChildProcess,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import path from "node:path";
|
||||
import { DEFAULT_OPENCODE_LOCAL_MODEL } from "../index.js";
|
||||
import { parseOpenCodeJsonl } from "./parse.js";
|
||||
|
||||
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
|
||||
if (checks.some((check) => check.level === "error")) return "fail";
|
||||
if (checks.some((check) => check.level === "warn")) return "warn";
|
||||
return "pass";
|
||||
}
|
||||
|
||||
function isNonEmpty(value: unknown): value is string {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function firstNonEmptyLine(text: string): string {
|
||||
return (
|
||||
text
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find(Boolean) ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
function commandLooksLike(command: string, expected: string): boolean {
|
||||
const base = path.basename(command).toLowerCase();
|
||||
return base === expected || base === `${expected}.cmd` || base === `${expected}.exe`;
|
||||
}
|
||||
|
||||
function summarizeProbeDetail(stdout: string, stderr: string, parsedError: string | null): string | null {
|
||||
const raw = parsedError?.trim() || firstNonEmptyLine(stderr) || firstNonEmptyLine(stdout);
|
||||
if (!raw) return null;
|
||||
const clean = raw.replace(/\s+/g, " ").trim();
|
||||
const max = 240;
|
||||
return clean.length > max ? `${clean.slice(0, max - 1)}…` : clean;
|
||||
}
|
||||
|
||||
const OPENCODE_AUTH_REQUIRED_RE =
|
||||
/(?:not\s+authenticated|authentication\s+required|unauthorized|forbidden|api(?:[_\s-]?key)?(?:\s+is)?\s+required|missing\s+api(?:[_\s-]?key)?|openai[_\s-]?api[_\s-]?key|provider\s+credentials|login\s+required)/i;
|
||||
|
||||
export async function testEnvironment(
|
||||
ctx: AdapterEnvironmentTestContext,
|
||||
): Promise<AdapterEnvironmentTestResult> {
|
||||
const checks: AdapterEnvironmentCheck[] = [];
|
||||
const config = parseObject(ctx.config);
|
||||
const command = asString(config.command, "opencode");
|
||||
const cwd = asString(config.cwd, process.cwd());
|
||||
|
||||
try {
|
||||
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
||||
checks.push({
|
||||
code: "opencode_cwd_valid",
|
||||
level: "info",
|
||||
message: `Working directory is valid: ${cwd}`,
|
||||
});
|
||||
} catch (err) {
|
||||
checks.push({
|
||||
code: "opencode_cwd_invalid",
|
||||
level: "error",
|
||||
message: err instanceof Error ? err.message : "Invalid working directory",
|
||||
detail: cwd,
|
||||
});
|
||||
}
|
||||
|
||||
const envConfig = parseObject(config.env);
|
||||
const env: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(envConfig)) {
|
||||
if (typeof value === "string") env[key] = value;
|
||||
}
|
||||
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
|
||||
try {
|
||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
||||
checks.push({
|
||||
code: "opencode_command_resolvable",
|
||||
level: "info",
|
||||
message: `Command is executable: ${command}`,
|
||||
});
|
||||
} catch (err) {
|
||||
checks.push({
|
||||
code: "opencode_command_unresolvable",
|
||||
level: "error",
|
||||
message: err instanceof Error ? err.message : "Command is not executable",
|
||||
detail: command,
|
||||
});
|
||||
}
|
||||
|
||||
const configOpenAiKey = env.OPENAI_API_KEY;
|
||||
const hostOpenAiKey = process.env.OPENAI_API_KEY;
|
||||
if (isNonEmpty(configOpenAiKey) || isNonEmpty(hostOpenAiKey)) {
|
||||
const source = isNonEmpty(configOpenAiKey) ? "adapter config env" : "server environment";
|
||||
checks.push({
|
||||
code: "opencode_openai_api_key_present",
|
||||
level: "info",
|
||||
message: "OPENAI_API_KEY is set for OpenCode authentication.",
|
||||
detail: `Detected in ${source}.`,
|
||||
});
|
||||
} else {
|
||||
checks.push({
|
||||
code: "opencode_openai_api_key_missing",
|
||||
level: "warn",
|
||||
message: "OPENAI_API_KEY is not set. OpenCode runs may fail until authentication is configured.",
|
||||
hint: "Set OPENAI_API_KEY in adapter env or shell environment.",
|
||||
});
|
||||
}
|
||||
|
||||
const canRunProbe =
|
||||
checks.every((check) => check.code !== "opencode_cwd_invalid" && check.code !== "opencode_command_unresolvable");
|
||||
if (canRunProbe) {
|
||||
if (!commandLooksLike(command, "opencode")) {
|
||||
checks.push({
|
||||
code: "opencode_hello_probe_skipped_custom_command",
|
||||
level: "info",
|
||||
message: "Skipped hello probe because command is not `opencode`.",
|
||||
detail: command,
|
||||
hint: "Use the `opencode` CLI command to run the automatic installation and auth probe.",
|
||||
});
|
||||
} else {
|
||||
const model = asString(config.model, DEFAULT_OPENCODE_LOCAL_MODEL).trim();
|
||||
const variant = asString(config.variant, asString(config.effort, "")).trim();
|
||||
const extraArgs = (() => {
|
||||
const fromExtraArgs = asStringArray(config.extraArgs);
|
||||
if (fromExtraArgs.length > 0) return fromExtraArgs;
|
||||
return asStringArray(config.args);
|
||||
})();
|
||||
|
||||
const args = ["run", "--format", "json"];
|
||||
if (model) args.push("--model", model);
|
||||
if (variant) args.push("--variant", variant);
|
||||
if (extraArgs.length > 0) args.push(...extraArgs);
|
||||
args.push("Respond with hello.");
|
||||
|
||||
const probe = await runChildProcess(
|
||||
`opencode-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||
command,
|
||||
args,
|
||||
{
|
||||
cwd,
|
||||
env,
|
||||
timeoutSec: 45,
|
||||
graceSec: 5,
|
||||
onLog: async () => {},
|
||||
},
|
||||
);
|
||||
const parsed = parseOpenCodeJsonl(probe.stdout);
|
||||
const detail = summarizeProbeDetail(probe.stdout, probe.stderr, parsed.errorMessage);
|
||||
const authEvidence = `${parsed.errorMessage ?? ""}\n${probe.stdout}\n${probe.stderr}`.trim();
|
||||
|
||||
if (probe.timedOut) {
|
||||
checks.push({
|
||||
code: "opencode_hello_probe_timed_out",
|
||||
level: "warn",
|
||||
message: "OpenCode hello probe timed out.",
|
||||
hint: "Retry the probe. If this persists, verify `opencode run --format json \"Respond with hello\"` manually.",
|
||||
});
|
||||
} else if ((probe.exitCode ?? 1) === 0) {
|
||||
const summary = parsed.summary.trim();
|
||||
const hasHello = /\bhello\b/i.test(summary);
|
||||
checks.push({
|
||||
code: hasHello ? "opencode_hello_probe_passed" : "opencode_hello_probe_unexpected_output",
|
||||
level: hasHello ? "info" : "warn",
|
||||
message: hasHello
|
||||
? "OpenCode hello probe succeeded."
|
||||
: "OpenCode probe ran but did not return `hello` as expected.",
|
||||
...(summary ? { detail: summary.replace(/\s+/g, " ").trim().slice(0, 240) } : {}),
|
||||
...(hasHello
|
||||
? {}
|
||||
: {
|
||||
hint: "Try `opencode run --format json \"Respond with hello\"` manually to inspect full output.",
|
||||
}),
|
||||
});
|
||||
} else if (OPENCODE_AUTH_REQUIRED_RE.test(authEvidence)) {
|
||||
checks.push({
|
||||
code: "opencode_hello_probe_auth_required",
|
||||
level: "warn",
|
||||
message: "OpenCode CLI is installed, but authentication is not ready.",
|
||||
...(detail ? { detail } : {}),
|
||||
hint: "Configure OPENAI_API_KEY in adapter env/shell, then retry the probe.",
|
||||
});
|
||||
} else {
|
||||
checks.push({
|
||||
code: "opencode_hello_probe_failed",
|
||||
level: "error",
|
||||
message: "OpenCode hello probe failed.",
|
||||
...(detail ? { detail } : {}),
|
||||
hint: "Run `opencode run --format json \"Respond with hello\"` manually in this working directory to debug.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
adapterType: ctx.adapterType,
|
||||
status: summarizeStatus(checks),
|
||||
checks,
|
||||
testedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user