diff options
| author | OpeOginni <[email protected]> | 2026-03-05 13:28:17 +0100 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-03-05 06:28:17 -0600 |
| commit | 27baa2d65cfa100283bda334e80244d6d8c440fb (patch) | |
| tree | db897fd42063652865ab28ad788412c557fc7eff /packages/app/src | |
| parent | 62909e917ada44f64bf46fb38936bc99357cb63c (diff) | |
| download | opencode-27baa2d65cfa100283bda334e80244d6d8c440fb.tar.gz opencode-27baa2d65cfa100283bda334e80244d6d8c440fb.zip | |
refactor(desktop): improve error handling and translation in server error formatting (#16171)
Diffstat (limited to 'packages/app/src')
| -rw-r--r-- | packages/app/src/components/prompt-input/submit.ts | 3 | ||||
| -rw-r--r-- | packages/app/src/context/global-sync.tsx | 11 | ||||
| -rw-r--r-- | packages/app/src/context/global-sync/bootstrap.ts | 16 | ||||
| -rw-r--r-- | packages/app/src/utils/server-errors.test.ts | 90 | ||||
| -rw-r--r-- | packages/app/src/utils/server-errors.ts | 75 |
5 files changed, 138 insertions, 57 deletions
diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index a8c2609f4..db1b5a5ca 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -16,6 +16,7 @@ import { Identifier } from "@/utils/id" import { Worktree as WorktreeState } from "@/utils/worktree" import { buildRequestParts } from "./build-request-parts" import { setCursorPosition } from "./editor-dom" +import { formatServerError } from "@/utils/server-errors" type PendingPrompt = { abort: AbortController @@ -286,7 +287,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { .catch((err) => { showToast({ title: language.t("prompt.toast.commandSendFailed.title"), - description: errorMessage(err), + description: formatServerError(err, language.t, language.t("common.requestFailed")), }) restoreInput() }) diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 574929115..b3a351382 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -228,10 +228,7 @@ function createGlobalSync() { showToast({ variant: "error", title: language.t("toast.session.listFailed.title", { project }), - description: formatServerError(err, { - unknown: language.t("error.chain.unknown"), - invalidConfiguration: language.t("error.server.invalidConfiguration"), - }), + description: formatServerError(err, language.t), }) }) @@ -261,8 +258,7 @@ function createGlobalSync() { setStore: child[1], vcsCache: cache, loadSessions, - unknownError: language.t("error.chain.unknown"), - invalidConfigurationError: language.t("error.server.invalidConfiguration"), + translate: language.t, }) })() @@ -331,8 +327,7 @@ function createGlobalSync() { url: globalSDK.url, }), requestFailedTitle: language.t("common.requestFailed"), - unknownError: language.t("error.chain.unknown"), - invalidConfigurationError: language.t("error.server.invalidConfiguration"), + translate: language.t, formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }), setGlobalStore: setBootStore, }) diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index bc84eb169..8b1a3c48c 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -36,8 +36,7 @@ export async function bootstrapGlobal(input: { connectErrorTitle: string connectErrorDescription: string requestFailedTitle: string - unknownError: string - invalidConfigurationError: string + translate: (key: string, vars?: Record<string, string | number>) => string formatMoreCount: (count: number) => string setGlobalStore: SetStoreFunction<GlobalStore> }) { @@ -91,10 +90,7 @@ export async function bootstrapGlobal(input: { const results = await Promise.allSettled(tasks) const errors = results.filter((r): r is PromiseRejectedResult => r.status === "rejected").map((r) => r.reason) if (errors.length) { - const message = formatServerError(errors[0], { - unknown: input.unknownError, - invalidConfiguration: input.invalidConfigurationError, - }) + const message = formatServerError(errors[0], input.translate) const more = errors.length > 1 ? input.formatMoreCount(errors.length - 1) : "" showToast({ variant: "error", @@ -122,8 +118,7 @@ export async function bootstrapDirectory(input: { setStore: SetStoreFunction<State> vcsCache: VcsCache loadSessions: (directory: string) => Promise<void> | void - unknownError: string - invalidConfigurationError: string + translate: (key: string, vars?: Record<string, string | number>) => string }) { if (input.store.status !== "complete") input.setStore("status", "loading") @@ -145,10 +140,7 @@ export async function bootstrapDirectory(input: { showToast({ variant: "error", title: `Failed to reload ${project}`, - description: formatServerError(err, { - unknown: input.unknownError, - invalidConfiguration: input.invalidConfigurationError, - }), + description: formatServerError(err, input.translate), }) input.setStore("status", "partial") return diff --git a/packages/app/src/utils/server-errors.test.ts b/packages/app/src/utils/server-errors.test.ts index 1969d1afc..1f53bb8cf 100644 --- a/packages/app/src/utils/server-errors.test.ts +++ b/packages/app/src/utils/server-errors.test.ts @@ -1,8 +1,37 @@ import { describe, expect, test } from "bun:test" -import type { ConfigInvalidError } from "./server-errors" -import { formatServerError, parseReabaleConfigInvalidError } from "./server-errors" +import type { ConfigInvalidError, ProviderModelNotFoundError } from "./server-errors" +import { formatServerError, parseReadableConfigInvalidError } from "./server-errors" -describe("parseReabaleConfigInvalidError", () => { +function fill(text: string, vars?: Record<string, string | number>) { + if (!vars) return text + return text.replace(/{{\s*(\w+)\s*}}/g, (_, key: string) => { + const value = vars[key] + if (value === undefined) return "" + return String(value) + }) +} + +function useLanguageMock() { + const dict: Record<string, string> = { + "error.chain.unknown": "Erro desconhecido", + "error.chain.configInvalid": "Arquivo de config em {{path}} invalido", + "error.chain.configInvalidWithMessage": "Arquivo de config em {{path}} invalido: {{message}}", + "error.chain.modelNotFound": "Modelo nao encontrado: {{provider}}/{{model}}", + "error.chain.didYouMean": "Voce quis dizer: {{suggestions}}", + "error.chain.checkConfig": "Revise provider/model no config", + } + return { + t(key: string, vars?: Record<string, string | number>) { + const text = dict[key] + if (!text) return key + return fill(text, vars) + }, + } +} + +const language = useLanguageMock() + +describe("parseReadableConfigInvalidError", () => { test("formats issues with file path", () => { const error = { name: "ConfigInvalidError", @@ -15,10 +44,10 @@ describe("parseReabaleConfigInvalidError", () => { }, } satisfies ConfigInvalidError - const result = parseReabaleConfigInvalidError(error) + const result = parseReadableConfigInvalidError(error, language.t) expect(result).toBe( - ["Invalid configuration", "opencode.config.ts", "settings.host: Required", "mode: Invalid"].join("\n"), + ["Arquivo de config em opencode.config.ts invalido: settings.host: Required", "mode: Invalid"].join("\n"), ) }) @@ -31,9 +60,9 @@ describe("parseReabaleConfigInvalidError", () => { }, } satisfies ConfigInvalidError - const result = parseReabaleConfigInvalidError(error) + const result = parseReadableConfigInvalidError(error, language.t) - expect(result).toBe(["Invalid configuration", "Bad value"].join("\n")) + expect(result).toBe("Arquivo de config em config invalido: Bad value") }) }) @@ -46,24 +75,57 @@ describe("formatServerError", () => { }, } satisfies ConfigInvalidError - const result = formatServerError(error) + const result = formatServerError(error, language.t) - expect(result).toBe(["Invalid configuration", "Missing host"].join("\n")) + expect(result).toBe("Arquivo de config em config invalido: Missing host") }) test("returns error messages", () => { - expect(formatServerError(new Error("Request failed with status 503"))).toBe("Request failed with status 503") + expect(formatServerError(new Error("Request failed with status 503"), language.t)).toBe( + "Request failed with status 503", + ) }) test("returns provided string errors", () => { - expect(formatServerError("Failed to connect to server")).toBe("Failed to connect to server") + expect(formatServerError("Failed to connect to server", language.t)).toBe("Failed to connect to server") }) - test("falls back to unknown", () => { - expect(formatServerError(0)).toBe("Unknown error") + test("uses translated unknown fallback", () => { + expect(formatServerError(0, language.t)).toBe("Erro desconhecido") }) test("falls back for unknown error objects and names", () => { - expect(formatServerError({ name: "ServerTimeoutError", data: { seconds: 30 } })).toBe("Unknown error") + expect(formatServerError({ name: "ServerTimeoutError", data: { seconds: 30 } }, language.t)).toBe( + "Erro desconhecido", + ) + }) + + test("formats provider model errors using provider/model", () => { + const error = { + name: "ProviderModelNotFoundError", + data: { + providerID: "openai", + modelID: "gpt-4.1", + }, + } satisfies ProviderModelNotFoundError + + expect(formatServerError(error, language.t)).toBe( + ["Modelo nao encontrado: openai/gpt-4.1", "Revise provider/model no config"].join("\n"), + ) + }) + + test("formats provider model suggestions", () => { + const error = { + name: "ProviderModelNotFoundError", + data: { + providerID: "x", + modelID: "y", + suggestions: ["x/y2", "x/y3"], + }, + } satisfies ProviderModelNotFoundError + + expect(formatServerError(error, language.t)).toBe( + ["Modelo nao encontrado: x/y", "Voce quis dizer: x/y2, x/y3", "Revise provider/model no config"].join("\n"), + ) }) }) diff --git a/packages/app/src/utils/server-errors.ts b/packages/app/src/utils/server-errors.ts index 85ebca132..2c3a8c54d 100644 --- a/packages/app/src/utils/server-errors.ts +++ b/packages/app/src/utils/server-errors.ts @@ -7,28 +7,31 @@ export type ConfigInvalidError = { } } -type Label = { - unknown: string - invalidConfiguration: string +export type ProviderModelNotFoundError = { + name: "ProviderModelNotFoundError" + data: { + providerID: string + modelID: string + suggestions?: string[] + } } -const fallback: Label = { - unknown: "Unknown error", - invalidConfiguration: "Invalid configuration", -} +type Translator = (key: string, vars?: Record<string, string | number>) => string -function resolveLabel(labels: Partial<Label> | undefined): Label { - return { - unknown: labels?.unknown ?? fallback.unknown, - invalidConfiguration: labels?.invalidConfiguration ?? fallback.invalidConfiguration, - } +function tr(translator: Translator | undefined, key: string, text: string, vars?: Record<string, string | number>) { + if (!translator) return text + const out = translator(key, vars) + if (!out || out === key) return text + return out } -export function formatServerError(error: unknown, labels?: Partial<Label>) { - if (isConfigInvalidErrorLike(error)) return parseReabaleConfigInvalidError(error, labels) +export function formatServerError(error: unknown, translate?: Translator, fallback?: string) { + if (isConfigInvalidErrorLike(error)) return parseReadableConfigInvalidError(error, translate) + if (isProviderModelNotFoundErrorLike(error)) return parseReadableProviderModelNotFoundError(error, translate) if (error instanceof Error && error.message) return error.message if (typeof error === "string" && error) return error - return resolveLabel(labels).unknown + if (fallback) return fallback + return tr(translate, "error.chain.unknown", "Unknown error") } function isConfigInvalidErrorLike(error: unknown): error is ConfigInvalidError { @@ -37,13 +40,41 @@ function isConfigInvalidErrorLike(error: unknown): error is ConfigInvalidError { return o.name === "ConfigInvalidError" && typeof o.data === "object" && o.data !== null } -export function parseReabaleConfigInvalidError(errorInput: ConfigInvalidError, labels?: Partial<Label>) { - const head = resolveLabel(labels).invalidConfiguration - const file = errorInput.data.path && errorInput.data.path !== "config" ? errorInput.data.path : "" +function isProviderModelNotFoundErrorLike(error: unknown): error is ProviderModelNotFoundError { + if (typeof error !== "object" || error === null) return false + const o = error as Record<string, unknown> + return o.name === "ProviderModelNotFoundError" && typeof o.data === "object" && o.data !== null +} + +export function parseReadableConfigInvalidError(errorInput: ConfigInvalidError, translator?: Translator) { + const file = errorInput.data.path && errorInput.data.path !== "config" ? errorInput.data.path : "config" const detail = errorInput.data.message?.trim() ?? "" - const issues = (errorInput.data.issues ?? []).map((issue) => { - return `${issue.path.join(".")}: ${issue.message}` + const issues = (errorInput.data.issues ?? []) + .map((issue) => { + const msg = issue.message.trim() + if (!issue.path.length) return msg + return `${issue.path.join(".")}: ${msg}` + }) + .filter(Boolean) + const msg = issues.length ? issues.join("\n") : detail + if (!msg) return tr(translator, "error.chain.configInvalid", `Config file at ${file} is invalid`, { path: file }) + return tr(translator, "error.chain.configInvalidWithMessage", `Config file at ${file} is invalid: ${msg}`, { + path: file, + message: msg, }) - if (issues.length) return [head, file, "", ...issues].filter(Boolean).join("\n") - return [head, file, detail].filter(Boolean).join("\n") +} + +function parseReadableProviderModelNotFoundError(errorInput: ProviderModelNotFoundError, translator?: Translator) { + const p = errorInput.data.providerID.trim() + const m = errorInput.data.modelID.trim() + const list = (errorInput.data.suggestions ?? []).map((v) => v.trim()).filter(Boolean) + const body = tr(translator, "error.chain.modelNotFound", `Model not found: ${p}/${m}`, { provider: p, model: m }) + const tail = tr(translator, "error.chain.checkConfig", "Check your config (opencode.json) provider/model names") + if (list.length) { + const suggestions = list.slice(0, 5).join(", ") + return [body, tr(translator, "error.chain.didYouMean", `Did you mean: ${suggestions}`, { suggestions }), tail].join( + "\n", + ) + } + return [body, tail].join("\n") } |
