fix(acp-next): add config switch fast paths (#29255)

This commit is contained in:
Shoubhit Dash
2026-05-26 00:24:03 +05:30
committed by GitHub
parent 56743dcf04
commit a5ea910eac
4 changed files with 398 additions and 3 deletions

View File

@@ -1,5 +1,12 @@
import { describe, expect, it } from "bun:test"
import type { AgentSideConnection, LoadSessionResponse, NewSessionResponse } from "@agentclientprotocol/sdk"
import type {
AgentSideConnection,
LoadSessionResponse,
NewSessionResponse,
SessionConfigOption,
SessionConfigSelectOption,
SetSessionConfigOptionResponse,
} from "@agentclientprotocol/sdk"
import type { OpencodeClient } from "@opencode-ai/sdk/v2"
import { Effect } from "effect"
import * as ACPNextService from "@/acp-next/service"
@@ -10,6 +17,7 @@ import type { Provider } from "@/provider/provider"
const providerID = ProviderID.make("test")
const modelID = ModelID.make("test-model")
const configuredModelID = ModelID.make("configured-model")
const secondModelID = ModelID.make("second-model")
const provider: Provider.Info = {
id: providerID,
@@ -88,6 +96,43 @@ const provider: Provider.Info = {
headers: {},
release_date: "2026-01-01",
},
[secondModelID]: {
id: secondModelID,
providerID,
api: {
id: secondModelID,
url: "https://example.com",
npm: "@ai-sdk/openai-compatible",
},
name: "Second Model",
family: "test",
capabilities: {
temperature: true,
reasoning: true,
attachment: false,
toolcall: true,
input: { text: true, audio: false, image: false, video: false, pdf: false },
output: { text: true, audio: false, image: false, video: false, pdf: false },
interleaved: false,
},
cost: {
input: 0,
output: 0,
cache: { read: 0, write: 0 },
},
limit: {
context: 128000,
output: 4096,
},
status: "active",
options: {},
headers: {},
release_date: "2026-01-01",
variants: {
low: { reasoningEffort: "low" },
medium: { reasoningEffort: "medium" },
},
},
},
}
@@ -359,8 +404,131 @@ describe("ACP next service sessions", () => {
expect(result.sessionId).toBe("configured-model")
expect(result.configOptions?.find((option) => option.id === "model")?.currentValue).toBe("test/configured-model")
})
it("switches model and returns updated model and effort options", async () => {
const { service } = makeService()
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
const updated = await Effect.runPromise(
service.setSessionConfigOption({
sessionId: session.sessionId,
configId: "model",
value: "test/second-model",
}),
)
expect(select(updated, "model")?.currentValue).toBe("test/second-model")
expect(select(updated, "effort")?.currentValue).toBe("low")
expect(flattenSelectOptions(select(updated, "effort")).map((option) => option.value)).toEqual(["low", "medium"])
})
it("switches effort and returns the updated effort current value", async () => {
const { service } = makeService()
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
const updated = await Effect.runPromise(
service.setSessionConfigOption({
sessionId: session.sessionId,
configId: "effort",
value: "high",
}),
)
expect(select(updated, "effort")?.currentValue).toBe("high")
})
it("switches mode and returns the updated mode current value", async () => {
const { service } = makeService()
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
const updated = await Effect.runPromise(
service.setSessionConfigOption({
sessionId: session.sessionId,
configId: "mode",
value: "plan",
}),
)
expect(select(updated, "mode")?.currentValue).toBe("plan")
})
it("maps invalid model effort mode and config id to invalid params", async () => {
const { service } = makeService()
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
const results = await Promise.all(
[
{ configId: "model", value: "test/missing-model" },
{ configId: "effort", value: "max" },
{ configId: "mode", value: "missing-mode" },
{ configId: "missing", value: "value" },
].map((input) =>
Effect.runPromise(
service
.setSessionConfigOption({ sessionId: session.sessionId, ...input })
.pipe(Effect.mapError(ACPNextError.toRequestError), Effect.flip),
),
),
)
expect(results.map((error) => error.code)).toEqual([-32602, -32602, -32602, -32602])
})
it("does not reload providers or commands when switching effort from a warm snapshot", async () => {
let providersCalls = 0
let commandCalls = 0
const sdk = {
config: {
providers: () => {
providersCalls++
return Promise.resolve({ data: { providers: [provider], default: { test: modelID } } })
},
get: () => Promise.resolve({ data: {} }),
},
app: {
agents: () => Promise.resolve({ data: [{ name: "build", mode: "primary", permission: [], options: {} }] }),
skills: () => Promise.resolve({ data: [] }),
},
command: {
list: () => {
commandCalls++
return Promise.resolve({ data: [] })
},
},
session: {
create: () => Promise.resolve({ data: { id: "ses_fast" } }),
list: () => Promise.resolve({ data: [] }),
},
mcp: {
add: () => Promise.resolve({ data: {} }),
},
} as unknown as OpencodeClient
const service = ACPNextService.make({ sdk })
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
expect(providersCalls).toBe(1)
expect(commandCalls).toBe(1)
await Effect.runPromise(
service.setSessionConfigOption({
sessionId: session.sessionId,
configId: "effort",
value: "high",
}),
)
expect(providersCalls).toBe(1)
expect(commandCalls).toBe(1)
})
})
function categories(result: NewSessionResponse | LoadSessionResponse) {
return result.configOptions?.map((option) => option.category) ?? []
}
function select(result: SetSessionConfigOptionResponse, id: string) {
return result.configOptions.find(
(option): option is Extract<SessionConfigOption, { type: "select" }> =>
option.id === id && option.type === "select",
)
}
function flattenSelectOptions(option: Extract<SessionConfigOption, { type: "select" }> | undefined) {
return option?.options.flatMap((item): SessionConfigSelectOption[] => ("value" in item ? [item] : item.options)) ?? []
}

