318 lines
10 KiB
TypeScript
318 lines
10 KiB
TypeScript
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) ?? []
|
|
}
|