diff --git a/packages/opencode/src/config/agent.ts b/packages/opencode/src/config/agent.ts index a6719e86743a..f665d20d1e7b 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 }) + try { + const { Session } = await import("@/session/session") + await 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..fb06edcfa743 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" @@ -81,6 +83,22 @@ const listDirs = () => const ready = () => Effect.runPromise(Config.Service.use((svc) => svc.waitForDependencies()).pipe(Effect.scoped, Effect.provide(layer))) +// Collect Session.Event.Error messages, and return a promise that resolves as soon +// as the first message containing `pattern` arrives (with a timeout so a missing +// event fails the test instead of hanging it). +function subscribeErrors(pattern: string, timeoutMs = 500) { + const errors: string[] = [] + const matched = Promise.withResolvers() + Bus.subscribe(Session.Event.Error, (evt) => { + const data = evt.properties.error?.data + if (!data || !("message" in data) || typeof data.message !== "string") return + errors.push(data.message) + if (data.message.includes(pattern)) matched.resolve() + }) + const wait = Promise.race([matched.promise, Bun.sleep(timeoutMs)]) + return { errors, wait } +} + // Get managed config directory from environment (set in preload.ts) const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR! @@ -891,6 +909,81 @@ 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, wait } = subscribeErrors("broken.md") + const config = await load() + await wait + // 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, wait } = subscribeErrors("broken.md") + const config = await load() + await wait + 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) => {