subprocess.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384
  1. import type {
  2. InitializeResponse,
  3. NewSessionResponse,
  4. SessionConfigOption,
  5. SessionConfigSelectOption,
  6. } from "@agentclientprotocol/sdk"
  7. import fs from "node:fs/promises"
  8. import os from "node:os"
  9. import path from "node:path"
  10. type JsonRpcRequest = {
  11. readonly jsonrpc: "2.0"
  12. readonly id: number
  13. readonly method: string
  14. readonly params?: unknown
  15. }
  16. export type JsonRpcError = {
  17. readonly code: number
  18. readonly message?: string
  19. readonly data?: unknown
  20. }
  21. export type JsonRpcResponse<T> = {
  22. readonly jsonrpc: "2.0"
  23. readonly id: number
  24. readonly result?: T
  25. readonly error?: JsonRpcError
  26. }
  27. type JsonRpcNotification<T> = {
  28. readonly jsonrpc: "2.0"
  29. readonly method: string
  30. readonly params: T
  31. }
  32. type JsonRpcMessage = Record<string, unknown>
  33. type Waiter = {
  34. readonly predicate: (message: JsonRpcMessage) => boolean
  35. readonly resolve: (message: JsonRpcMessage) => void
  36. readonly reject: (error: Error) => void
  37. readonly timer: ReturnType<typeof setTimeout>
  38. }
  39. export type AcpProcess = {
  40. readonly request: <T>(method: string, params?: unknown) => Promise<JsonRpcResponse<T>>
  41. readonly waitForNotification: <T>(
  42. method: string,
  43. predicate: (params: T) => boolean,
  44. timeoutMs?: number,
  45. ) => Promise<JsonRpcNotification<T>>
  46. readonly close: () => Promise<number>
  47. readonly stderr: () => string
  48. readonly [Symbol.asyncDispose]: () => Promise<void>
  49. }
  50. export const verifierSkill = `---
  51. name: verifier-skill
  52. description: Verifier compatibility skill.
  53. ---
  54. # Verifier Skill
  55. `
  56. export async function createAcpFixture(options: { readonly skill?: string } = {}) {
  57. const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-cli-acp-"))
  58. const home = path.join(root, "workspace")
  59. const config = path.join(root, "config")
  60. const skills = path.join(root, "skills")
  61. await Promise.all([fs.mkdir(home, { recursive: true }), fs.mkdir(config, { recursive: true })])
  62. if (options.skill) {
  63. await fs.mkdir(path.join(skills, "verifier-skill"), { recursive: true })
  64. await Bun.write(path.join(skills, "verifier-skill", "SKILL.md"), options.skill)
  65. }
  66. const requests: unknown[] = []
  67. const llm = Bun.serve({
  68. hostname: "127.0.0.1",
  69. port: 0,
  70. async fetch(request) {
  71. if (request.method !== "POST" || new URL(request.url).pathname !== "/v1/chat/completions") {
  72. return new Response("Not found", { status: 404 })
  73. }
  74. requests.push(await request.json().catch(() => undefined))
  75. return new Response(completion("accepted"), {
  76. headers: { "content-type": "text/event-stream" },
  77. })
  78. },
  79. })
  80. await Bun.write(
  81. path.join(config, "opencode.json"),
  82. JSON.stringify(verifierConfig(`http://127.0.0.1:${llm.port}/v1`, options.skill ? skills : undefined)),
  83. )
  84. const processes = new Set<AcpProcess>()
  85. return {
  86. root,
  87. home,
  88. llm: { requests },
  89. spawn(extraEnv: Record<string, string | undefined> = {}) {
  90. const acp = spawnAcp({
  91. env: {
  92. ...process.env,
  93. HOME: root,
  94. USERPROFILE: root,
  95. OPENCODE_CONFIG: undefined,
  96. OPENCODE_CONFIG_CONTENT: undefined,
  97. OPENCODE_CONFIG_DIR: config,
  98. OPENCODE_DB: path.join(root, "opencode.db"),
  99. OPENCODE_DISABLE_AUTOUPDATE: "true",
  100. OPENCODE_DISABLE_FILEWATCHER: "true",
  101. OPENCODE_DISABLE_MODELS_FETCH: "true",
  102. OPENCODE_MODELS_PATH: undefined,
  103. OPENCODE_TEST_HOME: root,
  104. XDG_CACHE_HOME: path.join(root, "cache"),
  105. XDG_CONFIG_HOME: path.join(root, "xdg-config"),
  106. XDG_DATA_HOME: path.join(root, "data"),
  107. XDG_STATE_HOME: path.join(root, "state"),
  108. ...extraEnv,
  109. },
  110. })
  111. processes.add(acp)
  112. return acp
  113. },
  114. async [Symbol.asyncDispose]() {
  115. await Promise.all([...processes].map((process) => process[Symbol.asyncDispose]()))
  116. await llm.stop(true)
  117. await fs.rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
  118. },
  119. }
  120. }
  121. export function initialize(acp: AcpProcess) {
  122. return acp
  123. .request<InitializeResponse>("initialize", {
  124. protocolVersion: 1,
  125. clientCapabilities: { _meta: { "terminal-auth": true } },
  126. clientInfo: { name: "opencode-local-acp", version: "0.1.0" },
  127. })
  128. .then(expectOk)
  129. }
  130. export function newSession(acp: AcpProcess, cwd: string) {
  131. return acp.request<NewSessionResponse>("session/new", { cwd, mcpServers: [] }).then(expectOk)
  132. }
  133. export function expectOk<T>(response: JsonRpcResponse<T>) {
  134. if (response.error) throw new Error(`ACP request failed: ${JSON.stringify(response.error)}`)
  135. if (response.result === undefined) throw new Error("ACP response did not include a result")
  136. return response.result
  137. }
  138. export function selectConfigOption(options: SessionConfigOption[] | null | undefined, id: string) {
  139. return options?.find(
  140. (option): option is Extract<SessionConfigOption, { type: "select" }> =>
  141. option.id === id && option.type === "select",
  142. )
  143. }
  144. export function requireSelectOption(options: SessionConfigOption[] | null | undefined, id: string) {
  145. const option = selectConfigOption(options, id)
  146. if (!option) throw new Error(`Missing ACP config option: ${id}`)
  147. return option
  148. }
  149. export function flattenSelectOptions(option: Extract<SessionConfigOption, { type: "select" }>) {
  150. return option.options.flatMap((item): SessionConfigSelectOption[] => ("value" in item ? [item] : item.options))
  151. }
  152. export function alternateValue(option: Extract<SessionConfigOption, { type: "select" }>) {
  153. const value = flattenSelectOptions(option).find((item) => item.value !== option.currentValue)?.value
  154. if (!value) throw new Error(`ACP config option ${option.id} has no alternate value`)
  155. return value
  156. }
  157. function verifierConfig(llmUrl: string, skills?: string) {
  158. const model = {
  159. capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
  160. cost: { input: 0, output: 0 },
  161. limit: { context: 100_000, output: 10_000 },
  162. }
  163. return {
  164. autoupdate: false,
  165. model: "test/test-model",
  166. ...(skills ? { skills: [skills] } : {}),
  167. providers: {
  168. test: {
  169. name: "Test",
  170. package: "aisdk:@ai-sdk/openai-compatible",
  171. settings: { apiKey: "test-key", baseURL: llmUrl },
  172. models: {
  173. "test-model": {
  174. ...model,
  175. name: "Test Model",
  176. variants: [{ id: "low" }, { id: "high" }],
  177. },
  178. "second-model": {
  179. ...model,
  180. name: "Second Test Model",
  181. variants: [{ id: "medium" }, { id: "max" }],
  182. },
  183. },
  184. },
  185. },
  186. }
  187. }
  188. function spawnAcp(input: { readonly env: Record<string, string | undefined> }): AcpProcess {
  189. const child = Bun.spawn([process.execPath, "run", "src/index.ts", "acp"], {
  190. cwd: path.join(import.meta.dir, "../.."),
  191. env: input.env,
  192. stdin: "pipe",
  193. stdout: "pipe",
  194. stderr: "pipe",
  195. })
  196. const encoder = new TextEncoder()
  197. const decoder = new TextDecoder()
  198. const errorDecoder = new TextDecoder()
  199. const messages: JsonRpcMessage[] = []
  200. const waiters: Waiter[] = []
  201. let nextID = 1
  202. let failure: Error | undefined
  203. let stderr = ""
  204. let inputClosed = false
  205. let disposed = false
  206. const fail = (error: Error) => {
  207. if (failure) return
  208. failure = error
  209. waiters.splice(0).forEach((waiter) => {
  210. clearTimeout(waiter.timer)
  211. waiter.reject(error)
  212. })
  213. }
  214. const dispatch = (message: JsonRpcMessage) => {
  215. const index = waiters.findIndex((waiter) => waiter.predicate(message))
  216. if (index === -1) {
  217. messages.push(message)
  218. return
  219. }
  220. const waiter = waiters.splice(index, 1)[0]
  221. clearTimeout(waiter.timer)
  222. waiter.resolve(message)
  223. }
  224. const output = (async () => {
  225. const reader = child.stdout.getReader()
  226. let buffered = ""
  227. while (true) {
  228. const chunk = await reader.read()
  229. if (chunk.done) break
  230. buffered += decoder.decode(chunk.value, { stream: true })
  231. while (true) {
  232. const newline = buffered.indexOf("\n")
  233. if (newline === -1) break
  234. const line = buffered.slice(0, newline).trim()
  235. buffered = buffered.slice(newline + 1)
  236. if (line) dispatch(parseMessage(line))
  237. }
  238. }
  239. buffered += decoder.decode()
  240. if (buffered.trim()) dispatch(parseMessage(buffered.trim()))
  241. fail(new Error(`ACP exited before another response${stderr ? `: ${stderr}` : ""}`))
  242. })().catch((error) => fail(asError(error)))
  243. const errors = (async () => {
  244. const reader = child.stderr.getReader()
  245. while (true) {
  246. const chunk = await reader.read()
  247. if (chunk.done) break
  248. stderr += errorDecoder.decode(chunk.value, { stream: true })
  249. }
  250. stderr += errorDecoder.decode()
  251. })()
  252. const take = (predicate: (message: JsonRpcMessage) => boolean, timeoutMs: number, description: string) => {
  253. const index = messages.findIndex(predicate)
  254. if (index !== -1) return Promise.resolve(messages.splice(index, 1)[0])
  255. if (failure) return Promise.reject(failure)
  256. return new Promise<JsonRpcMessage>((resolve, reject) => {
  257. const waiter: Waiter = {
  258. predicate,
  259. resolve,
  260. reject,
  261. timer: setTimeout(() => {
  262. const index = waiters.indexOf(waiter)
  263. if (index !== -1) waiters.splice(index, 1)
  264. reject(new Error(`Timed out waiting for ${description}${stderr ? `: ${stderr}` : ""}`))
  265. }, timeoutMs),
  266. }
  267. waiters.push(waiter)
  268. })
  269. }
  270. return {
  271. async request<T>(method: string, params?: unknown) {
  272. if (inputClosed) throw new Error("ACP stdin is closed")
  273. const id = nextID++
  274. const request: JsonRpcRequest =
  275. params === undefined ? { jsonrpc: "2.0", id, method } : { jsonrpc: "2.0", id, method, params }
  276. await child.stdin.write(encoder.encode(`${JSON.stringify(request)}\n`))
  277. await child.stdin.flush()
  278. const response = await take((message) => isResponse(message) && message.id === id, 20_000, `${method} response`)
  279. if (!isResponse<T>(response)) throw new Error(`Invalid ACP response: ${JSON.stringify(response)}`)
  280. return response
  281. },
  282. async waitForNotification<T>(method: string, predicate: (params: T) => boolean, timeoutMs = 20_000) {
  283. const notification = await take(
  284. (message) => isNotification<T>(message) && message.method === method && predicate(message.params),
  285. timeoutMs,
  286. `${method} notification`,
  287. )
  288. if (!isNotification<T>(notification)) {
  289. throw new Error(`Invalid ACP notification: ${JSON.stringify(notification)}`)
  290. }
  291. return notification
  292. },
  293. async close() {
  294. if (!inputClosed) {
  295. inputClosed = true
  296. await child.stdin.end()
  297. }
  298. const exitCode = await withTimeout(child.exited, 5_000, "ACP did not exit after stdin EOF")
  299. await Promise.all([output, errors])
  300. if (exitCode !== 0) throw new Error(`ACP exited with ${exitCode}: ${stderr}`)
  301. return exitCode
  302. },
  303. stderr: () => stderr,
  304. async [Symbol.asyncDispose]() {
  305. if (disposed) return
  306. disposed = true
  307. if (child.exitCode === null) child.kill("SIGKILL")
  308. await child.exited
  309. await Promise.all([output, errors])
  310. },
  311. }
  312. }
  313. function parseMessage(line: string): JsonRpcMessage {
  314. const message: unknown = JSON.parse(line)
  315. if (!isJsonRpcMessage(message)) throw new Error(`Invalid ACP message: ${line}`)
  316. return message
  317. }
  318. function isJsonRpcMessage(message: unknown): message is JsonRpcMessage {
  319. return !!message && typeof message === "object" && !Array.isArray(message)
  320. }
  321. function isResponse<T>(message: JsonRpcMessage): message is JsonRpcMessage & JsonRpcResponse<T> {
  322. return message.jsonrpc === "2.0" && typeof message.id === "number" && !("method" in message)
  323. }
  324. function isNotification<T>(message: JsonRpcMessage): message is JsonRpcMessage & JsonRpcNotification<T> {
  325. return message.jsonrpc === "2.0" && typeof message.method === "string" && !("id" in message)
  326. }
  327. function asError(error: unknown) {
  328. return error instanceof Error ? error : new Error(String(error))
  329. }
  330. function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string) {
  331. let timer: ReturnType<typeof setTimeout> | undefined
  332. return Promise.race([
  333. promise,
  334. new Promise<T>((_, reject) => {
  335. timer = setTimeout(() => reject(new Error(message)), timeoutMs)
  336. }),
  337. ]).finally(() => clearTimeout(timer))
  338. }
  339. function completion(text: string) {
  340. const chunks = [
  341. { choices: [{ delta: { role: "assistant" }, finish_reason: null }], usage: null },
  342. { choices: [{ delta: { content: text }, finish_reason: null }], usage: null },
  343. { choices: [{ delta: {}, finish_reason: "stop" }], usage: null },
  344. {
  345. choices: [],
  346. usage: { prompt_tokens: 10, completion_tokens: 1, total_tokens: 11 },
  347. },
  348. ]
  349. return `${chunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join("")}data: [DONE]\n\n`
  350. }