tool-question.test.ts 4.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119
  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. const capturedInput = () => captured
  14. const permission = Layer.succeed(
  15. PermissionV2.Service,
  16. PermissionV2.Service.of({
  17. assert: (input) => Effect.sync(() => assertions.push(input)),
  18. ask: () => Effect.die("unused"),
  19. reply: () => Effect.die("unused"),
  20. get: () => Effect.die("unused"),
  21. forSession: () => Effect.die("unused"),
  22. list: () => Effect.die("unused"),
  23. }),
  24. )
  25. const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
  26. const question = Layer.succeed(
  27. QuestionV2.Service,
  28. QuestionV2.Service.of({
  29. ask: (input: QuestionV2.AskInput) =>
  30. Effect.sync(() => {
  31. captured = input
  32. }).pipe(Effect.andThen(reject ? Effect.fail(new QuestionV2.RejectedError()) : Effect.succeed([["Build"], []]))),
  33. reply: () => Effect.die("unused"),
  34. reject: () => Effect.die("unused"),
  35. list: () => Effect.die("unused"),
  36. }),
  37. )
  38. const tool = QuestionTool.layer.pipe(Layer.provide(registry), Layer.provide(question))
  39. const it = testEffect(Layer.mergeAll(permission, registry, question, tool))
  40. describe("QuestionTool", () => {
  41. it.effect("registers question and projects user answers without a permission assertion", () =>
  42. Effect.gen(function* () {
  43. assertions.length = 0
  44. captured = undefined
  45. reject = false
  46. const registry = yield* ToolRegistry.Service
  47. const questions = [
  48. {
  49. question: "What should happen?",
  50. header: "Action",
  51. options: [{ label: "Build", description: "Build it" }],
  52. },
  53. {
  54. question: "Which environment?",
  55. header: "Environment",
  56. options: [{ label: "Dev", description: "Development" }],
  57. },
  58. ]
  59. expect((yield* registry.definitions()).map((definition) => definition.name)).toEqual(["question"])
  60. expect(
  61. yield* registry.settle({
  62. sessionID,
  63. call: { type: "tool-call", id: "call-question", name: "question", input: { questions } },
  64. }),
  65. ).toEqual({
  66. result: {
  67. type: "text",
  68. value:
  69. 'User has answered your questions: "What should happen?"="Build", "Which environment?"="Unanswered". You can now continue with the user\'s answers in mind.',
  70. },
  71. output: {
  72. structured: { answers: [["Build"], []] },
  73. content: [
  74. {
  75. type: "text",
  76. text: 'User has answered your questions: "What should happen?"="Build", "Which environment?"="Unanswered". You can now continue with the user\'s answers in mind.',
  77. },
  78. ],
  79. },
  80. })
  81. expect(assertions).toEqual([])
  82. expect(capturedInput()).toEqual({ sessionID, questions, tool: undefined })
  83. }),
  84. )
  85. it.effect("does not invent tool ownership metadata without a durable registry source", () =>
  86. Effect.gen(function* () {
  87. captured = undefined
  88. reject = false
  89. const registryService = yield* ToolRegistry.Service
  90. yield* registryService.execute({
  91. sessionID,
  92. call: { type: "tool-call", id: "call-question", name: "question", input: { questions: [] } },
  93. })
  94. expect(capturedInput()).toEqual({ sessionID, questions: [], tool: undefined })
  95. }),
  96. )
  97. it.effect("keeps dismissed questions out of model-facing output", () =>
  98. Effect.gen(function* () {
  99. captured = undefined
  100. reject = true
  101. const registryService = yield* ToolRegistry.Service
  102. const fiber = yield* registryService
  103. .execute({
  104. sessionID,
  105. call: { type: "tool-call", id: "call-question", name: "question", input: { questions: [] } },
  106. })
  107. .pipe(Effect.forkScoped)
  108. const exit = yield* Fiber.await(fiber)
  109. expect(Exit.isFailure(exit)).toBe(true)
  110. }),
  111. )
  112. })