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"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
2026-07-06 03:28:05 +00:00
import { Form } from "@opencode-ai/core/form"
2026-06-04 03:02:17 +00:00
import { PermissionV2 } from "@opencode-ai/core/permission"
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-07-01 21:12:00 +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-07-03 13:03:53 +00:00
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
import { toolIdentity , executeTool , registerToolPlugin , 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 [ ] = [ ]
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
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-06 22:14:06 +00:00
Effect . andThen (
deny
? Effect . fail (
new PermissionV2 . BlockedError ( {
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-06 03:28:05 +00:00
deps : [ ToolRegistry . toolsNode , PermissionV2 . node , Form . node ] ,
2026-07-03 13:03:53 +00:00
} )
2026-07-01 21:12:00 +00:00
const it = testEffect (
2026-07-03 13:03:53 +00:00
AppNodeBuilder . build ( LayerNode . group ( [ ToolRegistry . node , ToolRegistry . toolsNode , questionToolNode ] ) , [
2026-07-01 21:12:00 +00:00
[ PermissionV2 . node , permission ] ,
2026-07-06 03:28:05 +00:00
[ Form . node , form ] ,
2026-07-01 21:12:00 +00:00
[ 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 : [ ] } } ,
} ) ,
2026-07-06 22:14:06 +00:00
) . toEqual ( {
result : { type : "error" , value : "Permission denied: question" } ,
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-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-07-06 03:28:05 +00:00
multiple : true ,
} ,
{
question : "Anything else?" ,
header : "Optional" ,
options : [ ] ,
2026-06-04 03:02:17 +00:00
} ,
]
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 :
2026-07-06 03:28:05 +00:00
'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.' ,
2026-06-04 03:02:17 +00:00
} ,
output : {
2026-07-06 03:28:05 +00:00
structured : { answers : [ [ "Build" ] , [ "Dev" ] , [ ] ] } ,
2026-06-04 03:02:17 +00:00
content : [
{
type : "text" ,
2026-07-06 03:28:05 +00:00
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.' ,
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-06 03:28:05 +00:00
metadata : { kind : "question" , tool : { messageID : toolIdentity.assistantMessageID , callID : "call-question" } } ,
mode : "form" ,
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-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 ,
2026-07-06 03:28:05 +00:00
metadata : { kind : "question" , tool : { messageID : toolIdentity.assistantMessageID , callID : "call-question" } } ,
mode : "form" ,
fields : [ ] ,
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-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 )
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
} ) ,
)
} )