acp-test-client.ts 3.2 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697
  1. import { expect } from "bun:test"
  2. import type { SessionConfigOption, SessionConfigSelectOption } from "@agentclientprotocol/sdk"
  3. import { Duration, Effect } from "effect"
  4. import type { AcpHandle } from "../../lib/cli-process"
  5. type JsonRpcRequest = {
  6. readonly jsonrpc: "2.0"
  7. readonly id: number
  8. readonly method: string
  9. readonly params?: unknown
  10. }
  11. type JsonRpcResponse<T = unknown> = {
  12. readonly jsonrpc: "2.0"
  13. readonly id: number
  14. readonly result?: T
  15. readonly error?: unknown
  16. }
  17. type JsonRpcNotification<T = unknown> = {
  18. readonly jsonrpc: "2.0"
  19. readonly method: string
  20. readonly params?: T
  21. }
  22. export type AcpClient = {
  23. readonly request: <T>(method: string, params?: unknown) => Effect.Effect<JsonRpcResponse<T>, unknown>
  24. readonly receive: Effect.Effect<unknown>
  25. readonly waitForNotification: <T>(
  26. method: string,
  27. predicate: (params: T) => boolean,
  28. timeoutMs?: number,
  29. ) => Effect.Effect<JsonRpcNotification<T>, unknown>
  30. }
  31. export function createAcpClient(acp: AcpHandle): AcpClient {
  32. const state = { nextId: 1 }
  33. const request = <T>(method: string, params?: unknown) =>
  34. Effect.gen(function* () {
  35. const id = state.nextId++
  36. const message: JsonRpcRequest =
  37. params === undefined ? { jsonrpc: "2.0", id, method } : { jsonrpc: "2.0", id, method, params }
  38. yield* acp.send(message)
  39. while (true) {
  40. const received = yield* acp.receive.pipe(Effect.timeout(Duration.seconds(15)))
  41. if (isJsonRpcResponse<T>(received) && received.id === id) return received
  42. }
  43. })
  44. const waitForNotification = <T>(method: string, predicate: (params: T) => boolean, timeoutMs = 15_000) =>
  45. Effect.gen(function* () {
  46. while (true) {
  47. const received = yield* acp.receive.pipe(Effect.timeout(Duration.millis(timeoutMs)))
  48. if (!isJsonRpcNotification<T>(received)) continue
  49. if (received.method === method && predicate(received.params as T)) return received
  50. }
  51. })
  52. return {
  53. request,
  54. receive: acp.receive,
  55. waitForNotification,
  56. }
  57. }
  58. export function expectOk<T>(response: JsonRpcResponse<T>) {
  59. expect(response.error).toBeUndefined()
  60. expect(response.result).toBeDefined()
  61. return response.result as T
  62. }
  63. export function selectConfigOption(options: SessionConfigOption[] | null | undefined, id: string) {
  64. return options?.find(
  65. (option): option is Extract<SessionConfigOption, { type: "select" }> =>
  66. option.id === id && option.type === "select",
  67. )
  68. }
  69. export function firstAlternateValue(option: Extract<SessionConfigOption, { type: "select" }>) {
  70. return flattenSelectOptions(option).find((item) => item.value !== option.currentValue)?.value
  71. }
  72. export function flattenSelectOptions(option: Extract<SessionConfigOption, { type: "select" }>) {
  73. return option.options.flatMap((item): SessionConfigSelectOption[] => ("value" in item ? [item] : item.options))
  74. }
  75. function isJsonRpcResponse<T>(input: unknown): input is JsonRpcResponse<T> {
  76. if (!input || typeof input !== "object") return false
  77. return "id" in input && "jsonrpc" in input
  78. }
  79. function isJsonRpcNotification<T>(input: unknown): input is JsonRpcNotification<T> {
  80. if (!input || typeof input !== "object") return false
  81. return "method" in input && !("id" in input) && "jsonrpc" in input
  82. }