Skip to content

Commit 27baa2d

Browse files
authored
refactor(desktop): improve error handling and translation in server error formatting (#16171)
1 parent 62909e9 commit 27baa2d

5 files changed

Lines changed: 138 additions & 57 deletions

File tree

packages/app/src/components/prompt-input/submit.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { Identifier } from "@/utils/id"
1616
import { Worktree as WorktreeState } from "@/utils/worktree"
1717
import { buildRequestParts } from "./build-request-parts"
1818
import { setCursorPosition } from "./editor-dom"
19+
import { formatServerError } from "@/utils/server-errors"
1920

2021
type PendingPrompt = {
2122
abort: AbortController
@@ -286,7 +287,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
286287
.catch((err) => {
287288
showToast({
288289
title: language.t("prompt.toast.commandSendFailed.title"),
289-
description: errorMessage(err),
290+
description: formatServerError(err, language.t, language.t("common.requestFailed")),
290291
})
291292
restoreInput()
292293
})

packages/app/src/context/global-sync.tsx

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -228,10 +228,7 @@ function createGlobalSync() {
228228
showToast({
229229
variant: "error",
230230
title: language.t("toast.session.listFailed.title", { project }),
231-
description: formatServerError(err, {
232-
unknown: language.t("error.chain.unknown"),
233-
invalidConfiguration: language.t("error.server.invalidConfiguration"),
234-
}),
231+
description: formatServerError(err, language.t),
235232
})
236233
})
237234

@@ -261,8 +258,7 @@ function createGlobalSync() {
261258
setStore: child[1],
262259
vcsCache: cache,
263260
loadSessions,
264-
unknownError: language.t("error.chain.unknown"),
265-
invalidConfigurationError: language.t("error.server.invalidConfiguration"),
261+
translate: language.t,
266262
})
267263
})()
268264

@@ -331,8 +327,7 @@ function createGlobalSync() {
331327
url: globalSDK.url,
332328
}),
333329
requestFailedTitle: language.t("common.requestFailed"),
334-
unknownError: language.t("error.chain.unknown"),
335-
invalidConfigurationError: language.t("error.server.invalidConfiguration"),
330+
translate: language.t,
336331
formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }),
337332
setGlobalStore: setBootStore,
338333
})

