protocol.test.ts 7.9 KB

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