feat(acp): implement acp-next session slice (#29250)

This commit is contained in:
Shoubhit Dash
2026-05-25 22:55:30 +05:30
committed by GitHub
parent e1406e05a3
commit 0373ea9128
4 changed files with 775 additions and 13 deletions

View File

@@ -0,0 +1,318 @@
import { describe, expect, it } from "bun:test"
import type { AgentSideConnection, LoadSessionResponse, NewSessionResponse } from "@agentclientprotocol/sdk"
import type { OpencodeClient } from "@opencode-ai/sdk/v2"
import { Effect } from "effect"
import * as ACPNextService from "@/acp-next/service"
import * as ACPNextError from "@/acp-next/error"
import { ModelID, ProviderID } from "@/provider/schema"
import type { Provider } from "@/provider/provider"
const providerID = ProviderID.make("test")
const modelID = ModelID.make("test-model")
const configuredModelID = ModelID.make("configured-model")
const provider: Provider.Info = {
id: providerID,
name: "Test",
source: "config",
env: [],
options: {},
models: {
[modelID]: {
id: modelID,
providerID,
api: {
id: modelID,
url: "https://example.com",
npm: "@ai-sdk/openai-compatible",
},
name: "Test 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: {
default: {},
high: { reasoningEffort: "high" },
},
},
[configuredModelID]: {
id: configuredModelID,
providerID,
api: {
id: configuredModelID,
url: "https://example.com",
npm: "@ai-sdk/openai-compatible",
},
name: "Configured Model",
family: "test",
capabilities: {
temperature: true,
reasoning: false,
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",
},
},
}
describe("ACP next service sessions", () => {
const makeService = (messages: readonly { info: unknown; parts: readonly unknown[] }[] = []) => {
const updates: unknown[] = []
const mcpAdds: string[] = []
const sdk = {
config: {
providers: () => Promise.resolve({ data: { providers: [provider], default: { test: modelID } } }),
get: () => Promise.resolve({ data: {} }),
},
app: {
agents: () =>
Promise.resolve({
data: [
{ name: "build", mode: "primary", permission: [], options: {} },
{ name: "plan", mode: "primary", description: "Plan first", permission: [], options: {} },
{ name: "hidden", mode: "primary", hidden: true, permission: [], options: {} },
],
}),
skills: () =>
Promise.resolve({
data: [{ name: "review-skill", description: "Review", location: "/skills/review", content: "review" }],
}),
},
command: {
list: () =>
Promise.resolve({
data: [{ name: "init", description: "Initialize", source: "command", template: "init", hints: [] }],
}),
},
session: {
create: () => Promise.resolve({ data: { id: "ses_new" } }),
get: () => Promise.resolve({ data: { id: "ses_loaded" } }),
list: () => Promise.resolve({ data: [] }),
messages: () => Promise.resolve({ data: messages }),
},
mcp: {
add: (input: { name?: string }) => {
if (input.name) mcpAdds.push(input.name)
return Promise.resolve({ data: {} })
},
},
} as unknown as OpencodeClient
const connection = {
sessionUpdate: (update: unknown) => {
updates.push(update)
return Promise.resolve()
},
} as Pick<AgentSideConnection, "sessionUpdate">
return { service: ACPNextService.make({ sdk, connection }), updates, mcpAdds }
}
it("creates a backed session with config options and command update", async () => {
const { service, updates, mcpAdds } = makeService()
const result = await Effect.runPromise(
service.newSession({
cwd: "/workspace",
mcpServers: [
{ name: "tools", command: "node", args: ["server.js"], env: [] },
{ name: "tools", command: "node", args: ["server.js"], env: [] },
],
}),
)
await new Promise((resolve) => setTimeout(resolve, 5))
expect(result.sessionId).toBe("ses_new")
expect(categories(result)).toContain("model")
expect(categories(result)).toContain("thought_level")
expect(categories(result)).toContain("mode")
expect(updates).toHaveLength(1)
expect(JSON.stringify(updates[0])).toContain("available_commands_update")
expect(JSON.stringify(updates[0])).toContain("review-skill")
expect(mcpAdds).toEqual(["tools"])
})
it("loads a session and restores model variant and mode from messages", async () => {
const { service } = makeService([
{
info: {
role: "assistant",
providerID: "test",
modelID: "test-model",
variant: "high",
mode: "plan",
},
parts: [],
},
])
const result = await Effect.runPromise(
service.loadSession({ cwd: "/workspace", sessionId: "ses_loaded", mcpServers: [] }),
)
expect(result.configOptions?.find((option) => option.id === "effort")?.currentValue).toBe("high")
expect(result.configOptions?.find((option) => option.id === "mode")?.currentValue).toBe("plan")
})
it("restores model variant and mode from the latest user message", async () => {
const { service } = makeService([
{
info: {
role: "user",
model: { providerID: "test", modelID: "test-model", variant: "default" },
agent: "build",
},
parts: [],
},
{
info: {
role: "user",
model: { providerID: "test", modelID: "test-model", variant: "high" },
agent: "plan",
},
parts: [],
},
])
const result = await Effect.runPromise(
service.loadSession({ cwd: "/workspace", sessionId: "ses_loaded", mcpServers: [] }),
)
expect(result.configOptions?.find((option) => option.id === "effort")?.currentValue).toBe("high")
expect(result.configOptions?.find((option) => option.id === "mode")?.currentValue).toBe("plan")
})
it("maps provider auth failures to auth-required request errors", async () => {
const service = ACPNextService.make({
sdk: {
config: {
providers: () => Promise.reject({ name: "ProviderAuthError", data: { providerID: "test" } }),
get: () => Promise.resolve({ data: {} }),
},
app: {
agents: () => Promise.resolve({ data: [] }),
skills: () => Promise.resolve({ data: [] }),
},
command: {
list: () => Promise.resolve({ data: [] }),
},
} as unknown as OpencodeClient,
})
const error = await Effect.runPromise(
service
.newSession({ cwd: "/workspace", mcpServers: [] })
.pipe(Effect.mapError(ACPNextError.toRequestError), Effect.flip),
)
expect(error.code).toBe(-32000)
})
it("does not cache failed directory snapshots", async () => {
let providersCalls = 0
const sdk = {
config: {
providers: () => {
providersCalls++
if (providersCalls === 1) {
return Promise.reject({ name: "ProviderAuthError", data: { providerID: "test" } })
}
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: () => Promise.resolve({ data: [] }),
},
session: {
create: () => Promise.resolve({ data: { id: "ses_retry" } }),
list: () => Promise.resolve({ data: [] }),
},
mcp: {
add: () => Promise.resolve({ data: {} }),
},
} as unknown as OpencodeClient
const service = ACPNextService.make({ sdk })
const first = await Effect.runPromise(
service
.newSession({ cwd: "/workspace", mcpServers: [] })
.pipe(Effect.mapError(ACPNextError.toRequestError), Effect.flip),
)
const second = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
expect(first.code).toBe(-32000)
expect(second.sessionId).toBe("ses_retry")
expect(providersCalls).toBe(2)
})
it("uses the configured model as the new session default", async () => {
const sdk = {
config: {
providers: () => Promise.resolve({ data: { providers: [provider], default: { test: modelID } } }),
get: () => Promise.resolve({ data: { model: "test/configured-model" } }),
},
app: {
agents: () => Promise.resolve({ data: [{ name: "build", mode: "primary", permission: [], options: {} }] }),
skills: () => Promise.resolve({ data: [] }),
},
command: {
list: () => Promise.resolve({ data: [] }),
},
session: {
create: (input: { model?: { id?: string } }) => Promise.resolve({ data: { id: input.model?.id } }),
list: () => Promise.resolve({ data: [] }),
},
mcp: {
add: () => Promise.resolve({ data: {} }),
},
} as unknown as OpencodeClient
const service = ACPNextService.make({ sdk })
const result = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
expect(result.sessionId).toBe("configured-model")
expect(result.configOptions?.find((option) => option.id === "model")?.currentValue).toBe("test/configured-model")
})
})
function categories(result: NewSessionResponse | LoadSessionResponse) {
return result.configOptions?.map((option) => option.category) ?? []
}

View File

@@ -1,8 +1,15 @@
import { describe, expect } from "bun:test"
import type { AuthenticateResponse, InitializeResponse } from "@agentclientprotocol/sdk"
import type {
AuthenticateResponse,
InitializeResponse,
LoadSessionResponse,
NewSessionResponse,
SessionNotification,
} from "@agentclientprotocol/sdk"
import { Effect } from "effect"
import { cliIt } from "../../lib/cli-process"
import { createAcpClient, expectOk } from "../acp/acp-test-client"
import { testProviderConfig } from "../../lib/test-provider"
import { createAcpClient, expectOk, selectConfigOption } from "../acp/acp-test-client"
describe("opencode acp-next (subprocess)", () => {
cliIt.live(
@@ -22,6 +29,7 @@ describe("opencode acp-next (subprocess)", () => {
expect(initialized.agentCapabilities?.promptCapabilities?.image).toBe(true)
expect(initialized.agentCapabilities?.mcpCapabilities?.http).toBe(true)
expect(initialized.agentCapabilities?.mcpCapabilities?.sse).toBe(true)
expect(initialized.agentCapabilities?.loadSession).toBe(true)
expect(initialized.agentCapabilities?.sessionCapabilities).toBeUndefined()
expect(initialized.agentInfo?.name).toBe("OpenCode")
expect(initialized.authMethods?.[0]?.id).toBe("opencode-login")
@@ -48,14 +56,41 @@ describe("opencode acp-next (subprocess)", () => {
)
cliIt.live(
"SDK-required session stubs fail with safe unsupported errors",
({ home, opencode }) =>
"creates and loads sessions behind OPENCODE_ACP_NEXT",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = createAcpClient(yield* opencode.acp({ env: { OPENCODE_ACP_NEXT: "1" } }))
const acp = createAcpClient(
yield* opencode.acp({
env: {
OPENCODE_ACP_NEXT: "1",
OPENCODE_CONFIG_CONTENT: JSON.stringify(testProviderConfig(llm.url)),
},
}),
)
yield* acp.request<InitializeResponse>("initialize", { protocolVersion: 1 })
const newSession = yield* acp.request("session/new", { cwd: home, mcpServers: [] })
expect(errorCode(newSession.error)).toBe(-32601)
const session = expectOk(
yield* acp.request<NewSessionResponse>("session/new", { cwd: home, mcpServers: [] }),
)
expect(typeof session.sessionId).toBe("string")
expect(selectConfigOption(session.configOptions, "model")?.category).toBe("model")
const update = yield* acp.waitForNotification<SessionNotification>(
"session/update",
(params) =>
params.sessionId === session.sessionId &&
params.update.sessionUpdate === "available_commands_update",
)
expect(update.params?.sessionId).toBe(session.sessionId)
const loaded = expectOk(
yield* acp.request<LoadSessionResponse>("session/load", {
cwd: home,
sessionId: session.sessionId,
mcpServers: [],
}),
)
expect(selectConfigOption(loaded.configOptions, "model")?.category).toBe("model")
const prompt = yield* acp.request("session/prompt", {
sessionId: "ses_missing",