From 4602084a57bf890431c6bca4668468f90fa54509 Mon Sep 17 00:00:00 2001 From: EClinick Date: Wed, 27 May 2026 15:45:23 -0700 Subject: [PATCH] fix(opencode): report invalid agent/mode configs instead of crashing or dropping them An invalid file in agent/ threw ConfigInvalidError and aborted the whole load (no agents loaded); the same file in mode/ was silently dropped with no error. Both loaders now log, publish a session error, and skip just the bad file while loading the rest. Fixes #27133 --- packages/opencode/src/config/agent.ts | 52 +++++++----- packages/opencode/test/config/config.test.ts | 87 ++++++++++++++++++++ 2 files changed, 117 insertions(+), 22 deletions(-) diff --git a/packages/opencode/src/config/agent.ts b/packages/opencode/src/config/agent.ts index a6719e86743a..114edcaca2de 100644 --- a/packages/opencode/src/config/agent.ts +++ b/packages/opencode/src/config/agent.ts @@ -9,7 +9,6 @@ import { Glob } from "@opencode-ai/core/util/glob" import { configEntryNameFromPath } from "./entry-name" import * as ConfigMarkdown from "./markdown" import { ConfigModelID } from "./model-id" -import { ConfigParse } from "./parse" import { ConfigPermission } from "./permission" const log = Log.create({ service: "config" }) @@ -104,6 +103,18 @@ export const Info = AgentSchema.pipe( ).annotate({ identifier: "AgentConfig" }) export type Info = Schema.Schema.Type +// Surface a config-loading failure to the user and log it, so an invalid file is +// reported instead of crashing the whole load (load) or vanishing silently (loadMode). +// Publishing requires an instance context that isn't always present (e.g. tests), so a +// publish failure is swallowed — the log line is the durable record, the event is best-effort. +async function report(label: string, item: string, message: string, err: unknown) { + log.error(`failed to load ${label}`, { [label]: item, err }) + const { Session } = await import("@/session/session") + await Promise.resolve() + .then(() => Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })) + .catch(() => {}) +} + export async function load(dir: string) { const result: Record = {} for (const item of await Glob.scan("{agent,agents}/**/*.md", { @@ -113,25 +124,24 @@ export async function load(dir: string) { symlink: true, })) { const md = await ConfigMarkdown.parse(item).catch(async (err) => { - const message = ConfigMarkdown.FrontmatterError.isInstance(err) - ? err.data.message - : `Failed to parse agent ${item}` - const { Session } = await import("@/session/session") - void Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) - log.error("failed to load agent", { agent: item, err }) + const message = ConfigMarkdown.FrontmatterError.isInstance(err) ? err.data.message : `Failed to parse agent ${item}` + await report("agent", item, message, err) return undefined }) if (!md) continue const patterns = ["/.opencode/agent/", "/.opencode/agents/", "/agent/", "/agents/"] - const name = configEntryNameFromPath(item, patterns) - const config = { - name, + name: configEntryNameFromPath(item, patterns), ...md.data, prompt: md.content.trim(), } - result[config.name] = ConfigParse.schema(Info, config, item) + const parsed = Schema.decodeUnknownExit(Info)(config, { errors: "all", propertyOrder: "original" }) + if (Exit.isFailure(parsed)) { + await report("agent", item, `Failed to parse agent ${item}`, parsed.cause) + continue + } + result[config.name] = parsed.value } return result } @@ -145,12 +155,8 @@ export async function loadMode(dir: string) { symlink: true, })) { const md = await ConfigMarkdown.parse(item).catch(async (err) => { - const message = ConfigMarkdown.FrontmatterError.isInstance(err) - ? err.data.message - : `Failed to parse mode ${item}` - const { Session } = await import("@/session/session") - void Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) - log.error("failed to load mode", { mode: item, err }) + const message = ConfigMarkdown.FrontmatterError.isInstance(err) ? err.data.message : `Failed to parse mode ${item}` + await report("mode", item, message, err) return undefined }) if (!md) continue @@ -161,11 +167,13 @@ export async function loadMode(dir: string) { prompt: md.content.trim(), } const parsed = Schema.decodeUnknownExit(Info)(config, { errors: "all", propertyOrder: "original" }) - if (Exit.isSuccess(parsed)) { - result[config.name] = { - ...parsed.value, - mode: "primary" as const, - } + if (Exit.isFailure(parsed)) { + await report("mode", item, `Failed to parse mode ${item}`, parsed.cause) + continue + } + result[config.name] = { + ...parsed.value, + mode: "primary" as const, } } return result diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 90e78efcdbaa..a79e4dfa333f 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -1,6 +1,8 @@ import { test, expect, describe, mock, afterEach, beforeEach } from "bun:test" import { Effect, Layer, Option } from "effect" import { NodeFileSystem, NodePath } from "@effect/platform-node" +import { Bus } from "@/bus" +import { Session } from "@/session/session" import { Config } from "@/config/config" import { ConfigManaged } from "@/config/managed" import { ConfigParse } from "../../src/config/parse" @@ -891,6 +893,91 @@ Nested agent prompt`, }) }) +test("invalid agent file is reported and does not block valid agents", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const agentDir = path.join(dir, ".opencode", "agent") + await fs.mkdir(agentDir, { recursive: true }) + + await Filesystem.write( + path.join(agentDir, "good.md"), + `--- +model: test/model +--- +Good agent prompt`, + ) + + await Filesystem.write( + path.join(agentDir, "broken.md"), + `--- +temperature: "not a number" +--- +Broken agent prompt`, + ) + }, + }) + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const errors: string[] = [] + Bus.subscribe(Session.Event.Error, (evt) => { + const data = evt.properties.error?.data + if (data && "message" in data && typeof data.message === "string") errors.push(data.message) + }) + await Bun.sleep(10) + const config = await load() + await Bun.sleep(10) + // The valid agent still loads even though a sibling file is invalid. + expect(config.agent?.["good"]).toMatchObject({ name: "good", model: "test/model" }) + expect(config.agent?.["broken"]).toBeUndefined() + // The invalid file is reported rather than crashing the load. + expect(errors.some((m) => m.includes("broken.md"))).toBe(true) + }, + }) +}) + +test("invalid mode file is reported instead of being silently dropped", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const modeDir = path.join(dir, ".opencode", "mode") + await fs.mkdir(modeDir, { recursive: true }) + + await Filesystem.write( + path.join(modeDir, "good.md"), + `--- +model: test/model +--- +Good mode prompt`, + ) + + await Filesystem.write( + path.join(modeDir, "broken.md"), + `--- +temperature: "not a number" +--- +Broken mode prompt`, + ) + }, + }) + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const errors: string[] = [] + Bus.subscribe(Session.Event.Error, (evt) => { + const data = evt.properties.error?.data + if (data && "message" in data && typeof data.message === "string") errors.push(data.message) + }) + await Bun.sleep(10) + const config = await load() + await Bun.sleep(10) + expect(config.agent?.["good"]).toMatchObject({ name: "good", mode: "primary" }) + expect(config.agent?.["broken"]).toBeUndefined() + // Previously the invalid mode vanished with no error; now it must be reported. + expect(errors.some((m) => m.includes("broken.md"))).toBe(true) + }, + }) +}) + test("loads commands from .opencode/command (singular)", async () => { await using tmp = await tmpdir({ init: async (dir) => {