diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 30d09861447e..b5f88495ab01 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -8,6 +8,7 @@ import { Instance } from "../project/instance" import { Truncate } from "../tool/truncate" import { Auth } from "../auth" import { ProviderTransform } from "../provider/transform" +import { NamedError } from "@opencode-ai/util/error" import PROMPT_GENERATE from "./generate.txt" import PROMPT_COMPACTION from "./prompt/compaction.txt" @@ -49,6 +50,15 @@ export namespace Agent { }) export type Info = z.infer + export const NotFoundError = NamedError.create( + "AgentNotFoundError", + z.object({ + agent: z.string(), + available: z.array(z.string()), + message: z.string(), + }), + ) + const state = Instance.state(async () => { const cfg = await Config.get() @@ -255,6 +265,18 @@ export namespace Agent { return state().then((x) => x[agent]) } + export async function must(agent: string) { + const item = await get(agent) + if (item) return item + const names = await list().then((x) => x.filter((a) => !a.hidden).map((a) => a.name)) + const error = new NotFoundError({ + agent, + available: names, + message: `Agent not found: "${agent}".${names.length ? ` Available agents: ${names.join(", ")}` : ""}`, + }) + throw error + } + export async function list() { const cfg = await Config.get() return pipe( diff --git a/packages/opencode/src/cli/error.ts b/packages/opencode/src/cli/error.ts index d7120aa5e989..a81d91b5bc52 100644 --- a/packages/opencode/src/cli/error.ts +++ b/packages/opencode/src/cli/error.ts @@ -2,6 +2,8 @@ import { ConfigMarkdown } from "@/config/markdown" import { Config } from "../config/config" import { MCP } from "../mcp" import { Provider } from "../provider/provider" +import { Agent } from "../agent/agent" +import { Command } from "../command" import { UI } from "./ui" export function FormatError(input: unknown) { @@ -19,6 +21,12 @@ export function FormatError(input: unknown) { if (Provider.InitError.isInstance(input)) { return `Failed to initialize provider "${input.data.providerID}". Check credentials and configuration.` } + if (Agent.NotFoundError.isInstance(input)) { + return input.data.message + } + if (Command.NotFoundError.isInstance(input)) { + return input.data.message + } if (Config.JsonError.isInstance(input)) { return ( `Config file at ${input.data.path} is not valid JSON(C)` + (input.data.message ? `: ${input.data.message}` : "") diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index ff9382610321..a84fe9765b0f 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -7,6 +7,7 @@ import z from "zod" import { Config } from "../config/config" import { MCP } from "../mcp" import { Skill } from "../skill" +import { NamedError } from "@opencode-ai/util/error" import { Log } from "../util/log" import PROMPT_INITIALIZE from "./template/initialize.txt" import PROMPT_REVIEW from "./template/review.txt" @@ -50,6 +51,15 @@ export namespace Command { // for some reason zod is inferring `string` for z.promise(z.string()).or(z.string()) so we have to manually override it export type Info = Omit, "template"> & { template: Promise | string } + export const NotFoundError = NamedError.create( + "CommandNotFoundError", + z.object({ + command: z.string(), + available: z.array(z.string()), + message: z.string(), + }), + ) + export function hints(template: string) { const result: string[] = [] const numbered = template.match(/\$\d+/g) @@ -179,6 +189,18 @@ export namespace Command { return runPromise((svc) => svc.get(name)) } + export async function must(name: string) { + const item = await get(name) + if (item) return item + const names = await list().then((x) => x.map((c) => c.name)) + const error = new NotFoundError({ + command: name, + available: names, + message: `Command not found: "${name}".${names.length ? ` Available commands: ${names.join(", ")}` : ""}`, + }) + throw error + } + export async function list() { return runPromise((svc) => svc.list()) } diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 7ead4df8a3cb..17c64ec3a2b6 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -63,6 +63,8 @@ export namespace Server { let status: ContentfulStatusCode if (err instanceof NotFoundError) status = 404 else if (err instanceof Provider.ModelNotFoundError) status = 400 + else if (Agent.NotFoundError.isInstance(err)) status = 400 + else if (Command.NotFoundError.isInstance(err)) status = 400 else if (err.name === "ProviderAuthValidationFailed") status = 400 else if (err.name.startsWith("Worktree")) status = 400 else status = 500 diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index dca8085c5b2e..1f87f15248a0 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -417,7 +417,7 @@ export namespace SessionPrompt { { args: taskArgs }, ) let executionError: Error | undefined - const taskAgent = await Agent.get(task.agent) + const taskAgent = await Agent.must(task.agent) const taskCtx: Tool.Context = { agent: task.agent, messageID: assistantMessage.id, @@ -559,7 +559,7 @@ export namespace SessionPrompt { } // normal processing - const agent = await Agent.get(lastUser.agent) + const agent = await Agent.must(lastUser.agent) const maxSteps = agent.steps ?? Infinity const isLastStep = step >= maxSteps msgs = await insertReminders({ @@ -964,7 +964,7 @@ export namespace SessionPrompt { } async function createUserMessage(input: PromptInput) { - const agent = await Agent.get(input.agent ?? (await Agent.defaultAgent())) + const agent = await Agent.must(input.agent ?? (await Agent.defaultAgent())) const model = input.model ?? agent.model ?? (await lastModel(input.sessionID)) const full = @@ -1530,7 +1530,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the if (session.revert) { await SessionRevert.cleanup(session) } - const agent = await Agent.get(input.agent) + const agent = await Agent.must(input.agent) const model = input.model ?? agent.model ?? (await lastModel(input.sessionID)) const userMsg: MessageV2.User = { id: MessageID.ascending(), @@ -1781,10 +1781,15 @@ 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) { - throw new NamedError.Unknown({ message: `Command not found: "${input.command}"` }) - } + const command = await Command.must(input.command).catch((error) => { + if (Command.NotFoundError.isInstance(error)) { + Bus.publish(Session.Event.Error, { + sessionID: input.sessionID, + error: new NamedError.Unknown({ message: error.data.message }).toObject(), + }) + } + throw error + }) const agentName = command.agent ?? input.agent ?? (await Agent.defaultAgent()) const raw = input.arguments.match(argsRegex) ?? [] @@ -1857,17 +1862,15 @@ NOTE: At any point in time through this workflow you should feel free to ask the } throw e } - 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(), - }) + const agent = await Agent.must(agentName).catch((error) => { + if (Agent.NotFoundError.isInstance(error)) { + Bus.publish(Session.Event.Error, { + sessionID: input.sessionID, + error: new NamedError.Unknown({ message: error.data.message }).toObject(), + }) + } throw error - } + }) const templateParts = await resolvePromptParts(template) const isSubtask = (agent.mode === "subagent" && command.subtask !== false) || command.subtask === true diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 3986271dab96..7805d212cd67 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -1,6 +1,8 @@ import path from "path" import { describe, expect, test } from "bun:test" import { fileURLToPath } from "url" +import { Agent } from "../../src/agent/agent" +import { Command } from "../../src/command" import { Instance } from "../../src/project/instance" import { ModelID, ProviderID } from "../../src/provider/schema" import { Session } from "../../src/session" @@ -108,6 +110,55 @@ describe("session.prompt missing file", () => { }) }) +describe("session.prompt unknown names", () => { + test("throws AgentNotFoundError for an unknown prompt agent", async () => { + await using tmp = await tmpdir({ git: true }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + const error = await SessionPrompt.prompt({ + sessionID: session.id, + agent: "does-not-exist", + noReply: true, + parts: [{ type: "text", text: "hello" }], + }).catch((error) => error) + + expect(Agent.NotFoundError.isInstance(error)).toBe(true) + if (!Agent.NotFoundError.isInstance(error)) throw error + expect(error.data.agent).toBe("does-not-exist") + expect(error.data.message).toContain('Agent not found: "does-not-exist"') + + await Session.remove(session.id) + }, + }) + }) + + test("throws CommandNotFoundError for an unknown command", async () => { + await using tmp = await tmpdir({ git: true }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + const error = await SessionPrompt.command({ + sessionID: session.id, + command: "does-not-exist", + arguments: "", + }).catch((error) => error) + + expect(Command.NotFoundError.isInstance(error)).toBe(true) + if (!Command.NotFoundError.isInstance(error)) throw error + expect(error.data.command).toBe("does-not-exist") + expect(error.data.message).toContain('Command not found: "does-not-exist"') + + await Session.remove(session.id) + }, + }) + }) +}) + describe("session.prompt special characters", () => { test("handles filenames with # character", async () => { await using tmp = await tmpdir({