diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index d4d556485da..2155eef1d35 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -1007,7 +1007,7 @@ export namespace ACP { }) const availableModes = agents - .filter((agent) => agent.mode !== "subagent" && !agent.hidden) + .filter((agent) => AgentModule.isPrimaryVisible(agent)) .map((agent) => ({ id: agent.name, name: agent.name, diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 2b44308f130..8dfc5a6c77f 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -247,6 +247,19 @@ export namespace Agent { return state().then((x) => x[agent]) } + export function isPrimaryVisible(agent: Info) { + return agent.mode !== "subagent" && agent.hidden !== true + } + + export async function getOrDefault(name?: string): Promise { + const agent = name ? await get(name) : undefined + if (agent) return agent + const fallback = await defaultAgent() + const resolved = await get(fallback) + if (!resolved) throw new Error(`Agent not found: ${name}, fallback ${fallback} also missing`) + return resolved + } + export async function list() { const cfg = await Config.get() return pipe( @@ -268,7 +281,7 @@ export namespace Agent { return agent.name } - const primaryVisible = Object.values(agents).find((a) => a.mode !== "subagent" && a.hidden !== true) + const primaryVisible = Object.values(agents).find((a) => isPrimaryVisible(a)) if (!primaryVisible) throw new Error("no primary visible agent found") return primaryVisible.name } diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index 927c964c9d8..856df42dedb 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -887,7 +887,8 @@ export const GithubRunCommand = cmd({ providerID, modelID, }, - // agent is omitted - server will use default_agent from config or fall back to "build" + // agent is omitted - server will use default_agent from config (must be valid) + // or fall back to the first visible primary agent (usually "build") parts: [ { id: Identifier.ascending("part"), diff --git a/packages/opencode/src/cli/cmd/tui/context/local.tsx b/packages/opencode/src/cli/cmd/tui/context/local.tsx index d058ce54fb3..6ef9e7caaf3 100644 --- a/packages/opencode/src/cli/cmd/tui/context/local.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/local.tsx @@ -9,6 +9,7 @@ import { iife } from "@/util/iife" import { createSimpleContext } from "./helper" import { useToast } from "../ui/toast" import { Provider } from "@/provider/provider" +import { Agent } from "@/agent/agent" import { useArgs } from "./args" import { useSDK } from "./sdk" import { RGBA } from "@opentui/core" @@ -34,7 +35,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ } const agent = iife(() => { - const agents = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden)) + const agents = createMemo(() => sync.data.agent.filter((x) => Agent.isPrimaryVisible(x))) const [agentStore, setAgentStore] = createStore<{ current: string }>({ diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 1294ab849e9..ef2f41d076e 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -205,10 +205,12 @@ export function Session() { if (part.id === lastSwitch) return if (part.tool === "plan_exit") { - local.agent.set("build") + const target = (part.state.metadata as { agent?: string })?.agent ?? "build" + local.agent.set(target) lastSwitch = part.id } else if (part.tool === "plan_enter") { - local.agent.set("plan") + const target = (part.state.metadata as { agent?: string })?.agent ?? "plan" + local.agent.set(target) lastSwitch = part.id } }) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 020e626cba8..380047df59a 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -931,7 +931,7 @@ export namespace Config { .string() .optional() .describe( - "Default agent to use when none is specified. Must be a primary agent. Falls back to 'build' if not set or if the specified agent is invalid.", + "Default agent to use when none is specified. Must be a primary agent. If not set, falls back to the first visible primary agent (usually 'build'). If set to an invalid/hidden/subagent, opencode will error.", ), username: z .string() diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 27071056180..16deac20eaf 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -153,7 +153,7 @@ export namespace SessionProcessor { JSON.stringify(p.state.input) === JSON.stringify(value.input), ) ) { - const agent = await Agent.get(input.assistantMessage.agent) + const agent = await Agent.getOrDefault(input.assistantMessage.agent) await PermissionNext.ask({ permission: "doom_loop", patterns: [value.toolName], diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index de62788200b..1d1456cb10e 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -511,7 +511,7 @@ export namespace SessionPrompt { } // normal processing - const agent = await Agent.get(lastUser.agent) + const agent = await Agent.getOrDefault(lastUser.agent) const maxSteps = agent.steps ?? Infinity const isLastStep = step >= maxSteps msgs = await insertReminders({ @@ -820,7 +820,7 @@ export namespace SessionPrompt { } async function createUserMessage(input: PromptInput) { - const agent = await Agent.get(input.agent ?? (await Agent.defaultAgent())) + const agent = await Agent.getOrDefault(input.agent) const info: MessageV2.Info = { id: input.messageID ?? Identifier.ascending("message"), role: "user", @@ -1351,7 +1351,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the if (session.revert) { SessionRevert.cleanup(session) } - const agent = await Agent.get(input.agent) + const agent = await Agent.getOrDefault(input.agent) const model = input.model ?? agent.model ?? (await lastModel(input.sessionID)) const userMsg: MessageV2.User = { id: Identifier.ascending("message"), diff --git a/packages/opencode/src/tool/plan.ts b/packages/opencode/src/tool/plan.ts index 6cb7a691c88..0321c59c8bf 100644 --- a/packages/opencode/src/tool/plan.ts +++ b/packages/opencode/src/tool/plan.ts @@ -7,9 +7,36 @@ import { MessageV2 } from "../session/message-v2" import { Identifier } from "../id/id" import { Provider } from "../provider/provider" import { Instance } from "../project/instance" +import { Agent } from "../agent/agent" import EXIT_DESCRIPTION from "./plan-exit.txt" import ENTER_DESCRIPTION from "./plan-enter.txt" +/** + * Resolve target agent for plan_exit. + * Prefers "build" if available, otherwise falls back to default_agent or first visible primary. + * Exported for testing. + */ +export async function resolveImplementationAgent(): Promise { + const build = await Agent.get("build") + if (build && Agent.isPrimaryVisible(build)) return "build" + + const agents = await Agent.list() + const defaultName = await Agent.defaultAgent().catch(() => undefined) + + // Prefer default_agent if it's a usable implementation agent (not plan, not subagent, not hidden) + if (defaultName && defaultName !== "plan") { + const agent = agents.find((a) => a.name === defaultName) + if (agent && Agent.isPrimaryVisible(agent)) return defaultName + } + + // Fallback: first visible primary agent that is not "plan" + const fallback = agents.find((a) => Agent.isPrimaryVisible(a) && a.name !== "plan") + if (fallback) return fallback.name + + // Last resort: use defaultAgent result even if it's "plan" + return defaultName ?? "plan" +} + async function getLastModel(sessionID: string) { for await (const item of MessageV2.stream(sessionID)) { if (item.info.role === "user" && item.info.model) return item.info.model @@ -23,15 +50,17 @@ export const PlanExitTool = Tool.define("plan_exit", { async execute(_params, ctx) { const session = await Session.get(ctx.sessionID) const plan = path.relative(Instance.worktree, Session.plan(session)) + const target = await resolveImplementationAgent() + const answers = await Question.ask({ sessionID: ctx.sessionID, questions: [ { - question: `Plan at ${plan} is complete. Would you like to switch to the build agent and start implementing?`, - header: "Build Agent", + question: `Plan at ${plan} is complete. Would you like to switch to the ${target} agent and start implementing?`, + header: "Switch Agent", custom: false, options: [ - { label: "Yes", description: "Switch to build agent and start implementing the plan" }, + { label: "Yes", description: `Switch to ${target} agent and start implementing the plan` }, { label: "No", description: "Stay with plan agent to continue refining the plan" }, ], }, @@ -51,7 +80,7 @@ export const PlanExitTool = Tool.define("plan_exit", { time: { created: Date.now(), }, - agent: "build", + agent: target, model, } await Session.updateMessage(userMsg) @@ -65,9 +94,9 @@ export const PlanExitTool = Tool.define("plan_exit", { } satisfies MessageV2.TextPart) return { - title: "Switching to build agent", - output: "User approved switching to build agent. Wait for further instructions.", - metadata: {}, + title: `Switching to ${target} agent`, + output: `User approved switching to ${target} agent. Wait for further instructions.`, + metadata: { agent: target }, } }, }) @@ -124,7 +153,7 @@ export const PlanEnterTool = Tool.define("plan_enter", { return { title: "Switching to plan agent", output: `User confirmed to switch to plan mode. A new message has been created to switch you to plan mode. The plan file will be at ${plan}. Begin planning.`, - metadata: {}, + metadata: { agent: "plan" }, } }, }) diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index 1ff303b7662..592507ef010 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -636,3 +636,72 @@ test("defaultAgent throws when all primary agents are disabled", async () => { }, }) }) + +test("getOrDefault returns agent when name exists", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const agent = await Agent.getOrDefault("build") + expect(agent).toBeDefined() + expect(agent.name).toBe("build") + }, + }) +}) + +test("getOrDefault returns default agent when name is undefined", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const agent = await Agent.getOrDefault() + expect(agent).toBeDefined() + expect(agent.name).toBe("build") // default agent + }, + }) +}) + +test("getOrDefault returns default agent when name does not exist", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const agent = await Agent.getOrDefault("nonexistent_agent") + expect(agent).toBeDefined() + expect(agent.name).toBe("build") // falls back to default + }, + }) +}) + +test("getOrDefault respects custom default_agent config", async () => { + await using tmp = await tmpdir({ + config: { + default_agent: "plan", + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const agent = await Agent.getOrDefault() + expect(agent).toBeDefined() + expect(agent.name).toBe("plan") + }, + }) +}) + +test("getOrDefault throws when both name and fallback do not exist", async () => { + await using tmp = await tmpdir({ + config: { + agent: { + build: { disable: true }, + plan: { disable: true }, + }, + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await expect(Agent.getOrDefault("nonexistent")).rejects.toThrow() + }, + }) +}) diff --git a/packages/opencode/test/tool/plan.test.ts b/packages/opencode/test/tool/plan.test.ts new file mode 100644 index 00000000000..d5fb267c32f --- /dev/null +++ b/packages/opencode/test/tool/plan.test.ts @@ -0,0 +1,99 @@ +import { test, expect } from "bun:test" +import { tmpdir } from "../fixture/fixture" +import { Instance } from "../../src/project/instance" +import { Agent } from "../../src/agent/agent" +import { resolveImplementationAgent } from "../../src/tool/plan" + +test("resolveImplementationAgent returns build when build exists", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const target = await resolveImplementationAgent() + expect(target).toBe("build") + }, + }) +}) + +test("resolveImplementationAgent returns default_agent when build disabled", async () => { + await using tmp = await tmpdir({ + config: { + default_agent: "myagent", + agent: { + build: { disable: true }, + myagent: { mode: "primary" }, + }, + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const build = await Agent.get("build") + expect(build).toBeUndefined() + const target = await resolveImplementationAgent() + expect(target).toBe("myagent") + }, + }) +}) + +test("resolveImplementationAgent falls back to first visible primary when build disabled and no default_agent", async () => { + await using tmp = await tmpdir({ + config: { + agent: { + build: { disable: true }, + }, + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const build = await Agent.get("build") + expect(build).toBeUndefined() + const target = await resolveImplementationAgent() + // plan is the next native primary, but we prefer non-plan if possible + // Since only plan is left as primary visible, it should return plan + expect(target).toBe("plan") + }, + }) +}) + +test("resolveImplementationAgent prefers non-plan custom agent over plan", async () => { + await using tmp = await tmpdir({ + config: { + default_agent: "custom", + agent: { + build: { disable: true }, + custom: { mode: "primary" }, + }, + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const build = await Agent.get("build") + expect(build).toBeUndefined() + const target = await resolveImplementationAgent() + expect(target).toBe("custom") + }, + }) +}) + +test("resolveImplementationAgent skips subagent when selecting fallback", async () => { + await using tmp = await tmpdir({ + config: { + default_agent: "explore", // subagent - should be skipped + agent: { + build: { disable: true }, + myimpl: { mode: "primary" }, + }, + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const target = await resolveImplementationAgent() + // explore is subagent, so it should fallback to myimpl or plan + expect(["myimpl", "plan"]).toContain(target) + }, + }) +}) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index fabb16e8a2b..9eca77c70da 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1663,7 +1663,7 @@ export type Config = { */ small_model?: string /** - * Default agent to use when none is specified. Must be a primary agent. Falls back to 'build' if not set or if the specified agent is invalid. + * Default agent to use when none is specified. Must be a primary agent. If not set, falls back to the first visible primary agent (usually 'build'). If set to an invalid/hidden/subagent, opencode will error. */ default_agent?: string /**