Merge PR #62: Full OpenCode adapter integration

Merges paperclipai/paperclip#62 onto latest master (494448d).
Adds complete OpenCode provider with strict model selection,
dynamic model discovery, CLI/server/UI adapter registration.

Resolved conflicts with master's cursor adapter additions,
node v24 typing, and containerized opencode support (201d91b).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Aaron
2026-03-06 15:23:55 +00:00
39 changed files with 1304 additions and 629 deletions

View File

@@ -1,5 +1,13 @@
import pc from "picocolors";
function safeJsonParse(text: string): unknown {
try {
return JSON.parse(text);
} catch {
return null;
}
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
return value as Record<string, unknown>;
@@ -13,42 +21,21 @@ function asNumber(value: unknown, fallback = 0): number {
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
}
function printToolEvent(part: Record<string, unknown>): void {
const tool = asString(part.tool, "tool");
const callId = asString(part.callID, asString(part.id, ""));
const state = asRecord(part.state);
const status = asString(state?.status);
const input = state?.input;
const output = asString(state?.output).replace(/\s+$/, "");
const metadata = asRecord(state?.metadata);
const exit = asNumber(metadata?.exit, NaN);
const isError =
status === "failed" ||
status === "error" ||
status === "cancelled" ||
(Number.isFinite(exit) && exit !== 0);
console.log(pc.yellow(`tool_call: ${tool}${callId ? ` (${callId})` : ""}`));
if (input !== undefined) {
try {
console.log(pc.gray(JSON.stringify(input, null, 2)));
} catch {
console.log(pc.gray(String(input)));
}
}
if (status || output) {
const summary = [
"tool_result",
status ? `status=${status}` : "",
Number.isFinite(exit) ? `exit=${exit}` : "",
]
.filter(Boolean)
.join(" ");
console.log((isError ? pc.red : pc.cyan)(summary));
if (output) {
console.log((isError ? pc.red : pc.gray)(output));
}
function errorText(value: unknown): string {
if (typeof value === "string") return value;
const rec = asRecord(value);
if (!rec) return "";
const data = asRecord(rec.data);
const message =
asString(rec.message) ||
asString(data?.message) ||
asString(rec.name) ||
"";
if (message) return message;
try {
return JSON.stringify(rec);
} catch {
return "";
}
}
@@ -56,10 +43,8 @@ export function printOpenCodeStreamEvent(raw: string, _debug: boolean): void {
const line = raw.trim();
if (!line) return;
let parsed: Record<string, unknown> | null = null;
try {
parsed = JSON.parse(line) as Record<string, unknown>;
} catch {
const parsed = asRecord(safeJsonParse(line));
if (!parsed) {
console.log(line);
return;
}
@@ -74,18 +59,36 @@ export function printOpenCodeStreamEvent(raw: string, _debug: boolean): void {
if (type === "text") {
const part = asRecord(parsed.part);
const text = asString(part?.text);
const text = asString(part?.text).trim();
if (text) console.log(pc.green(`assistant: ${text}`));
return;
}
if (type === "reasoning") {
const part = asRecord(parsed.part);
const text = asString(part?.text).trim();
if (text) console.log(pc.gray(`thinking: ${text}`));
return;
}
if (type === "tool_use") {
const part = asRecord(parsed.part);
if (part) {
printToolEvent(part);
} else {
console.log(pc.yellow("tool_use"));
const tool = asString(part?.tool, "tool");
const state = asRecord(part?.state);
const status = asString(state?.status);
const summary = `tool_${status || "event"}: ${tool}`;
const isError = status === "error";
console.log((isError ? pc.red : pc.yellow)(summary));
const input = state?.input;
if (input !== undefined) {
try {
console.log(pc.gray(JSON.stringify(input, null, 2)));
} catch {
console.log(pc.gray(String(input)));
}
}
const output = asString(state?.output) || asString(state?.error);
if (output) console.log((isError ? pc.red : pc.gray)(output));
return;
}
@@ -93,20 +96,18 @@ export function printOpenCodeStreamEvent(raw: string, _debug: boolean): void {
const part = asRecord(parsed.part);
const tokens = asRecord(part?.tokens);
const cache = asRecord(tokens?.cache);
const reason = asString(part?.reason, "step_finish");
const input = asNumber(tokens?.input);
const output = asNumber(tokens?.output);
const cached = asNumber(cache?.read);
const cost = asNumber(part?.cost);
console.log(pc.blue(`step finished: reason=${reason}`));
console.log(pc.blue(`tokens: in=${input} out=${output} cached=${cached} cost=$${cost.toFixed(6)}`));
const input = asNumber(tokens?.input, 0);
const output = asNumber(tokens?.output, 0) + asNumber(tokens?.reasoning, 0);
const cached = asNumber(cache?.read, 0);
const cost = asNumber(part?.cost, 0);
const reason = asString(part?.reason, "step");
console.log(pc.blue(`step finished (${reason}) tokens: in=${input} out=${output} cached=${cached} cost=$${cost.toFixed(6)}`));
return;
}
if (type === "error") {
const part = asRecord(parsed.part);
const message = asString(parsed.message) || asString(part?.message) || line;
console.log(pc.red(`error: ${message}`));
const message = errorText(parsed.error ?? parsed.message);
if (message) console.log(pc.red(`error: ${message}`));
return;
}

View File

@@ -1,13 +1,7 @@
export const type = "opencode_local";
export const label = "OpenCode (local)";
export const DEFAULT_OPENCODE_LOCAL_MODEL = "openai/gpt-5.2-codex";
export const models = [
{ id: DEFAULT_OPENCODE_LOCAL_MODEL, label: DEFAULT_OPENCODE_LOCAL_MODEL },
{ id: "openai/gpt-5.2", label: "openai/gpt-5.2" },
{ id: "openai/gpt-5.1-codex-max", label: "openai/gpt-5.1-codex-max" },
{ id: "openai/gpt-5.1-codex-mini", label: "openai/gpt-5.1-codex-mini" },
];
export const models: Array<{ id: string; label: string }> = [];
export const agentConfigurationDoc = `# opencode_local agent configuration
@@ -26,8 +20,8 @@ Don't use when:
Core fields:
- cwd (string, optional): default absolute working directory fallback for the agent process (created if missing when possible)
- instructionsFilePath (string, optional): absolute path to a markdown instructions file prepended to the run prompt
- model (string, optional): OpenCode model id in provider/model format (for example openai/gpt-5.2-codex)
- variant (string, optional): provider-specific reasoning/profile variant passed as --variant
- model (string, required): OpenCode model id in provider/model format (for example anthropic/claude-sonnet-4-5)
- variant (string, optional): provider-specific model variant (for example minimal|low|medium|high|max)
- promptTemplate (string, optional): run prompt template
- command (string, optional): defaults to "opencode"
- extraArgs (string[], optional): additional CLI args
@@ -38,7 +32,9 @@ Operational fields:
- graceSec (number, optional): SIGTERM grace period in seconds
Notes:
- OpenCode supports multiple providers and models. Use \
\`opencode models\` to list available options in provider/model format.
- Paperclip requires an explicit \`model\` value for \`opencode_local\` agents.
- Runs are executed with: opencode run --format json ...
- Prompts are passed as the final positional message argument.
- Sessions are resumed with --session when stored session cwd matches current cwd.
`;

View File

@@ -16,8 +16,8 @@ import {
renderTemplate,
runChildProcess,
} from "@paperclipai/adapter-utils/server-utils";
import { DEFAULT_OPENCODE_LOCAL_MODEL } from "../index.js";
import { parseOpenCodeJsonl, isOpenCodeUnknownSessionError } from "./parse.js";
import { isOpenCodeUnknownSessionError, parseOpenCodeJsonl } from "./parse.js";
import { ensureOpenCodeModelConfiguredAndAvailable } from "./models.js";
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
const PAPERCLIP_SKILLS_CANDIDATES = [
@@ -34,81 +34,11 @@ function firstNonEmptyLine(text: string): string {
);
}
function getEffectiveEnvValue(envOverrides: Record<string, string>, key: string): string {
if (Object.prototype.hasOwnProperty.call(envOverrides, key)) {
const raw = envOverrides[key];
return typeof raw === "string" ? raw : "";
}
const raw = process.env[key];
return typeof raw === "string" ? raw : "";
}
function hasEffectiveEnvValue(envOverrides: Record<string, string>, key: string): boolean {
return getEffectiveEnvValue(envOverrides, key).trim().length > 0;
}
function resolveOpenCodeBillingType(env: Record<string, string>): "api" | "subscription" {
return hasEffectiveEnvValue(env, "OPENAI_API_KEY") ? "api" : "subscription";
}
function resolveProviderFromModel(model: string): string | null {
function parseModelProvider(model: string | null): string | null {
if (!model) return 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 isProviderModelNotFoundFailure(stdout: string, stderr: string): boolean {
const haystack = `${stdout}\n${stderr}`;
return /ProviderModelNotFoundError|provider model not found/i.test(haystack);
}
type ProviderModelNotFoundDetails = {
providerId: string | null;
modelId: string | null;
suggestions: string[];
};
function parseProviderModelNotFoundDetails(
stdout: string,
stderr: string,
): ProviderModelNotFoundDetails | null {
if (!isProviderModelNotFoundFailure(stdout, stderr)) return null;
const haystack = `${stdout}\n${stderr}`;
const providerMatch = haystack.match(/providerID:\s*"([^"]+)"/i);
const modelMatch = haystack.match(/modelID:\s*"([^"]+)"/i);
const suggestionsMatch = haystack.match(/suggestions:\s*\[([^\]]*)\]/i);
const suggestions = suggestionsMatch
? Array.from(
suggestionsMatch[1].matchAll(/"([^"]+)"/g),
(match) => match[1].trim(),
).filter((value) => value.length > 0)
: [];
return {
providerId: providerMatch?.[1]?.trim().toLowerCase() || null,
modelId: modelMatch?.[1]?.trim() || null,
suggestions,
};
}
function formatModelNotFoundError(
model: string,
providerFromModel: string | null,
details: ProviderModelNotFoundDetails | null,
): string {
const provider = details?.providerId || providerFromModel || "unknown";
const missingModel = details?.modelId || model;
const suggestions = details?.suggestions ?? [];
const suggestionText =
suggestions.length > 0 ? ` Suggested models: ${suggestions.map((value) => `\`${value}\``).join(", ")}.` : "";
return (
`OpenCode model \`${missingModel}\` is unavailable for provider \`${provider}\`.` +
` Run \`opencode models ${provider}\` and set adapterConfig.model to a supported value.` +
suggestionText
);
if (!trimmed.includes("/")) return null;
return trimmed.slice(0, trimmed.indexOf("/")).trim() || null;
}
function claudeSkillsHome(): string {
@@ -160,8 +90,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
"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 model = asString(config.model, "").trim();
const variant = asString(config.variant, "").trim();
const workspaceContext = parseObject(context.paperclipWorkspace);
const workspaceCwd = asString(workspaceContext.cwd, "");
@@ -209,52 +139,39 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
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 (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 [key, value] of Object.entries(envConfig)) {
if (typeof value === "string") env[key] = value;
}
if (!hasExplicitApiKey && authToken) {
env.PAPERCLIP_API_KEY = authToken;
}
const billingType = resolveOpenCodeBillingType(env);
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
const runtimeEnv = Object.fromEntries(
Object.entries(ensurePathInEnv({ ...process.env, ...env })).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
),
);
await ensureCommandResolvable(command, cwd, runtimeEnv);
await ensureOpenCodeModelConfiguredAndAvailable({
model,
command,
cwd,
env: runtimeEnv,
});
const timeoutSec = asNumber(config.timeoutSec, 0);
const graceSec = asNumber(config.graceSec, 20);
const extraArgs = (() => {
@@ -278,37 +195,41 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
}
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
const instructionsDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : "";
const resolvedInstructionsFilePath = instructionsFilePath
? path.resolve(cwd, instructionsFilePath)
: "";
const instructionsDir = resolvedInstructionsFilePath ? `${path.dirname(resolvedInstructionsFilePath)}/` : "";
let instructionsPrefix = "";
if (instructionsFilePath) {
if (resolvedInstructionsFilePath) {
try {
const instructionsContents = await fs.readFile(instructionsFilePath, "utf8");
const instructionsContents = await fs.readFile(resolvedInstructionsFilePath, "utf8");
instructionsPrefix =
`${instructionsContents}\n\n` +
`The above agent instructions were loaded from ${instructionsFilePath}. ` +
`The above agent instructions were loaded from ${resolvedInstructionsFilePath}. ` +
`Resolve any relative file references from ${instructionsDir}.\n\n`;
await onLog(
"stderr",
`[paperclip] Loaded agent instructions file: ${instructionsFilePath}\n`,
`[paperclip] Loaded agent instructions file: ${resolvedInstructionsFilePath}\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`,
`[paperclip] Warning: could not read agent instructions file "${resolvedInstructionsFilePath}": ${reason}\n`,
);
}
}
const commandNotes = (() => {
if (!instructionsFilePath) return [] as string[];
if (!resolvedInstructionsFilePath) return [] as string[];
if (instructionsPrefix.length > 0) {
return [
`Loaded agent instructions from ${instructionsFilePath}`,
`Prepended instructions + path directive to prompt (relative references from ${instructionsDir}).`,
`Loaded agent instructions from ${resolvedInstructionsFilePath}`,
`Prepended instructions + path directive to stdin prompt (relative references from ${instructionsDir}).`,
];
}
return [
`Configured instructionsFilePath ${instructionsFilePath}, but file could not be read; continuing without injected instructions.`,
`Configured instructionsFilePath ${resolvedInstructionsFilePath}, but file could not be read; continuing without injected instructions.`,
];
})();
@@ -329,7 +250,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
if (model) args.push("--model", model);
if (variant) args.push("--variant", variant);
if (extraArgs.length > 0) args.push(...extraArgs);
args.push(prompt);
return args;
};
@@ -341,10 +261,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
command,
cwd,
commandNotes,
commandArgs: args.map((value, idx) => {
if (idx === args.length - 1) return `<prompt ${prompt.length} chars>`;
return value;
}),
commandArgs: [...args, `<stdin prompt ${prompt.length} chars>`],
env: redactEnvForLogs(env),
prompt,
context,
@@ -353,29 +270,23 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const proc = await runChildProcess(runId, command, args, {
cwd,
env,
env: runtimeEnv,
stdin: prompt,
timeoutSec,
graceSec,
onLog,
});
return {
proc,
rawStderr: proc.stderr,
parsed: parseOpenCodeJsonl(proc.stdout),
};
};
const providerFromModel = resolveProviderFromModel(model);
const toResult = (
attempt: {
proc: {
exitCode: number | null;
signal: string | null;
timedOut: boolean;
stdout: string;
stderr: string;
};
proc: { exitCode: number | null; signal: string | null; timedOut: boolean; stdout: string; stderr: string };
rawStderr: string;
parsed: ReturnType<typeof parseOpenCodeJsonl>;
},
clearSessionOnMissingSession = false,
@@ -390,7 +301,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
};
}
const resolvedSessionId = attempt.parsed.sessionId ?? runtimeSessionId ?? runtime.sessionId ?? null;
const resolvedSessionId =
attempt.parsed.sessionId ??
(clearSessionOnMissingSession ? null : runtimeSessionId ?? runtime.sessionId ?? null);
const resolvedSessionParams = resolvedSessionId
? ({
sessionId: resolvedSessionId,
@@ -400,50 +313,54 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
...(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 modelNotFound = parseProviderModelNotFoundDetails(attempt.proc.stdout, attempt.proc.stderr);
const fallbackErrorMessage = modelNotFound
? formatModelNotFoundError(model, providerFromModel, modelNotFound)
: parsedError ||
stderrLine ||
`OpenCode exited with code ${attempt.proc.exitCode ?? -1}`;
const rawExitCode = attempt.proc.exitCode;
const synthesizedExitCode = parsedError && (rawExitCode ?? 0) === 0 ? 1 : rawExitCode;
const fallbackErrorMessage =
parsedError ||
stderrLine ||
`OpenCode exited with code ${synthesizedExitCode ?? -1}`;
const modelId = model || null;
return {
exitCode: attempt.proc.exitCode,
exitCode: synthesizedExitCode,
signal: attempt.proc.signal,
timedOut: false,
errorMessage:
(attempt.proc.exitCode ?? 0) === 0
? null
: fallbackErrorMessage,
usage: attempt.parsed.usage,
errorMessage: (synthesizedExitCode ?? 0) === 0 ? null : fallbackErrorMessage,
usage: {
inputTokens: attempt.parsed.usage.inputTokens,
outputTokens: attempt.parsed.usage.outputTokens,
cachedInputTokens: attempt.parsed.usage.cachedInputTokens,
},
sessionId: resolvedSessionId,
sessionParams: resolvedSessionParams,
sessionDisplayId: resolvedSessionId,
provider: providerFromModel,
model,
billingType,
costUsd: attempt.parsed.costUsd,
provider: parseModelProvider(modelId),
model: modelId,
billingType: "unknown",
costUsd: attempt.parsed.usage.costUsd,
resultJson: {
stdout: attempt.proc.stdout,
stderr: attempt.proc.stderr,
},
summary: attempt.parsed.summary,
clearSession: Boolean(clearSessionOnMissingSession && !resolvedSessionId),
clearSession: Boolean(clearSessionOnMissingSession && !attempt.parsed.sessionId),
};
};
const initial = await runAttempt(sessionId);
const initialFailed =
!initial.proc.timedOut && ((initial.proc.exitCode ?? 0) !== 0 || Boolean(initial.parsed.errorMessage));
if (
sessionId &&
!initial.proc.timedOut &&
(initial.proc.exitCode ?? 0) !== 0 &&
isOpenCodeUnknownSessionError(initial.proc.stdout, initial.proc.stderr)
initialFailed &&
isOpenCodeUnknownSessionError(initial.proc.stdout, initial.rawStderr)
) {
await onLog(
"stderr",
`[paperclip] OpenCode resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
`[paperclip] OpenCode session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
);
const retry = await runAttempt(null);
return toResult(retry, true);

View File

@@ -1,6 +1,3 @@
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 {
@@ -62,3 +59,13 @@ export const sessionCodec: AdapterSessionCodec = {
);
},
};
export { execute } from "./execute.js";
export { testEnvironment } from "./test.js";
export {
listOpenCodeModels,
discoverOpenCodeModels,
ensureOpenCodeModelConfiguredAndAvailable,
resetOpenCodeModelsCacheForTests,
} from "./models.js";
export { parseOpenCodeJsonl, isOpenCodeUnknownSessionError } from "./parse.js";

View File

@@ -0,0 +1,33 @@
import { afterEach, describe, expect, it } from "vitest";
import {
ensureOpenCodeModelConfiguredAndAvailable,
listOpenCodeModels,
resetOpenCodeModelsCacheForTests,
} from "./models.js";
describe("openCode models", () => {
afterEach(() => {
delete process.env.PAPERCLIP_OPENCODE_COMMAND;
resetOpenCodeModelsCacheForTests();
});
it("returns an empty list when discovery command is unavailable", async () => {
process.env.PAPERCLIP_OPENCODE_COMMAND = "__paperclip_missing_opencode_command__";
await expect(listOpenCodeModels()).resolves.toEqual([]);
});
it("rejects when model is missing", async () => {
await expect(
ensureOpenCodeModelConfiguredAndAvailable({ model: "" }),
).rejects.toThrow("OpenCode requires `adapterConfig.model`");
});
it("rejects when discovery cannot run for configured model", async () => {
process.env.PAPERCLIP_OPENCODE_COMMAND = "__paperclip_missing_opencode_command__";
await expect(
ensureOpenCodeModelConfiguredAndAvailable({
model: "openai/gpt-5",
}),
).rejects.toThrow("Failed to start command");
});
});

View File

@@ -0,0 +1,198 @@
import { createHash } from "node:crypto";
import type { AdapterModel } from "@paperclipai/adapter-utils";
import {
asString,
ensurePathInEnv,
runChildProcess,
} from "@paperclipai/adapter-utils/server-utils";
const MODELS_CACHE_TTL_MS = 60_000;
const discoveryCache = new Map<string, { expiresAt: number; models: AdapterModel[] }>();
const VOLATILE_ENV_KEY_PREFIXES = ["PAPERCLIP_", "npm_", "NPM_"] as const;
const VOLATILE_ENV_KEY_EXACT = new Set(["PWD", "OLDPWD", "SHLVL", "_", "TERM_SESSION_ID"]);
function dedupeModels(models: AdapterModel[]): AdapterModel[] {
const seen = new Set<string>();
const deduped: AdapterModel[] = [];
for (const model of models) {
const id = model.id.trim();
if (!id || seen.has(id)) continue;
seen.add(id);
deduped.push({ id, label: model.label.trim() || id });
}
return deduped;
}
function sortModels(models: AdapterModel[]): AdapterModel[] {
return [...models].sort((a, b) =>
a.id.localeCompare(b.id, "en", { numeric: true, sensitivity: "base" }),
);
}
function firstNonEmptyLine(text: string): string {
return (
text
.split(/\r?\n/)
.map((line) => line.trim())
.find(Boolean) ?? ""
);
}
function parseModelsOutput(stdout: string): AdapterModel[] {
const parsed: AdapterModel[] = [];
for (const raw of stdout.split(/\r?\n/)) {
const line = raw.trim();
if (!line) continue;
const firstToken = line.split(/\s+/)[0]?.trim() ?? "";
if (!firstToken.includes("/")) continue;
const provider = firstToken.slice(0, firstToken.indexOf("/")).trim();
const model = firstToken.slice(firstToken.indexOf("/") + 1).trim();
if (!provider || !model) continue;
parsed.push({ id: `${provider}/${model}`, label: `${provider}/${model}` });
}
return dedupeModels(parsed);
}
function normalizeEnv(input: unknown): Record<string, string> {
const envInput = typeof input === "object" && input !== null && !Array.isArray(input)
? (input as Record<string, unknown>)
: {};
const env: Record<string, string> = {};
for (const [key, value] of Object.entries(envInput)) {
if (typeof value === "string") env[key] = value;
}
return env;
}
function isVolatileEnvKey(key: string): boolean {
if (VOLATILE_ENV_KEY_EXACT.has(key)) return true;
return VOLATILE_ENV_KEY_PREFIXES.some((prefix) => key.startsWith(prefix));
}
function hashValue(value: string): string {
return createHash("sha256").update(value).digest("hex");
}
function discoveryCacheKey(command: string, cwd: string, env: Record<string, string>) {
const envKey = Object.entries(env)
.filter(([key]) => !isVolatileEnvKey(key))
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, value]) => `${key}=${hashValue(value)}`)
.join("\n");
return `${command}\n${cwd}\n${envKey}`;
}
function pruneExpiredDiscoveryCache(now: number) {
for (const [key, value] of discoveryCache.entries()) {
if (value.expiresAt <= now) discoveryCache.delete(key);
}
}
export async function discoverOpenCodeModels(input: {
command?: unknown;
cwd?: unknown;
env?: unknown;
} = {}): Promise<AdapterModel[]> {
const command = asString(
input.command,
(typeof process.env.PAPERCLIP_OPENCODE_COMMAND === "string" &&
process.env.PAPERCLIP_OPENCODE_COMMAND.trim().length > 0
? process.env.PAPERCLIP_OPENCODE_COMMAND.trim()
: "opencode"),
);
const cwd = asString(input.cwd, process.cwd());
const env = normalizeEnv(input.env);
const runtimeEnv = normalizeEnv(ensurePathInEnv({ ...process.env, ...env }));
const result = await runChildProcess(
`opencode-models-${Date.now()}-${Math.random().toString(16).slice(2)}`,
command,
["models"],
{
cwd,
env: runtimeEnv,
timeoutSec: 20,
graceSec: 3,
onLog: async () => {},
},
);
if (result.timedOut) {
throw new Error("`opencode models` timed out.");
}
if ((result.exitCode ?? 1) !== 0) {
const detail = firstNonEmptyLine(result.stderr) || firstNonEmptyLine(result.stdout);
throw new Error(detail ? `\`opencode models\` failed: ${detail}` : "`opencode models` failed.");
}
return sortModels(parseModelsOutput(result.stdout));
}
export async function discoverOpenCodeModelsCached(input: {
command?: unknown;
cwd?: unknown;
env?: unknown;
} = {}): Promise<AdapterModel[]> {
const command = asString(
input.command,
(typeof process.env.PAPERCLIP_OPENCODE_COMMAND === "string" &&
process.env.PAPERCLIP_OPENCODE_COMMAND.trim().length > 0
? process.env.PAPERCLIP_OPENCODE_COMMAND.trim()
: "opencode"),
);
const cwd = asString(input.cwd, process.cwd());
const env = normalizeEnv(input.env);
const key = discoveryCacheKey(command, cwd, env);
const now = Date.now();
pruneExpiredDiscoveryCache(now);
const cached = discoveryCache.get(key);
if (cached && cached.expiresAt > now) return cached.models;
const models = await discoverOpenCodeModels({ command, cwd, env });
discoveryCache.set(key, { expiresAt: now + MODELS_CACHE_TTL_MS, models });
return models;
}
export async function ensureOpenCodeModelConfiguredAndAvailable(input: {
model?: unknown;
command?: unknown;
cwd?: unknown;
env?: unknown;
}): Promise<AdapterModel[]> {
const model = asString(input.model, "").trim();
if (!model) {
throw new Error("OpenCode requires `adapterConfig.model` in provider/model format.");
}
const models = await discoverOpenCodeModelsCached({
command: input.command,
cwd: input.cwd,
env: input.env,
});
if (models.length === 0) {
throw new Error("OpenCode returned no models. Run `opencode models` and verify provider auth.");
}
if (!models.some((entry) => entry.id === model)) {
const sample = models.slice(0, 12).map((entry) => entry.id).join(", ");
throw new Error(
`Configured OpenCode model is unavailable: ${model}. Available models: ${sample}${models.length > 12 ? ", ..." : ""}`,
);
}
return models;
}
export async function listOpenCodeModels(): Promise<AdapterModel[]> {
try {
return await discoverOpenCodeModelsCached();
} catch {
return [];
}
}
export function resetOpenCodeModelsCacheForTests() {
discoveryCache.clear();
}

