| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162 |
- import { describe, expect } from "bun:test"
- import { Effect, Exit, Fiber, Layer } from "effect"
- import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
- import { LayerNode } from "@opencode-ai/core/effect/layer-node"
- import { PermissionV2 } from "@opencode-ai/core/permission"
- import { QuestionV2 } from "@opencode-ai/core/question"
- import { SessionV2 } from "@opencode-ai/core/session"
- import { ToolRegistry } from "@opencode-ai/core/tool/registry"
- import { QuestionTool } from "@opencode-ai/core/tool/question"
- import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
- import { testEffect } from "./lib/effect"
- import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
- const sessionID = SessionV2.ID.make("ses_question_tool_test")
- const assertions: PermissionV2.AssertInput[] = []
- let captured: QuestionV2.AskInput | undefined
- let reject = false
- let deny = false
- const capturedInput = () => captured
- const permission = Layer.succeed(
- PermissionV2.Service,
- PermissionV2.Service.of({
- assert: (input) =>
- Effect.sync(() => assertions.push(input)).pipe(
- Effect.andThen(deny ? Effect.fail(new PermissionV2.BlockedError({ rules: [] })) : Effect.void),
- ),
- ask: () => Effect.die("unused"),
- reply: () => Effect.die("unused"),
- get: () => Effect.die("unused"),
- forSession: () => Effect.die("unused"),
- list: () => Effect.die("unused"),
- }),
- )
- const question = Layer.succeed(
- QuestionV2.Service,
- QuestionV2.Service.of({
- ask: (input: QuestionV2.AskInput) =>
- Effect.sync(() => {
- captured = input
- }).pipe(Effect.andThen(reject ? Effect.fail(new QuestionV2.RejectedError()) : Effect.succeed([["Build"], []]))),
- reply: () => Effect.die("unused"),
- reject: () => Effect.die("unused"),
- list: () => Effect.die("unused"),
- }),
- )
- const it = testEffect(
- AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, QuestionTool.node]), [
- [PermissionV2.node, permission],
- [QuestionV2.node, question],
- [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
- ]),
- )
- describe("QuestionTool", () => {
- it.effect("omits a denied built-in question and terminally settles a stale call", () =>
- Effect.gen(function* () {
- captured = undefined
- deny = true
- const registry = yield* ToolRegistry.Service
- expect(yield* toolDefinitions(registry, [{ action: "question", resource: "*", effect: "deny" }])).toEqual([])
- expect(
- yield* settleTool(registry, {
- sessionID,
- ...toolIdentity,
- call: { type: "tool-call", id: "call-question-denied", name: "question", input: { questions: [] } },
- }),
- ).toEqual({ result: { type: "error", value: "Permission denied: question" } })
- expect(capturedInput()).toBeUndefined()
- deny = false
- }),
- )
- it.effect("registers question and projects user answers without a permission assertion", () =>
- Effect.gen(function* () {
- assertions.length = 0
- captured = undefined
- reject = false
- deny = false
- const registry = yield* ToolRegistry.Service
- const questions = [
- {
- question: "What should happen?",
- header: "Action",
- options: [{ label: "Build", description: "Build it" }],
- },
- {
- question: "Which environment?",
- header: "Environment",
- options: [{ label: "Dev", description: "Development" }],
- },
- ]
- expect((yield* toolDefinitions(registry)).map((definition) => definition.name)).toEqual(["question"])
- expect(
- yield* settleTool(registry, {
- sessionID,
- ...toolIdentity,
- call: { type: "tool-call", id: "call-question", name: "question", input: { questions } },
- }),
- ).toEqual({
- result: {
- type: "text",
- value:
- 'User has answered your questions: "What should happen?"="Build", "Which environment?"="Unanswered". You can now continue with the user\'s answers in mind.',
- },
- output: {
- structured: { answers: [["Build"], []] },
- content: [
- {
- type: "text",
- text: 'User has answered your questions: "What should happen?"="Build", "Which environment?"="Unanswered". You can now continue with the user\'s answers in mind.',
- },
- ],
- },
- })
- expect(assertions).toMatchObject([{ sessionID, action: "question", resources: ["*"] }])
- expect(capturedInput()).toEqual({
- sessionID,
- questions,
- tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" },
- })
- }),
- )
- it.effect("does not invent tool ownership metadata without a durable registry source", () =>
- Effect.gen(function* () {
- captured = undefined
- reject = false
- deny = false
- const registryService = yield* ToolRegistry.Service
- yield* executeTool(registryService, {
- sessionID,
- ...toolIdentity,
- call: { type: "tool-call", id: "call-question", name: "question", input: { questions: [] } },
- })
- expect(capturedInput()).toEqual({
- sessionID,
- questions: [],
- tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" },
- })
- }),
- )
- it.effect("keeps dismissed questions out of model-facing output", () =>
- Effect.gen(function* () {
- captured = undefined
- reject = true
- deny = false
- const registryService = yield* ToolRegistry.Service
- const fiber = yield* executeTool(registryService, {
- sessionID,
- ...toolIdentity,
- call: { type: "tool-call", id: "call-question", name: "question", input: { questions: [] } },
- }).pipe(Effect.forkScoped)
- const exit = yield* Fiber.await(fiber)
- expect(Exit.isFailure(exit)).toBe(true)
- }),
- )
- })
|