2026-06-04 03:02:17 +00:00
import { describe , expect } from "bun:test"
2026-07-06 03:28:05 +00:00
import { Cause , Effect , Exit , Fiber , Layer } from "effect"
2026-07-01 21:12:00 +00:00
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
2026-07-21 14:34:29 +00:00
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
2026-07-06 03:28:05 +00:00
import { Form } from "@opencode-ai/core/form"
2026-07-27 00:08:55 +00:00
import { Permission } from "@opencode-ai/core/permission"
import { Session } from "@opencode-ai/core/session"
import { Tool } from "@opencode-ai/core/tool"
import { QuestionTool } from "@opencode-ai/core/tool/plugin/question"
2026-07-16 16:16:19 +00:00
import { Image } from "@opencode-ai/core/image"
2026-06-04 03:02:17 +00:00
import { testEffect } from "./lib/effect"
2026-07-16 16:16:19 +00:00
import { imagePassthrough } from "./lib/image"
2026-07-21 14:34:29 +00:00
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
2026-07-23 21:13:31 +00:00
import { toolIdentity , executeTool , registerToolPlugin , toolDefinitions } from "./lib/tool"
2026-06-04 03:02:17 +00:00
2026-07-27 00:08:55 +00:00
const sessionID = Session . ID . make ( "ses_question_tool_test" )
const assertions : Permission.AssertInput [ ] = [ ]
2026-07-06 03:28:05 +00:00
let captured : Form.CreateInput | undefined
2026-06-04 03:02:17 +00:00
let reject = false
2026-06-06 12:00:24 +00:00
let deny = false
2026-06-04 03:02:17 +00:00
const capturedInput = ( ) = > captured
2026-07-10 05:22:01 +00:00
const questionInput = {
questions : [
{
question : "Continue?" ,
header : "Continue" ,
options : [ { label : "Yes" , description : "Continue" } ] ,
} ,
] ,
}
2026-06-04 03:02:17 +00:00
const permission = Layer . succeed (
2026-07-27 00:08:55 +00:00
Permission . Service ,
Permission . Service . of ( {
2026-06-06 12:00:24 +00:00
assert : ( input ) = >
Effect . sync ( ( ) = > assertions . push ( input ) ) . pipe (
2026-07-06 22:14:06 +00:00
Effect . andThen (
deny
? Effect . fail (
2026-07-27 00:08:55 +00:00
new Permission . BlockedError ( {
2026-07-06 22:14:06 +00:00
rules : [ ] ,
permission : input.action ,
resources : input.resources ,
} ) ,
)
: Effect . void ,
) ,
2026-06-06 12:00:24 +00:00
) ,
2026-06-04 03:02:17 +00:00
ask : ( ) = > Effect . die ( "unused" ) ,
reply : ( ) = > Effect . die ( "unused" ) ,
get : ( ) = > Effect . die ( "unused" ) ,
forSession : ( ) = > Effect . die ( "unused" ) ,
list : ( ) = > Effect . die ( "unused" ) ,
} ) ,
)
2026-07-06 03:28:05 +00:00
const form = Layer . succeed (
Form . Service ,
Form . Service . of ( {
ask : ( input : Form.CreateInput ) = >
2026-07-03 05:32:10 +00:00
Effect . sync ( ( ) = > {
2026-06-04 03:02:17 +00:00
captured = input
2026-07-06 03:28:05 +00:00
} ) . pipe (
Effect . andThen (
Effect . sync (
( ) : Form . TerminalState = >
reject ? { status : "cancelled" } : { status : "answered" , answer : { q0 : "Build" , q1 : [ "Dev" ] } } ,
) ,
) ,
) ,
create : ( ) = > Effect . die ( "unused" ) ,
get : ( ) = > Effect . die ( "unused" ) ,
2026-07-03 05:32:10 +00:00
list : ( ) = > Effect . die ( "unused" ) ,
2026-07-06 03:28:05 +00:00
state : ( ) = > Effect . die ( "unused" ) ,
reply : ( ) = > Effect . die ( "unused" ) ,
cancel : ( ) = > Effect . die ( "unused" ) ,
2026-06-04 03:02:17 +00:00
} ) ,
)
2026-07-03 13:03:53 +00:00
const questionToolNode = makeLocationNode ( {
name : "test/question-tool-plugin" ,
layer : Layer.effectDiscard ( registerToolPlugin ( QuestionTool . Plugin ) ) ,
2026-07-27 00:08:55 +00:00
deps : [ Tool . node , Permission . node , Form . node ] ,
2026-07-03 13:03:53 +00:00
} )
2026-07-01 21:12:00 +00:00
const it = testEffect (
2026-07-27 00:08:55 +00:00
AppNodeBuilder . build ( LayerNode . group ( [ Tool . node , questionToolNode ] ) , [
[ Permission . node , permission ] ,
2026-07-06 03:28:05 +00:00
[ Form . node , form ] ,
2026-07-16 16:16:19 +00:00
[ Image . node , imagePassthrough ] ,
2026-07-01 21:12:00 +00:00
] ) ,
)
2026-06-04 03:02:17 +00:00
describe ( "QuestionTool" , ( ) = > {
2026-07-10 02:01:31 +00:00
it . effect ( "omits a catalog-denied question and enforces its leaf permission" , ( ) = >
2026-06-06 12:00:24 +00:00
Effect . gen ( function * ( ) {
captured = undefined
deny = true
2026-07-27 00:08:55 +00:00
const registry = yield * Tool . Service
2026-06-06 12:00:24 +00:00
2026-07-25 18:01:05 +00:00
expect (
( yield * toolDefinitions ( registry , [ { action : "question" , resource : "*" , effect : "deny" } ] ) ) . map (
( tool ) = > tool . name ,
) ,
) . toEqual ( [ "execute" ] )
2026-06-06 12:00:24 +00:00
expect (
2026-07-23 21:13:31 +00:00
yield * executeTool ( registry , {
2026-06-06 12:00:24 +00:00
sessionID ,
2026-06-07 00:49:12 +00:00
. . . toolIdentity ,
2026-07-10 05:22:01 +00:00
call : { type : "tool-call" , id : "call-question-denied" , name : "question" , input : questionInput } ,
2026-06-06 12:00:24 +00:00
} ) ,
2026-07-06 22:14:06 +00:00
) . toEqual ( {
2026-07-23 21:13:31 +00:00
status : "error" ,
2026-07-06 22:14:06 +00:00
error : {
type : "permission.rejected" ,
message : "Permission denied: question" ,
} ,
} )
2026-06-06 12:00:24 +00:00
expect ( capturedInput ( ) ) . toBeUndefined ( )
deny = false
} ) ,
)
2026-06-04 03:02:17 +00:00
it . effect ( "registers question and projects user answers without a permission assertion" , ( ) = >
Effect . gen ( function * ( ) {
assertions . length = 0
captured = undefined
reject = false
2026-06-06 12:00:24 +00:00
deny = false
2026-07-27 00:08:55 +00:00
const registry = yield * Tool . Service
2026-06-04 03:02:17 +00:00
const questions = [
{
question : "What should happen?" ,
header : "Action" ,
options : [ { label : "Build" , description : "Build it" } ] ,
} ,
{
question : "Which environment?" ,
header : "Environment" ,
options : [ { label : "Dev" , description : "Development" } ] ,
2026-07-06 03:28:05 +00:00
multiple : true ,
} ,
{
question : "Anything else?" ,
header : "Optional" ,
options : [ ] ,
2026-06-04 03:02:17 +00:00
} ,
]
2026-07-25 18:01:05 +00:00
expect ( ( yield * toolDefinitions ( registry ) ) . map ( ( definition ) = > definition . name ) ) . toEqual ( [ "question" , "execute" ] )
2026-06-04 03:02:17 +00:00
expect (
2026-07-23 21:13:31 +00:00
yield * executeTool ( registry , {
2026-06-04 03:02:17 +00:00
sessionID ,
2026-06-07 00:49:12 +00:00
. . . toolIdentity ,
2026-06-04 03:02:17 +00:00
call : { type : "tool-call" , id : "call-question" , name : "question" , input : { questions } } ,
} ) ,
) . toEqual ( {
2026-07-23 21:13:31 +00:00
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" ] , [ ] ] } ,
2026-06-04 03:02:17 +00:00
} )
2026-06-07 00:49:12 +00:00
expect ( assertions ) . toMatchObject ( [ { sessionID , action : "question" , resources : [ "*" ] } ] )
expect ( capturedInput ( ) ) . toEqual ( {
sessionID ,
2026-07-08 22:25:34 +00:00
title : "Questions" ,
2026-07-14 19:57:16 +00:00
metadata : { kind : "question" , tool : { messageID : toolIdentity.messageID , callID : "call-question" } } ,
2026-07-06 03:28:05 +00:00
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" ,
} ,
] ,
2026-06-07 00:49:12 +00:00
} )
2026-06-04 03:02:17 +00:00
} ) ,
)
it . effect ( "does not invent tool ownership metadata without a durable registry source" , ( ) = >
Effect . gen ( function * ( ) {
captured = undefined
reject = false
2026-06-06 12:00:24 +00:00
deny = false
2026-07-27 00:08:55 +00:00
const registryService = yield * Tool . Service
2026-06-04 03:02:17 +00:00
2026-06-07 00:49:12 +00:00
yield * executeTool ( registryService , {
2026-06-04 03:02:17 +00:00
sessionID ,
2026-06-07 00:49:12 +00:00
. . . toolIdentity ,
2026-07-10 05:22:01 +00:00
call : { type : "tool-call" , id : "call-question" , name : "question" , input : questionInput } ,
2026-06-04 03:02:17 +00:00
} )
2026-06-07 00:49:12 +00:00
expect ( capturedInput ( ) ) . toEqual ( {
sessionID ,
2026-07-08 22:25:34 +00:00
title : "Questions" ,
2026-07-14 19:57:16 +00:00
metadata : { kind : "question" , tool : { messageID : toolIdentity.messageID , callID : "call-question" } } ,
2026-07-10 05:22:01 +00:00
fields : [
{
key : "q0" ,
title : "Continue" ,
description : "Continue?" ,
options : [ { value : "Yes" , label : "Yes" , description : "Continue" } ] ,
custom : true ,
type : "string" ,
} ,
] ,
2026-06-07 00:49:12 +00:00
} )
2026-06-04 03:02:17 +00:00
} ) ,
)
it . effect ( "keeps dismissed questions out of model-facing output" , ( ) = >
Effect . gen ( function * ( ) {
captured = undefined
reject = true
2026-06-06 12:00:24 +00:00
deny = false
2026-07-27 00:08:55 +00:00
const registryService = yield * Tool . Service
2026-06-07 00:49:12 +00:00
const fiber = yield * executeTool ( registryService , {
sessionID ,
. . . toolIdentity ,
2026-07-10 05:22:01 +00:00
call : { type : "tool-call" , id : "call-question" , name : "question" , input : questionInput } ,
2026-06-07 00:49:12 +00:00
} ) . pipe ( Effect . forkScoped )
2026-06-04 03:02:17 +00:00
const exit = yield * Fiber . await ( fiber )
expect ( Exit . isFailure ( exit ) ) . toBe ( true )
2026-07-06 03:28:05 +00:00
if ( Exit . isFailure ( exit ) ) {
const error = Cause . squash ( exit . cause )
expect ( error ) . toBeInstanceOf ( QuestionTool . CancelledError )
expect ( error ) . toHaveProperty ( "message" , "The user dismissed this question" )
}
2026-06-04 03:02:17 +00:00
} ) ,
)
} )