View File

@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { parseOpenCodeJsonl, isOpenCodeUnknownSessionError } from "./parse.js";
describe("parseOpenCodeJsonl", () => {
it("parses assistant text, usage, cost, and errors", () => {
const stdout = [
JSON.stringify({
type: "text",
sessionID: "session_123",
part: { text: "Hello from OpenCode" },
}),
JSON.stringify({
type: "step_finish",
sessionID: "session_123",
part: {
reason: "done",
cost: 0.0025,
tokens: {
input: 120,
output: 40,
reasoning: 10,
cache: { read: 20, write: 0 },
},
},
}),
JSON.stringify({
type: "error",
sessionID: "session_123",
error: { message: "model unavailable" },
}),
].join("\n");
const parsed = parseOpenCodeJsonl(stdout);
expect(parsed.sessionId).toBe("session_123");
expect(parsed.summary).toBe("Hello from OpenCode");
expect(parsed.usage).toEqual({
inputTokens: 120,
cachedInputTokens: 20,
outputTokens: 50,
costUsd: 0.0025,
});
expect(parsed.errorMessage).toContain("model unavailable");
});
it("detects unknown session errors", () => {
expect(isOpenCodeUnknownSessionError("Session not found: s_123", "")).toBe(true);
expect(isOpenCodeUnknownSessionError("", "unknown session id")).toBe(true);
expect(isOpenCodeUnknownSessionError("all good", "")).toBe(false);
});
});

