Skip to content

Commit 814a515

Browse files
fix: improve plugin system robustness — agent/command resolution, async errors, hook timing, two-phase init (#18280)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
1 parent 235a82a commit 814a515

6 files changed

Lines changed: 169 additions & 4 deletions

File tree

‎packages/opencode/src/plugin/index.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,11 @@ export namespace Plugin {
136136

137137
// Notify plugins of current config
138138
for(consthookofhooks){
139-
await(hookasany).config?.(cfg)
139+
try{
140+
await(hookasany).config?.(cfg)
141+
}catch(err){
142+
log.error("plugin config hook failed",{error: err})
143+
}
140144
}
141145
})
142146

‎packages/opencode/src/server/routes/session.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ import { PermissionID } from "@/permission/schema"
1919
import{ModelID,ProviderID}from"@/provider/schema"
2020
import{errors}from"../error"
2121
import{lazy}from"../../util/lazy"
22+
import{Bus}from"../../bus"
23+
import{NamedError}from"@opencode-ai/util/error"
2224

2325
constlog=Log.create({service: "server"})
2426

@@ -846,7 +848,13 @@ export const SessionRoutes = lazy(() =>
846848
returnstream(c,async()=>{
847849
constsessionID=c.req.valid("param").sessionID
848850
constbody=c.req.valid("json")
849-
SessionPrompt.prompt({ ...body, sessionID })
851+
SessionPrompt.prompt({ ...body, sessionID }).catch((err)=>{
852+
log.error("prompt_async failed",{ sessionID,error: err})
853+
Bus.publish(Session.Event.Error,{
854+
sessionID,
855+
error: newNamedError.Unknown({message: errinstanceofError ? err.message : String(err)}).toObject(),
856+
})
857+
})
850858
})
851859
},
852860
)

