Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions packages/opencode/src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -49,6 +50,15 @@ export namespace Agent {
})
export type Info = z.infer<typeof Info>

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()

Expand Down Expand Up @@ -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(
Expand Down
8 changes: 8 additions & 0 deletions packages/opencode/src/cli/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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}` : "")
Expand Down
22 changes: 22 additions & 0 deletions packages/opencode/src/command/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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<z.infer<typeof Info>, "template"> & { template: Promise<string> | 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)
Expand Down Expand Up @@ -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())
}
Expand Down
2 changes: 2 additions & 0 deletions packages/opencode/src/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 21 additions & 18 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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) ?? []
Expand Down Expand Up @@ -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
Expand Down
51 changes: 51 additions & 0 deletions packages/opencode/test/session/prompt.test.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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({
Expand Down
Loading