View File

@@ -1,10 +1,17 @@
import { asString, asNumber, parseObject, parseJson } from "@paperclipai/adapter-utils/server-utils";
import { asNumber, asString, parseJson, parseObject } from "@paperclipai/adapter-utils/server-utils";
function asErrorText(value: unknown): string {
function errorText(value: unknown): string {
if (typeof value === "string") return value;
const rec = parseObject(value);
const message = asString(rec.message, "") || asString(rec.error, "") || asString(rec.code, "");
const message = asString(rec.message, "").trim();
if (message) return message;
const data = parseObject(rec.data);
const nestedMessage = asString(data.message, "").trim();
if (nestedMessage) return nestedMessage;
const name = asString(rec.name, "").trim();
if (name) return name;
const code = asString(rec.code, "").trim();
if (code) return code;
try {
return JSON.stringify(rec);
} catch {
@@ -15,12 +22,12 @@ function asErrorText(value: unknown): string {
export function parseOpenCodeJsonl(stdout: string) {
let sessionId: string | null = null;
const messages: string[] = [];
let errorMessage: string | null = null;
let totalCostUsd = 0;
const errors: string[] = [];
const usage = {
inputTokens: 0,
cachedInputTokens: 0,
outputTokens: 0,
costUsd: 0,
};
for (const rawLine of stdout.split(/\r?\n/)) {
@@ -30,8 +37,8 @@ export function parseOpenCodeJsonl(stdout: string) {
const event = parseJson(line);
if (!event) continue;
const foundSession = asString(event.sessionID, "").trim();
if (foundSession) sessionId = foundSession;
const currentSessionId = asString(event.sessionID, "").trim();
if (currentSessionId) sessionId = currentSessionId;
const type = asString(event.type, "");
@@ -48,15 +55,25 @@ export function parseOpenCodeJsonl(stdout: string) {
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);
usage.outputTokens += asNumber(tokens.output, 0) + asNumber(tokens.reasoning, 0);
usage.costUsd += asNumber(part.cost, 0);
continue;
}
if (type === "tool_use") {
const part = parseObject(event.part);
const state = parseObject(part.state);
if (asString(state.status, "") === "error") {
const text = asString(state.error, "").trim();
if (text) errors.push(text);
}
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;
const text = errorText(event.error ?? event.message).trim();
if (text) errors.push(text);
continue;
}
}
@@ -64,8 +81,7 @@ export function parseOpenCodeJsonl(stdout: string) {
sessionId,
summary: messages.join("\n\n").trim(),
usage,
costUsd: totalCostUsd > 0 ? totalCostUsd : null,
errorMessage,
errorMessage: errors.length > 0 ? errors.join("\n") : null,
};
}
@@ -76,7 +92,7 @@ export function isOpenCodeUnknownSessionError(stdout: string, stderr: string): b
.filter(Boolean)
.join("\n");
return /unknown\s+session|session\s+.*\s+not\s+found|resource\s+not\s+found:.*[\\/]session[\\/].*\.json|notfounderror/i.test(
return /unknown\s+session|session\s+.*\s+not\s+found|resource\s+not\s+found:.*[\\/]session[\\/].*\.json|notfounderror|no session/i.test(
haystack,
);
}

View File

@@ -12,8 +12,7 @@ import {
ensurePathInEnv,
runChildProcess,
} from "@paperclipai/adapter-utils/server-utils";
import path from "node:path";
import { DEFAULT_OPENCODE_LOCAL_MODEL } from "../index.js";
import { discoverOpenCodeModels, ensureOpenCodeModelConfiguredAndAvailable } from "./models.js";
import { parseOpenCodeJsonl } from "./parse.js";
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
@@ -22,19 +21,6 @@ function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentT
return "pass";
}
function isNonEmpty(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
function getEffectiveEnvValue(envOverrides: Record<string, string>, key: string): string {
if (Object.prototype.hasOwnProperty.call(envOverrides, key)) {
const raw = envOverrides[key];
return typeof raw === "string" ? raw : "";
}
const raw = process.env[key];
return typeof raw === "string" ? raw : "";
}
function firstNonEmptyLine(text: string): string {
return (
text
@@ -44,22 +30,25 @@ function firstNonEmptyLine(text: string): string {
);
}
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;
return clean.length > max ? `${clean.slice(0, max - 1)}...` : clean;
}
function normalizeEnv(input: unknown): Record<string, string> {
if (typeof input !== "object" || input === null || Array.isArray(input)) return {};
const env: Record<string, string> = {};
for (const [key, value] of Object.entries(input as Record<string, unknown>)) {
if (typeof value === "string") env[key] = value;
}
return env;
}
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;
const OPENCODE_MODEL_NOT_FOUND_RE = /ProviderModelNotFoundError|provider\s+model\s+not\s+found/i;
/(?:auth(?:entication)?\s+required|api\s*key|invalid\s*api\s*key|not\s+logged\s+in|opencode\s+auth\s+login|free\s+usage\s+exceeded)/i;
export async function testEnvironment(
ctx: AdapterEnvironmentTestContext,
@@ -70,7 +59,7 @@ export async function testEnvironment(
const cwd = asString(config.cwd, process.cwd());
try {
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
await ensureAbsoluteDirectory(cwd, { createIfMissing: false });
checks.push({
code: "opencode_cwd_valid",
level: "info",
@@ -90,100 +79,138 @@ export async function testEnvironment(
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);
const runtimeEnv = normalizeEnv(ensurePathInEnv({ ...process.env, ...env }));
const cwdInvalid = checks.some((check) => check.code === "opencode_cwd_invalid");
if (cwdInvalid) {
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",
code: "opencode_command_skipped",
level: "warn",
message: "Skipped command check because working directory validation failed.",
detail: command,
});
}
const configDefinesOpenAiKey = Object.prototype.hasOwnProperty.call(env, "OPENAI_API_KEY");
const effectiveOpenAiKey = getEffectiveEnvValue(env, "OPENAI_API_KEY");
if (isNonEmpty(effectiveOpenAiKey)) {
const source = configDefinesOpenAiKey ? "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: configDefinesOpenAiKey
? "adapterConfig.env defines OPENAI_API_KEY but it is empty. Set a non-empty value or remove the override."
: "Set OPENAI_API_KEY in adapter env/shell, or authenticate with `opencode auth login`.",
});
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 canRunProbe =
checks.every((check) => check.code !== "opencode_cwd_invalid" && check.code !== "opencode_command_unresolvable");
let modelValidationPassed = false;
if (canRunProbe) {
if (!commandLooksLike(command, "opencode")) {
try {
const discovered = await discoverOpenCodeModels({ command, cwd, env: runtimeEnv });
if (discovered.length > 0) {
checks.push({
code: "opencode_models_discovered",
level: "info",
message: `Discovered ${discovered.length} model(s) from OpenCode providers.`,
});
} else {
checks.push({
code: "opencode_models_empty",
level: "error",
message: "OpenCode returned no models.",
hint: "Run `opencode models` and verify provider authentication.",
});
}
} catch (err) {
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.",
code: "opencode_models_discovery_failed",
level: "error",
message: err instanceof Error ? err.message : "OpenCode model discovery failed.",
hint: "Run `opencode models` manually to verify provider auth and config.",
});
} 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 configuredModel = asString(config.model, "").trim();
if (!configuredModel) {
checks.push({
code: "opencode_model_required",
level: "error",
message: "OpenCode requires a configured model in provider/model format.",
hint: "Set adapterConfig.model using an ID from `opencode models`.",
});
} else if (canRunProbe) {
try {
await ensureOpenCodeModelConfiguredAndAvailable({
model: configuredModel,
command,
cwd,
env: runtimeEnv,
});
checks.push({
code: "opencode_model_configured",
level: "info",
message: `Configured model: ${configuredModel}`,
});
modelValidationPassed = true;
} catch (err) {
checks.push({
code: "opencode_model_invalid",
level: "error",
message: err instanceof Error ? err.message : "Configured model is unavailable.",
hint: "Run `opencode models` and choose a currently available provider/model ID.",
});
}
}
if (canRunProbe && modelValidationPassed) {
const extraArgs = (() => {
const fromExtraArgs = asStringArray(config.extraArgs);
if (fromExtraArgs.length > 0) return fromExtraArgs;
return asStringArray(config.args);
})();
const variant = asString(config.variant, "").trim();
const probeModel = configuredModel;
const args = ["run", "--format", "json"];
args.push("--model", probeModel);
if (variant) args.push("--variant", variant);
if (extraArgs.length > 0) args.push(...extraArgs);
try {
const probe = await runChildProcess(
`opencode-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`,
command,
args,
{
cwd,
env,
timeoutSec: 45,
env: runtimeEnv,
timeoutSec: 60,
graceSec: 5,
stdin: "Respond with hello.",
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();
const modelNotFound = OPENCODE_MODEL_NOT_FOUND_RE.test(authEvidence);
const modelProvider = (() => {
const slash = model.indexOf("/");
if (slash <= 0) return "openai";
return model.slice(0, slash).toLowerCase();
})();
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.",
hint: "Retry the probe. If this persists, run OpenCode manually in this working directory.",
});
} else if ((probe.exitCode ?? 1) === 0) {
} else if ((probe.exitCode ?? 1) === 0 && !parsed.errorMessage) {
const summary = parsed.summary.trim();
const hasHello = /\bhello\b/i.test(summary);
checks.push({
@@ -196,24 +223,16 @@ export async function testEnvironment(
...(hasHello
? {}
: {
hint: "Try `opencode run --format json \"Respond with hello\"` manually to inspect full output.",
hint: "Run `opencode run --format json` manually and prompt `Respond with hello` to inspect output.",
}),
});
} else if (modelNotFound) {
checks.push({
code: "opencode_hello_probe_model_unavailable",
level: "warn",
message: `OpenCode could not run model \`${model}\`.`,
...(detail ? { detail } : {}),
hint: `Run \`opencode models ${modelProvider}\` and set adapterConfig.model to one of the available models.`,
});
} 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.",
message: "OpenCode is installed, but provider authentication is not ready.",
...(detail ? { detail } : {}),
hint: "Configure OPENAI_API_KEY in adapter env/shell, then retry the probe.",
hint: "Run `opencode auth login` or set provider credentials, then retry the probe.",
});
} else {
checks.push({
@@ -221,9 +240,17 @@ export async function testEnvironment(
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.",
hint: "Run `opencode run --format json` manually in this working directory to debug.",
});
}
} catch (err) {
checks.push({
code: "opencode_hello_probe_failed",
level: "error",
message: "OpenCode hello probe failed.",
detail: err instanceof Error ? err.message : String(err),
hint: "Run `opencode run --format json` manually in this working directory to debug.",
});
}
}

View File

@@ -1,5 +1,4 @@
import type { CreateConfigValues } from "@paperclipai/adapter-utils";
import { DEFAULT_OPENCODE_LOCAL_MODEL } from "../index.js";
function parseCommaArgs(value: string): string[] {
return value
@@ -56,10 +55,12 @@ export function buildOpenCodeLocalConfig(v: CreateConfigValues): Record<string,
if (v.cwd) ac.cwd = v.cwd;
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
ac.model = v.model || DEFAULT_OPENCODE_LOCAL_MODEL;
if (v.model) ac.model = v.model;
if (v.thinkingEffort) ac.variant = v.thinkingEffort;
// OpenCode sessions can run until the CLI exits naturally; keep timeout disabled (0)
// and rely on graceSec for termination handling when a timeout is configured elsewhere.
ac.timeoutSec = 0;
ac.graceSec = 15;
ac.graceSec = 20;
const env = parseEnvBindings(v.envBindings);
const legacy = parseEnvVars(v.envVars);
for (const [key, value] of Object.entries(legacy)) {

View File

@@ -21,26 +21,57 @@ function asNumber(value: unknown, fallback = 0): number {
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
}
function stringifyUnknown(value: unknown): string {
function errorText(value: unknown): string {
if (typeof value === "string") return value;
if (value === null || value === undefined) return "";
const rec = asRecord(value);
if (!rec) return "";
const data = asRecord(rec.data);
const msg =
asString(rec.message) ||
asString(data?.message) ||
asString(rec.name) ||
"";
if (msg) return msg;
try {
return JSON.stringify(value, null, 2);
return JSON.stringify(rec);
} catch {
return String(value);
return "";
}
}
function isJsonLike(text: string): boolean {
const trimmed = text.trim();
if (!trimmed) return false;
if (!(trimmed.startsWith("{") || trimmed.startsWith("["))) return false;
try {
JSON.parse(trimmed);
return true;
} catch {
return false;
}
function parseToolUse(parsed: Record<string, unknown>, ts: string): TranscriptEntry[] {
const part = asRecord(parsed.part);
if (!part) return [{ kind: "system", ts, text: "tool event" }];
const toolName = asString(part.tool, "tool");
const state = asRecord(part.state);
const input = state?.input ?? {};
const callEntry: TranscriptEntry = {
kind: "tool_call",
ts,
name: toolName,
input,
};
const status = asString(state?.status);
if (status !== "completed" && status !== "error") return [callEntry];
const output =
asString(state?.output) ||
asString(state?.error) ||
asString(part.title) ||
`${toolName} ${status}`;
return [
callEntry,
{
kind: "tool_result",
ts,
toolUseId: asString(part.id, toolName),
content: output,
isError: status === "error",
},
];
}
export function parseOpenCodeStdoutLine(line: string, ts: string): TranscriptEntry[] {
@@ -51,6 +82,24 @@ export function parseOpenCodeStdoutLine(line: string, ts: string): TranscriptEnt
const type = asString(parsed.type);
if (type === "text") {
const part = asRecord(parsed.part);
const text = asString(part?.text).trim();
if (!text) return [];
return [{ kind: "assistant", ts, text }];
}
if (type === "reasoning") {
const part = asRecord(parsed.part);
const text = asString(part?.text).trim();
if (!text) return [];
return [{ kind: "thinking", ts, text }];
}
if (type === "tool_use") {
return parseToolUse(parsed, ts);
}
if (type === "step_start") {
const sessionId = asString(parsed.sessionID);
return [
@@ -62,93 +111,31 @@ export function parseOpenCodeStdoutLine(line: string, ts: string): TranscriptEnt
];
}
if (type === "text") {
const part = asRecord(parsed.part);
const text = asString(part?.text).trim();
if (!text) return [];
return [{ kind: "assistant", ts, text }];
}
if (type === "tool_use") {
const part = asRecord(parsed.part);
const toolUseId = asString(part?.callID, asString(part?.id, "tool_use"));
const toolName = asString(part?.tool, "tool");
const state = asRecord(part?.state);
const input = state?.input ?? {};
const output = asString(state?.output).trim();
const status = asString(state?.status).trim();
const exitCode = asNumber(asRecord(state?.metadata)?.exit, NaN);
const isError =
status === "failed" ||
status === "error" ||
status === "cancelled" ||
(Number.isFinite(exitCode) && exitCode !== 0);
const entries: TranscriptEntry[] = [
{
kind: "tool_call",
ts,
name: toolName,
input,
},
];
if (status || output) {
const lines: string[] = [];
if (status) lines.push(`status: ${status}`);
if (Number.isFinite(exitCode)) lines.push(`exit: ${exitCode}`);
if (output) {
if (lines.length > 0) lines.push("");
if (isJsonLike(output)) {
try {
lines.push(JSON.stringify(JSON.parse(output), null, 2));
} catch {
lines.push(output);
}
} else {
lines.push(output);
}
}
entries.push({
kind: "tool_result",
ts,
toolUseId,
content: lines.join("\n").trim() || "tool completed",
isError,
});
}
return entries;
}
if (type === "step_finish") {
const part = asRecord(parsed.part);
const tokens = asRecord(part?.tokens);
const cache = asRecord(tokens?.cache);
const reason = asString(part?.reason);
const reason = asString(part?.reason, "step");
const output = asNumber(tokens?.output, 0) + asNumber(tokens?.reasoning, 0);
return [
{
kind: "result",
ts,
text: reason,
inputTokens: asNumber(tokens?.input),
outputTokens: asNumber(tokens?.output),
cachedTokens: asNumber(cache?.read),
costUsd: asNumber(part?.cost),
subtype: reason || "step_finish",
isError: reason === "error" || reason === "failed",
inputTokens: asNumber(tokens?.input, 0),
outputTokens: output,
cachedTokens: asNumber(cache?.read, 0),
costUsd: asNumber(part?.cost, 0),
subtype: reason,
isError: false,
errors: [],
},
];
}
if (type === "error") {
const message =
asString(parsed.message) ||
asString(asRecord(parsed.part)?.message) ||
stringifyUnknown(parsed.error ?? asRecord(parsed.part)?.error) ||
line;
return [{ kind: "stderr", ts, text: message }];
const text = errorText(parsed.error ?? parsed.message);
return [{ kind: "stderr", ts, text: text || line }];
}
return [{ kind: "stdout", ts, text: line }];