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
48 changes: 35 additions & 13 deletions packages/opencode/src/acp/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import { pathToFileURL } from "url"
import { Filesystem } from "../util/filesystem"
import { Hash } from "../util/hash"
import { ACPSessionManager } from "./session"
import type { ACPConfig } from "./types"
import type { ACPConfig, ACPSessionState } from "./types"
import { Provider } from "../provider/provider"
import { ModelID, ProviderID } from "../provider/schema"
import { Agent as AgentModule } from "../agent/agent"
Expand All @@ -46,7 +46,13 @@ import { LoadAPIKeyError } from "ai"
import type { AssistantMessage, Event, OpencodeClient, SessionMessageResponse, ToolPart } from "@opencode-ai/sdk/v2"
import { applyPatch } from "diff"

type ModeOption = { id: string; name: string; description?: string }
type ModeOption = {
id: string
name: string
description?: string
model?: ACPSessionState["model"]
variant?: string
}
type ModelOption = { modelId: string; name: string }

const DEFAULT_VARIANT_VALUE = "default"
Expand Down Expand Up @@ -1125,9 +1131,23 @@ export namespace ACP {
id: agent.name,
name: agent.name,
description: agent.description,
model: agent.model
? {
providerID: ProviderID.make(agent.model.providerID),
modelID: ModelID.make(agent.model.modelID),
}
: undefined,
variant: agent.model ? agent.variant : undefined,
}))
}

private selectMode(sessionId: string, mode: ModeOption) {
this.sessionManager.setMode(sessionId, mode.id)
if (!mode.model) return
this.sessionManager.setModel(sessionId, mode.model)
this.sessionManager.setVariant(sessionId, mode.variant)
}

