1
0

service-fixture.ts 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207
  1. import type { AgentSideConnection } from "@agentclientprotocol/sdk"
  2. import {
  3. OpenCode,
  4. type AgentInfo,
  5. type CommandInfo,
  6. type ModelInfo,
  7. type ModelRef,
  8. type SessionInfo,
  9. type SkillInfo,
  10. type TokenUsageInfo,
  11. } from "@opencode-ai/client/promise"
  12. import { ACPService } from "../../src/acp/service"
  13. export type FixtureRequest = {
  14. readonly method: string
  15. readonly path: string
  16. readonly query: Record<string, string>
  17. readonly body: unknown
  18. }
  19. export type FixtureContext = {
  20. readonly requests: FixtureRequest[]
  21. send(event: unknown): void
  22. }
  23. type FixtureHandler = (
  24. request: FixtureRequest,
  25. context: FixtureContext,
  26. ) => Response | undefined | Promise<Response | undefined>
  27. type FixtureOptions = {
  28. readonly fetch?: FixtureHandler
  29. readonly models?: readonly ModelInfo[]
  30. readonly defaultModel?: ModelInfo
  31. readonly agents?: readonly AgentInfo[]
  32. readonly commands?: readonly CommandInfo[]
  33. readonly skills?: readonly SkillInfo[]
  34. }
  35. export const testModel = {
  36. id: "test-model",
  37. modelID: "test-model",
  38. providerID: "test",
  39. name: "Test Model",
  40. capabilities: { tools: true, input: ["text"], output: ["text"] },
  41. variants: [{ id: "default" }, { id: "high" }],
  42. time: { released: 0 },
  43. cost: [],
  44. status: "active",
  45. enabled: true,
  46. limit: { context: 100_000, output: 10_000 },
  47. } satisfies ModelInfo
  48. export const secondModel = {
  49. id: "second-model",
  50. modelID: "second-model",
  51. providerID: "test",
  52. name: "Second Model",
  53. capabilities: { tools: true, input: ["text"], output: ["text"] },
  54. variants: [{ id: "low" }, { id: "medium" }],
  55. time: { released: 0 },
  56. cost: [],
  57. status: "active",
  58. enabled: true,
  59. limit: { context: 200_000, output: 20_000 },
  60. } satisfies ModelInfo
  61. export const buildAgent = {
  62. id: "build",
  63. name: "Build",
  64. request: { settings: {}, headers: {}, body: {} },
  65. mode: "primary",
  66. hidden: false,
  67. permissions: [],
  68. } satisfies AgentInfo
  69. export const planAgent = {
  70. id: "plan",
  71. name: "Plan",
  72. description: "Plan first",
  73. request: { settings: {}, headers: {}, body: {} },
  74. mode: "primary",
  75. hidden: false,
  76. permissions: [],
  77. } satisfies AgentInfo
  78. export const reviewCommand = {
  79. name: "review",
  80. description: "Review changes",
  81. template: "",
  82. } satisfies CommandInfo
  83. export const verifySkill = {
  84. id: "verify",
  85. name: "verify",
  86. description: "Verify work",
  87. slash: true,
  88. location: "/skills/verify.md",
  89. content: "verify",
  90. } satisfies SkillInfo
  91. export function makeSession(
  92. id: string,
  93. input: {
  94. readonly cwd?: string
  95. readonly agent?: string
  96. readonly model?: ModelRef
  97. readonly cost?: number
  98. readonly tokens?: TokenUsageInfo
  99. readonly time?: SessionInfo["time"]
  100. readonly title?: string
  101. } = {},
  102. ): SessionInfo {
  103. return {
  104. id,
  105. projectID: "global",
  106. agent: input.agent ?? "build",
  107. model: input.model ?? { providerID: "test", id: "test-model", variant: "default" },
  108. cost: input.cost ?? 0,
  109. tokens: input.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
  110. time: input.time ?? { created: 0, updated: 0 },
  111. title: input.title ?? `Session ${id}`,
  112. location: { directory: input.cwd ?? "/workspace" },
  113. }
  114. }
  115. export function makeACPFixture(options: FixtureOptions = {}) {
  116. const requests: FixtureRequest[] = []
  117. const updates: Parameters<AgentSideConnection["sessionUpdate"]>[0][] = []
  118. const encoder = new TextEncoder()
  119. let eventController: ReadableStreamDefaultController<Uint8Array> | undefined
  120. const models = options.models ?? [testModel, secondModel]
  121. const context: FixtureContext = {
  122. requests,
  123. send(event) {
  124. if (!eventController) throw new Error("ACP fixture has no active event stream")
  125. eventController.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`))
  126. },
  127. }
  128. const server = Bun.serve({
  129. port: 0,
  130. async fetch(raw) {
  131. const url = new URL(raw.url)
  132. const request: FixtureRequest = {
  133. method: raw.method,
  134. path: url.pathname,
  135. query: Object.fromEntries(url.searchParams.entries()),
  136. body: raw.method === "GET" || raw.method === "HEAD" ? undefined : await raw.json().catch(() => undefined),
  137. }
  138. requests.push(request)
  139. const response = await options.fetch?.(request, context)
  140. if (response) return response
  141. const directory = request.query["location[directory]"] ?? "/workspace"
  142. const location = { directory, project: { id: "global", directory } }
  143. if (request.path === "/api/event") {
  144. let controller: ReadableStreamDefaultController<Uint8Array> | undefined
  145. return new Response(
  146. new ReadableStream<Uint8Array>({
  147. start(value) {
  148. controller = value
  149. eventController = value
  150. context.send({ id: "evt_connected", type: "server.connected", data: {} })
  151. },
  152. cancel() {
  153. if (eventController === controller) eventController = undefined
  154. },
  155. }),
  156. { headers: { "content-type": "text/event-stream" } },
  157. )
  158. }
  159. if (request.path === "/api/model") return Response.json({ location, data: models })
  160. if (request.path === "/api/model/default") {
  161. return Response.json({ location, data: options.defaultModel ?? models[0] ?? null })
  162. }
  163. if (request.path === "/api/agent") {
  164. return Response.json({ location, data: options.agents ?? [buildAgent, planAgent] })
  165. }
  166. if (request.path === "/api/command") {
  167. return Response.json({ location, data: options.commands ?? [reviewCommand] })
  168. }
  169. if (request.path === "/api/skill") {
  170. return Response.json({ location, data: options.skills ?? [verifySkill] })
  171. }
  172. return new Response(null, { status: 404 })
  173. },
  174. })
  175. const service = ACPService.make({
  176. client: OpenCode.make({ baseUrl: server.url.toString() }),
  177. connection: {
  178. sessionUpdate: async (update) => {
  179. updates.push(update)
  180. },
  181. requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
  182. },
  183. })
  184. return {
  185. service,
  186. requests,
  187. updates,
  188. async [Symbol.asyncDispose]() {
  189. eventController?.close()
  190. await server.stop(true)
  191. },
  192. }
  193. }