tool-question.test.ts 5.9 KB

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