summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorRyan Skidmore <[email protected]>2026-03-24 12:50:55 -0500
committerGitHub <[email protected]>2026-03-24 12:50:55 -0500
commit814a515a8a2f474585ea061a99e1058b2bb8b374 (patch)
treef9d69feef0d9fed48ace185307887af8cdfd3106
parent235a82aea97cd35c190bc95e916be5bdc0cce04a (diff)
downloadopencode-814a515a8a2f474585ea061a99e1058b2bb8b374.tar.gz
opencode-814a515a8a2f474585ea061a99e1058b2bb8b374.zip
fix: improve plugin system robustness — agent/command resolution, async errors, hook timing, two-phase init (#18280)
Co-authored-by: Aiden Cline <[email protected]>
-rw-r--r--packages/opencode/src/plugin/index.ts6
-rw-r--r--packages/opencode/src/server/routes/session.ts10
-rw-r--r--packages/opencode/src/session/prompt.ts52
-rw-r--r--packages/opencode/test/plugin/auth-override.test.ts16
-rw-r--r--packages/opencode/test/server/session-messages.test.ts13
-rw-r--r--packages/opencode/test/session/prompt.test.ts76
6 files changed, 169 insertions, 4 deletions
diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts
index 57dcff8f6..e519f9f35 100644
--- a/packages/opencode/src/plugin/index.ts
+++ b/packages/opencode/src/plugin/index.ts
@@ -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 })
+ }
}
})
diff --git a/packages/opencode/src/server/routes/session.ts b/packages/opencode/src/server/routes/session.ts
index abc820c2a..3c9ebfdc5 100644
--- a/packages/opencode/src/server/routes/session.ts
+++ b/packages/opencode/src/server/routes/session.ts
@@ -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" })
@@ -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(),
+ })
+ })
})
},
)
diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts
index dca8085c5..b3c34539e 100644
--- a/packages/opencode/src/session/prompt.ts
+++ b/packages/opencode/src/session/prompt.ts
@@ -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,
@@ -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({
@@ -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 =
@@ -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(),
@@ -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())
diff --git a/packages/opencode/test/plugin/auth-override.test.ts b/packages/opencode/test/plugin/auth-override.test.ts
index b96726225..667b7ba9a 100644
--- a/packages/opencode/test/plugin/auth-override.test.ts
+++ b/packages/opencode/test/plugin/auth-override.test.ts
@@ -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)
+ })
+})
diff --git a/packages/opencode/test/server/session-messages.test.ts b/packages/opencode/test/server/session-messages.test.ts
index ee4c51646..91e0fd926 100644
--- a/packages/opencode/test/server/session-messages.test.ts
+++ b/packages/opencode/test/server/session-messages.test.ts
@@ -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")
+ })
+})
diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts
index 3986271da..7d1d42905 100644
--- a/packages/opencode/test/session/prompt.test.ts
+++ b/packages/opencode/test/session/prompt.test.ts
@@ -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"
@@ -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)
+})