form.test.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346
  1. import { describe, expect } from "bun:test"
  2. import { Deferred, Effect, Exit, Fiber } from "effect"
  3. import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
  4. import { LayerNode } from "@opencode-ai/util/effect/layer-node"
  5. import { Bus } from "@opencode-ai/core/bus"
  6. import { Form } from "@opencode-ai/core/form"
  7. import { SessionSchema } from "@opencode-ai/core/session/schema"
  8. import { testEffect } from "./lib/effect"
  9. const forms = AppNodeBuilder.build(LayerNode.group([Bus.node, Form.node]))
  10. const it = testEffect(forms)
  11. const formID = Form.ID.create("frm_test")
  12. const input = {
  13. id: formID,
  14. sessionID: SessionSchema.ID.make("ses_test"),
  15. title: "Test form",
  16. fields: [{ key: "name", type: "string", required: true }],
  17. } satisfies Form.CreateInput
  18. describe("Form", () => {
  19. it.effect("returns a terminal cancelled state from ask", () =>
  20. Effect.gen(function* () {
  21. const service = yield* Form.Service
  22. const bus = yield* Bus.Service
  23. const created = yield* Deferred.make<Form.Info>()
  24. const unsubscribe = yield* bus.listen((event) =>
  25. event.type === Form.Event.Created.type
  26. ? Deferred.succeed(created, (event.data as { readonly form: Form.Info }).form).pipe(Effect.asVoid)
  27. : Effect.void,
  28. )
  29. yield* Effect.addFinalizer(() => unsubscribe)
  30. const fiber = yield* service.ask(input).pipe(Effect.forkScoped)
  31. const form = yield* Deferred.await(created)
  32. yield* service.cancel(form.id)
  33. expect(yield* Fiber.join(fiber)).toEqual({ status: "cancelled" })
  34. expect(yield* service.state(form.id)).toEqual({ status: "cancelled" })
  35. }),
  36. )
  37. it.effect("supports the temporary global mcp elicitation owner", () =>
  38. Effect.gen(function* () {
  39. const service = yield* Form.Service
  40. const created = yield* service.create({
  41. sessionID: "global",
  42. title: "MCP input",
  43. fields: [{ key: "name", type: "string", required: true }],
  44. })
  45. expect(created.sessionID).toBe("global")
  46. expect(created.title).toBe("MCP input")
  47. const owned = yield* service.list({ sessionID: "global" })
  48. expect(owned.map((form) => form.id)).toEqual([created.id])
  49. expect(yield* service.list({ sessionID: "other" })).toEqual([])
  50. yield* service.reply({ id: created.id, answer: { name: "Ava" } })
  51. expect(yield* service.state(created.id)).toEqual({ status: "answered", answer: { name: "Ava" } })
  52. const externalOnly = yield* service.create({
  53. sessionID: "global",
  54. title: "External setup",
  55. fields: [{ key: "setup", type: "external", url: "https://example.com/setup" }],
  56. })
  57. yield* service.reply({ id: externalOnly.id, answer: { setup: true } })
  58. expect(yield* service.state(externalOnly.id)).toEqual({ status: "answered", answer: { setup: true } })
  59. }),
  60. )
  61. it.effect("gates required fields and rejects inactive answers via when", () =>
  62. Effect.gen(function* () {
  63. const service = yield* Form.Service
  64. const created = yield* service.create({
  65. sessionID: "global",
  66. title: "Conditional form",
  67. fields: [
  68. { key: "confirm", type: "boolean", required: true },
  69. { key: "reason", type: "string", required: true, when: [{ key: "confirm", op: "eq", value: false }] },
  70. ],
  71. })
  72. const inactive = yield* service
  73. .reply({ id: created.id, answer: { confirm: true, reason: "x" } })
  74. .pipe(Effect.flip)
  75. expect(inactive).toEqual(
  76. new Form.InvalidAnswerError({ id: created.id, message: "Form field is not active: reason" }),
  77. )
  78. const missing = yield* service.reply({ id: created.id, answer: { confirm: false } }).pipe(Effect.flip)
  79. expect(missing).toEqual(
  80. new Form.InvalidAnswerError({ id: created.id, message: "Missing required form field: reason" }),
  81. )
  82. yield* service.reply({ id: created.id, answer: { confirm: false, reason: "not ready" } })
  83. expect(yield* service.state(created.id)).toEqual({
  84. status: "answered",
  85. answer: { confirm: false, reason: "not ready" },
  86. })
  87. }),
  88. )
  89. it.effect("evaluates when against multiselect answers as inclusion", () =>
  90. Effect.gen(function* () {
  91. const service = yield* Form.Service
  92. const options = [
  93. { value: "go", label: "Go" },
  94. { value: "ts", label: "TypeScript" },
  95. ]
  96. const created = yield* service.create({
  97. sessionID: "global",
  98. title: "Multiselect form",
  99. fields: [
  100. { key: "langs", type: "multiselect", options },
  101. { key: "goVersion", type: "string", required: true, when: [{ key: "langs", op: "eq", value: "go" }] },
  102. ],
  103. })
  104. const missing = yield* service.reply({ id: created.id, answer: { langs: ["go", "ts"] } }).pipe(Effect.flip)
  105. expect(missing).toEqual(
  106. new Form.InvalidAnswerError({ id: created.id, message: "Missing required form field: goVersion" }),
  107. )
  108. yield* service.reply({ id: created.id, answer: { langs: ["ts"] } })
  109. expect(yield* service.state(created.id)).toEqual({ status: "answered", answer: { langs: ["ts"] } })
  110. }),
  111. )
  112. it.effect("requires every when condition to match and treats empty when as active", () =>
  113. Effect.gen(function* () {
  114. const service = yield* Form.Service
  115. const created = yield* service.create({
  116. sessionID: "global",
  117. title: "Dependent form",
  118. fields: [
  119. { key: "a", type: "boolean" },
  120. { key: "b", type: "boolean" },
  121. {
  122. key: "x",
  123. type: "string",
  124. required: true,
  125. when: [
  126. { key: "a", op: "eq", value: true },
  127. { key: "b", op: "eq", value: true },
  128. ],
  129. },
  130. { key: "z", type: "string", required: true, when: [] },
  131. ],
  132. })
  133. const missingX = yield* service.reply({ id: created.id, answer: { a: true, b: true, z: "ok" } }).pipe(Effect.flip)
  134. expect(missingX).toEqual(
  135. new Form.InvalidAnswerError({ id: created.id, message: "Missing required form field: x" }),
  136. )
  137. const inactiveX = yield* service
  138. .reply({ id: created.id, answer: { a: true, b: false, x: "nope", z: "ok" } })
  139. .pipe(Effect.flip)
  140. expect(inactiveX).toEqual(new Form.InvalidAnswerError({ id: created.id, message: "Form field is not active: x" }))
  141. const missingZ = yield* service.reply({ id: created.id, answer: { a: true, b: false } }).pipe(Effect.flip)
  142. expect(missingZ).toEqual(
  143. new Form.InvalidAnswerError({ id: created.id, message: "Missing required form field: z" }),
  144. )
  145. yield* service.reply({ id: created.id, answer: { a: true, b: false, z: "ok" } })
  146. expect(yield* service.state(created.id)).toEqual({ status: "answered", answer: { a: true, b: false, z: "ok" } })
  147. }),
  148. )
  149. it.effect("evaluates neq against multiselect answers as non-inclusion", () =>
  150. Effect.gen(function* () {
  151. const service = yield* Form.Service
  152. const options = [
  153. { value: "go", label: "Go" },
  154. { value: "ts", label: "TypeScript" },
  155. ]
  156. const created = yield* service.create({
  157. sessionID: "global",
  158. title: "Selection form",
  159. fields: [
  160. { key: "langs", type: "multiselect", options },
  161. { key: "note", type: "string", required: true, when: [{ key: "langs", op: "neq", value: "go" }] },
  162. ],
  163. })
  164. const missing = yield* service.reply({ id: created.id, answer: { langs: ["ts"] } }).pipe(Effect.flip)
  165. expect(missing).toEqual(
  166. new Form.InvalidAnswerError({ id: created.id, message: "Missing required form field: note" }),
  167. )
  168. // an answered-but-empty multiselect also satisfies neq
  169. const missingEmpty = yield* service.reply({ id: created.id, answer: { langs: [] } }).pipe(Effect.flip)
  170. expect(missingEmpty).toEqual(
  171. new Form.InvalidAnswerError({ id: created.id, message: "Missing required form field: note" }),
  172. )
  173. const inactive = yield* service.reply({ id: created.id, answer: { langs: ["go"], note: "x" } }).pipe(Effect.flip)
  174. expect(inactive).toEqual(
  175. new Form.InvalidAnswerError({ id: created.id, message: "Form field is not active: note" }),
  176. )
  177. yield* service.reply({ id: created.id, answer: { langs: ["go"] } })
  178. expect(yield* service.state(created.id)).toEqual({ status: "answered", answer: { langs: ["go"] } })
  179. }),
  180. )
  181. it.effect("treats unanswered when references as false and cascades inactivity", () =>
  182. Effect.gen(function* () {
  183. const service = yield* Form.Service
  184. const created = yield* service.create({
  185. sessionID: "global",
  186. title: "Cascading form",
  187. fields: [
  188. { key: "a", type: "boolean" },
  189. { key: "b", type: "string", when: [{ key: "a", op: "eq", value: true }] },
  190. // neq also fails against an unanswered reference, and hiding b cascades here through
  191. // the reject-inactive-answers rule: b can never be answered while a is false.
  192. { key: "c", type: "string", required: true, when: [{ key: "b", op: "neq", value: "x" }] },
  193. ],
  194. })
  195. const inactive = yield* service.reply({ id: created.id, answer: { a: false, b: "yes" } }).pipe(Effect.flip)
  196. expect(inactive).toEqual(new Form.InvalidAnswerError({ id: created.id, message: "Form field is not active: b" }))
  197. yield* service.reply({ id: created.id, answer: { a: false } })
  198. expect(yield* service.state(created.id)).toEqual({ status: "answered", answer: { a: false } })
  199. }),
  200. )
  201. it.effect("rejects invalid when definitions at creation", () =>
  202. Effect.gen(function* () {
  203. const service = yield* Form.Service
  204. const flipCreate = (fields: Form.CreateInput["fields"]) =>
  205. service.create({ sessionID: "global", title: "Invalid form", fields }).pipe(Effect.flip)
  206. expect(
  207. yield* flipCreate([{ key: "b", type: "string", when: [{ key: "missing", op: "eq", value: "x" }] }]),
  208. ).toEqual(
  209. new Form.InvalidFormError({ message: "Form field condition must reference an earlier field: b -> missing" }),
  210. )
  211. expect(
  212. yield* flipCreate([
  213. { key: "a", type: "string" },
  214. { key: "a", type: "string" },
  215. ]),
  216. ).toEqual(new Form.InvalidFormError({ message: "Duplicate form field key: a" }))
  217. expect(
  218. yield* flipCreate([
  219. { key: "a", type: "external", url: "https://example.com" },
  220. { key: "a", type: "string" },
  221. ]),
  222. ).toEqual(new Form.InvalidFormError({ message: "Duplicate form field key: a" }))
  223. expect(
  224. yield* flipCreate([
  225. { key: "a", type: "boolean" },
  226. { key: "b", type: "string", when: [{ key: "a", op: "eq", value: "yes" }] },
  227. ]),
  228. ).toEqual(new Form.InvalidFormError({ message: "Form field condition value must be a boolean: b -> a" }))
  229. expect(
  230. yield* flipCreate([
  231. { key: "a", type: "string", options: [{ value: "x", label: "X" }] },
  232. { key: "b", type: "string", when: [{ key: "a", op: "eq", value: "y" }] },
  233. ]),
  234. ).toEqual(
  235. new Form.InvalidFormError({
  236. message: "Form field condition value must be one of the field's options: b -> a",
  237. }),
  238. )
  239. }),
  240. )
  241. it.effect("requires external field acknowledgements", () =>
  242. Effect.gen(function* () {
  243. const service = yield* Form.Service
  244. const created = yield* service.create({
  245. sessionID: "global",
  246. title: "External setup",
  247. fields: [
  248. { key: "authorization", type: "external", url: "https://example.com/setup", title: "Open setup" },
  249. { key: "name", type: "string", required: true },
  250. ],
  251. })
  252. const invalidAnswers: ReadonlyArray<Form.Answer> = [
  253. { name: "Ava" },
  254. { authorization: false, name: "Ava" },
  255. { authorization: "yes", name: "Ava" },
  256. ]
  257. for (const answer of invalidAnswers) {
  258. expect(yield* service.reply({ id: created.id, answer }).pipe(Effect.flip)).toEqual(
  259. new Form.InvalidAnswerError({
  260. id: created.id,
  261. message: "External form field must be acknowledged: authorization",
  262. }),
  263. )
  264. }
  265. yield* service.reply({ id: created.id, answer: { authorization: true, name: "Ava" } })
  266. expect(yield* service.state(created.id)).toEqual({
  267. status: "answered",
  268. answer: { authorization: true, name: "Ava" },
  269. })
  270. }),
  271. )
  272. it.effect("cleans up created forms when event publication fails", () =>
  273. Effect.gen(function* () {
  274. const service = yield* Form.Service
  275. const bus = yield* Bus.Service
  276. const unsubscribe = yield* bus.listen((event) =>
  277. event.type === Form.Event.Created.type ? Effect.die("create listener failed") : Effect.void,
  278. )
  279. yield* Effect.addFinalizer(() => unsubscribe)
  280. expect(Exit.isFailure(yield* Effect.exit(service.create(input)))).toBe(true)
  281. expect(yield* service.get(formID).pipe(Effect.flip)).toEqual(new Form.NotFoundError({ id: formID }))
  282. yield* unsubscribe
  283. expect(yield* service.create(input)).toMatchObject({ id: formID })
  284. }),
  285. )
  286. it.effect("keeps forms pending when reply event publication fails", () =>
  287. Effect.gen(function* () {
  288. const service = yield* Form.Service
  289. const bus = yield* Bus.Service
  290. yield* service.create(input)
  291. const unsubscribe = yield* bus.listen((event) =>
  292. event.type === Form.Event.Replied.type ? Effect.die("reply listener failed") : Effect.void,
  293. )
  294. yield* Effect.addFinalizer(() => unsubscribe)
  295. expect(Exit.isFailure(yield* Effect.exit(service.reply({ id: formID, answer: { name: "Ava" } })))).toBe(true)
  296. expect(yield* service.state(formID)).toEqual({ status: "pending" })
  297. yield* unsubscribe
  298. yield* service.reply({ id: formID, answer: { name: "Ava" } })
  299. expect(yield* service.state(formID)).toEqual({ status: "answered", answer: { name: "Ava" } })
  300. }),
  301. )
  302. })