Skip to content
6 changes: 5 additions & 1 deletion packages/opencode/src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,11 @@ export namespace Plugin {

// Notify plugins of current config
for (const hook of hooks) {
await (hook as any).config?.(cfg)
try {
await (hook as any).config?.(cfg)
} catch (err) {
log.error("plugin config hook failed", { error: err })
}
}
})

Expand Down
10 changes: 9 additions & 1 deletion packages/opencode/src/server/routes/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import { PermissionID } from "@/permission/schema"
import { ModelID, ProviderID } from "@/provider/schema"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
import { Bus } from "../../bus"
import { NamedError } from "@opencode-ai/util/error"

const log = Log.create({ service: "server" })

Expand Down Expand Up @@ -846,7 +848,13 @@ export const SessionRoutes = lazy(() =>
return stream(c, async () => {
const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json")
SessionPrompt.prompt({ ...body, sessionID })
SessionPrompt.prompt({ ...body, sessionID }).catch((err) => {
log.error("prompt_async failed", { sessionID, error: err })
Bus.publish(Session.Event.Error, {
sessionID,
error: new NamedError.Unknown({ message: err instanceof Error ? err.message : String(err) }).toObject(),
})
})
})
},
)
Expand Down
52 changes: 50 additions & 2 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,16 @@ export namespace SessionPrompt {
)
let executionError: Error | undefined
const taskAgent = await Agent.get(task.agent)
if (!taskAgent) {
const available = await Agent.list().then((agents) => agents.filter((a) => !a.hidden).map((a) => a.name))
const hint = available.length ? ` Available agents: ${available.join(", ")}` : ""
const error = new NamedError.Unknown({ message: `Agent not found: "${task.agent}".${hint}` })
Bus.publish(Session.Event.Error, {
sessionID,
error: error.toObject(),
})
throw error
}
const taskCtx: Tool.Context = {
agent: task.agent,
messageID: assistantMessage.id,
Expand Down Expand Up @@ -560,6 +570,16 @@ export namespace SessionPrompt {

// normal processing
const agent = await Agent.get(lastUser.agent)
if (!agent) {
const available = await Agent.list().then((agents) => agents.filter((a) => !a.hidden).map((a) => a.name))
const hint = available.length ? ` Available agents: ${available.join(", ")}` : ""
const error = new NamedError.Unknown({ message: `Agent not found: "${lastUser.agent}".${hint}` })
Bus.publish(Session.Event.Error, {
sessionID,
error: error.toObject(),
})
throw error
}
const maxSteps = agent.steps ?? Infinity
const isLastStep = step >= maxSteps
msgs = await insertReminders({
Expand Down Expand Up @@ -964,7 +984,18 @@ export namespace SessionPrompt {
}

async function createUserMessage(input: PromptInput) {
const agent = await Agent.get(input.agent ?? (await Agent.defaultAgent()))
const agentName = input.agent || (await Agent.defaultAgent())
const agent = await Agent.get(agentName)
if (!agent) {
const available = await Agent.list().then((agents) => agents.filter((a) => !a.hidden).map((a) => a.name))
const hint = available.length ? ` Available agents: ${available.join(", ")}` : ""
const error = new NamedError.Unknown({ message: `Agent not found: "${agentName}".${hint}` })
Bus.publish(Session.Event.Error, {
sessionID: input.sessionID,
error: error.toObject(),
})
throw error
}

const model = input.model ?? agent.model ?? (await lastModel(input.sessionID))
const full =
Expand Down Expand Up @@ -1531,6 +1562,16 @@ NOTE: At any point in time through this workflow you should feel free to ask the
await SessionRevert.cleanup(session)
}
const agent = await Agent.get(input.agent)
if (!agent) {
const available = await Agent.list().then((agents) => agents.filter((a) => !a.hidden).map((a) => a.name))
const hint = available.length ? ` Available agents: ${available.join(", ")}` : ""
const error = new NamedError.Unknown({ message: `Agent not found: "${input.agent}".${hint}` })
Bus.publish(Session.Event.Error, {
sessionID: input.sessionID,
error: error.toObject(),
})
throw error
}
const model = input.model ?? agent.model ?? (await lastModel(input.sessionID))
const userMsg: MessageV2.User = {
id: MessageID.ascending(),
Expand Down Expand Up @@ -1783,7 +1824,14 @@ NOTE: At any point in time through this workflow you should feel free to ask the
log.info("command", input)
const command = await Command.get(input.command)
if (!command) {
throw new NamedError.Unknown({ message: `Command not found: "${input.command}"` })
const available = await Command.list().then((cmds) => cmds.map((c) => c.name))
const hint = available.length ? ` Available commands: ${available.join(", ")}` : ""
const error = new NamedError.Unknown({ message: `Command not found: "${input.command}".${hint}` })
Bus.publish(Session.Event.Error, {
sessionID: input.sessionID,
error: error.toObject(),
})
throw error
}
const agentName = command.agent ?? input.agent ?? (await Agent.defaultAgent())

Expand Down
16 changes: 16 additions & 0 deletions packages/opencode/test/plugin/auth-override.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,19 @@ describe("plugin.auth-override", () => {
expect(plainMethods[ProviderID.make("github-copilot")][0].label).not.toBe("Test Override Auth")
}, 30000) // Increased timeout for plugin installation
})

const file = path.join(import.meta.dir, "../../src/plugin/index.ts")

describe("plugin.config-hook-error-isolation", () => {
test("config hooks are individually error-isolated in the layer factory", async () => {
const src = await Bun.file(file).text()

// The config hook try/catch lives in the InstanceState factory (layer definition),
// not in init() which now just delegates to the Effect service.
expect(src).toContain("plugin config hook failed")

const pattern =
/for\s*\(const hook of hooks\)\s*\{[\s\S]*?try\s*\{[\s\S]*?\.config\?\.\([\s\S]*?\}\s*catch\s*\(err\)\s*\{[\s\S]*?plugin config hook failed[\s\S]*?\}/
expect(pattern.test(src)).toBe(true)
})
})
13 changes: 13 additions & 0 deletions packages/opencode/test/server/session-messages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,3 +117,16 @@ describe("session messages endpoint", () => {
})
})
})

describe("session.prompt_async error handling", () => {
test("prompt_async route has error handler for detached prompt call", async () => {
const src = await Bun.file(path.join(import.meta.dir, "../../src/server/routes/session.ts")).text()
const start = src.indexOf('"/:sessionID/prompt_async"')
const end = src.indexOf('"/:sessionID/command"', start)
expect(start).toBeGreaterThan(-1)
expect(end).toBeGreaterThan(start)
const route = src.slice(start, end)
expect(route).toContain(".catch(")
expect(route).toContain("Bus.publish(Session.Event.Error")
})
})
76 changes: 76 additions & 0 deletions packages/opencode/test/session/prompt.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import path from "path"
import { describe, expect, test } from "bun:test"
import { NamedError } from "@opencode-ai/util/error"
import { fileURLToPath } from "url"
import { Instance } from "../../src/project/instance"
import { ModelID, ProviderID } from "../../src/provider/schema"
Expand Down Expand Up @@ -210,3 +211,78 @@ describe("session.prompt agent variant", () => {
}
})
})

