Something went wrong. Try again.
opencode v2 mirror
Something went wrong. Try again.
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264import { describe, expect } from "bun:test"import { Cause, Effect, Exit, Fiber, Layer } from "effect"import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"import { LayerNode } from "@opencode/util/effect/layer-node"import { Form } from "@opencode/core/form"import { Permission } from "@opencode/core/permission"import { Session } from "@opencode/core/session"import { Tool } from "@opencode/core/tool"import { QuestionTool } from "@opencode/core/tool/plugin/question"import { Image } from "@opencode/core/image"import { testEffect } from "./lib/effect"import { imagePassthrough } from "./lib/image"import { permissionLayer } from "./lib/permission"import { makeLocationNode } from "@opencode/util/effect/app-node"import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
const sessionID = Session.ID.make("ses_question_tool_test")const assertions: Permission.AssertInput[] = []let captured: Form.CreateInput | undefinedlet reject = falselet deny = falseconst capturedInput = () => capturedconst questionInput = { questions: [ { question: "Continue?", header: "Continue", options: [{ label: "Yes", description: "Continue" }], }, ],}const permission = permissionLayer({ assert: (input) => Effect.sync(() => assertions.push(input)).pipe( Effect.andThen( deny ? Effect.fail( new Permission.BlockedError({ rules: [], permission: input.action, resources: input.resources, }), ) : Effect.void, ), ),})const form = Layer.mock(Form.Service, { ask: (input: Form.CreateInput) => Effect.sync(() => { captured = input }).pipe( Effect.andThen( Effect.sync( (): Form.TerminalState => reject ? { status: "cancelled" } : { status: "answered", answer: { q0: "Build", q1: ["Dev"] } }, ), ), ),})const questionToolNode = makeLocationNode({ name: "test/question-tool-plugin", layer: Layer.effectDiscard(registerToolPlugin(QuestionTool.Plugin)), deps: [Tool.node, Permission.node, Form.node],})
const it = testEffect( AppNodeBuilder.build(LayerNode.group([Tool.node, questionToolNode]), [ Permission.node.replace(permission), Form.node.replace(form), Image.node.replace(imagePassthrough), ]),)
describe("QuestionTool", () => { it.effect("emits one item schema for the nonempty questions array", () => Effect.gen(function* () { captured = undefined const registry = yield* Tool.Service const definition = (yield* toolDefinitions(registry)).find((tool) => tool.name === QuestionTool.name)
expect(definition?.inputSchema).toHaveProperty("properties.questions.type", "array") expect(definition?.inputSchema).toHaveProperty("properties.questions.minItems", 1) expect(definition?.inputSchema).toHaveProperty("properties.questions.items") expect(definition?.inputSchema).not.toHaveProperty("properties.questions.prefixItems") expect( yield* executeTool(registry, { sessionID, ...toolIdentity, call: { type: "tool-call", id: "call-question-empty", name: QuestionTool.name, input: { questions: [] } }, }), ).toMatchObject({ status: "error", error: { type: "tool.execution", message: 'Invalid arguments for tool "question":\n- questions: Expected a value with a length of at least 1\n\nArguments provided:\n{\n "questions": []\n}\n\nUpdate the arguments and call the tool again.', }, }) expect(capturedInput()).toBeUndefined() }), )
it.effect("omits a catalog-denied question and enforces its leaf permission", () => Effect.gen(function* () { captured = undefined deny = true yield* Effect.addFinalizer(() => Effect.sync(() => (deny = false))) const registry = yield* Tool.Service
expect( (yield* toolDefinitions(registry, [{ action: "question", resource: "*", effect: "deny" }])).map( (tool) => tool.name, ), ).toEqual(["execute"]) expect( yield* executeTool(registry, { sessionID, ...toolIdentity, call: { type: "tool-call", id: "call-question-denied", name: "question", input: questionInput }, }), ).toEqual({ status: "error", error: { type: "permission.rejected", message: "Permission denied: question", }, }) expect(capturedInput()).toBeUndefined() }), )
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* Tool.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" }], multiple: true, }, { question: "Anything else?", header: "Optional", options: [], }, ]
expect((yield* toolDefinitions(registry)).map((definition) => definition.name)).toEqual(["question", "execute"]) expect( yield* executeTool(registry, { sessionID, ...toolIdentity, call: { type: "tool-call", id: "call-question", name: "question", input: { questions } }, }), ).toEqual({ status: "completed", output: { answers: [["Build"], ["Dev"], []] }, content: [ { type: "text", text: 'User has answered your questions: "What should happen?"="Build", "Which environment?"="Dev", "Anything else?"="Unanswered". You can now continue with the user\'s answers in mind.', }, ], metadata: { answers: [["Build"], ["Dev"], []] }, }) expect(assertions).toMatchObject([{ sessionID, action: "question", resources: ["*"] }]) expect(capturedInput()).toEqual({ sessionID, title: "Questions", metadata: { kind: "question", tool: { messageID: toolIdentity.messageID, id: "call-question" } }, fields: [ { key: "q0", title: "Action", description: "What should happen?", options: [{ value: "Build", label: "Build", description: "Build it" }], custom: true, type: "string", }, { key: "q1", title: "Environment", description: "Which environment?", options: [{ value: "Dev", label: "Dev", description: "Development" }], custom: true, type: "multiselect", }, { key: "q2", title: "Optional", description: "Anything else?", options: [], custom: true, type: "string", }, ], }) }), )
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* Tool.Service
yield* executeTool(registryService, { sessionID, ...toolIdentity, call: { type: "tool-call", id: "call-question", name: "question", input: questionInput }, }) expect(capturedInput()).toEqual({ sessionID, title: "Questions", metadata: { kind: "question", tool: { messageID: toolIdentity.messageID, id: "call-question" } }, fields: [ { key: "q0", title: "Continue", description: "Continue?", options: [{ value: "Yes", label: "Yes", description: "Continue" }], custom: true, type: "string", }, ], }) }), )
it.effect("keeps dismissed questions out of model-facing output", () => Effect.gen(function* () { captured = undefined reject = true deny = false const registryService = yield* Tool.Service const fiber = yield* executeTool(registryService, { sessionID, ...toolIdentity, call: { type: "tool-call", id: "call-question", name: "question", input: questionInput }, }).pipe(Effect.forkScoped)
const exit = yield* Fiber.await(fiber) expect(Exit.isFailure(exit)).toBe(true) if (Exit.isFailure(exit)) { const error = Cause.squash(exit.cause) expect(error).toBeInstanceOf(QuestionTool.CancelledError) expect(error).toHaveProperty("message", "The user dismissed this question") } }), )})