| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248 |
- import { describe, expect, it } from "bun:test"
- import type {
- AgentSideConnection,
- ForkSessionResponse,
- LoadSessionResponse,
- NewSessionResponse,
- SessionNotification,
- ResumeSessionResponse,
- SessionConfigOption,
- SessionConfigSelectOption,
- SetSessionConfigOptionResponse,
- } from "@agentclientprotocol/sdk"
- import type { AssistantMessage, OpencodeClient } from "@kirincode-ai/sdk/v2"
- import { ProviderV2 } from "@kirincode-ai/core/provider"
- import { ModelV2 } from "@kirincode-ai/core/model"
- import { Effect } from "effect"
- import * as ACPService from "@/acp/service"
- import * as ACPError from "@/acp/error"
- import { UsageService } from "@/acp/usage"
- import type { Provider } from "@/provider/provider"
- const providerID = ProviderV2.ID.make("test")
- const modelID = ModelV2.ID.make("test-model")
- const configuredModelID = ModelV2.ID.make("configured-model")
- const secondModelID = ModelV2.ID.make("second-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",
- },
- [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" },
- },
- },
- },
- }
- describe("ACP service sessions", () => {
- const makeService = (
- messages: readonly { info: unknown; parts: readonly unknown[] }[] = [],
- options?: {
- abort?: (input: { sessionID: string }) => Promise<{ data: boolean }>
- prompt?: (input: unknown) => Promise<{ data: { info: ReturnType<typeof assistantInfo> } }>
- },
- ) => {
- const updates: SessionNotification[] = []
- const mcpAdds: string[] = []
- const aborts: string[] = []
- const forks: string[] = []
- const prompts: unknown[] = []
- const commands: unknown[] = []
- const summarizes: unknown[] = []
- const usageUpdates: string[] = []
- const sessions = Array.from({ length: 102 }, (_, index) => ({
- id: `ses_${index + 1}`,
- directory: index % 2 === 0 ? "/workspace" : "/other",
- title: `Session ${index + 1}`,
- time: { created: index + 1, updated: index + 1 },
- }))
- 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: (input: { directory?: string }) =>
- Promise.resolve({
- data: input.directory ? sessions.filter((session) => session.directory === input.directory) : sessions,
- }),
- messages: () => Promise.resolve({ data: messages }),
- prompt:
- options?.prompt ??
- ((input: unknown) => {
- prompts.push(input)
- return Promise.resolve({
- data: {
- info: assistantInfo({
- input: 100,
- output: 40,
- reasoning: 7,
- cache: { read: 11, write: 13 },
- }),
- },
- })
- }),
- command: (input: unknown) => {
- commands.push(input)
- return Promise.resolve({
- data: {
- info: assistantInfo({
- input: 3,
- output: 4,
- reasoning: 0,
- cache: { read: 0, write: 0 },
- }),
- },
- })
- },
- summarize: (input: unknown) => {
- summarizes.push(input)
- return Promise.resolve({ data: true })
- },
- abort:
- options?.abort ??
- ((input: { sessionID: string }) => {
- aborts.push(input.sessionID)
- return Promise.resolve({ data: true })
- }),
- fork: (input: { sessionID: string }) => {
- forks.push(input.sessionID)
- return Promise.resolve({ data: { id: `fork_${input.sessionID}` } })
- },
- },
- mcp: {
- add: (input: { name?: string }) => {
- if (input.name) mcpAdds.push(input.name)
- return Promise.resolve({ data: {} })
- },
- },
- } as unknown as OpencodeClient
- const connection = {
- sessionUpdate: (update: SessionNotification) => {
- updates.push(update)
- return Promise.resolve()
- },
- } as Pick<AgentSideConnection, "sessionUpdate">
- const usage = UsageService.Service.of({
- buildUsage: UsageService.buildUsage,
- latestAssistantMessage: UsageService.latestAssistantMessage,
- totalSessionCost: UsageService.totalSessionCost,
- contextLimit: () => Effect.succeed(128000),
- sendUpdate: (input) =>
- Effect.sync(() => {
- usageUpdates.push(input.sessionID)
- }),
- })
- return {
- service: ACPService.make({ sdk, connection, usage }),
- updates,
- mcpAdds,
- aborts,
- forks,
- prompts,
- commands,
- summarizes,
- usageUpdates,
- }
- }
- 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("replays loaded session transcript chunks", async () => {
- const { service, updates } = makeService([
- {
- info: { id: "msg_user", sessionID: "ses_loaded", role: "user" },
- parts: [{ id: "part_user", sessionID: "ses_loaded", messageID: "msg_user", type: "text", text: "hello" }],
- },
- {
- info: { id: "msg_assistant", sessionID: "ses_loaded", role: "assistant" },
- parts: [
- {
- id: "part_assistant",
- sessionID: "ses_loaded",
- messageID: "msg_assistant",
- type: "text",
- text: "hi there",
- },
- ],
- },
- ])
- await Effect.runPromise(service.loadSession({ cwd: "/workspace", sessionId: "ses_loaded", mcpServers: [] }))
- expect(
- updates
- .map((item) => item.update)
- .filter((item) => item.sessionUpdate === "user_message_chunk" || item.sessionUpdate === "agent_message_chunk"),
- ).toEqual([
- {
- sessionUpdate: "user_message_chunk",
- messageId: "msg_user",
- content: { type: "text", text: "hello" },
- },
- {
- sessionUpdate: "agent_message_chunk",
- messageId: "msg_assistant",
- content: { type: "text", text: "hi there" },
- },
- ])
- })
- it("lists sessions sorted by updated time with cursor support", async () => {
- const { service } = makeService()
- const first = await Effect.runPromise(service.listSessions({ cwd: "/workspace" }))
- const second = await Effect.runPromise(service.listSessions({ cwd: "/workspace", cursor: first.nextCursor }))
- expect(first.sessions).toHaveLength(51)
- expect(first.sessions[0]?.sessionId).toBe("ses_101")
- expect(first.sessions.at(-1)?.sessionId).toBe("ses_1")
- expect(first.nextCursor).toBeUndefined()
- expect(second.sessions).toEqual(first.sessions)
- })
- it("includes live ACP sessions before they appear in server-backed session list", async () => {
- const { service } = makeService()
- const created = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
- const listed = await Effect.runPromise(service.listSessions({ cwd: "/workspace" }))
- expect(listed.sessions[0]?.sessionId).toBe(created.sessionId)
- expect(listed.sessions[0]?.cwd).toBe("/workspace")
- })
- it("lists all sessions with next cursor when the first page is full", async () => {
- const { service } = makeService()
- const first = await Effect.runPromise(service.listSessions({}))
- const second = await Effect.runPromise(service.listSessions({ cursor: first.nextCursor }))
- expect(first.sessions).toHaveLength(100)
- expect(first.sessions[0]?.sessionId).toBe("ses_102")
- expect(first.sessions.at(-1)?.sessionId).toBe("ses_3")
- expect(first.nextCursor).toBe("3")
- expect(second.sessions.map((session) => session.sessionId)).toEqual(["ses_2", "ses_1"])
- })
- it("resumes a session and stores restored state without replaying transcript chunks", async () => {
- const { service, updates } = makeService([
- {
- info: {
- id: "msg_user",
- sessionID: "ses_resume",
- role: "user",
- model: { providerID: "test", modelID: "test-model", variant: "high" },
- agent: "plan",
- },
- parts: [{ id: "part_user", sessionID: "ses_resume", messageID: "msg_user", type: "text", text: "hello" }],
- },
- {
- info: { id: "msg_assistant", sessionID: "ses_resume", role: "assistant" },
- parts: [
- {
- id: "part_assistant",
- sessionID: "ses_resume",
- messageID: "msg_assistant",
- type: "text",
- text: "hi there",
- },
- ],
- },
- ])
- const resumed = await Effect.runPromise(
- service.resumeSession({ cwd: "/workspace", sessionId: "ses_resume", mcpServers: [] }),
- )
- const updated = await Effect.runPromise(
- service.setSessionConfigOption({ sessionId: "ses_resume", configId: "effort", value: "default" }),
- )
- expect(select(resumed, "effort")?.currentValue).toBe("high")
- expect(select(updated, "effort")?.currentValue).toBe("default")
- expect(
- updates
- .map((item) => item.update)
- .filter((item) => item.sessionUpdate === "user_message_chunk" || item.sessionUpdate === "agent_message_chunk"),
- ).toEqual([])
- })
- it("closes local ACP state and aborts the backing session best-effort", async () => {
- const { service, aborts } = makeService()
- const created = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
- expect(await Effect.runPromise(service.closeSession({ sessionId: created.sessionId }))).toEqual({})
- const missing = await Effect.runPromise(
- service
- .setSessionConfigOption({ sessionId: created.sessionId, configId: "effort", value: "high" })
- .pipe(Effect.mapError(ACPError.toRequestError), Effect.flip),
- )
- expect(missing.code).toBe(-32602)
- expect(aborts).toEqual([created.sessionId])
- expect(await Effect.runPromise(service.closeSession({ sessionId: "missing" }))).toEqual({})
- })
- it("cancel aborts the backing session and keeps the ACP session", async () => {
- const { service, aborts } = makeService()
- const created = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
- await Effect.runPromise(service.cancel({ sessionId: created.sessionId }))
- // The running turn was aborted via the core session API.
- expect(aborts).toEqual([created.sessionId])
- // Unlike closeSession, the ACP session is still present afterwards so
- // the client can keep prompting.
- const stillUsable = await Effect.runPromise(
- service.setSessionConfigOption({ sessionId: created.sessionId, configId: "effort", value: "high" }),
- )
- expect(stillUsable).toBeDefined()
- })
- it("does not fail cancel or close when the backing abort fails", async () => {
- const { service } = makeService([], { abort: () => Promise.reject(new Error("nope")) })
- const created = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
- await Effect.runPromise(service.cancel({ sessionId: created.sessionId }))
- expect(await Effect.runPromise(service.closeSession({ sessionId: created.sessionId }))).toEqual({})
- expect(await Effect.runPromise(service.closeSession({ sessionId: "missing" }))).toEqual({})
- })
- it("forks a session, loads fork state, and returns config options", async () => {
- const { service, forks } = makeService([
- {
- info: {
- role: "assistant",
- providerID: "test",
- modelID: "second-model",
- variant: "medium",
- mode: "plan",
- },
- parts: [],
- },
- ])
- const forked = await Effect.runPromise(
- service.forkSession({ cwd: "/workspace", sessionId: "ses_parent", mcpServers: [] }),
- )
- const updated = await Effect.runPromise(
- service.setSessionConfigOption({ sessionId: forked.sessionId, configId: "effort", value: "low" }),
- )
- expect(forked.sessionId).toBe("fork_ses_parent")
- expect(select(forked, "model")?.currentValue).toBe("test/second-model")
- expect(select(forked, "effort")?.currentValue).toBe("medium")
- expect(select(updated, "effort")?.currentValue).toBe("low")
- expect(forks).toEqual(["ses_parent"])
- })
- 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 = ACPService.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(ACPError.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 = ACPService.make({ sdk })
- const first = await Effect.runPromise(
- service
- .newSession({ cwd: "/workspace", mcpServers: [] })
- .pipe(Effect.mapError(ACPError.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("registers same-name MCP servers again for different sessions or configs", async () => {
- const adds: unknown[] = []
- let nextSession = 0
- 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: {} }] }),
- skills: () => Promise.resolve({ data: [] }),
- },
- command: {
- list: () => Promise.resolve({ data: [] }),
- },
- session: {
- create: () => {
- nextSession++
- return Promise.resolve({ data: { id: `ses_${nextSession}` } })
- },
- list: () => Promise.resolve({ data: [] }),
- },
- mcp: {
- add: (input: unknown) => {
- adds.push(input)
- return Promise.resolve({ data: {} })
- },
- },
- } as unknown as OpencodeClient
- const service = ACPService.make({ sdk })
- await Effect.runPromise(
- service.newSession({
- cwd: "/workspace",
- mcpServers: [{ name: "tools", command: "node", args: ["one.js"], env: [] }],
- }),
- )
- await Effect.runPromise(
- service.newSession({
- cwd: "/workspace",
- mcpServers: [{ name: "tools", command: "node", args: ["two.js"], env: [] }],
- }),
- )
- expect(adds).toHaveLength(2)
- expect(JSON.stringify(adds[0])).toContain("one.js")
- expect(JSON.stringify(adds[1])).toContain("two.js")
- })
- 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 = ACPService.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")
- })
- it("does not scan last-used sessions when resolving the new session default", async () => {
- const historyCalls: 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: {} }] }),
- skills: () => Promise.resolve({ data: [] }),
- },
- command: {
- list: () => Promise.resolve({ data: [] }),
- },
- session: {
- create: (input: { model?: { id?: string } }) => Promise.resolve({ data: { id: input.model?.id } }),
- list: () => {
- historyCalls.push("list")
- return Promise.resolve({ data: [{ id: "ses_recent" }] })
- },
- messages: () => {
- historyCalls.push("messages")
- return Promise.resolve({
- data: [{ info: { role: "user", model: { providerID: "test", modelID: "second-model" } } }],
- })
- },
- },
- mcp: {
- add: () => Promise.resolve({ data: {} }),
- },
- } as unknown as OpencodeClient
- const service = ACPService.make({ sdk })
- const result = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
- expect(result.sessionId).toBe("test-model")
- expect(result.configOptions?.find((option) => option.id === "model")?.currentValue).toBe("test/test-model")
- expect(historyCalls).toEqual([])
- })
- 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(ACPError.toRequestError), Effect.flip),
- ),
- ),
- )
- expect(results.map((error) => error.code)).toEqual([-32602, -32602, -32602, -32602])
- })
- it("does not refetch providers modes or commands when switching effort from session snapshot", async () => {
- const calls = {
- providers: 0,
- agents: 0,
- commands: 0,
- skills: 0,
- mcpAdds: 0,
- }
- const sdk = {
- config: {
- providers: () => {
- calls.providers++
- return Promise.resolve({ data: { providers: [provider], default: { test: modelID } } })
- },
- get: () => Promise.resolve({ data: {} }),
- },
- app: {
- agents: () => {
- calls.agents++
- return Promise.resolve({ data: [{ name: "build", mode: "primary", permission: [], options: {} }] })
- },
- skills: () => {
- calls.skills++
- return Promise.resolve({ data: [] })
- },
- },
- command: {
- list: () => {
- calls.commands++
- return Promise.resolve({ data: [] })
- },
- },
- session: {
- create: () => Promise.resolve({ data: { id: "ses_fast" } }),
- list: () => Promise.resolve({ data: [] }),
- },
- mcp: {
- add: () => {
- calls.mcpAdds++
- return Promise.resolve({ data: {} })
- },
- },
- } as unknown as OpencodeClient
- const service = ACPService.make({ sdk })
- const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
- expect(calls).toEqual({ providers: 1, agents: 1, commands: 1, skills: 1, mcpAdds: 0 })
- await Effect.runPromise(
- service.setSessionConfigOption({
- sessionId: session.sessionId,
- configId: "effort",
- value: "high",
- }),
- )
- expect(calls).toEqual({ providers: 1, agents: 1, commands: 1, skills: 1, mcpAdds: 0 })
- })
- it("switches model against the warm provider snapshot without refetching", async () => {
- const calls = {
- providers: 0,
- agents: 0,
- commands: 0,
- skills: 0,
- }
- const sdk = {
- config: {
- providers: () => {
- calls.providers++
- return Promise.resolve({ data: { providers: [provider], default: { test: modelID } } })
- },
- get: () => Promise.resolve({ data: {} }),
- },
- app: {
- agents: () => {
- calls.agents++
- return Promise.resolve({ data: [{ name: "build", mode: "primary", permission: [], options: {} }] })
- },
- skills: () => {
- calls.skills++
- return Promise.resolve({ data: [] })
- },
- },
- command: {
- list: () => {
- calls.commands++
- return Promise.resolve({ data: [] })
- },
- },
- session: {
- create: () => Promise.resolve({ data: { id: "ses_model_fast" } }),
- list: () => Promise.resolve({ data: [] }),
- },
- mcp: {
- add: () => Promise.resolve({ data: {} }),
- },
- } as unknown as OpencodeClient
- const service = ACPService.make({ sdk })
- 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(calls).toEqual({ providers: 1, agents: 1, commands: 1, skills: 1 })
- })
- it("reuses the warm directory snapshot for a second new session in the same cwd", async () => {
- const calls = {
- providers: 0,
- config: 0,
- agents: 0,
- commands: 0,
- skills: 0,
- sessionList: 0,
- messages: 0,
- creates: 0,
- }
- const sdk = {
- config: {
- providers: () => {
- calls.providers++
- return Promise.resolve({ data: { providers: [provider], default: { test: modelID } } })
- },
- get: () => {
- calls.config++
- return Promise.resolve({ data: {} })
- },
- },
- app: {
- agents: () => {
- calls.agents++
- return Promise.resolve({ data: [{ name: "build", mode: "primary", permission: [], options: {} }] })
- },
- skills: () => {
- calls.skills++
- return Promise.resolve({ data: [] })
- },
- },
- command: {
- list: () => {
- calls.commands++
- return Promise.resolve({ data: [] })
- },
- },
- session: {
- create: () => {
- calls.creates++
- return Promise.resolve({ data: { id: `ses_warm_${calls.creates}` } })
- },
- list: () => {
- calls.sessionList++
- return Promise.resolve({ data: [] })
- },
- messages: () => {
- calls.messages++
- return Promise.resolve({ data: [] })
- },
- },
- mcp: {
- add: () => Promise.resolve({ data: {} }),
- },
- } as unknown as OpencodeClient
- const service = ACPService.make({ sdk })
- const first = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
- const second = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
- expect(first.sessionId).toBe("ses_warm_1")
- expect(second.sessionId).toBe("ses_warm_2")
- expect(calls).toEqual({
- providers: 1,
- config: 1,
- agents: 1,
- commands: 1,
- skills: 1,
- sessionList: 0,
- messages: 0,
- creates: 2,
- })
- })
- it("normal text prompt sends model variant mode and converted parts", async () => {
- const { service, prompts, usageUpdates } = makeService()
- const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
- await Effect.runPromise(
- service.setSessionConfigOption({
- sessionId: session.sessionId,
- configId: "effort",
- value: "high",
- }),
- )
- await Effect.runPromise(
- service.setSessionConfigOption({
- sessionId: session.sessionId,
- configId: "mode",
- value: "plan",
- }),
- )
- const result = await Effect.runPromise(
- service.prompt({
- sessionId: session.sessionId,
- messageId: "00000000-0000-4000-8000-000000000001",
- prompt: [{ type: "text", text: "hello" }],
- }),
- )
- expect(prompts).toEqual([
- {
- sessionID: session.sessionId,
- model: { providerID, modelID },
- variant: "high",
- parts: [{ type: "text", text: "hello" }],
- agent: "plan",
- directory: "/workspace",
- },
- ])
- expect(result).toEqual({
- stopReason: "end_turn",
- usage: {
- inputTokens: 100,
- outputTokens: 40,
- thoughtTokens: 7,
- cachedReadTokens: 11,
- cachedWriteTokens: 13,
- totalTokens: 171,
- },
- userMessageId: "00000000-0000-4000-8000-000000000001",
- _meta: {},
- })
- expect(usageUpdates).toEqual([session.sessionId])
- })
- it("maps assistant prompt errors to request errors instead of end turn", async () => {
- const { service } = makeService([], {
- prompt: () =>
- Promise.resolve({
- data: {
- info: assistantInfo(
- { input: 8, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
- { name: "APIError", data: { message: "Provider request failed", isRetryable: false } },
- ),
- },
- }),
- })
- const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
- const error = await Effect.runPromise(
- service
- .prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "hello" }] })
- .pipe(Effect.mapError(ACPError.toRequestError), Effect.flip),
- )
- expect(error.code).toBe(-32603)
- expect(error.message).toBe("Internal error: Provider request failed")
- expect(error.data).toEqual({ service: "session", errorName: "APIError" })
- })
- it("maps aborted assistant prompt errors to cancelled", async () => {
- const { service } = makeService([], {
- prompt: () =>
- Promise.resolve({
- data: {
- info: assistantInfo(
- { input: 8, output: 1, reasoning: 0, cache: { read: 0, write: 0 } },
- { name: "MessageAbortedError", data: { message: "Aborted" } },
- ),
- },
- }),
- })
- const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
- const result = await Effect.runPromise(
- service.prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "hello" }] }),
- )
- expect(result.stopReason).toBe("cancelled")
- })
- it("prompt maps assistant and user audience annotations", async () => {
- const { service, prompts } = makeService()
- const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
- await Effect.runPromise(
- service.prompt({
- sessionId: session.sessionId,
- prompt: [
- { type: "text", text: "assistant context", annotations: { audience: ["assistant"] } },
- { type: "text", text: "user context", annotations: { audience: ["user"] } },
- ],
- }),
- )
- expect(prompts).toContainEqual({
- sessionID: session.sessionId,
- model: { providerID, modelID },
- variant: "default",
- parts: [
- { type: "text", text: "assistant context", synthetic: true },
- { type: "text", text: "user context", ignored: true },
- ],
- agent: "build",
- directory: "/workspace",
- })
- })
- it("prompt sends image and resource parts", async () => {
- const { service, prompts } = makeService()
- const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
- await Effect.runPromise(
- service.prompt({
- sessionId: session.sessionId,
- prompt: [
- { type: "image", data: "AAAA", mimeType: "image/png", uri: "file:///tmp/screenshot.png" },
- {
- type: "resource",
- resource: {
- uri: "file:///tmp/report.pdf",
- mimeType: "application/pdf",
- blob: "JVBERg==",
- },
- },
- ],
- }),
- )
- expect((prompts[0] as { parts?: unknown }).parts).toEqual([
- {
- type: "file",
- url: "data:image/png;base64,AAAA",
- filename: "screenshot.png",
- mime: "image/png",
- },
- {
- type: "file",
- url: "data:application/pdf;base64,JVBERg==",
- filename: "report.pdf",
- mime: "application/pdf",
- },
- ])
- })
- it("slash command prompt calls session command", async () => {
- const { service, prompts, commands } = makeService()
- const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
- const result = await Effect.runPromise(
- service.prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "/init now" }] }),
- )
- expect(prompts).toEqual([])
- expect(commands).toEqual([
- {
- sessionID: session.sessionId,
- command: "init",
- arguments: "now",
- model: "test/test-model",
- variant: "default",
- agent: "build",
- directory: "/workspace",
- },
- ])
- expect(result.usage).toEqual({ inputTokens: 3, outputTokens: 4, totalTokens: 7 })
- })
- it("compact slash command calls summarize path", async () => {
- const { service, prompts, commands, summarizes } = makeService()
- const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
- await Effect.runPromise(
- service.prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "/compact" }] }),
- )
- expect(prompts).toEqual([])
- expect(commands).toEqual([])
- expect(summarizes).toEqual([
- {
- sessionID: session.sessionId,
- directory: "/workspace",
- providerID,
- modelID,
- },
- ])
- })
- it("maps prompt auth failures to auth-required request errors", async () => {
- const { service } = makeService()
- const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
- const failing = ACPService.make({
- 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: {} }] }),
- skills: () => Promise.resolve({ data: [] }),
- },
- command: {
- list: () => Promise.resolve({ data: [] }),
- },
- session: {
- create: () => Promise.resolve({ data: { id: session.sessionId } }),
- list: () => Promise.resolve({ data: [] }),
- prompt: () => Promise.reject({ name: "ProviderAuthError", data: { providerID: "test" } }),
- },
- mcp: {
- add: () => Promise.resolve({ data: {} }),
- },
- } as unknown as OpencodeClient,
- usage: UsageService.Service.of({
- buildUsage: UsageService.buildUsage,
- latestAssistantMessage: UsageService.latestAssistantMessage,
- totalSessionCost: UsageService.totalSessionCost,
- contextLimit: () => Effect.succeed(128000),
- sendUpdate: () => Effect.void,
- }),
- })
- await Effect.runPromise(failing.newSession({ cwd: "/workspace", mcpServers: [] }))
- const error = await Effect.runPromise(
- failing
- .prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "hello" }] })
- .pipe(Effect.mapError(ACPError.toRequestError), Effect.flip),
- )
- expect(error.code).toBe(-32000)
- })
- })
- function assistantInfo(
- tokens: UsageService.AssistantTokenCost["tokens"],
- error?: AssistantMessage["error"],
- ): UsageService.AssistantMessage & Pick<AssistantMessage, "error"> {
- return {
- role: "assistant",
- providerID: "test",
- modelID: "test-model",
- cost: 0,
- tokens,
- ...(error ? { error } : {}),
- }
- }
- function categories(result: NewSessionResponse | LoadSessionResponse) {
- return result.configOptions?.map((option) => option.category) ?? []
- }
- function select(
- result: SetSessionConfigOptionResponse | ResumeSessionResponse | NewSessionResponse | ForkSessionResponse,
- 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)) ?? []
- }
|