| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207 |
- import type { AgentSideConnection } from "@agentclientprotocol/sdk"
- import {
- OpenCode,
- type AgentInfo,
- type CommandInfo,
- type ModelInfo,
- type ModelRef,
- type SessionInfo,
- type SkillInfo,
- type TokenUsageInfo,
- } from "@opencode-ai/client/promise"
- import { ACPService } from "../../src/acp/service"
- export type FixtureRequest = {
- readonly method: string
- readonly path: string
- readonly query: Record<string, string>
- readonly body: unknown
- }
- export type FixtureContext = {
- readonly requests: FixtureRequest[]
- send(event: unknown): void
- }
- type FixtureHandler = (
- request: FixtureRequest,
- context: FixtureContext,
- ) => Response | undefined | Promise<Response | undefined>
- type FixtureOptions = {
- readonly fetch?: FixtureHandler
- readonly models?: readonly ModelInfo[]
- readonly defaultModel?: ModelInfo
- readonly agents?: readonly AgentInfo[]
- readonly commands?: readonly CommandInfo[]
- readonly skills?: readonly SkillInfo[]
- }
- export const testModel = {
- id: "test-model",
- modelID: "test-model",
- providerID: "test",
- name: "Test Model",
- capabilities: { tools: true, input: ["text"], output: ["text"] },
- variants: [{ id: "default" }, { id: "high" }],
- time: { released: 0 },
- cost: [],
- status: "active",
- enabled: true,
- limit: { context: 100_000, output: 10_000 },
- } satisfies ModelInfo
- export const secondModel = {
- id: "second-model",
- modelID: "second-model",
- providerID: "test",
- name: "Second Model",
- capabilities: { tools: true, input: ["text"], output: ["text"] },
- variants: [{ id: "low" }, { id: "medium" }],
- time: { released: 0 },
- cost: [],
- status: "active",
- enabled: true,
- limit: { context: 200_000, output: 20_000 },
- } satisfies ModelInfo
- export const buildAgent = {
- id: "build",
- name: "Build",
- request: { settings: {}, headers: {}, body: {} },
- mode: "primary",
- hidden: false,
- permissions: [],
- } satisfies AgentInfo
- export const planAgent = {
- id: "plan",
- name: "Plan",
- description: "Plan first",
- request: { settings: {}, headers: {}, body: {} },
- mode: "primary",
- hidden: false,
- permissions: [],
- } satisfies AgentInfo
- export const reviewCommand = {
- name: "review",
- description: "Review changes",
- template: "",
- } satisfies CommandInfo
- export const verifySkill = {
- id: "verify",
- name: "verify",
- description: "Verify work",
- slash: true,
- location: "/skills/verify.md",
- content: "verify",
- } satisfies SkillInfo
- export function makeSession(
- id: string,
- input: {
- readonly cwd?: string
- readonly agent?: string
- readonly model?: ModelRef
- readonly cost?: number
- readonly tokens?: TokenUsageInfo
- readonly time?: SessionInfo["time"]
- readonly title?: string
- } = {},
- ): SessionInfo {
- return {
- id,
- projectID: "global",
- agent: input.agent ?? "build",
- model: input.model ?? { providerID: "test", id: "test-model", variant: "default" },
- cost: input.cost ?? 0,
- tokens: input.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
- time: input.time ?? { created: 0, updated: 0 },
- title: input.title ?? `Session ${id}`,
- location: { directory: input.cwd ?? "/workspace" },
- }
- }
- export function makeACPFixture(options: FixtureOptions = {}) {
- const requests: FixtureRequest[] = []
- const updates: Parameters<AgentSideConnection["sessionUpdate"]>[0][] = []
- const encoder = new TextEncoder()
- let eventController: ReadableStreamDefaultController<Uint8Array> | undefined
- const models = options.models ?? [testModel, secondModel]
- const context: FixtureContext = {
- requests,
- send(event) {
- if (!eventController) throw new Error("ACP fixture has no active event stream")
- eventController.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`))
- },
- }
- const server = Bun.serve({
- port: 0,
- async fetch(raw) {
- const url = new URL(raw.url)
- const request: FixtureRequest = {
- method: raw.method,
- path: url.pathname,
- query: Object.fromEntries(url.searchParams.entries()),
- body: raw.method === "GET" || raw.method === "HEAD" ? undefined : await raw.json().catch(() => undefined),
- }
- requests.push(request)
- const response = await options.fetch?.(request, context)
- if (response) return response
- const directory = request.query["location[directory]"] ?? "/workspace"
- const location = { directory, project: { id: "global", directory } }
- if (request.path === "/api/event") {
- let controller: ReadableStreamDefaultController<Uint8Array> | undefined
- return new Response(
- new ReadableStream<Uint8Array>({
- start(value) {
- controller = value
- eventController = value
- context.send({ id: "evt_connected", type: "server.connected", data: {} })
- },
- cancel() {
- if (eventController === controller) eventController = undefined
- },
- }),
- { headers: { "content-type": "text/event-stream" } },
- )
- }
- if (request.path === "/api/model") return Response.json({ location, data: models })
- if (request.path === "/api/model/default") {
- return Response.json({ location, data: options.defaultModel ?? models[0] ?? null })
- }
- if (request.path === "/api/agent") {
- return Response.json({ location, data: options.agents ?? [buildAgent, planAgent] })
- }
- if (request.path === "/api/command") {
- return Response.json({ location, data: options.commands ?? [reviewCommand] })
- }
- if (request.path === "/api/skill") {
- return Response.json({ location, data: options.skills ?? [verifySkill] })
- }
- return new Response(null, { status: 404 })
- },
- })
- const service = ACPService.make({
- client: OpenCode.make({ baseUrl: server.url.toString() }),
- connection: {
- sessionUpdate: async (update) => {
- updates.push(update)
- },
- requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
- },
- })
- return {
- service,
- requests,
- updates,
- async [Symbol.asyncDispose]() {
- eventController?.close()
- await server.stop(true)
- },
- }
- }
|