Merge pull request #240 from aaaaron/fix-opencode-local-adapter-tests

Fix opencode-local adapter tests and behavior
This commit is contained in:
Dotta
2026-03-07 14:56:31 -06:00
committed by GitHub
7 changed files with 110 additions and 40 deletions

View File

@@ -74,20 +74,25 @@ export function printOpenCodeStreamEvent(raw: string, _debug: boolean): void {
if (type === "tool_use") {
const part = asRecord(parsed.part);
const tool = asString(part?.tool, "tool");
const callID = asString(part?.callID);
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 metadata = asRecord(state?.metadata);
console.log(pc.yellow(`tool_call: ${tool}${callID ? ` (${callID})` : ""}`));
if (status) {
const metaParts = [`status=${status}`];
if (metadata) {
for (const [key, value] of Object.entries(metadata)) {
if (value !== undefined && value !== null) metaParts.push(`${key}=${value}`);
}
}
console.log((isError ? pc.red : pc.gray)(`tool_result ${metaParts.join(" ")}`));
}
const output = asString(state?.output) || asString(state?.error);
const output = (asString(state?.output) || asString(state?.error)).trim();
if (output) console.log((isError ? pc.red : pc.gray)(output));
return;
}
@@ -101,7 +106,8 @@ export function printOpenCodeStreamEvent(raw: string, _debug: boolean): void {
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)}`));
console.log(pc.blue(`step finished: reason=${reason}`));
console.log(pc.blue(`tokens: in=${input} out=${output} cached=${cached} cost=$${cost.toFixed(6)}`));
return;
}

View File

@@ -340,7 +340,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
provider: parseModelProvider(modelId),
model: modelId,
billingType: "unknown",
costUsd: attempt.parsed.usage.costUsd,
costUsd: attempt.parsed.costUsd,
resultJson: {
stdout: attempt.proc.stdout,
stderr: attempt.proc.stderr,

View File

@@ -37,8 +37,8 @@ describe("parseOpenCodeJsonl", () => {
inputTokens: 120,
cachedInputTokens: 20,
outputTokens: 50,
costUsd: 0.0025,
});
expect(parsed.costUsd).toBeCloseTo(0.0025, 6);
expect(parsed.errorMessage).toContain("model unavailable");
});

View File

@@ -27,8 +27,8 @@ export function parseOpenCodeJsonl(stdout: string) {
inputTokens: 0,
cachedInputTokens: 0,
outputTokens: 0,
costUsd: 0,
};
let costUsd = 0;
for (const rawLine of stdout.split(/\r?\n/)) {
const line = rawLine.trim();
@@ -56,7 +56,7 @@ export function parseOpenCodeJsonl(stdout: string) {
usage.inputTokens += asNumber(tokens.input, 0);
usage.cachedInputTokens += asNumber(cache.read, 0);
usage.outputTokens += asNumber(tokens.output, 0) + asNumber(tokens.reasoning, 0);
usage.costUsd += asNumber(part.cost, 0);
costUsd += asNumber(part.cost, 0);
continue;
}
@@ -81,6 +81,7 @@ export function parseOpenCodeJsonl(stdout: string) {
sessionId,
summary: messages.join("\n\n").trim(),
usage,
costUsd,
errorMessage: errors.length > 0 ? errors.join("\n") : null,
};
}
@@ -92,7 +93,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|no session/i.test(
return /unknown\s+session|session\b.*\bnot\s+found|resource\s+not\s+found:.*[\\/]session[\\/].*\.json|notfounderror|no session/i.test(
haystack,
);
}

View File

@@ -79,6 +79,17 @@ export async function testEnvironment(
for (const [key, value] of Object.entries(envConfig)) {
if (typeof value === "string") env[key] = value;
}
const openaiKeyOverride = "OPENAI_API_KEY" in envConfig ? asString(envConfig.OPENAI_API_KEY, "") : null;
if (openaiKeyOverride !== null && openaiKeyOverride.trim() === "") {
checks.push({
code: "opencode_openai_api_key_missing",
level: "warn",
message: "OPENAI_API_KEY override is empty.",
hint: "The OPENAI_API_KEY override is empty. Set a valid key or remove the override.",
});
}
const runtimeEnv = normalizeEnv(ensurePathInEnv({ ...process.env, ...env }));
const cwdInvalid = checks.some((check) => check.code === "opencode_cwd_invalid");
@@ -111,7 +122,9 @@ export async function testEnvironment(
checks.every((check) => check.code !== "opencode_cwd_invalid" && check.code !== "opencode_command_unresolvable");
let modelValidationPassed = false;
if (canRunProbe) {
const configuredModel = asString(config.model, "").trim();
if (canRunProbe && configuredModel) {
try {
const discovered = await discoverOpenCodeModels({ command, cwd, env: runtimeEnv });
if (discovered.length > 0) {
@@ -129,24 +142,59 @@ export async function testEnvironment(
});
}
} catch (err) {
checks.push({
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.",
});
const errMsg = err instanceof Error ? err.message : String(err);
if (/ProviderModelNotFoundError/i.test(errMsg)) {
checks.push({
code: "opencode_hello_probe_model_unavailable",
level: "warn",
message: "The configured model was not found by the provider.",
detail: errMsg,
hint: "Run `opencode models` and choose an available provider/model ID.",
});
} else {
checks.push({
code: "opencode_models_discovery_failed",
level: "error",
message: errMsg || "OpenCode model discovery failed.",
hint: "Run `opencode models` manually to verify provider auth and config.",
});
}
}
} else if (canRunProbe && !configuredModel) {
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.`,
});
}
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
if (/ProviderModelNotFoundError/i.test(errMsg)) {
checks.push({
code: "opencode_hello_probe_model_unavailable",
level: "warn",
message: "The configured model was not found by the provider.",
detail: errMsg,
hint: "Run `opencode models` and choose an available provider/model ID.",
});
} else {
checks.push({
code: "opencode_models_discovery_failed",
level: "warn",
message: errMsg || "OpenCode model discovery failed (best-effort, no model configured).",
hint: "Run `opencode models` manually to verify provider auth and config.",
});
}
}
}
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) {
const modelUnavailable = checks.some((check) => check.code === "opencode_hello_probe_model_unavailable");
if (!configuredModel && !modelUnavailable) {
// No model configured skip model requirement if no model-related checks exist
} else if (configuredModel && canRunProbe) {
try {
await ensureOpenCodeModelConfiguredAndAvailable({
model: configuredModel,
@@ -226,6 +274,14 @@ export async function testEnvironment(
hint: "Run `opencode run --format json` manually and prompt `Respond with hello` to inspect output.",
}),
});
} else if (/ProviderModelNotFoundError/i.test(authEvidence)) {
checks.push({
code: "opencode_hello_probe_model_unavailable",
level: "warn",
message: "The configured model was not found by the provider.",
...(detail ? { detail } : {}),
hint: "Run `opencode models` and choose an available provider/model ID.",
});
} else if (OPENCODE_AUTH_REQUIRED_RE.test(authEvidence)) {
checks.push({
code: "opencode_hello_probe_auth_required",

View File

@@ -56,19 +56,28 @@ function parseToolUse(parsed: Record<string, unknown>, ts: string): TranscriptEn
const status = asString(state?.status);
if (status !== "completed" && status !== "error") return [callEntry];
const output =
const rawOutput =
asString(state?.output) ||
asString(state?.error) ||
asString(part.title) ||
`${toolName} ${status}`;
const metadata = asRecord(state?.metadata);
const headerParts: string[] = [`status: ${status}`];
if (metadata) {
for (const [key, value] of Object.entries(metadata)) {
if (value !== undefined && value !== null) headerParts.push(`${key}: ${value}`);
}
}
const content = `${headerParts.join("\n")}\n\n${rawOutput}`.trim();
return [
callEntry,
{
kind: "tool_result",
ts,
toolUseId: asString(part.id, toolName),
content: output,
toolUseId: asString(part.callID) || asString(part.id, toolName),
content,
isError: status === "error",
},
];

View File

@@ -5,7 +5,7 @@ import path from "node:path";
import { testEnvironment } from "@paperclipai/adapter-opencode-local/server";
describe("opencode_local environment diagnostics", () => {
it("creates a missing working directory when cwd is absolute", async () => {
it("reports a missing working directory as an error when cwd is absolute", async () => {
const cwd = path.join(
os.tmpdir(),
`paperclip-opencode-local-cwd-${Date.now()}-${Math.random().toString(16).slice(2)}`,
@@ -23,11 +23,9 @@ describe("opencode_local environment diagnostics", () => {
},
});
expect(result.checks.some((check) => check.code === "opencode_cwd_valid")).toBe(true);
expect(result.checks.some((check) => check.level === "error")).toBe(false);
const stats = await fs.stat(cwd);
expect(stats.isDirectory()).toBe(true);
await fs.rm(path.dirname(cwd), { recursive: true, force: true });
expect(result.checks.some((check) => check.code === "opencode_cwd_invalid")).toBe(true);
expect(result.checks.some((check) => check.level === "error")).toBe(true);
expect(result.status).toBe("fail");
});
it("treats an empty OPENAI_API_KEY override as missing", async () => {