Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 53 additions & 10 deletions packages/app/src/context/global-sync/bootstrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
bootstrapDirectory,
hydratePendingExternalResults,
mergeSessionStatusSnapshot,
surfaceConfigErrors,
type ConfigLoadError,
} from "./bootstrap"
import { loadSessionsQuery } from "../global-sync"
import type { State, VcsCache } from "./types"
Expand Down Expand Up @@ -131,7 +133,7 @@ describe("bootstrapDirectory", () => {
}) as typeof console.warn
const sdk = {
app: { agents: async () => ({ data: [] }) },
config: { get: async () => ({ data: {} as Config }) },
config: { get: async () => ({ data: {} as Config }), errors: async () => ({ data: [] }) },
session: {
status: async () => ({ data: {} }),
get: async () => ({ data: undefined }),
Expand Down Expand Up @@ -192,7 +194,7 @@ describe("bootstrapDirectory", () => {
}) as typeof console.warn
const sdk = {
app: { agents: async () => ({ data: [] }) },
config: { get: async () => ({ data: {} as Config }) },
config: { get: async () => ({ data: {} as Config }), errors: async () => ({ data: [] }) },
session: {
status: async () => ({ data: {} }),
get: async () => ({ data: undefined }),
Expand Down Expand Up @@ -262,7 +264,7 @@ describe("bootstrapDirectory", () => {

const sdk = {
app: { agents: async () => ({ data: [] }) },
config: { get: async () => ({ data: {} as Config }) },
config: { get: async () => ({ data: {} as Config }), errors: async () => ({ data: [] }) },
session: {
status: async () => ({ data: {} }),
get: async () => ({ data: undefined }),
Expand Down Expand Up @@ -347,7 +349,7 @@ describe("bootstrapDirectory", () => {

const sdk = {
app: { agents: async () => ({ data: [] }) },
config: { get: async () => ({ data: {} as Config }) },
config: { get: async () => ({ data: {} as Config }), errors: async () => ({ data: [] }) },
session: {
status: async () => {
throw new Error("status failed")
Expand Down Expand Up @@ -419,7 +421,7 @@ describe("bootstrapDirectory", () => {
}
const sdk = {
app: { agents: async () => ({ data: [] }) },
config: { get: async () => ({ data: {} as Config }) },
config: { get: async () => ({ data: {} as Config }), errors: async () => ({ data: [] }) },
session: {
status: async () => ({ data: {} }),
get: async ({ sessionID }: { sessionID: string }) => {
Expand Down Expand Up @@ -493,7 +495,7 @@ describe("bootstrapDirectory", () => {

const sdk = {
app: { agents: async () => ({ data: [] }) },
config: { get: async () => ({ data: {} as Config }) },
config: { get: async () => ({ data: {} as Config }), errors: async () => ({ data: [] }) },
session: {
status: async () => {
statusStarted = true
Expand Down Expand Up @@ -553,7 +555,7 @@ describe("bootstrapDirectory", () => {

const sdk = {
app: { agents: async () => ({ data: [] }) },
config: { get: async () => ({ data: {} as Config }) },
config: { get: async () => ({ data: {} as Config }), errors: async () => ({ data: [] }) },
session: {
status: async () => ({ data: {} }),
get: async () => ({ data: undefined }),
Expand Down Expand Up @@ -603,7 +605,7 @@ describe("bootstrapDirectory", () => {

const sdk = {
app: { agents: async () => ({ data: [] }) },
config: { get: async () => ({ data: {} as Config }) },
config: { get: async () => ({ data: {} as Config }), errors: async () => ({ data: [] }) },
session: {
status: async () => ({ data: {} }),
get: async () => ({ data: undefined }),
Expand Down Expand Up @@ -663,7 +665,7 @@ describe("bootstrapDirectory", () => {

const sdk = {
app: { agents: async () => ({ data: [] }) },
config: { get: async () => ({ data: {} as Config }) },
config: { get: async () => ({ data: {} as Config }), errors: async () => ({ data: [] }) },
session: {
status: async () => ({ data: {} }),
get: async () => ({ data: undefined }),
Expand Down Expand Up @@ -737,7 +739,7 @@ describe("bootstrapDirectory", () => {

const sdk = {
app: { agents: async () => ({ data: [] }) },
config: { get: async () => ({ data: {} as Config }) },
config: { get: async () => ({ data: {} as Config }), errors: async () => ({ data: [] }) },
session: {
status: async () => ({ data: {} }),
get: async () => ({ data: undefined }),
Expand Down Expand Up @@ -790,6 +792,47 @@ describe("bootstrapDirectory", () => {
})
})

describe("surfaceConfigErrors", () => {
const translate = (key: string) => key

test("emits one toast per distinct config error", () => {
const emitted: Array<{ title: string; description: string }> = []
const errors: ConfigLoadError[] = [
{ name: "ConfigJsonError", data: { path: "/repo/one/pawwork.json" } },
{ name: "ConfigInvalidError", data: { path: "/repo/one/opencode.json", message: "bad field" } },
]
surfaceConfigErrors("/repo/one", errors, translate, (toast) => emitted.push(toast))
expect(emitted.length).toBe(2)
expect(emitted[0].title).toBe("toast.config.invalid.title")
expect(emitted[0].description).toContain("toast.config.invalid.hint")
})

test("does not re-toast the same unresolved error across repeated bootstraps", () => {
const emitted: Array<{ title: string }> = []
const error: ConfigLoadError = { name: "ConfigJsonError", data: { path: "/repo/two/pawwork.json" } }
surfaceConfigErrors("/repo/two", [error], translate, (toast) => emitted.push(toast))
surfaceConfigErrors("/repo/two", [error], translate, (toast) => emitted.push(toast))
expect(emitted.length).toBe(1)
})

test("toasts again when the same file fails a different way", () => {
const emitted: Array<{ title: string }> = []
surfaceConfigErrors(
"/repo/three",
[{ name: "ConfigJsonError", data: { path: "/repo/three/pawwork.json" } }],
translate,
(toast) => emitted.push(toast),
)
surfaceConfigErrors(
"/repo/three",
[{ name: "ConfigInvalidError", data: { path: "/repo/three/pawwork.json", message: "now a schema error" } }],
translate,
(toast) => emitted.push(toast),
)
expect(emitted.length).toBe(2)
})
})

describe("hydratePendingExternalResults", () => {
const directory = "/tmp/project"
const parentSession = {
Expand Down
40 changes: 40 additions & 0 deletions packages/app/src/context/global-sync/bootstrap.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import { Binary } from "@opencode-ai/util/binary"
import type {
Config,
Expand Down Expand Up @@ -60,6 +60,38 @@
providerRev.delete(directory)
}

// A config file that fails to load (bad JSON or schema) is skipped by the engine so the rest of the config
// still works, and reported once via /config/errors. Re-bootstrap runs on every project reload, so without a
// guard the same unresolved error would toast repeatedly — the spam we are fixing, just moved to the UI. Track
// which (directory, file, message) we have already surfaced and only toast the ones we have not seen.
const seenConfigErrors = new Set<string>()

export type ConfigLoadError = {
name: string
data: { path?: string | null; message?: string | null; issues?: unknown }
}

// `emit` is injectable so the dedup behavior can be tested without a live toast host; production passes showToast.
export function surfaceConfigErrors(
directory: string,
errors: ConfigLoadError[],
translate: (key: string, vars?: Record<string, string | number>) => string,
emit: (toast: { variant: "error"; title: string; description: string }) => void = showToast,
) {
for (const error of errors) {
const key = [directory, error.data?.path ?? "", error.data?.message ?? "", JSON.stringify(error.data?.issues ?? "")].join(
" | ",
)
if (seenConfigErrors.has(key)) continue
seenConfigErrors.add(key)
emit({
variant: "error",
title: translate("toast.config.invalid.title"),
description: `${formatServerError(error, translate)}\n${translate("toast.config.invalid.hint")}`,
})
}
}

function runAll(list: Array<() => Promise<unknown>>) {
return Promise.allSettled(list.map((item) => item()))
}
Expand Down Expand Up @@ -436,6 +468,14 @@
),
}),
() => retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))),
// Best-effort surface of config files the engine had to skip (bad JSON/schema). Never let it fail the
// bootstrap — a broken config already degrades gracefully; this only turns the recorded errors into one
// readable toast per unseen problem.
() =>
input.sdk.config
.errors()
.then((x) => surfaceConfigErrors(input.directory, x.data ?? [], input.translate))
.catch((err) => console.error("Failed to load config errors", err)),
() =>
retry(() =>
input.sdk.session.status().then((x) => {
Expand Down
3 changes: 3 additions & 0 deletions packages/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -513,6 +513,9 @@ export const dict = {
"toast.session.listFailed.title": "Failed to load sessions for {{project}}",
"toast.project.reloadFailed.title": "Failed to reload {{project}}",

"toast.config.invalid.title": "Some settings couldn't be loaded",
"toast.config.invalid.hint": "Your other settings still work. Fix the file, then reopen the project to apply it.",

"toast.update.title": "Update available",
"toast.update.description": "A new version of PawWork ({{version}}) is now available to install.",
"toast.update.action.installRestart": "Install and restart",
Expand Down
2 changes: 2 additions & 0 deletions packages/app/src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1169,6 +1169,8 @@ export const dict = {
"dialog.releaseNotes.action.hideFuture": "不再显示",
"dialog.releaseNotes.media.alt": "发布预览",
"toast.project.reloadFailed.title": "无法重新加载 {{project}}",
"toast.config.invalid.title": "部分设置未能加载",
"toast.config.invalid.hint": "其余设置仍然生效。修正该文件后重新打开项目即可应用。",
"error.server.invalidConfiguration": "配置无效",
"common.moreCountSuffix": " (还有 {{count}} 个)",
"common.time.justNow": "刚刚",
Expand Down
16 changes: 15 additions & 1 deletion packages/app/src/utils/server-errors.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import type { ConfigInvalidError, ProviderModelNotFoundError } from "./server-errors"
import type { ConfigInvalidError, ConfigJsonError, ProviderModelNotFoundError } from "./server-errors"
import { formatServerError, parseReadableConfigInvalidError } from "./server-errors"

function fill(text: string, vars?: Record<string, string | number>) {
Expand All @@ -16,6 +16,7 @@ function useLanguageMock() {
"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.configJsonInvalid": "Arquivo de config em {{path}} nao e JSON valido",
"error.chain.modelNotFound": "Modelo nao encontrado: {{provider}}/{{model}}",
"error.chain.didYouMean": "Voce quis dizer: {{suggestions}}",
"error.chain.checkConfig": "Revise provider/model no config",
Expand Down Expand Up @@ -80,6 +81,19 @@ describe("formatServerError", () => {
expect(result).toBe("Arquivo de config em config invalido: Missing host")
})

test("formats config JSON errors by file, ignoring the noisy dump", () => {
const error = {
name: "ConfigJsonError",
data: {
path: "pawwork.json",
message: "\n--- JSONC Input ---\n{ bad }\n--- Errors ---\nPropertyNameExpected at line 1",
},
} satisfies ConfigJsonError

// Only the file path surfaces; the multi-line parser dump never reaches the toast.
expect(formatServerError(error, language.t)).toBe("Arquivo de config em pawwork.json nao e JSON valido")
})

test("returns error messages", () => {
expect(formatServerError(new Error("Request failed with status 503"), language.t)).toBe(
"Request failed with status 503",
Expand Down
21 changes: 21 additions & 0 deletions packages/app/src/utils/server-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ export type ConfigInvalidError = {
}
}

export type ConfigJsonError = {
name: "ConfigJsonError"
data: {
path?: string
message?: string
}
}

export type ProviderModelNotFoundError = {
name: "ProviderModelNotFoundError"
data: {
Expand All @@ -29,6 +37,7 @@ function tr(translator: Translator | undefined, key: string, text: string, vars?

export function formatServerError(error: unknown, translate?: Translator, fallback?: string) {
if (isConfigInvalidErrorLike(error)) return parseReadableConfigInvalidError(error, translate)
if (isConfigJsonErrorLike(error)) return parseReadableConfigJsonError(error, translate)
if (isProviderModelNotFoundErrorLike(error)) return parseReadableProviderModelNotFoundError(error, translate)
// Server / assistant error payloads ({ name, data }) are neither Error
// instances nor strings; share the session card's decoder so a toast surfaces
Expand All @@ -48,6 +57,12 @@ function isConfigInvalidErrorLike(error: unknown): error is ConfigInvalidError {
return o.name === "ConfigInvalidError" && typeof o.data === "object" && o.data !== null
}

function isConfigJsonErrorLike(error: unknown): error is ConfigJsonError {
if (typeof error !== "object" || error === null) return false
const o = error as Record<string, unknown>
return o.name === "ConfigJsonError" && typeof o.data === "object" && o.data !== null
}

function isProviderModelNotFoundErrorLike(error: unknown): error is ProviderModelNotFoundError {
if (typeof error !== "object" || error === null) return false
const o = error as Record<string, unknown>
Expand All @@ -72,6 +87,12 @@ export function parseReadableConfigInvalidError(errorInput: ConfigInvalidError,
})
}

export function parseReadableConfigJsonError(errorInput: ConfigJsonError, translator?: Translator) {
const file = errorInput.data.path && errorInput.data.path !== "config" ? errorInput.data.path : "config"
// The backend message is a full multi-line JSONC dump — too noisy for a toast, so surface only the file.
return tr(translator, "error.chain.configJsonInvalid", `Config file at ${file} is not valid JSON(C)`, { path: file })
}

function parseReadableProviderModelNotFoundError(errorInput: ProviderModelNotFoundError, translator?: Translator) {
const p = errorInput.data.providerID.trim()
const m = errorInput.data.modelID.trim()
Expand Down
Loading
Loading