View File

@@ -5,11 +5,12 @@ import type {
LoadSessionResponse,
NewSessionResponse,
SessionNotification,
SetSessionConfigOptionResponse,
} from "@agentclientprotocol/sdk"
import { Effect } from "effect"
import { cliIt } from "../../lib/cli-process"
import { testProviderConfig } from "../../lib/test-provider"
import { createAcpClient, expectOk, selectConfigOption } from "../acp/acp-test-client"
import { createAcpClient, expectOk, firstAlternateValue, selectConfigOption } from "../acp/acp-test-client"
describe("opencode acp-next (subprocess)", () => {
cliIt.live(
@@ -98,6 +99,66 @@ describe("opencode acp-next (subprocess)", () => {
60_000,
)
cliIt.live(
"switches model through config options behind OPENCODE_ACP_NEXT",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = createAcpClient(
yield* opencode.acp({
env: {
OPENCODE_ACP_NEXT: "1",
OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)),
},
}),
)
yield* acp.request<InitializeResponse>("initialize", { protocolVersion: 1 })
const session = expectOk(yield* acp.request<NewSessionResponse>("session/new", { cwd: home, mcpServers: [] }))
const updated = expectOk(
yield* acp.request<SetSessionConfigOptionResponse>("session/set_config_option", {
sessionId: session.sessionId,
configId: "model",
value: "test/second-model",
}),
)
expect(selectConfigOption(updated.configOptions, "model")?.currentValue).toBe("test/second-model")
}),
60_000,
)
cliIt.live(
"switches effort through config options behind OPENCODE_ACP_NEXT",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = createAcpClient(
yield* opencode.acp({
env: {
OPENCODE_ACP_NEXT: "1",
OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)),
},
}),
)
yield* acp.request<InitializeResponse>("initialize", { protocolVersion: 1 })
const session = expectOk(yield* acp.request<NewSessionResponse>("session/new", { cwd: home, mcpServers: [] }))
const effort = selectConfigOption(session.configOptions, "effort")
expect(effort?.category).toBe("thought_level")
const nextEffort = effort ? firstAlternateValue(effort) : undefined
expect(nextEffort).toBe("high")
const updated = expectOk(
yield* acp.request<SetSessionConfigOptionResponse>("session/set_config_option", {
sessionId: session.sessionId,
configId: "effort",
value: nextEffort,
}),
)
expect(selectConfigOption(updated.configOptions, "effort")?.currentValue).toBe(nextEffort)
}),
60_000,
)
cliIt.live(
"exits cleanly when flagged stdin is closed",
({ opencode }) =>
@@ -134,3 +195,34 @@ function errorCode(error: unknown) {
if (!("code" in error)) return undefined
return typeof error.code === "number" ? error.code : undefined
}
function verifierConfig(llmUrl: string) {
const config = testProviderConfig(llmUrl)
return {
...config,
model: "test/test-model",
provider: {
test: {
...config.provider.test,
models: {
"test-model": {
...config.provider.test.models["test-model"],
variants: {
low: {},
high: {},
},
},
"second-model": {
...config.provider.test.models["test-model"],
id: "second-model",
name: "Second Test Model",
variants: {
medium: {},
max: {},
},
},
},
},
},
}
}