tool-question.test.ts 5.2 KB

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