tool-question.test.ts 7.9 KB

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