Skip to content
Closed
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 })
const { Session } = await import("@/session/session")
await Promise.resolve()
.then(() => Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }))
.catch(() => {})
Comment on lines +113 to +115
}

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
}
result[config.name] = parsed.value
Comment on lines +139 to +144
}
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
87 changes: 87 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 @@ -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)
Comment on lines +923 to +929
// 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) => {
Expand Down
Loading