tool-question.test.ts 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254
  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, settleTool, 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(yield* toolDefinitions(registry, [{ action: "question", resource: "*", effect: "deny" }])).toEqual([])
  97. expect(
  98. yield* settleTool(registry, {
  99. sessionID,
  100. ...toolIdentity,
  101. call: { type: "tool-call", id: "call-question-denied", name: "question", input: questionInput },
  102. }),
  103. ).toEqual({
  104. result: { type: "error", value: "Permission denied: question" },
  105. error: {
  106. type: "permission.rejected",
  107. message: "Permission denied: question",
  108. },
  109. })
  110. expect(capturedInput()).toBeUndefined()
  111. deny = false
  112. }),
  113. )
  114. it.effect("registers question and projects user answers without a permission assertion", () =>
  115. Effect.gen(function* () {
  116. assertions.length = 0
  117. captured = undefined
  118. reject = false
  119. deny = false
  120. const registry = yield* ToolRegistry.Service
  121. const questions = [
  122. {
  123. question: "What should happen?",
  124. header: "Action",
  125. options: [{ label: "Build", description: "Build it" }],
  126. },
  127. {
  128. question: "Which environment?",
  129. header: "Environment",
  130. options: [{ label: "Dev", description: "Development" }],
  131. multiple: true,
  132. },
  133. {
  134. question: "Anything else?",
  135. header: "Optional",
  136. options: [],
  137. },
  138. ]
  139. expect((yield* toolDefinitions(registry)).map((definition) => definition.name)).toEqual(["question"])
  140. expect(
  141. yield* settleTool(registry, {
  142. sessionID,
  143. ...toolIdentity,
  144. call: { type: "tool-call", id: "call-question", name: "question", input: { questions } },
  145. }),
  146. ).toEqual({
  147. result: {
  148. type: "text",
  149. value:
  150. '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.',
  151. },
  152. output: {
  153. structured: { answers: [["Build"], ["Dev"], []] },
  154. content: [
  155. {
  156. type: "text",
  157. 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.',
  158. },
  159. ],
  160. },
  161. })
  162. expect(assertions).toMatchObject([{ sessionID, action: "question", resources: ["*"] }])
  163. expect(capturedInput()).toEqual({
  164. sessionID,
  165. title: "Questions",
  166. metadata: { kind: "question", tool: { messageID: toolIdentity.messageID, callID: "call-question" } },
  167. fields: [
  168. {
  169. key: "q0",
  170. title: "Action",
  171. description: "What should happen?",
  172. options: [{ value: "Build", label: "Build", description: "Build it" }],
  173. custom: true,
  174. type: "string",
  175. },
  176. {
  177. key: "q1",
  178. title: "Environment",
  179. description: "Which environment?",
  180. options: [{ value: "Dev", label: "Dev", description: "Development" }],
  181. custom: true,
  182. type: "multiselect",
  183. },
  184. {
  185. key: "q2",
  186. title: "Optional",
  187. description: "Anything else?",
  188. options: [],
  189. custom: true,
  190. type: "string",
  191. },
  192. ],
  193. })
  194. }),
  195. )
  196. it.effect("does not invent tool ownership metadata without a durable registry source", () =>
  197. Effect.gen(function* () {
  198. captured = undefined
  199. reject = false
  200. deny = false
  201. const registryService = yield* ToolRegistry.Service
  202. yield* executeTool(registryService, {
  203. sessionID,
  204. ...toolIdentity,
  205. call: { type: "tool-call", id: "call-question", name: "question", input: questionInput },
  206. })
  207. expect(capturedInput()).toEqual({
  208. sessionID,
  209. title: "Questions",
  210. metadata: { kind: "question", tool: { messageID: toolIdentity.messageID, callID: "call-question" } },
  211. fields: [
  212. {
  213. key: "q0",
  214. title: "Continue",
  215. description: "Continue?",
  216. options: [{ value: "Yes", label: "Yes", description: "Continue" }],
  217. custom: true,
  218. type: "string",
  219. },
  220. ],
  221. })
  222. }),
  223. )
  224. it.effect("keeps dismissed questions out of model-facing output", () =>
  225. Effect.gen(function* () {
  226. captured = undefined
  227. reject = true
  228. deny = false
  229. const registryService = yield* ToolRegistry.Service
  230. const fiber = yield* executeTool(registryService, {
  231. sessionID,
  232. ...toolIdentity,
  233. call: { type: "tool-call", id: "call-question", name: "question", input: questionInput },
  234. }).pipe(Effect.forkScoped)
  235. const exit = yield* Fiber.await(fiber)
  236. expect(Exit.isFailure(exit)).toBe(true)
  237. if (Exit.isFailure(exit)) {
  238. const error = Cause.squash(exit.cause)
  239. expect(error).toBeInstanceOf(QuestionTool.CancelledError)
  240. expect(error).toHaveProperty("message", "The user dismissed this question")
  241. }
  242. }),
  243. )
  244. })