tool-question.test.ts 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253
  1. import { describe, expect } from "bun:test"
  2. import { Cause, Effect, Exit, Fiber, Layer } 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 { Form } from "@opencode-ai/core/form"
  6. import { PermissionV2 } from "@opencode-ai/core/permission"
  7. import { SessionV2 } from "@opencode-ai/core/session"
  8. import { ToolRegistry } from "@opencode-ai/core/tool/registry"
  9. import { QuestionTool } from "@opencode-ai/core/tool/question"
  10. import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
  11. import { Image } from "@opencode-ai/core/image"
  12. import { testEffect } from "./lib/effect"
  13. import { imagePassthrough } from "./lib/image"
  14. import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
  15. import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
  16. const sessionID = SessionV2.ID.make("ses_question_tool_test")
  17. const assertions: PermissionV2.AssertInput[] = []
  18. let captured: Form.CreateInput | undefined
  19. let reject = false
  20. let deny = false
  21. const capturedInput = () => captured
  22. const questionInput = {
  23. questions: [
  24. {
  25. question: "Continue?",
  26. header: "Continue",
  27. options: [{ label: "Yes", description: "Continue" }],
  28. },
  29. ],
  30. }
  31. const permission = Layer.succeed(
  32. PermissionV2.Service,
  33. PermissionV2.Service.of({
  34. assert: (input) =>
  35. Effect.sync(() => assertions.push(input)).pipe(
  36. Effect.andThen(
  37. deny
  38. ? Effect.fail(
  39. new PermissionV2.BlockedError({
  40. rules: [],
  41. permission: input.action,
  42. resources: input.resources,
  43. }),
  44. )
  45. : Effect.void,
  46. ),
  47. ),
  48. ask: () => Effect.die("unused"),
  49. reply: () => Effect.die("unused"),
  50. get: () => Effect.die("unused"),
  51. forSession: () => Effect.die("unused"),
  52. list: () => Effect.die("unused"),
  53. }),
  54. )
  55. const form = Layer.succeed(
  56. Form.Service,
  57. Form.Service.of({
  58. ask: (input: Form.CreateInput) =>
  59. Effect.sync(() => {
  60. captured = input
  61. }).pipe(
  62. Effect.andThen(
  63. Effect.sync(
  64. (): Form.TerminalState =>
  65. reject ? { status: "cancelled" } : { status: "answered", answer: { q0: "Build", q1: ["Dev"] } },
  66. ),
  67. ),
  68. ),
  69. create: () => Effect.die("unused"),
  70. get: () => Effect.die("unused"),
  71. list: () => Effect.die("unused"),
  72. state: () => Effect.die("unused"),
  73. reply: () => Effect.die("unused"),
  74. cancel: () => Effect.die("unused"),
  75. }),
  76. )
  77. const questionToolNode = makeLocationNode({
  78. name: "test/question-tool-plugin",
  79. layer: Layer.effectDiscard(registerToolPlugin(QuestionTool.Plugin)),
  80. deps: [ToolRegistry.toolsNode, PermissionV2.node, Form.node],
  81. })
  82. const it = testEffect(
  83. AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, questionToolNode]), [
  84. [PermissionV2.node, permission],
  85. [Form.node, form],
  86. [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
  87. [Image.node, imagePassthrough],
  88. ]),
  89. )
  90. describe("QuestionTool", () => {
  91. it.effect("omits a catalog-denied question and enforces its leaf permission", () =>
  92. Effect.gen(function* () {
  93. captured = undefined
  94. deny = true
  95. const registry = yield* ToolRegistry.Service
  96. expect(
  97. (yield* toolDefinitions(registry, [{ action: "question", resource: "*", effect: "deny" }])).map(
  98. (tool) => tool.name,
  99. ),
  100. ).toEqual(["execute"])
  101. expect(
  102. yield* executeTool(registry, {
  103. sessionID,
  104. ...toolIdentity,
  105. call: { type: "tool-call", id: "call-question-denied", name: "question", input: questionInput },
  106. }),
  107. ).toEqual({
  108. status: "error",
  109. error: {
  110. type: "permission.rejected",
  111. message: "Permission denied: question",
  112. },
  113. })
  114. expect(capturedInput()).toBeUndefined()
  115. deny = false
  116. }),
  117. )
  118. it.effect("registers question and projects user answers without a permission assertion", () =>
  119. Effect.gen(function* () {
  120. assertions.length = 0
  121. captured = undefined
  122. reject = false
  123. deny = false
  124. const registry = yield* ToolRegistry.Service
  125. const questions = [
  126. {
  127. question: "What should happen?",
  128. header: "Action",
  129. options: [{ label: "Build", description: "Build it" }],
  130. },
  131. {
  132. question: "Which environment?",
  133. header: "Environment",
  134. options: [{ label: "Dev", description: "Development" }],
  135. multiple: true,
  136. },
  137. {
  138. question: "Anything else?",
  139. header: "Optional",
  140. options: [],
  141. },
  142. ]
  143. expect((yield* toolDefinitions(registry)).map((definition) => definition.name)).toEqual(["question", "execute"])
  144. expect(
  145. yield* executeTool(registry, {
  146. sessionID,
  147. ...toolIdentity,
  148. call: { type: "tool-call", id: "call-question", name: "question", input: { questions } },
  149. }),
  150. ).toEqual({
  151. status: "completed",
  152. output: { answers: [["Build"], ["Dev"], []] },
  153. content: [
  154. {
  155. type: "text",
  156. text: 'User has answered your questions: "What should happen?"="Build", "Which environment?"="Dev", "Anything else?"="Unanswered". You can now continue with the user\'s answers in mind.',
  157. },
  158. ],
  159. metadata: { answers: [["Build"], ["Dev"], []] },
  160. })
  161. expect(assertions).toMatchObject([{ sessionID, action: "question", resources: ["*"] }])
  162. expect(capturedInput()).toEqual({
  163. sessionID,
  164. title: "Questions",
  165. metadata: { kind: "question", tool: { messageID: toolIdentity.messageID, callID: "call-question" } },
  166. fields: [
  167. {
  168. key: "q0",
  169. title: "Action",
  170. description: "What should happen?",
  171. options: [{ value: "Build", label: "Build", description: "Build it" }],
  172. custom: true,
  173. type: "string",
  174. },
  175. {
  176. key: "q1",
  177. title: "Environment",
  178. description: "Which environment?",
  179. options: [{ value: "Dev", label: "Dev", description: "Development" }],
  180. custom: true,
  181. type: "multiselect",
  182. },
  183. {
  184. key: "q2",
  185. title: "Optional",
  186. description: "Anything else?",
  187. options: [],
  188. custom: true,
  189. type: "string",
  190. },
  191. ],
  192. })
  193. }),
  194. )
  195. it.effect("does not invent tool ownership metadata without a durable registry source", () =>
  196. Effect.gen(function* () {
  197. captured = undefined
  198. reject = false
  199. deny = false
  200. const registryService = yield* ToolRegistry.Service
  201. yield* executeTool(registryService, {
  202. sessionID,
  203. ...toolIdentity,
  204. call: { type: "tool-call", id: "call-question", name: "question", input: questionInput },
  205. })
  206. expect(capturedInput()).toEqual({
  207. sessionID,
  208. title: "Questions",
  209. metadata: { kind: "question", tool: { messageID: toolIdentity.messageID, callID: "call-question" } },
  210. fields: [
  211. {
  212. key: "q0",
  213. title: "Continue",
  214. description: "Continue?",
  215. options: [{ value: "Yes", label: "Yes", description: "Continue" }],
  216. custom: true,
  217. type: "string",
  218. },
  219. ],
  220. })
  221. }),
  222. )
  223. it.effect("keeps dismissed questions out of model-facing output", () =>
  224. Effect.gen(function* () {
  225. captured = undefined
  226. reject = true
  227. deny = false
  228. const registryService = yield* ToolRegistry.Service
  229. const fiber = yield* executeTool(registryService, {
  230. sessionID,
  231. ...toolIdentity,
  232. call: { type: "tool-call", id: "call-question", name: "question", input: questionInput },
  233. }).pipe(Effect.forkScoped)
  234. const exit = yield* Fiber.await(fiber)
  235. expect(Exit.isFailure(exit)).toBe(true)
  236. if (Exit.isFailure(exit)) {
  237. const error = Cause.squash(exit.cause)
  238. expect(error).toBeInstanceOf(QuestionTool.CancelledError)
  239. expect(error).toHaveProperty("message", "The user dismissed this question")
  240. }
  241. }),
  242. )
  243. })