From 367665dba23f967202a3b4ab6f01c63ca5e83440 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 13 May 2026 11:01:18 +0530 Subject: [PATCH] fix(cli): render tagged config errors (#27256) --- packages/opencode/src/cli/error.ts | 62 +++++++++++++++++------- packages/opencode/test/cli/error.test.ts | 35 +++++++++++++ 2 files changed, 79 insertions(+), 18 deletions(-) diff --git a/packages/opencode/src/cli/error.ts b/packages/opencode/src/cli/error.ts index 6fd7b573e2..9551fac1e1 100644 --- a/packages/opencode/src/cli/error.ts +++ b/packages/opencode/src/cli/error.ts @@ -5,13 +5,37 @@ interface ErrorLike { name?: string _tag?: string message?: string - data?: Record + data?: Record +} + +type ConfigIssue = { message: string; path: string[] } + +function isRecord(input: unknown): input is Record { + return typeof input === "object" && input !== null } function isTaggedError(error: unknown, tag: string): boolean { - return ( - typeof error === "object" && error !== null && "_tag" in error && (error as Record)._tag === tag - ) + return isRecord(error) && error._tag === tag +} + +function configData(input: unknown, tag: string): Record | undefined { + if (!isRecord(input)) return undefined + if (input.name === tag && isRecord(input.data)) return input.data + if (input._tag === tag) return input + return undefined +} + +function stringField(input: Record, key: string): string | undefined { + return typeof input[key] === "string" ? input[key] : undefined +} + +function configIssues(input: Record): ConfigIssue[] { + return Array.isArray(input.issues) + ? input.issues.filter((issue): issue is ConfigIssue => { + if (!isRecord(issue)) return false + return typeof issue.message === "string" && Array.isArray(issue.path) && issue.path.every((x) => typeof x === "string") + }) + : [] } export function FormatError(input: unknown) { @@ -35,7 +59,7 @@ export function FormatError(input: unknown) { // ProviderModelNotFoundError: { providerID: string, modelID: string, suggestions?: string[] } if (NamedError.hasName(input, "ProviderModelNotFoundError")) { const data = (input as ErrorLike).data - const suggestions: string[] = Array.isArray(data?.suggestions) ? data.suggestions : [] + const suggestions = Array.isArray(data?.suggestions) ? data.suggestions.filter((x) => typeof x === "string") : [] return [ `Model not found: ${data?.providerID}/${data?.modelID}`, ...(suggestions.length ? ["Did you mean: " + suggestions.join(", ")] : []), @@ -50,28 +74,30 @@ export function FormatError(input: unknown) { } // ConfigJsonError: { path: string, message?: string } - if (NamedError.hasName(input, "ConfigJsonError")) { - const data = (input as ErrorLike).data - return `Config file at ${data?.path} is not valid JSON(C)` + (data?.message ? `: ${data.message}` : "") + const configJson = configData(input, "ConfigJsonError") + if (configJson) { + const message = stringField(configJson, "message") + return `Config file at ${stringField(configJson, "path")} is not valid JSON(C)` + (message ? `: ${message}` : "") } // ConfigDirectoryTypoError: { dir: string, path: string, suggestion: string } - if (NamedError.hasName(input, "ConfigDirectoryTypoError")) { - const data = (input as ErrorLike).data - return `Directory "${data?.dir}" in ${data?.path} is not valid. Rename the directory to "${data?.suggestion}" or remove it. This is a common typo.` + const configDirectoryTypo = configData(input, "ConfigDirectoryTypoError") + if (configDirectoryTypo) { + return `Directory "${stringField(configDirectoryTypo, "dir")}" in ${stringField(configDirectoryTypo, "path")} is not valid. Rename the directory to "${stringField(configDirectoryTypo, "suggestion")}" or remove it. This is a common typo.` } // ConfigFrontmatterError: { message: string } - if (NamedError.hasName(input, "ConfigFrontmatterError")) { - return (input as ErrorLike).data?.message ?? "" + const configFrontmatter = configData(input, "ConfigFrontmatterError") + if (configFrontmatter) { + return stringField(configFrontmatter, "message") ?? "" } // ConfigInvalidError: { path?: string, message?: string, issues?: Array<{ message: string, path: string[] }> } - if (NamedError.hasName(input, "ConfigInvalidError")) { - const data = (input as ErrorLike).data - const path = data?.path - const message = data?.message - const issues: Array<{ message: string; path: string[] }> = Array.isArray(data?.issues) ? data.issues : [] + const configInvalid = configData(input, "ConfigInvalidError") + if (configInvalid) { + const path = stringField(configInvalid, "path") + const message = stringField(configInvalid, "message") + const issues = configIssues(configInvalid) return [ `Configuration is invalid${path && path !== "config" ? ` at ${path}` : ""}` + (message ? `: ${message}` : ""), ...issues.map((issue) => "↳ " + issue.message + " " + issue.path.join(".")), diff --git a/packages/opencode/test/cli/error.test.ts b/packages/opencode/test/cli/error.test.ts index b4d1dbeda7..e9ede9cdb7 100644 --- a/packages/opencode/test/cli/error.test.ts +++ b/packages/opencode/test/cli/error.test.ts @@ -4,6 +4,41 @@ import { FormatError } from "../../src/cli/error" import { UI } from "../../src/cli/ui" describe("cli.error", () => { + test("formats legacy and tagged config errors the same way", () => { + const cases = [ + { + tag: "ConfigJsonError", + data: { path: "/tmp/opencode.jsonc", message: "Unexpected token" }, + expected: "Config file at /tmp/opencode.jsonc is not valid JSON(C): Unexpected token", + }, + { + tag: "ConfigDirectoryTypoError", + data: { path: "/tmp/opencode.jsonc", dir: ".opencode", suggestion: "opencode" }, + expected: + 'Directory ".opencode" in /tmp/opencode.jsonc is not valid. Rename the directory to "opencode" or remove it. This is a common typo.', + }, + { + tag: "ConfigFrontmatterError", + data: { path: "/tmp/AGENTS.md", message: "failed frontmatter" }, + expected: "failed frontmatter", + }, + { + tag: "ConfigInvalidError", + data: { + path: "/tmp/opencode.jsonc", + message: "schema mismatch", + issues: [{ message: "Expected string", path: ["provider", "id"] }], + }, + expected: "Configuration is invalid at /tmp/opencode.jsonc: schema mismatch\n↳ Expected string provider.id", + }, + ] + + for (const item of cases) { + expect(FormatError({ name: item.tag, data: item.data })).toBe(item.expected) + expect(FormatError({ _tag: item.tag, ...item.data })).toBe(item.expected) + } + }) + test("formats account transport errors clearly", () => { const error = new AccountTransportError({ method: "POST",