describe("session.agent-resolution", () => {
test("unknown agent throws typed error", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const err = await SessionPrompt.prompt({
sessionID: session.id,
agent: "nonexistent-agent-xyz",
noReply: true,
parts: [{ type: "text", text: "hello" }],
}).then(
() => undefined,
(e) => e,
)
expect(err).toBeDefined()
expect(err).not.toBeInstanceOf(TypeError)
expect(NamedError.Unknown.isInstance(err)).toBe(true)
if (NamedError.Unknown.isInstance(err)) {
expect(err.data.message).toContain('Agent not found: "nonexistent-agent-xyz"')
}
},
})
}, 30000)

test("unknown agent error includes available agent names", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const err = await SessionPrompt.prompt({
sessionID: session.id,
agent: "nonexistent-agent-xyz",
noReply: true,
parts: [{ type: "text", text: "hello" }],
}).then(
() => undefined,
(e) => e,
)
expect(NamedError.Unknown.isInstance(err)).toBe(true)
if (NamedError.Unknown.isInstance(err)) {
expect(err.data.message).toContain("build")
}
},
})
}, 30000)

test("unknown command throws typed error with available names", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const err = await SessionPrompt.command({
sessionID: session.id,
command: "nonexistent-command-xyz",
arguments: "",
}).then(
() => undefined,
(e) => e,
)
expect(err).toBeDefined()
expect(err).not.toBeInstanceOf(TypeError)
expect(NamedError.Unknown.isInstance(err)).toBe(true)
if (NamedError.Unknown.isInstance(err)) {
expect(err.data.message).toContain('Command not found: "nonexistent-command-xyz"')
expect(err.data.message).toContain("init")
}
},
})
}, 30000)
})
Loading