From fef1c42cb7d797a98a1c5c4e2a88cc56514e21db Mon Sep 17 00:00:00 2001 From: Ryan Skidmore Date: Thu, 19 Mar 2026 15:04:51 -0500 Subject: [PATCH 1/4] =?UTF-8?q?fix:=20improve=20plugin=20system=20robustne?= =?UTF-8?q?ss=20=E2=80=94=20agent/command=20resolution,=20async=20error=20?= =?UTF-8?q?handling,=20hook=20timing,=20and=20two-phase=20init?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Guard against undefined Agent.get()/Command.get() at all call sites with typed NamedError and available-name hints instead of silent TypeErrors - Add .catch() on prompt_async route to surface detached prompt failures via Session.Event.Error instead of silently swallowing them - Isolate plugin config hook errors with per-hook try/catch so one failing hook no longer kills the entire bootstrap - Move command.execute.before hook to fire before template parts are merged with input parts, giving plugins access to raw template content - Add two-phase plugin initialization so plugins injected by config hooks are loaded and initialized in the same startup pass - Add tests for agent/command resolution errors, async error handling, command hook timing, and plugin config hook ordering --- packages/opencode/src/plugin/index.ts | 69 ++++++- .../opencode/src/server/routes/session.ts | 10 +- packages/opencode/src/session/prompt.ts | 57 +++++- .../test/plugin/auth-override.test.ts | 38 ++++ .../test/server/session-messages.test.ts | 13 ++ packages/opencode/test/session/prompt.test.ts | 176 +++++++++++++++++- 6 files changed, 356 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 755ce2c21178..fb8f785175ee 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -126,12 +126,75 @@ export namespace Plugin { } export async function init() { - const hooks = await state().then((x) => x.hooks) + const st = await state() + const hooks = st.hooks + const input = st.input const config = await Config.get() + const loaded = new Set(config.plugin ?? []) for (const hook of hooks) { - // @ts-expect-error this is because we haven't moved plugin to sdk v2 - await hook.config?.(config) + try { + // @ts-expect-error this is because we haven't moved plugin to sdk v2 + await hook.config?.(config) + } catch (err) { + log.error("plugin config hook failed", { error: err }) + } + } + + const added = (config.plugin ?? []).filter((x) => !loaded.has(x)) + const next: Hooks[] = [] + if (added.length) await Config.waitForDependencies() + + for (let plugin of added) { + if (plugin.includes("opencode-openai-codex-auth") || plugin.includes("opencode-copilot-auth")) continue + log.info("loading plugin", { path: plugin }) + if (!plugin.startsWith("file://")) { + const i = plugin.lastIndexOf("@") + const pkg = i > 0 ? plugin.substring(0, i) : plugin + const version = i > 0 ? plugin.substring(i + 1) : "latest" + plugin = await BunProc.install(pkg, version).catch((err) => { + const cause = err instanceof Error ? err.cause : err + const detail = cause instanceof Error ? cause.message : String(cause ?? err) + log.error("failed to install plugin", { pkg, version, error: detail }) + Bus.publish(Session.Event.Error, { + error: new NamedError.Unknown({ + message: `Failed to install plugin ${pkg}@${version}: ${detail}`, + }).toObject(), + }) + return "" + }) + if (!plugin) continue + } + await import(plugin) + .then(async (mod) => { + const seen = new Set() + for (const [_name, fn] of Object.entries(mod)) { + if (seen.has(fn)) continue + seen.add(fn) + next.push(await fn(input)) + } + }) + .catch((err) => { + const message = err instanceof Error ? err.message : String(err) + log.error("failed to load plugin", { path: plugin, error: message }) + Bus.publish(Session.Event.Error, { + error: new NamedError.Unknown({ + message: `Failed to load plugin ${plugin}: ${message}`, + }).toObject(), + }) + }) } + + for (const hook of next) { + try { + // @ts-expect-error this is because we haven't moved plugin to sdk v2 + await hook.config?.(config) + } catch (err) { + log.error("plugin config hook failed", { error: err }) + } + } + + hooks.push(...next) + Bus.subscribeAll(async (input) => { const hooks = await state().then((x) => x.hooks) for (const hook of hooks) { diff --git a/packages/opencode/src/server/routes/session.ts b/packages/opencode/src/server/routes/session.ts index 613c8b05c170..f2751ae88a36 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 36162656aa3c..560fba41e23b 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -417,6 +417,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, @@ -559,6 +569,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({ @@ -963,7 +983,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 = @@ -1530,6 +1561,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(), @@ -1781,6 +1822,16 @@ NOTE: At any point in time through this workflow you should feel free to ask the export async function command(input: CommandInput) { log.info("command", input) const command = await Command.get(input.command) + if (!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()) const raw = input.arguments.match(argsRegex) ?? [] @@ -1884,7 +1935,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the prompt: templateParts.find((y) => y.type === "text")?.text ?? "", }, ] - : [...templateParts, ...(input.parts ?? [])] + : [...templateParts] const userAgent = isSubtask ? (input.agent ?? (await Agent.defaultAgent())) : agentName const userModel = isSubtask @@ -1903,6 +1954,8 @@ NOTE: At any point in time through this workflow you should feel free to ask the { parts }, ) + if (!isSubtask) parts.push(...(input.parts ?? [])) + const result = (await prompt({ sessionID: input.sessionID, messageID: input.messageID, diff --git a/packages/opencode/test/plugin/auth-override.test.ts b/packages/opencode/test/plugin/auth-override.test.ts index 0095ff38753b..e8c60e3f70ae 100644 --- a/packages/opencode/test/plugin/auth-override.test.ts +++ b/packages/opencode/test/plugin/auth-override.test.ts @@ -43,3 +43,41 @@ describe("plugin.auth-override", () => { }) }, 30000) // Increased timeout for plugin installation }) + +const file = path.join(import.meta.dir, "../../src/plugin/index.ts") + +describe("plugin.config-hook-ordering", () => { + test("init loads plugins added by config hooks in a second phase", async () => { + const src = await Bun.file(file).text() + const init = src.slice(src.indexOf("export async function init()")) + const first = init.indexOf("for (const hook of hooks)") + const added = init.indexOf("const added = (config.plugin ?? []).filter((x) => !loaded.has(x))") + const next = init.indexOf("const next: Hooks[] = []") + const load = init.indexOf("for (let plugin of added)") + const second = init.indexOf("for (const hook of next)") + const push = init.indexOf("hooks.push(...next)") + + expect(first).toBeGreaterThan(-1) + expect(added).toBeGreaterThan(first) + expect(next).toBeGreaterThan(added) + expect(load).toBeGreaterThan(next) + expect(second).toBeGreaterThan(load) + expect(push).toBeGreaterThan(second) + }) + + test("config hooks are individually error-isolated", async () => { + const src = await Bun.file(file).text() + const init = src.slice(src.indexOf("export async function init()")) + + expect(init).toContain("plugin config hook failed") + + const loops = [ + /for\s*\(const hook of hooks\)\s*\{[\s\S]*?try\s*\{[\s\S]*?hook\.config\?\.\(config\)[\s\S]*?\}\s*catch\s*\(err\)\s*\{[\s\S]*?plugin config hook failed[\s\S]*?\}/, + /for\s*\(const hook of next\)\s*\{[\s\S]*?try\s*\{[\s\S]*?hook\.config\?\.\(config\)[\s\S]*?\}\s*catch\s*\(err\)\s*\{[\s\S]*?plugin config hook failed[\s\S]*?\}/, + ] + + for (const loop of loops) { + expect(loop.test(init)).toBe(true) + } + }) +}) diff --git a/packages/opencode/test/server/session-messages.test.ts b/packages/opencode/test/server/session-messages.test.ts index ee4c51646f3f..91e0fd92634c 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 3986271dab96..cb7417971ee8 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -1,7 +1,10 @@ import path from "path" import { describe, expect, test } from "bun:test" -import { fileURLToPath } from "url" +import fs from "fs/promises" +import { NamedError } from "@opencode-ai/util/error" +import { fileURLToPath, pathToFileURL } from "url" import { Instance } from "../../src/project/instance" +import { Provider } from "../../src/provider/provider" import { ModelID, ProviderID } from "../../src/provider/schema" import { Session } from "../../src/session" import { MessageV2 } from "../../src/session/message-v2" @@ -210,3 +213,174 @@ 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) +}) + +const key = "__command_template_capture__" + +type Part = { + type: string + filename?: string | null + text?: string | null +} + +describe("session.command-template", () => { + test("command hook receives template parts before input parts are merged", async () => { + Reflect.deleteProperty(globalThis, key) + + await using tmp = await tmpdir({ + git: true, + config: { + command: { + inspect: { + agent: "build", + template: "Inspect @template.txt", + }, + }, + }, + init: async (dir) => { + const plugin = path.join(dir, ".opencode", "plugin") + await fs.mkdir(plugin, { recursive: true }) + await Bun.write(path.join(dir, "template.txt"), "from template\n") + await Bun.write(path.join(dir, "input.txt"), "from input\n") + await Bun.write( + path.join(plugin, "capture-command-hook.js"), + [ + "export default async () => ({", + ' "command.execute.before": async (_input, output) => {', + ` globalThis[${JSON.stringify(key)}] = output.parts.map((part) => ({`, + " type: part.type,", + ' filename: "filename" in part ? (part.filename ?? null) : null,', + ' text: part.type === "text" ? part.text : null,', + " }))", + ' throw new Error("capture-stop")', + " },", + "})", + "", + ].join("\n"), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + const model = await Provider.defaultModel() + const err = await SessionPrompt.command({ + sessionID: session.id, + command: "inspect", + arguments: "", + model: `${model.providerID}/${model.modelID}`, + parts: [ + { + type: "file", + mime: "text/plain", + url: pathToFileURL(path.join(tmp.path, "input.txt")).href, + filename: "input.txt", + }, + ], + }).then( + () => undefined, + (err) => err, + ) + + expect(err).toBeInstanceOf(Error) + expect(err instanceof Error ? err.message : String(err)).toContain("capture-stop") + + const parts = Reflect.get(globalThis, key) + expect(parts).toBeDefined() + expect(Array.isArray(parts)).toBe(true) + expect(parts).toEqual([ + { + type: "text", + filename: null, + text: "Inspect @template.txt", + }, + { + type: "file", + filename: "template.txt", + text: null, + }, + ] satisfies Part[]) + + await Session.remove(session.id) + }, + }) + + Reflect.deleteProperty(globalThis, key) + }) +}) From 8ce184ed64cc3373d4a375a7b391a31f5cd98700 Mon Sep 17 00:00:00 2001 From: Ryan Skidmore Date: Mon, 23 Mar 2026 14:23:41 -0500 Subject: [PATCH 2/4] fix: add try/catch around plugin config hooks to prevent one plugin from failing all --- packages/opencode/src/plugin/index.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 57dcff8f67af..e519f9f3508e 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 }) + } } }) From 53a989a48401349b6ed5d3b3c042fecb3c354f38 Mon Sep 17 00:00:00 2001 From: Ryan Skidmore Date: Mon, 23 Mar 2026 14:40:27 -0500 Subject: [PATCH 3/4] fix: update plugin tests for InstanceState architecture --- .../test/plugin/auth-override.test.ts | 38 ++++--------------- 1 file changed, 8 insertions(+), 30 deletions(-) diff --git a/packages/opencode/test/plugin/auth-override.test.ts b/packages/opencode/test/plugin/auth-override.test.ts index f4de7e452181..667b7ba9aa69 100644 --- a/packages/opencode/test/plugin/auth-override.test.ts +++ b/packages/opencode/test/plugin/auth-override.test.ts @@ -57,38 +57,16 @@ describe("plugin.auth-override", () => { const file = path.join(import.meta.dir, "../../src/plugin/index.ts") -describe("plugin.config-hook-ordering", () => { - test("init loads plugins added by config hooks in a second phase", async () => { +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() - const init = src.slice(src.indexOf("export async function init()")) - const first = init.indexOf("for (const hook of hooks)") - const added = init.indexOf("const added = (config.plugin ?? []).filter((x) => !loaded.has(x))") - const next = init.indexOf("const next: Hooks[] = []") - const load = init.indexOf("for (let plugin of added)") - const second = init.indexOf("for (const hook of next)") - const push = init.indexOf("hooks.push(...next)") - expect(first).toBeGreaterThan(-1) - expect(added).toBeGreaterThan(first) - expect(next).toBeGreaterThan(added) - expect(load).toBeGreaterThan(next) - expect(second).toBeGreaterThan(load) - expect(push).toBeGreaterThan(second) - }) - - test("config hooks are individually error-isolated", async () => { - const src = await Bun.file(file).text() - const init = src.slice(src.indexOf("export async function init()")) - - expect(init).toContain("plugin config hook failed") - - const loops = [ - /for\s*\(const hook of hooks\)\s*\{[\s\S]*?try\s*\{[\s\S]*?hook\.config\?\.\(config\)[\s\S]*?\}\s*catch\s*\(err\)\s*\{[\s\S]*?plugin config hook failed[\s\S]*?\}/, - /for\s*\(const hook of next\)\s*\{[\s\S]*?try\s*\{[\s\S]*?hook\.config\?\.\(config\)[\s\S]*?\}\s*catch\s*\(err\)\s*\{[\s\S]*?plugin config hook failed[\s\S]*?\}/, - ] + // 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") - for (const loop of loops) { - expect(loop.test(init)).toBe(true) - } + 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) }) }) From e20ec40885eada32a2e6a00f1137295f06e555bb Mon Sep 17 00:00:00 2001 From: Ryan Skidmore Date: Mon, 23 Mar 2026 17:52:04 -0500 Subject: [PATCH 4/4] revert: drop command parts ordering change to keep PR focused --- packages/opencode/src/session/prompt.ts | 4 +- packages/opencode/test/session/prompt.test.ts | 100 +----------------- 2 files changed, 2 insertions(+), 102 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 213022f061d9..b3c34539e77e 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1934,7 +1934,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the prompt: templateParts.find((y) => y.type === "text")?.text ?? "", }, ] - : [...templateParts] + : [...templateParts, ...(input.parts ?? [])] const userAgent = isSubtask ? (input.agent ?? (await Agent.defaultAgent())) : agentName const userModel = isSubtask @@ -1953,8 +1953,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the { parts }, ) - if (!isSubtask) parts.push(...(input.parts ?? [])) - const result = (await prompt({ sessionID: input.sessionID, messageID: input.messageID, diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index cb7417971ee8..7d1d42905792 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -1,10 +1,8 @@ import path from "path" import { describe, expect, test } from "bun:test" -import fs from "fs/promises" import { NamedError } from "@opencode-ai/util/error" -import { fileURLToPath, pathToFileURL } from "url" +import { fileURLToPath } from "url" import { Instance } from "../../src/project/instance" -import { Provider } from "../../src/provider/provider" import { ModelID, ProviderID } from "../../src/provider/schema" import { Session } from "../../src/session" import { MessageV2 } from "../../src/session/message-v2" @@ -288,99 +286,3 @@ describe("session.agent-resolution", () => { }) }, 30000) }) - -const key = "__command_template_capture__" - -type Part = { - type: string - filename?: string | null - text?: string | null -} - -describe("session.command-template", () => { - test("command hook receives template parts before input parts are merged", async () => { - Reflect.deleteProperty(globalThis, key) - - await using tmp = await tmpdir({ - git: true, - config: { - command: { - inspect: { - agent: "build", - template: "Inspect @template.txt", - }, - }, - }, - init: async (dir) => { - const plugin = path.join(dir, ".opencode", "plugin") - await fs.mkdir(plugin, { recursive: true }) - await Bun.write(path.join(dir, "template.txt"), "from template\n") - await Bun.write(path.join(dir, "input.txt"), "from input\n") - await Bun.write( - path.join(plugin, "capture-command-hook.js"), - [ - "export default async () => ({", - ' "command.execute.before": async (_input, output) => {', - ` globalThis[${JSON.stringify(key)}] = output.parts.map((part) => ({`, - " type: part.type,", - ' filename: "filename" in part ? (part.filename ?? null) : null,', - ' text: part.type === "text" ? part.text : null,', - " }))", - ' throw new Error("capture-stop")', - " },", - "})", - "", - ].join("\n"), - ) - }, - }) - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const session = await Session.create({}) - const model = await Provider.defaultModel() - const err = await SessionPrompt.command({ - sessionID: session.id, - command: "inspect", - arguments: "", - model: `${model.providerID}/${model.modelID}`, - parts: [ - { - type: "file", - mime: "text/plain", - url: pathToFileURL(path.join(tmp.path, "input.txt")).href, - filename: "input.txt", - }, - ], - }).then( - () => undefined, - (err) => err, - ) - - expect(err).toBeInstanceOf(Error) - expect(err instanceof Error ? err.message : String(err)).toContain("capture-stop") - - const parts = Reflect.get(globalThis, key) - expect(parts).toBeDefined() - expect(Array.isArray(parts)).toBe(true) - expect(parts).toEqual([ - { - type: "text", - filename: null, - text: "Inspect @template.txt", - }, - { - type: "file", - filename: "template.txt", - text: null, - }, - ] satisfies Part[]) - - await Session.remove(session.id) - }, - }) - - Reflect.deleteProperty(globalThis, key) - }) -})