protocol.test.ts 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347
  1. import { describe, expect, test } from "bun:test"
  2. import { Effect, Schema } from "effect"
  3. import { Backend, Frontend, Handshake, JsonRpc } from "../src/protocol"
  4. const successResponse: Schema.Schema.Type<typeof JsonRpc.Response> = { jsonrpc: "2.0", id: 1, result: null }
  5. // @ts-expect-error responses require one outcome
  6. const missingResponse: Schema.Schema.Type<typeof JsonRpc.Response> = { jsonrpc: "2.0", id: 1 }
  7. // @ts-expect-error responses cannot contain both outcomes
  8. const invalidResponse: Schema.Schema.Type<typeof JsonRpc.Response> = {
  9. jsonrpc: "2.0",
  10. id: 1,
  11. result: null,
  12. error: { code: -32600, message: "Invalid request" },
  13. }
  14. void [successResponse, missingResponse, invalidResponse]
  15. test("normalizes an omitted finish reason", () => {
  16. expect(Backend.decodeRequest({ jsonrpc: "2.0", id: 1, method: "llm.finish", params: { id: "inv_1" } })).toMatchObject(
  17. { params: { id: "inv_1", reason: "stop" } },
  18. )
  19. })
  20. test("decodes typed backend notifications", () => {
  21. expect(
  22. Backend.decodeNotification({
  23. jsonrpc: "2.0",
  24. method: "tool.cancel",
  25. params: { id: "tool_1", reason: "interrupted" },
  26. }),
  27. ).toEqual({
  28. jsonrpc: "2.0",
  29. method: "tool.cancel",
  30. params: { id: "tool_1", reason: "interrupted" },
  31. })
  32. expect(() =>
  33. Backend.decodeNotification({
  34. jsonrpc: "2.0",
  35. method: "tool.cancel",
  36. params: { id: "tool_1", reason: "unknown" },
  37. }),
  38. ).toThrow()
  39. })
  40. test("requires exactly one JSON-RPC response outcome", () => {
  41. const decode = Schema.decodeUnknownSync(JsonRpc.Response)
  42. expect(decode({ jsonrpc: "2.0", id: 1, result: null })).toEqual({ jsonrpc: "2.0", id: 1, result: null })
  43. expect(decode({ jsonrpc: "2.0", id: 1, error: { code: -32600, message: "Invalid request" } })).toEqual({
  44. jsonrpc: "2.0",
  45. id: 1,
  46. error: { code: -32600, message: "Invalid request" },
  47. })
  48. expect(() => decode({ jsonrpc: "2.0", id: 1 })).toThrow()
  49. expect(() =>
  50. decode({
  51. jsonrpc: "2.0",
  52. id: 1,
  53. result: null,
  54. error: { code: -32600, message: "Invalid request" },
  55. }),
  56. ).toThrow()
  57. })
  58. test("decodes ui.matches text params", () => {
  59. expect(
  60. Frontend.decodeRequest({
  61. jsonrpc: "2.0",
  62. id: 1,
  63. method: "ui.matches",
  64. params: { text: "OpenCode [ready].*" },
  65. }),
  66. ).toMatchObject({ method: "ui.matches", params: { text: "OpenCode [ready].*" } })
  67. expect(() =>
  68. Frontend.decodeRequest({
  69. jsonrpc: "2.0",
  70. id: 1,
  71. method: "ui.matches",
  72. params: { pattern: "OpenCode.*" },
  73. }),
  74. ).toThrow()
  75. })
  76. test("decodes semantic click identity", () => {
  77. expect(
  78. Frontend.decodeRequest({
  79. jsonrpc: "2.0",
  80. id: 1,
  81. method: "ui.click",
  82. params: {
  83. target: 12,
  84. x: 4,
  85. y: 0,
  86. semantic: {
  87. id: "session.permission.action.once",
  88. instance: "permission-1",
  89. element: 12,
  90. },
  91. },
  92. }),
  93. ).toMatchObject({
  94. method: "ui.click",
  95. params: {
  96. semantic: {
  97. id: "session.permission.action.once",
  98. instance: "permission-1",
  99. element: 12,
  100. },
  101. },
  102. })
  103. })
  104. test("decodes semantic UI snapshots", () => {
  105. expect(
  106. Frontend.decodeRequest({
  107. jsonrpc: "2.0",
  108. id: 1,
  109. method: "ui.snapshot",
  110. }),
  111. ).toMatchObject({ method: "ui.snapshot" })
  112. const decode = Schema.decodeUnknownSync(Frontend.SemanticSnapshot)
  113. expect(
  114. decode({
  115. format: "opencode-ui-snapshot-v1",
  116. nodes: [
  117. {
  118. id: "session.permission",
  119. role: "dialog",
  120. label: "Permission required",
  121. element: 1,
  122. expanded: false,
  123. },
  124. ],
  125. }),
  126. ).toMatchObject({ nodes: [{ role: "dialog", expanded: false }] })
  127. expect(() =>
  128. decode({
  129. format: "opencode-ui-snapshot-v1",
  130. nodes: [{ id: "", role: "dialog", element: 0 }],
  131. }),
  132. ).toThrow()
  133. for (const nodes of [
  134. [
  135. { id: "duplicate", role: "dialog", element: 1 },
  136. { id: "duplicate", role: "option", element: 2 },
  137. ],
  138. [
  139. { id: "first", role: "dialog", element: 1 },
  140. { id: "second", role: "option", element: 1 },
  141. ],
  142. [{ id: "orphan", parent: "missing", role: "option", element: 1 }],
  143. [
  144. { id: "first", parent: "second", role: "dialog", element: 1 },
  145. { id: "second", parent: "first", role: "option", element: 2 },
  146. ],
  147. ])
  148. expect(() => decode({ format: "opencode-ui-snapshot-v1", nodes })).toThrow()
  149. })
  150. test("decodes the simulated tool lifecycle", () => {
  151. const registration = {
  152. name: "lookup",
  153. description: "Look up a value",
  154. inputSchema: {
  155. type: "object",
  156. properties: { query: { type: "string" } },
  157. required: ["query"],
  158. additionalProperties: false,
  159. },
  160. outputSchema: { type: "object" },
  161. permission: "lookup",
  162. options: { codemode: false },
  163. }
  164. expect(
  165. Backend.decodeRequest({
  166. jsonrpc: "2.0",
  167. id: 1,
  168. method: "tool.attach",
  169. params: { tools: [registration] },
  170. }),
  171. ).toMatchObject({ method: "tool.attach", params: { tools: [registration] } })
  172. expect(
  173. Backend.decodeRequest({
  174. jsonrpc: "2.0",
  175. id: 2,
  176. method: "tool.update",
  177. params: {
  178. id: "tool_1",
  179. sequence: 0,
  180. update: { phase: "searching" },
  181. },
  182. }),
  183. ).toMatchObject({ method: "tool.update" })
  184. expect(
  185. Backend.decodeRequest({
  186. jsonrpc: "2.0",
  187. id: 3,
  188. method: "tool.finish",
  189. params: {
  190. id: "tool_1",
  191. output: { structured: { answer: 42 }, content: [{ type: "text", text: "42" }] },
  192. },
  193. }),
  194. ).toMatchObject({ method: "tool.finish" })
  195. expect(
  196. Backend.decodeRequest({
  197. jsonrpc: "2.0",
  198. id: 4,
  199. method: "tool.fail",
  200. params: { id: "tool_2", message: "lookup failed" },
  201. }),
  202. ).toMatchObject({ method: "tool.fail" })
  203. expect(() =>
  204. Backend.decodeRequest({
  205. jsonrpc: "2.0",
  206. id: 5,
  207. method: "tool.attach",
  208. params: { tools: [registration, registration] },
  209. }),
  210. ).toThrow()
  211. for (const invalid of [
  212. { ...registration, name: "1lookup" },
  213. { ...registration, options: { namespace: "bad group", codemode: false } },
  214. { ...registration, name: "execute", options: { codemode: false } },
  215. { ...registration, options: { namespace: "a".repeat(64), codemode: false } },
  216. ])
  217. expect(() =>
  218. Backend.decodeRequest({
  219. jsonrpc: "2.0",
  220. id: 6,
  221. method: "tool.attach",
  222. params: { tools: [invalid] },
  223. }),
  224. ).toThrow()
  225. expect(() =>
  226. Backend.decodeRequest({
  227. jsonrpc: "2.0",
  228. id: 7,
  229. method: "tool.attach",
  230. params: {
  231. tools: [
  232. { ...registration, name: "b_c", options: { namespace: "a", codemode: false } },
  233. { ...registration, name: "c", options: { namespace: "a.b", codemode: false } },
  234. ],
  235. },
  236. }),
  237. ).toThrow()
  238. })
  239. const params: Handshake.Params = {
  240. client: { name: "opencode-drive", version: "test" },
  241. expectedRole: "ui",
  242. offeredVersions: [1],
  243. requiredCapabilities: ["ui.state"],
  244. optionalCapabilities: ["ui.capture", "future.capability"],
  245. }
  246. const ui: Handshake.DispatchAction = {
  247. role: "ui",
  248. server: { name: "opencode", version: "test" },
  249. capabilities: Frontend.Capabilities,
  250. }
  251. describe("simulation.handshake", () => {
  252. test("decodes through both endpoint request protocols", () => {
  253. const request = {
  254. jsonrpc: "2.0" as const,
  255. id: 1,
  256. method: "simulation.handshake" as const,
  257. params,
  258. }
  259. expect(Frontend.decodeRequest(request)).toEqual(request)
  260. expect(Backend.decodeRequest({ ...request, params: { ...params, expectedRole: "backend" } })).toMatchObject({
  261. method: "simulation.handshake",
  262. params: { expectedRole: "backend" },
  263. })
  264. })
  265. test("rejects invalid version and capability declarations", () => {
  266. const request = {
  267. jsonrpc: "2.0" as const,
  268. id: 1,
  269. method: "simulation.handshake" as const,
  270. params,
  271. }
  272. expect(() =>
  273. Frontend.decodeRequest({
  274. ...request,
  275. params: { ...params, offeredVersions: [] },
  276. }),
  277. ).toThrow()
  278. expect(() =>
  279. Frontend.decodeRequest({
  280. ...request,
  281. params: { ...params, requiredCapabilities: ["ui.state", "ui.state"] },
  282. }),
  283. ).toThrow()
  284. expect(() =>
  285. Frontend.decodeRequest({
  286. ...request,
  287. params: { ...params, optionalCapabilities: [""] },
  288. }),
  289. ).toThrow()
  290. })
  291. test("selects the protocol and advertises only installed capabilities", async () => {
  292. await expect(Effect.runPromise(Handshake.dispatch(ui, params))).resolves.toEqual({
  293. protocolVersion: 1,
  294. role: "ui",
  295. server: { name: "opencode", version: "test" },
  296. capabilities: [...Frontend.Capabilities],
  297. })
  298. })
  299. test("rejects a role mismatch", async () => {
  300. await expect(
  301. Effect.runPromise(Handshake.dispatch(ui, { ...params, expectedRole: "backend" })),
  302. ).rejects.toMatchObject({ _tag: "SimulationHandshake.RoleMismatchError", expected: "backend", actual: "ui" })
  303. })
  304. test("rejects unsupported protocol versions", async () => {
  305. await expect(Effect.runPromise(Handshake.dispatch(ui, { ...params, offeredVersions: [2] }))).rejects.toMatchObject({
  306. _tag: "SimulationHandshake.UnsupportedProtocolError",
  307. offered: [2],
  308. supported: [1],
  309. })
  310. })
  311. test("rejects a missing required capability but ignores missing optional capabilities", async () => {
  312. await expect(
  313. Effect.runPromise(
  314. Handshake.dispatch(ui, {
  315. ...params,
  316. requiredCapabilities: ["ui.state", "ui.future"],
  317. }),
  318. ),
  319. ).rejects.toMatchObject({ _tag: "SimulationHandshake.MissingCapabilityError", missing: ["ui.future"] })
  320. await expect(
  321. Effect.runPromise(
  322. Handshake.dispatch(ui, {
  323. ...params,
  324. requiredCapabilities: [],
  325. optionalCapabilities: ["ui.future"],
  326. }),
  327. ),
  328. ).resolves.toMatchObject({ capabilities: Frontend.Capabilities })
  329. })
  330. })