‎packages/opencode/src/session/prompt.ts‎

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,16 @@ export namespace SessionPrompt {
418418
)
419419
letexecutionError: Error|undefined
420420
consttaskAgent=awaitAgent.get(task.agent)
421+
if(!taskAgent){
422+
constavailable=awaitAgent.list().then((agents)=>agents.filter((a)=>!a.hidden).map((a)=>a.name))
423+
consthint=available.length ? ` Available agents: ${available.join(", ")}` : ""
424+
consterror=newNamedError.Unknown({message: `Agent not found: "${task.agent}".${hint}`})
425+
Bus.publish(Session.Event.Error,{
426+
sessionID,
427+
error: error.toObject(),
428+
})
429+
throwerror
430+
}
421431
consttaskCtx: Tool.Context={
422432
agent: task.agent,
423433
messageID: assistantMessage.id,
@@ -560,6 +570,16 @@ export namespace SessionPrompt {
560570

561571
// normal processing
562572
constagent=awaitAgent.get(lastUser.agent)
573+
if(!agent){
574+
constavailable=awaitAgent.list().then((agents)=>agents.filter((a)=>!a.hidden).map((a)=>a.name))
575+
consthint=available.length ? ` Available agents: ${available.join(", ")}` : ""
576+
consterror=newNamedError.Unknown({message: `Agent not found: "${lastUser.agent}".${hint}`})
577+
Bus.publish(Session.Event.Error,{
578+
sessionID,
579+
error: error.toObject(),
580+
})
581+
throwerror
582+
}
563583
constmaxSteps=agent.steps??Infinity
564584
constisLastStep=step>=maxSteps
565585
msgs=awaitinsertReminders({
@@ -964,7 +984,18 @@ export namespace SessionPrompt {
964984
}
965985

966986
asyncfunctioncreateUserMessage(input: PromptInput){
967-
constagent=awaitAgent.get(input.agent??(awaitAgent.defaultAgent()))
987+
constagentName=input.agent||(awaitAgent.defaultAgent())
988+
constagent=awaitAgent.get(agentName)
989+
if(!agent){
990+
constavailable=awaitAgent.list().then((agents)=>agents.filter((a)=>!a.hidden).map((a)=>a.name))
991+
consthint=available.length ? ` Available agents: ${available.join(", ")}` : ""
992+
consterror=newNamedError.Unknown({message: `Agent not found: "${agentName}".${hint}`})
993+
Bus.publish(Session.Event.Error,{
994+
sessionID: input.sessionID,
995+
error: error.toObject(),
996+
})
997+
throwerror
998+
}
968999

9691000
constmodel=input.model??agent.model??(awaitlastModel(input.sessionID))
9701001
constfull=
@@ -1531,6 +1562,16 @@ NOTE: At any point in time through this workflow you should feel free to ask the
15311562
awaitSessionRevert.cleanup(session)
15321563
}
15331564
constagent=awaitAgent.get(input.agent)
1565+
if(!agent){
1566+
constavailable=awaitAgent.list().then((agents)=>agents.filter((a)=>!a.hidden).map((a)=>a.name))
1567+
consthint=available.length ? ` Available agents: ${available.join(", ")}` : ""
1568+
consterror=newNamedError.Unknown({message: `Agent not found: "${input.agent}".${hint}`})
1569+
Bus.publish(Session.Event.Error,{
1570+
sessionID: input.sessionID,
1571+
error: error.toObject(),
1572+
})
1573+
throwerror
1574+
}
15341575
constmodel=input.model??agent.model??(awaitlastModel(input.sessionID))
15351576
constuserMsg: MessageV2.User={
15361577
id: MessageID.ascending(),
@@ -1783,7 +1824,14 @@ NOTE: At any point in time through this workflow you should feel free to ask the
17831824
log.info("command",input)
17841825
constcommand=awaitCommand.get(input.command)
17851826
if(!command){
1786-
thrownewNamedError.Unknown({message: `Command not found: "${input.command}"`})
1827+
constavailable=awaitCommand.list().then((cmds)=>cmds.map((c)=>c.name))
1828+
consthint=available.length ? ` Available commands: ${available.join(", ")}` : ""
1829+
consterror=newNamedError.Unknown({message: `Command not found: "${input.command}".${hint}`})
1830+
Bus.publish(Session.Event.Error,{
1831+
sessionID: input.sessionID,
1832+
error: error.toObject(),
1833+
})
1834+
throwerror
17871835
}
17881836
constagentName=command.agent??input.agent??(awaitAgent.defaultAgent())
17891837

‎packages/opencode/test/plugin/auth-override.test.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,19 @@ describe("plugin.auth-override", () => {
5454
expect(plainMethods[ProviderID.make("github-copilot")][0].label).not.toBe("Test Override Auth")
5555
},30000)// Increased timeout for plugin installation
5656
})
57+
58+
constfile=path.join(import.meta.dir,"../../src/plugin/index.ts")
59+
60+
describe("plugin.config-hook-error-isolation",()=>{
61+
test("config hooks are individually error-isolated in the layer factory",async()=>{
62+
constsrc=awaitBun.file(file).text()
63+
64+
// The config hook try/catch lives in the InstanceState factory (layer definition),
65+
// not in init() which now just delegates to the Effect service.
66+
expect(src).toContain("plugin config hook failed")
67+
68+
constpattern=
69+
/for\s*\(consthookofhooks\)\s*\{[\s\S]*?try\s*\{[\s\S]*?\.config\?\.\([\s\S]*?\}\s*catch\s*\(err\)\s*\{[\s\S]*?pluginconfighookfailed[\s\S]*?\}/
70+
expect(pattern.test(src)).toBe(true)
71+
})
72+
})

‎packages/opencode/test/server/session-messages.test.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,3 +117,16 @@ describe("session messages endpoint", () => {
117117
})
118118
})
119119
})
120+
121+
describe("session.prompt_async error handling",()=>{
122+
test("prompt_async route has error handler for detached prompt call",async()=>{
123+
constsrc=awaitBun.file(path.join(import.meta.dir,"../../src/server/routes/session.ts")).text()
124+
conststart=src.indexOf('"/:sessionID/prompt_async"')
125+
constend=src.indexOf('"/:sessionID/command"',start)
126+
expect(start).toBeGreaterThan(-1)
127+
expect(end).toBeGreaterThan(start)
128+
constroute=src.slice(start,end)
129+
expect(route).toContain(".catch(")
130+
expect(route).toContain("Bus.publish(Session.Event.Error")
131+
})
132+
})

‎packages/opencode/test/session/prompt.test.ts‎

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
importpathfrom"path"
22
import{describe,expect,test}from"bun:test"
3+
import{NamedError}from"@opencode-ai/util/error"
34
import{fileURLToPath}from"url"
45
import{Instance}from"../../src/project/instance"
56
import{ModelID,ProviderID}from"../../src/provider/schema"
@@ -210,3 +211,78 @@ describe("session.prompt agent variant", () => {
210211
}
211212
})
212213
})
214+
215+
describe("session.agent-resolution",()=>{
216+
test("unknown agent throws typed error",async()=>{
217+
await using tmp=awaittmpdir({git: true})
218+
awaitInstance.provide({
219+
directory: tmp.path,
220+
fn: async()=>{
221+
constsession=awaitSession.create({})
222+
consterr=awaitSessionPrompt.prompt({
223+
sessionID: session.id,
224+
agent: "nonexistent-agent-xyz",
225+
noReply: true,
226+
parts: [{type: "text",text: "hello"}],
227+
}).then(
228+
()=>undefined,
229+
(e)=>e,
230+
)
231+
expect(err).toBeDefined()
232+
expect(err).not.toBeInstanceOf(TypeError)
233+
expect(NamedError.Unknown.isInstance(err)).toBe(true)
234+
if(NamedError.Unknown.isInstance(err)){
235+
expect(err.data.message).toContain('Agent not found: "nonexistent-agent-xyz"')
236+
}
237+
},
238+
})
239+
},30000)
240+
241+
test("unknown agent error includes available agent names",async()=>{
242+
await using tmp=awaittmpdir({git: true})
243+
awaitInstance.provide({
244+
directory: tmp.path,
245+
fn: async()=>{
246+
constsession=awaitSession.create({})
247+
consterr=awaitSessionPrompt.prompt({
248+
sessionID: session.id,
249+
agent: "nonexistent-agent-xyz",
250+
noReply: true,
251+
parts: [{type: "text",text: "hello"}],
252+
}).then(
253+
()=>undefined,
254+
(e)=>e,
255+
)
256+
expect(NamedError.Unknown.isInstance(err)).toBe(true)
257+
if(NamedError.Unknown.isInstance(err)){
258+
expect(err.data.message).toContain("build")
259+
}
260+
},
261+
})
262+
},30000)
263+
264+
test("unknown command throws typed error with available names",async()=>{
265+
await using tmp=awaittmpdir({git: true})
266+
awaitInstance.provide({
267+
directory: tmp.path,
268+
fn: async()=>{
269+
constsession=awaitSession.create({})
270+
consterr=awaitSessionPrompt.command({
271+
sessionID: session.id,
272+
command: "nonexistent-command-xyz",
273+
arguments: "",
274+
}).then(
275+
()=>undefined,
276+
(e)=>e,
277+
)
278+
expect(err).toBeDefined()
279+
expect(err).not.toBeInstanceOf(TypeError)
280+
expect(NamedError.Unknown.isInstance(err)).toBe(true)
281+
if(NamedError.Unknown.isInstance(err)){
282+
expect(err.data.message).toContain('Command not found: "nonexistent-command-xyz"')
283+
expect(err.data.message).toContain("init")
284+
}
285+
},
286+
})
287+
},30000)
288+
})

0 commit comments

Comments
 (0)