2026-06-04 03:02:17 +00:00
import { describe , expect } from "bun:test"
import { Effect , Exit , Fiber , Layer } from "effect"
2026-06-29 03:48:18 +00:00
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
2026-06-04 03:02:17 +00:00
import { PermissionV2 } from "@opencode-ai/core/permission"
import { QuestionV2 } from "@opencode-ai/core/question"
import { SessionV2 } from "@opencode-ai/core/session"
2026-06-05 03:12:17 +00:00
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
2026-06-04 03:02:17 +00:00
import { QuestionTool } from "@opencode-ai/core/tool/question"
2026-06-29 03:48:18 +00:00
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
2026-06-04 03:02:17 +00:00
import { testEffect } from "./lib/effect"
2026-06-07 00:49:12 +00:00
import { toolIdentity , executeTool , settleTool , toolDefinitions } from "./lib/tool"
2026-06-04 03:02:17 +00:00
const sessionID = SessionV2 . ID . make ( "ses_question_tool_test" )
const assertions : PermissionV2.AssertInput [ ] = [ ]
let captured : QuestionV2.AskInput | undefined
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
const permission = Layer . succeed (
PermissionV2 . Service ,
PermissionV2 . Service . of ( {
2026-06-06 12:00:24 +00:00
assert : ( input ) = >
Effect . sync ( ( ) = > assertions . push ( input ) ) . pipe (
2026-07-04 21:17:11 +00:00
Effect . andThen ( deny ? Effect . fail ( new PermissionV2 . BlockedError ( { rules : [ ] } ) ) : 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" ) ,
} ) ,
)
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" ) ,
} ) ,
)
2026-06-29 15:35:17 +00:00
const it = testEffect (
AppNodeBuilder . build ( LayerNode . group ( [ ToolRegistry . node , ToolRegistry . toolsNode , QuestionTool . node ] ) , [
[ PermissionV2 . node , permission ] ,
[ QuestionV2 . node , question ] ,
[ ToolOutputStore . node , ToolOutputStore . nodeWithoutConfig ] ,
] ) ,
)
2026-06-04 03:02:17 +00:00
describe ( "QuestionTool" , ( ) = > {
2026-06-06 12:00:24 +00:00
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
2026-06-07 00:49:12 +00:00
expect ( yield * toolDefinitions ( registry , [ { action : "question" , resource : "*" , effect : "deny" } ] ) ) . toEqual ( [ ] )
2026-06-06 12:00:24 +00:00
expect (
2026-06-07 00:49:12 +00:00
yield * settleTool ( registry , {
2026-06-06 12:00:24 +00:00
sessionID ,
2026-06-07 00:49:12 +00:00
. . . toolIdentity ,
2026-06-06 12:00:24 +00:00
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
} ) ,
)
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-06-04 03:02:17 +00:00
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" } ] ,
} ,
]
2026-06-07 00:49:12 +00:00
expect ( ( yield * toolDefinitions ( registry ) ) . map ( ( definition ) = > definition . name ) ) . toEqual ( [ "question" ] )
2026-06-04 03:02:17 +00:00
expect (
2026-06-07 00:49:12 +00:00
yield * settleTool ( 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 ( {
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.' ,
} ,
] ,
} ,
} )
2026-06-07 00:49:12 +00:00
expect ( assertions ) . toMatchObject ( [ { sessionID , action : "question" , resources : [ "*" ] } ] )
expect ( capturedInput ( ) ) . toEqual ( {
sessionID ,
questions ,
tool : { messageID : toolIdentity.assistantMessageID , callID : "call-question" } ,
} )
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-06-04 03:02:17 +00:00
const registryService = yield * ToolRegistry . Service
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-06-04 03:02:17 +00:00
call : { type : "tool-call" , id : "call-question" , name : "question" , input : { questions : [ ] } } ,
} )
2026-06-07 00:49:12 +00:00
expect ( capturedInput ( ) ) . toEqual ( {
sessionID ,
questions : [ ] ,
tool : { messageID : toolIdentity.assistantMessageID , callID : "call-question" } ,
} )
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-06-04 03:02:17 +00:00
const registryService = yield * ToolRegistry . Service
2026-06-07 00:49:12 +00:00
const fiber = yield * executeTool ( registryService , {
sessionID ,
. . . toolIdentity ,
call : { type : "tool-call" , id : "call-question" , name : "question" , input : { questions : [ ] } } ,
} ) . pipe ( Effect . forkScoped )
2026-06-04 03:02:17 +00:00
const exit = yield * Fiber . await ( fiber )
expect ( Exit . isFailure ( exit ) ) . toBe ( true )
} ) ,
)
} )