tool-question.test.ts 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162
  1. import { describe, expect } from "bun:test"
  2. import { 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 { PermissionV2 } from "@opencode-ai/core/permission"
  6. import { QuestionV2 } from "@opencode-ai/core/question"
  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 { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
  13. const sessionID = SessionV2.ID.make("ses_question_tool_test")
  14. const assertions: PermissionV2.AssertInput[] = []
  15. let captured: QuestionV2.AskInput | undefined
  16. let reject = false
  17. let deny = false
  18. const capturedInput = () => captured
  19. const permission = Layer.succeed(
  20. PermissionV2.Service,
  21. PermissionV2.Service.of({
  22. assert: (input) =>
  23. Effect.sync(() => assertions.push(input)).pipe(
  24. Effect.andThen(deny ? Effect.fail(new PermissionV2.BlockedError({ rules: [] })) : Effect.void),
  25. ),
  26. ask: () => Effect.die("unused"),
  27. reply: () => Effect.die("unused"),
  28. get: () => Effect.die("unused"),
  29. forSession: () => Effect.die("unused"),
  30. list: () => Effect.die("unused"),
  31. }),
  32. )
  33. const question = Layer.succeed(
  34. QuestionV2.Service,
  35. QuestionV2.Service.of({
  36. ask: (input: QuestionV2.AskInput) =>
  37. Effect.sync(() => {
  38. captured = input
  39. }).pipe(Effect.andThen(reject ? Effect.fail(new QuestionV2.RejectedError()) : Effect.succeed([["Build"], []]))),
  40. reply: () => Effect.die("unused"),
  41. reject: () => Effect.die("unused"),
  42. list: () => Effect.die("unused"),
  43. }),
  44. )
  45. const it = testEffect(
  46. AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, QuestionTool.node]), [
  47. [PermissionV2.node, permission],
  48. [QuestionV2.node, question],
  49. [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
  50. ]),
  51. )
  52. describe("QuestionTool", () => {
  53. it.effect("omits a denied built-in question and terminally settles a stale call", () =>
  54. Effect.gen(function* () {
  55. captured = undefined
  56. deny = true
  57. const registry = yield* ToolRegistry.Service
  58. expect(yield* toolDefinitions(registry, [{ action: "question", resource: "*", effect: "deny" }])).toEqual([])
  59. expect(
  60. yield* settleTool(registry, {
  61. sessionID,
  62. ...toolIdentity,
  63. call: { type: "tool-call", id: "call-question-denied", name: "question", input: { questions: [] } },
  64. }),
  65. ).toEqual({ result: { type: "error", value: "Permission denied: question" } })
  66. expect(capturedInput()).toBeUndefined()
  67. deny = false
  68. }),
  69. )
  70. it.effect("registers question and projects user answers without a permission assertion", () =>
  71. Effect.gen(function* () {
  72. assertions.length = 0
  73. captured = undefined
  74. reject = false
  75. deny = false
  76. const registry = yield* ToolRegistry.Service
  77. const questions = [
  78. {
  79. question: "What should happen?",
  80. header: "Action",
  81. options: [{ label: "Build", description: "Build it" }],
  82. },
  83. {
  84. question: "Which environment?",
  85. header: "Environment",
  86. options: [{ label: "Dev", description: "Development" }],
  87. },
  88. ]
  89. expect((yield* toolDefinitions(registry)).map((definition) => definition.name)).toEqual(["question"])
  90. expect(
  91. yield* settleTool(registry, {
  92. sessionID,
  93. ...toolIdentity,
  94. call: { type: "tool-call", id: "call-question", name: "question", input: { questions } },
  95. }),
  96. ).toEqual({
  97. result: {
  98. type: "text",
  99. value:
  100. 'User has answered your questions: "What should happen?"="Build", "Which environment?"="Unanswered". You can now continue with the user\'s answers in mind.',
  101. },
  102. output: {
  103. structured: { answers: [["Build"], []] },
  104. content: [
  105. {
  106. type: "text",
  107. text: 'User has answered your questions: "What should happen?"="Build", "Which environment?"="Unanswered". You can now continue with the user\'s answers in mind.',
  108. },
  109. ],
  110. },
  111. })
  112. expect(assertions).toMatchObject([{ sessionID, action: "question", resources: ["*"] }])
  113. expect(capturedInput()).toEqual({
  114. sessionID,
  115. questions,
  116. tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" },
  117. })
  118. }),
  119. )
  120. it.effect("does not invent tool ownership metadata without a durable registry source", () =>
  121. Effect.gen(function* () {
  122. captured = undefined
  123. reject = false
  124. deny = false
  125. const registryService = yield* ToolRegistry.Service
  126. yield* executeTool(registryService, {
  127. sessionID,
  128. ...toolIdentity,
  129. call: { type: "tool-call", id: "call-question", name: "question", input: { questions: [] } },
  130. })
  131. expect(capturedInput()).toEqual({
  132. sessionID,
  133. questions: [],
  134. tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" },
  135. })
  136. }),
  137. )
  138. it.effect("keeps dismissed questions out of model-facing output", () =>
  139. Effect.gen(function* () {
  140. captured = undefined
  141. reject = true
  142. deny = false
  143. const registryService = yield* ToolRegistry.Service
  144. const fiber = yield* executeTool(registryService, {
  145. sessionID,
  146. ...toolIdentity,
  147. call: { type: "tool-call", id: "call-question", name: "question", input: { questions: [] } },
  148. }).pipe(Effect.forkScoped)
  149. const exit = yield* Fiber.await(fiber)
  150. expect(Exit.isFailure(exit)).toBe(true)
  151. }),
  152. )
  153. })