private async resolveModeState(
directory: string,
sessionId: string,
Expand All @@ -1138,29 +1158,30 @@ export namespace ACP {
(await (async () => {
if (!availableModes.length) return undefined
const defaultAgentName = await AgentModule.defaultAgent()
const resolvedModeId =
availableModes.find((mode) => mode.name === defaultAgentName)?.id ?? availableModes[0].id
this.sessionManager.setMode(sessionId, resolvedModeId)
return resolvedModeId
const mode = availableModes.find((mode) => mode.name === defaultAgentName) ?? availableModes[0]
if (!mode) return undefined
this.selectMode(sessionId, mode)
return mode.id
})())

return { availableModes, currentModeId }
}

private async loadSessionMode(params: LoadSessionRequest) {
const directory = params.cwd
const model = await defaultModel(this.config, directory)
const sessionId = params.sessionId

const providers = await this.sdk.config.providers({ directory }).then((x) => x.data!.providers)
const entries = sortProvidersByName(providers)
const availableModels = buildAvailableModels(entries, { includeVariants: true })
const modeState = await this.resolveModeState(directory, sessionId)
const model = this.sessionManager.getModel(sessionId) ?? (await defaultModel(this.config, directory))
const availableVariants = modelVariantsFromProviders(entries, model)
const currentVariant = this.sessionManager.getVariant(sessionId)
if (currentVariant && !availableVariants.includes(currentVariant)) {
this.sessionManager.setVariant(sessionId, undefined)
}
const availableModels = buildAvailableModels(entries, { includeVariants: true })
const modeState = await this.resolveModeState(directory, sessionId)
const variant = this.sessionManager.getVariant(sessionId)
const currentModeId = modeState.currentModeId
const modes = currentModeId
? {
Expand Down Expand Up @@ -1242,13 +1263,13 @@ export namespace ACP {
return {
sessionId,
models: {
currentModelId: formatModelIdWithVariant(model, currentVariant, availableVariants, true),
currentModelId: formatModelIdWithVariant(model, variant, availableVariants, true),
availableModels,
},
modes,
_meta: buildVariantMeta({
model,
variant: this.sessionManager.getVariant(sessionId),
variant,
availableVariants,
}),
}
Expand Down Expand Up @@ -1279,10 +1300,11 @@ export namespace ACP {
async setSessionMode(params: SetSessionModeRequest): Promise<SetSessionModeResponse | void> {
const session = this.sessionManager.get(params.sessionId)
const availableModes = await this.loadAvailableModes(session.cwd)
if (!availableModes.some((mode) => mode.id === params.modeId)) {
const mode = availableModes.find((mode) => mode.id === params.modeId)
if (!mode) {
throw new Error(`Agent not found: ${params.modeId}`)
}
this.sessionManager.setMode(params.sessionId, params.modeId)
this.selectMode(session.id, mode)
}

async prompt(params: PromptRequest) {
Expand Down
278 changes: 278 additions & 0 deletions packages/opencode/test/acp/set-session-mode.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,278 @@
import { describe, expect, test } from "bun:test"
import type { AgentSideConnection } from "@agentclientprotocol/sdk"
import { ACP } from "../../src/acp/agent"
import type { ACPConfig } from "../../src/acp/types"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"

function fake() {
const conn = {
async sessionUpdate() {},
async requestPermission() {
return {
outcome: {
outcome: "selected",
optionId: "once",
},
}
},
} as unknown as AgentSideConnection

const sdk = {
global: {
event: async (opts?: { signal?: AbortSignal }) => ({
stream: (async function* () {
if (opts?.signal?.aborted) return
await new Promise((resolve) => opts?.signal?.addEventListener("abort", resolve, { once: true }))
})(),
}),
},
session: {
create: async () => ({
data: {
id: "ses_1",
time: { created: new Date().toISOString() },
},
}),
messages: async () => ({ data: [] }),
},
config: {
providers: async () => ({
data: {
providers: [
{
id: "opencode",
name: "OpenCode",
models: {
"big-pickle": { id: "big-pickle", name: "Big Pickle" },
},
},
{
id: "openai",
name: "OpenAI",
models: {
"gpt-5": {
id: "gpt-5",
name: "GPT-5",
variants: {
default: {},
high: {},
},
},
"gpt-4.1": {
id: "gpt-4.1",
name: "GPT-4.1",
variants: {
default: {},
fast: {},
},
},
},
},
],
},
}),
},
app: {
agents: async () => ({
data: [
{
name: "build",
description: "build",
mode: "primary",
},
{
name: "review",
description: "review",
mode: "primary",
model: {
providerID: "openai",
modelID: "gpt-5",
},
variant: "high",
},
{
name: "base",
description: "base",
mode: "primary",
model: {
providerID: "openai",
modelID: "gpt-4.1",
},
},
{
name: "plain",
description: "plain",
mode: "primary",
},
],
}),
},
command: {
list: async () => ({ data: [] }),
},
mcp: {
add: async () => ({ data: true }),
},
} as unknown as ACPConfig["sdk"]

const agent = new ACP.Agent(conn, {
sdk,
defaultModel: {
providerID: ProviderID.make("opencode"),
modelID: ModelID.make("big-pickle"),
},
})

const raw = agent as unknown as {
eventAbort: AbortController
sessionManager: {
get: (id: string) => {
modeId?: string
model?: {
providerID: string
modelID: string
}
variant?: string
}
}
}

return {
agent,
stop() {
raw.eventAbort.abort()
},
get(id: string) {
return raw.sessionManager.get(id)
},
}
}

describe("acp.agent setSessionMode", () => {
test("newSession activates the configured model for the default mode", async () => {
await using tmp = await tmpdir({
config: {
default_agent: "review",
agent: {
review: {
model: "openai/gpt-5",
variant: "high",
},
},
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const { agent, stop, get } = fake()

try {
const res = await agent.newSession({ cwd: tmp.path, mcpServers: [] } as Parameters<ACP.Agent["newSession"]>[0])
const state = get(res.sessionId)

expect(res.modes?.currentModeId).toBe("review")
expect(res.models.currentModelId).toBe("openai/gpt-5/high")
expect(state.modeId).toBe("review")
expect(state.model).toEqual({
providerID: "openai",
modelID: "gpt-5",
})
expect(state.variant).toBe("high")
} finally {
stop()
}
},
})
})

test("activates the selected mode's configured model and variant", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const { agent, stop, get } = fake()
const cwd = "/tmp/opencode-acp-test"

try {
const sid = await agent
.newSession({ cwd, mcpServers: [] } as Parameters<ACP.Agent["newSession"]>[0])
.then((x) => x.sessionId)

await agent.setSessionMode({ sessionId: sid, modeId: "review" } as Parameters<ACP.Agent["setSessionMode"]>[0])

const state = get(sid)
expect(state.modeId).toBe("review")
expect(state.model).toEqual({
providerID: "openai",
modelID: "gpt-5",
})
expect(state.variant).toBe("high")
} finally {
stop()
}
},
})
})

test("keeps the current session model when the selected mode has no configured model", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const { agent, stop, get } = fake()
const cwd = "/tmp/opencode-acp-test"

try {
const sid = await agent
.newSession({ cwd, mcpServers: [] } as Parameters<ACP.Agent["newSession"]>[0])
.then((x) => x.sessionId)

await agent.setSessionMode({ sessionId: sid, modeId: "review" } as Parameters<ACP.Agent["setSessionMode"]>[0])
await agent.setSessionMode({ sessionId: sid, modeId: "plain" } as Parameters<ACP.Agent["setSessionMode"]>[0])

const state = get(sid)
expect(state.modeId).toBe("plain")
expect(state.model).toEqual({
providerID: "openai",
modelID: "gpt-5",
})
expect(state.variant).toBe("high")
} finally {
stop()
}
},
})
})

test("clears the current variant when the selected mode changes the model without a variant", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const { agent, stop, get } = fake()
const cwd = "/tmp/opencode-acp-test"

try {
const sid = await agent
.newSession({ cwd, mcpServers: [] } as Parameters<ACP.Agent["newSession"]>[0])
.then((x) => x.sessionId)

await agent.setSessionMode({ sessionId: sid, modeId: "review" } as Parameters<ACP.Agent["setSessionMode"]>[0])
await agent.setSessionMode({ sessionId: sid, modeId: "base" } as Parameters<ACP.Agent["setSessionMode"]>[0])

const state = get(sid)
expect(state.modeId).toBe("base")
expect(state.model).toEqual({
providerID: "openai",
modelID: "gpt-4.1",
})
expect(state.variant).toBeUndefined()
} finally {
stop()
}
},
})
})
})
Loading