207 lines
6.1 KiB
TypeScript
207 lines
6.1 KiB
TypeScript
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)
|
|
},
|
|
}
|
|
}
|