diff --git a/packages/adapters/opencode-local/src/cli/format-event.ts b/packages/adapters/opencode-local/src/cli/format-event.ts index 00d0ec76..58d2038d 100644 --- a/packages/adapters/opencode-local/src/cli/format-event.ts +++ b/packages/adapters/opencode-local/src/cli/format-event.ts @@ -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; } diff --git a/packages/adapters/opencode-local/src/server/execute.ts b/packages/adapters/opencode-local/src/server/execute.ts index 338646b3..970896af 100644 --- a/packages/adapters/opencode-local/src/server/execute.ts +++ b/packages/adapters/opencode-local/src/server/execute.ts @@ -340,7 +340,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise { inputTokens: 120, cachedInputTokens: 20, outputTokens: 50, - costUsd: 0.0025, }); + expect(parsed.costUsd).toBeCloseTo(0.0025, 6); expect(parsed.errorMessage).toContain("model unavailable"); }); diff --git a/packages/adapters/opencode-local/src/server/parse.ts b/packages/adapters/opencode-local/src/server/parse.ts index 5cbfa46c..96af0ed1 100644 --- a/packages/adapters/opencode-local/src/server/parse.ts +++ b/packages/adapters/opencode-local/src/server/parse.ts @@ -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, ); } diff --git a/packages/adapters/opencode-local/src/server/test.ts b/packages/adapters/opencode-local/src/server/test.ts index 569f0d75..5bb7aa36 100644 --- a/packages/adapters/opencode-local/src/server/test.ts +++ b/packages/adapters/opencode-local/src/server/test.ts @@ -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", diff --git a/packages/adapters/opencode-local/src/ui/parse-stdout.ts b/packages/adapters/opencode-local/src/ui/parse-stdout.ts index dc48e5d1..2060125a 100644 --- a/packages/adapters/opencode-local/src/ui/parse-stdout.ts +++ b/packages/adapters/opencode-local/src/ui/parse-stdout.ts @@ -56,19 +56,28 @@ function parseToolUse(parsed: Record, 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", }, ]; diff --git a/server/src/__tests__/opencode-local-adapter-environment.test.ts b/server/src/__tests__/opencode-local-adapter-environment.test.ts index c539d771..736dd9f8 100644 --- a/server/src/__tests__/opencode-local-adapter-environment.test.ts +++ b/server/src/__tests__/opencode-local-adapter-environment.test.ts @@ -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 () => {