packages/app/src/context/global-sync/bootstrap.ts

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,7 @@ export async function bootstrapGlobal(input: {
3636
connectErrorTitle: string
3737
connectErrorDescription: string
3838
requestFailedTitle: string
39-
unknownError: string
40-
invalidConfigurationError: string
39+
translate: (key: string, vars?: Record<string, string | number>) => string
4140
formatMoreCount: (count: number) => string
4241
setGlobalStore: SetStoreFunction<GlobalStore>
4342
}) {
@@ -91,10 +90,7 @@ export async function bootstrapGlobal(input: {
9190
const results = await Promise.allSettled(tasks)
9291
const errors = results.filter((r): r is PromiseRejectedResult => r.status === "rejected").map((r) => r.reason)
9392
if (errors.length) {
94-
const message = formatServerError(errors[0], {
95-
unknown: input.unknownError,
96-
invalidConfiguration: input.invalidConfigurationError,
97-
})
93+
const message = formatServerError(errors[0], input.translate)
9894
const more = errors.length > 1 ? input.formatMoreCount(errors.length - 1) : ""
9995
showToast({
10096
variant: "error",
@@ -122,8 +118,7 @@ export async function bootstrapDirectory(input: {
122118
setStore: SetStoreFunction<State>
123119
vcsCache: VcsCache
124120
loadSessions: (directory: string) => Promise<void> | void
125-
unknownError: string
126-
invalidConfigurationError: string
121+
translate: (key: string, vars?: Record<string, string | number>) => string
127122
}) {
128123
if (input.store.status !== "complete") input.setStore("status", "loading")
129124

@@ -145,10 +140,7 @@ export async function bootstrapDirectory(input: {
145140
showToast({
146141
variant: "error",
147142
title: `Failed to reload ${project}`,
148-
description: formatServerError(err, {
149-
unknown: input.unknownError,
150-
invalidConfiguration: input.invalidConfigurationError,
151-
}),
143+
description: formatServerError(err, input.translate),
152144
})
153145
input.setStore("status", "partial")
154146
return

packages/app/src/utils/server-errors.test.ts

Lines changed: 76 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,37 @@
11
import { describe, expect, test } from "bun:test"
2-
import type { ConfigInvalidError } from "./server-errors"
3-
import { formatServerError, parseReabaleConfigInvalidError } from "./server-errors"
2+
import type { ConfigInvalidError, ProviderModelNotFoundError } from "./server-errors"
3+
import { formatServerError, parseReadableConfigInvalidError } from "./server-errors"
44

5-
describe("parseReabaleConfigInvalidError", () => {
5+
function fill(text: string, vars?: Record<string, string | number>) {
6+
if (!vars) return text
7+
return text.replace(/{{\s*(\w+)\s*}}/g, (_, key: string) => {
8+
const value = vars[key]
9+
if (value === undefined) return ""
10+
return String(value)
11+
})
12+
}
13+
14+
function useLanguageMock() {
15+
const dict: Record<string, string> = {
16+
"error.chain.unknown": "Erro desconhecido",
17+
"error.chain.configInvalid": "Arquivo de config em {{path}} invalido",
18+
"error.chain.configInvalidWithMessage": "Arquivo de config em {{path}} invalido: {{message}}",
19+
"error.chain.modelNotFound": "Modelo nao encontrado: {{provider}}/{{model}}",
20+
"error.chain.didYouMean": "Voce quis dizer: {{suggestions}}",
21+
"error.chain.checkConfig": "Revise provider/model no config",
22+
}
23+
return {
24+
t(key: string, vars?: Record<string, string | number>) {
25+
const text = dict[key]
26+
if (!text) return key
27+
return fill(text, vars)
28+
},
29+
}
30+
}
31+
32+
const language = useLanguageMock()
33+
34+
describe("parseReadableConfigInvalidError", () => {
635
test("formats issues with file path", () => {
736
const error = {
837
name: "ConfigInvalidError",
@@ -15,10 +44,10 @@ describe("parseReabaleConfigInvalidError", () => {
1544
},
1645
} satisfies ConfigInvalidError
1746

18-
const result = parseReabaleConfigInvalidError(error)
47+
const result = parseReadableConfigInvalidError(error, language.t)
1948

2049
expect(result).toBe(
21-
["Invalid configuration", "opencode.config.ts", "settings.host: Required", "mode: Invalid"].join("\n"),
50+
["Arquivo de config em opencode.config.ts invalido: settings.host: Required", "mode: Invalid"].join("\n"),
2251
)
2352
})
2453

@@ -31,9 +60,9 @@ describe("parseReabaleConfigInvalidError", () => {
3160
},
3261
} satisfies ConfigInvalidError
3362

34-
const result = parseReabaleConfigInvalidError(error)
63+
const result = parseReadableConfigInvalidError(error, language.t)
3564

36-
expect(result).toBe(["Invalid configuration", "Bad value"].join("\n"))
65+
expect(result).toBe("Arquivo de config em config invalido: Bad value")
3766
})
3867
})
3968

@@ -46,24 +75,57 @@ describe("formatServerError", () => {
4675
},
4776
} satisfies ConfigInvalidError
4877

49-
const result = formatServerError(error)
78+
const result = formatServerError(error, language.t)
5079

51-
expect(result).toBe(["Invalid configuration", "Missing host"].join("\n"))
80+
expect(result).toBe("Arquivo de config em config invalido: Missing host")
5281
})
5382

5483
test("returns error messages", () => {
55-
expect(formatServerError(new Error("Request failed with status 503"))).toBe("Request failed with status 503")
84+
expect(formatServerError(new Error("Request failed with status 503"), language.t)).toBe(
85+
"Request failed with status 503",
86+
)
5687
})
5788

5889
test("returns provided string errors", () => {
59-
expect(formatServerError("Failed to connect to server")).toBe("Failed to connect to server")
90+
expect(formatServerError("Failed to connect to server", language.t)).toBe("Failed to connect to server")
6091
})
6192

62-
test("falls back to unknown", () => {
63-
expect(formatServerError(0)).toBe("Unknown error")
93+
test("uses translated unknown fallback", () => {
94+
expect(formatServerError(0, language.t)).toBe("Erro desconhecido")
6495
})
6596

6697
test("falls back for unknown error objects and names", () => {
67-
expect(formatServerError({ name: "ServerTimeoutError", data: { seconds: 30 } })).toBe("Unknown error")
98+
expect(formatServerError({ name: "ServerTimeoutError", data: { seconds: 30 } }, language.t)).toBe(
99+
"Erro desconhecido",
100+
)
101+
})
102+
103+
test("formats provider model errors using provider/model", () => {
104+
const error = {
105+
name: "ProviderModelNotFoundError",
106+
data: {
107+
providerID: "openai",
108+
modelID: "gpt-4.1",
109+
},
110+
} satisfies ProviderModelNotFoundError
111+
112+
expect(formatServerError(error, language.t)).toBe(
113+
["Modelo nao encontrado: openai/gpt-4.1", "Revise provider/model no config"].join("\n"),
114+
)
115+
})
116+
117+
test("formats provider model suggestions", () => {
118+
const error = {
119+
name: "ProviderModelNotFoundError",
120+
data: {
121+
providerID: "x",
122+
modelID: "y",
123+
suggestions: ["x/y2", "x/y3"],
124+
},
125+
} satisfies ProviderModelNotFoundError
126+
127+
expect(formatServerError(error, language.t)).toBe(
128+
["Modelo nao encontrado: x/y", "Voce quis dizer: x/y2, x/y3", "Revise provider/model no config"].join("\n"),
129+
)
68130
})
69131
})

packages/app/src/utils/server-errors.ts

Lines changed: 53 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -7,28 +7,31 @@ export type ConfigInvalidError = {
77
}
88
}
99

10-
type Label = {
11-
unknown: string
12-
invalidConfiguration: string
10+
export type ProviderModelNotFoundError = {
11+
name: "ProviderModelNotFoundError"
12+
data: {
13+
providerID: string
14+
modelID: string
15+
suggestions?: string[]
16+
}
1317
}
1418

15-
const fallback: Label = {
16-
unknown: "Unknown error",
17-
invalidConfiguration: "Invalid configuration",
18-
}
19+
type Translator = (key: string, vars?: Record<string, string | number>) => string
1920

20-
function resolveLabel(labels: Partial<Label> | undefined): Label {
21-
return {
22-
unknown: labels?.unknown ?? fallback.unknown,
23-
invalidConfiguration: labels?.invalidConfiguration ?? fallback.invalidConfiguration,
24-
}
21+
function tr(translator: Translator | undefined, key: string, text: string, vars?: Record<string, string | number>) {
22+
if (!translator) return text
23+
const out = translator(key, vars)
24+
if (!out || out === key) return text
25+
return out
2526
}
2627

27-
export function formatServerError(error: unknown, labels?: Partial<Label>) {
28-
if (isConfigInvalidErrorLike(error)) return parseReabaleConfigInvalidError(error, labels)
28+
export function formatServerError(error: unknown, translate?: Translator, fallback?: string) {
29+
if (isConfigInvalidErrorLike(error)) return parseReadableConfigInvalidError(error, translate)
30+
if (isProviderModelNotFoundErrorLike(error)) return parseReadableProviderModelNotFoundError(error, translate)
2931
if (error instanceof Error && error.message) return error.message
3032
if (typeof error === "string" && error) return error
31-
return resolveLabel(labels).unknown
33+
if (fallback) return fallback
34+
return tr(translate, "error.chain.unknown", "Unknown error")
3235
}
3336

3437
function isConfigInvalidErrorLike(error: unknown): error is ConfigInvalidError {
@@ -37,13 +40,41 @@ function isConfigInvalidErrorLike(error: unknown): error is ConfigInvalidError {
3740
return o.name === "ConfigInvalidError" && typeof o.data === "object" && o.data !== null
3841
}
3942

40-
export function parseReabaleConfigInvalidError(errorInput: ConfigInvalidError, labels?: Partial<Label>) {
41-
const head = resolveLabel(labels).invalidConfiguration
42-
const file = errorInput.data.path && errorInput.data.path !== "config" ? errorInput.data.path : ""
43+
function isProviderModelNotFoundErrorLike(error: unknown): error is ProviderModelNotFoundError {
44+
if (typeof error !== "object" || error === null) return false
45+
const o = error as Record<string, unknown>
46+
return o.name === "ProviderModelNotFoundError" && typeof o.data === "object" && o.data !== null
47+
}
48+
49+
export function parseReadableConfigInvalidError(errorInput: ConfigInvalidError, translator?: Translator) {
50+
const file = errorInput.data.path && errorInput.data.path !== "config" ? errorInput.data.path : "config"
4351
const detail = errorInput.data.message?.trim() ?? ""
44-
const issues = (errorInput.data.issues ?? []).map((issue) => {
45-
return `${issue.path.join(".")}: ${issue.message}`
52+
const issues = (errorInput.data.issues ?? [])
53+
.map((issue) => {
54+
const msg = issue.message.trim()
55+
if (!issue.path.length) return msg
56+
return `${issue.path.join(".")}: ${msg}`
57+
})
58+
.filter(Boolean)
59+
const msg = issues.length ? issues.join("\n") : detail
60+
if (!msg) return tr(translator, "error.chain.configInvalid", `Config file at ${file} is invalid`, { path: file })
61+
return tr(translator, "error.chain.configInvalidWithMessage", `Config file at ${file} is invalid: ${msg}`, {
62+
path: file,
63+
message: msg,
4664
})
47-
if (issues.length) return [head, file, "", ...issues].filter(Boolean).join("\n")
48-
return [head, file, detail].filter(Boolean).join("\n")
65+
}
66+
67+
function parseReadableProviderModelNotFoundError(errorInput: ProviderModelNotFoundError, translator?: Translator) {
68+
const p = errorInput.data.providerID.trim()
69+
const m = errorInput.data.modelID.trim()
70+
const list = (errorInput.data.suggestions ?? []).map((v) => v.trim()).filter(Boolean)
71+
const body = tr(translator, "error.chain.modelNotFound", `Model not found: ${p}/${m}`, { provider: p, model: m })
72+
const tail = tr(translator, "error.chain.checkConfig", "Check your config (opencode.json) provider/model names")
73+
if (list.length) {
74+
const suggestions = list.slice(0, 5).join(", ")
75+
return [body, tr(translator, "error.chain.didYouMean", `Did you mean: ${suggestions}`, { suggestions }), tail].join(
76+
"\n",
77+
)
78+
}
79+
return [body, tail].join("\n")
4980
}

0 commit comments

Comments
 (0)