Skip to content
Closed
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
2 changes: 1 addition & 1 deletion packages/opencode/src/acp/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
15 changes: 14 additions & 1 deletion packages/opencode/src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Info> {
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(
Expand All @@ -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
}
Expand Down
3 changes: 2 additions & 1 deletion packages/opencode/src/cli/cmd/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
3 changes: 2 additions & 1 deletion packages/opencode/src/cli/cmd/tui/context/local.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
}>({
Expand Down
6 changes: 4 additions & 2 deletions packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
})
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/session/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
6 changes: 3 additions & 3 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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"),
Expand Down
45 changes: 37 additions & 8 deletions packages/opencode/src/tool/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
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
Expand All @@ -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" },
],
},
Expand All @@ -51,7 +80,7 @@ export const PlanExitTool = Tool.define("plan_exit", {
time: {
created: Date.now(),
},
agent: "build",
agent: target,
model,
}
await Session.updateMessage(userMsg)
Expand All @@ -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 },
}
},
})
Expand Down Expand Up @@ -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" },
}
},
})
69 changes: 69 additions & 0 deletions packages/opencode/test/agent/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
},
})
})
99 changes: 99 additions & 0 deletions packages/opencode/test/tool/plan.test.ts
Original file line number Diff line number Diff line change
@@ -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)
},
})
})
2 changes: 1 addition & 1 deletion packages/sdk/js/src/v2/gen/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
/**
Expand Down
Loading