Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
52 changes: 30 additions & 22 deletions packages/opencode/src/config/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" })
Expand Down Expand Up @@ -104,6 +103,18 @@ export const Info = AgentSchema.pipe(
).annotate({ identifier: "AgentConfig" })
export type Info = Schema.Schema.Type<typeof Info>

// 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<string, Info> = {}
for (const item of await Glob.scan("{agent,agents}/**/*.md", {
Expand All @@ -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
}
Comment on lines +139 to +143
result[config.name] = parsed.value
}
return result
}
Expand All @@ -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
Expand All @@ -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
Expand Down
93 changes: 93 additions & 0 deletions packages/opencode/test/config/config.test.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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<void>()
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 }
}
Comment on lines +89 to +100

// Get managed config directory from environment (set in preload.ts)
const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!

Expand Down Expand Up @@ -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) => {
Expand Down
Loading