2026-06-25 22:51:38 +08:00
import { describe , expect , it } from 'vitest'
import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
2026-08-08 15:30:08 +08:00
import AgentRegistry , { type Agent } from '@deepseek-ai/dsh-agent'
2026-06-25 22:51:38 +08:00
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import UserInteractionService , { type AskUserQuestionRequest } from '@deepseek-ai/dsh-user-interaction'
import * as toolAskUser from '@deepseek-ai/dsh-tool-ask-user'
2026-07-19 23:38:54 +08:00
const testToolSignal = new AbortController ( ) . signal
2026-06-25 22:51:38 +08:00
interface OptionSchemaShape {
properties : {
2026-07-07 14:57:06 +08:00
questions : {
2026-06-25 22:51:38 +08:00
items : {
2026-07-07 14:57:06 +08:00
properties : {
options : {
items : {
properties : Record < string , { type : string } >
}
}
} & Record < string , unknown >
2026-06-25 22:51:38 +08:00
}
}
}
}
async function setup() {
const ctx = new Context ( )
2026-08-08 15:30:08 +08:00
await ctx . plugin ( AgentRegistry )
2026-06-25 22:51:38 +08:00
await ctx . plugin ( SystemPrompt )
await ctx . plugin ( ToolRegistry )
await ctx . plugin ( UserInteractionService )
await ctx . plugin ( toolAskUser )
return ctx
}
2026-08-08 15:30:08 +08:00
function stubAgent ( id : string , delegationDepth = 0 ) : Agent {
const agentId = id as Agent [ 'id' ]
return {
id : agentId ,
session : { id : agentId , header : { delegationDepth } } ,
} as unknown as Agent
}
2026-06-25 22:51:38 +08:00
describe ( 'ask_user_question tool' , ( ) = > {
it ( 'registers a model-facing tool schema' , async ( ) = > {
const ctx = await setup ( )
const schema = ctx . tools . schemas ( ) . find ( tool = > tool . name === 'ask_user_question' )
expect ( schema ) . toMatchObject ( {
name : 'ask_user_question' ,
parameters : {
type : 'object' ,
properties : {
2026-07-07 14:57:06 +08:00
questions : { type : 'array' } ,
2026-06-25 22:51:38 +08:00
} ,
2026-07-07 14:57:06 +08:00
required : [ 'questions' ] ,
2026-06-25 22:51:38 +08:00
} ,
} )
const parameters = schema ? . parameters as unknown as OptionSchemaShape
2026-07-07 14:57:06 +08:00
expect ( parameters . properties . questions . items . properties ) . toMatchObject ( {
id : { type : 'string' } ,
question : { type : 'string' } ,
header : { type : 'string' } ,
options : { type : 'array' } ,
multi_select : { type : 'boolean' } ,
} )
expect ( parameters . properties . questions . items . properties . options . items . properties ) . toMatchObject ( {
label : { type : 'string' } ,
2026-06-25 22:51:38 +08:00
description : { type : 'string' } ,
} )
2026-07-07 14:57:06 +08:00
expect ( parameters . properties . questions . items . properties . options . items . properties ) . not . toHaveProperty ( 'value' )
expect ( parameters . properties . questions . items . properties . options . items . properties ) . not . toHaveProperty ( 'recommended' )
expect ( parameters . properties . questions . items . properties . options . items . properties ) . not . toHaveProperty ( 'preview' )
2026-06-25 22:51:38 +08:00
} )
2026-07-07 14:57:06 +08:00
it ( 'asks the registered user-interaction provider and projects structured answers to text' , async ( ) = > {
2026-06-25 22:51:38 +08:00
const ctx = await setup ( )
const seen : AskUserQuestionRequest [ ] = [ ]
ctx . userInteraction . registerProvider ( {
async ask ( request ) {
seen . push ( request )
2026-07-07 14:57:06 +08:00
return { answers : [ { id : 'pkg' , selected : [ 'pnpm' ] } ] }
2026-06-25 22:51:38 +08:00
} ,
} )
const result = await ctx . tools . execute ( {
2026-07-19 23:38:54 +08:00
signal : testToolSignal ,
2026-06-25 22:51:38 +08:00
callId : CallId ( 'ask-1' ) ,
name : 'ask_user_question' ,
arguments : {
2026-07-07 14:57:06 +08:00
questions : [ {
id : 'pkg' ,
question : 'Which package manager should I use?' ,
options : [ { label : 'pnpm' , description : 'Use pnpm workspaces.' } ] ,
} ] ,
2026-06-25 22:51:38 +08:00
} ,
} )
expect ( result ) . toMatchObject ( {
isError : false ,
2026-07-07 14:57:06 +08:00
content : [ { type : 'text' , text : '{"answers":[{"id":"pkg","selected":["pnpm"]}]}' } ] ,
2026-06-25 22:51:38 +08:00
} )
expect ( seen ) . toMatchObject ( [ {
2026-07-07 14:57:06 +08:00
questions : [ {
id : 'pkg' ,
question : 'Which package manager should I use?' ,
options : [ { label : 'pnpm' , description : 'Use pnpm workspaces.' } ] ,
} ] ,
} ] )
} )
2026-07-09 11:20:17 +08:00
it ( 'passes recommended option labels through without adding schema fields' , async ( ) = > {
const ctx = await setup ( )
const seen : AskUserQuestionRequest [ ] = [ ]
ctx . userInteraction . registerProvider ( {
async ask ( request ) {
seen . push ( request )
return { answers : [ { id : 'pkg' , selected : [ 'pnpm (Recommended)' ] } ] }
} ,
} )
await ctx . tools . execute ( {
2026-07-19 23:38:54 +08:00
signal : testToolSignal ,
2026-07-09 11:20:17 +08:00
callId : CallId ( 'ask-recommended' ) ,
name : 'ask_user_question' ,
arguments : {
questions : [ {
id : 'pkg' ,
question : 'Which package manager should I use?' ,
options : [
{ label : 'pnpm (Recommended)' } ,
{ label : 'npm' } ,
] ,
} ] ,
} ,
} )
expect ( seen [ 0 ] ? . questions [ 0 ] ? . options ) . toEqual ( [
{ label : 'pnpm (Recommended)' } ,
{ label : 'npm' } ,
] )
} )
2026-07-07 14:57:06 +08:00
it ( 'projects custom answers and multi-select choices' , async ( ) = > {
const ctx = await setup ( )
ctx . userInteraction . registerProvider ( {
async ask() {
return {
answers : [
2026-07-30 00:21:47 +08:00
{ id : 'targets' , selected : [ 'tests' , 'docs' ] , custom : 'release notes' } ,
2026-07-30 17:59:37 +08:00
{ id : 'labels-only' , selected : [ 'tests' ] } ,
2026-07-07 14:57:06 +08:00
{ id : 'notes' , selected : [ ] , custom : 'ship today' } ,
] ,
}
} ,
} )
const result = await ctx . tools . execute ( {
2026-07-19 23:38:54 +08:00
signal : testToolSignal ,
2026-07-07 14:57:06 +08:00
callId : CallId ( 'ask-multi' ) ,
name : 'ask_user_question' ,
arguments : {
questions : [
{
id : 'targets' ,
question : 'What should I update?' ,
options : [ { label : 'tests' } , { label : 'docs' } ] ,
multi_select : true ,
} ,
2026-07-30 17:59:37 +08:00
{
id : 'labels-only' ,
question : 'Which labels should I keep?' ,
options : [ { label : 'tests' } , { label : 'docs' } ] ,
multi_select : true ,
} ,
2026-07-07 14:57:06 +08:00
{ id : 'notes' , question : 'Any note?' } ,
] ,
} ,
} )
2026-07-21 03:08:35 +08:00
expect ( result . isError ) . toBe ( false )
if ( result . isError ) throw new Error ( 'expected ask_user_question success' )
expect ( result . value ) . toEqual ( {
answers : [
2026-07-30 00:21:47 +08:00
{ id : 'targets' , selected : [ 'tests' , 'docs' ] , custom : 'release notes' } ,
2026-07-30 17:59:37 +08:00
{ id : 'labels-only' , selected : [ 'tests' ] } ,
2026-07-21 03:08:35 +08:00
{ id : 'notes' , selected : [ ] , custom : 'ship today' } ,
] ,
} )
2026-07-07 14:57:06 +08:00
expect ( result . content ) . toEqual ( [ {
type : 'text' ,
2026-07-30 17:59:37 +08:00
text : '{"answers":[{"id":"targets","selected":["tests","docs"],"custom":"release notes"},{"id":"labels-only","selected":["tests"]},{"id":"notes","selected":[],"custom":"ship today"}]}' ,
2026-06-25 22:51:38 +08:00
} ] )
} )
it ( 'passes the tool abort signal to the user-interaction request' , async ( ) = > {
const ctx = await setup ( )
const seen : AskUserQuestionRequest [ ] = [ ]
ctx . userInteraction . registerProvider ( {
async ask ( request ) {
seen . push ( request )
2026-07-07 14:57:06 +08:00
return { answers : [ { id : 'continue' , selected : [ 'ok' ] } ] }
2026-06-25 22:51:38 +08:00
} ,
} )
const controller = new AbortController ( )
await ctx . tools . execute ( {
callId : CallId ( 'ask-2' ) ,
name : 'ask_user_question' ,
2026-07-07 14:57:06 +08:00
arguments : { questions : [ { id : 'continue' , question : 'Continue?' } ] } ,
2026-06-25 22:51:38 +08:00
signal : controller.signal ,
} )
expect ( seen [ 0 ] ? . signal ) . toBe ( controller . signal )
} )
2026-08-08 15:30:08 +08:00
it ( 'passes optional header and a resumed runtime root through to the user-interaction request' , async ( ) = > {
2026-06-25 22:51:38 +08:00
const ctx = await setup ( )
const seen : AskUserQuestionRequest [ ] = [ ]
ctx . userInteraction . registerProvider ( {
async ask ( request ) {
seen . push ( request )
2026-07-07 14:57:06 +08:00
return { answers : [ { id : 'continue' , selected : [ 'ok' ] } ] }
2026-06-25 22:51:38 +08:00
} ,
} )
2026-08-08 15:30:08 +08:00
const agent = stubAgent ( 'resumed-root' , 1 )
ctx . agents . enter ( agent , undefined )
2026-06-25 22:51:38 +08:00
const result = await ctx . tools . execute ( {
2026-07-19 23:38:54 +08:00
signal : testToolSignal ,
2026-06-25 22:51:38 +08:00
callId : CallId ( 'ask-3' ) ,
name : 'ask_user_question' ,
2026-07-07 14:57:06 +08:00
arguments : { questions : [ { id : 'continue' , header : 'Confirm' , question : 'Continue?' } ] } ,
2026-06-25 22:51:38 +08:00
agent ,
} )
2026-07-07 14:57:06 +08:00
expect ( result . content ) . toEqual ( [ { type : 'text' , text : '{"answers":[{"id":"continue","selected":["ok"]}]}' } ] )
expect ( seen [ 0 ] ) . toMatchObject ( { questions : [ { id : 'continue' , header : 'Confirm' , question : 'Continue?' } ] , agent } )
2026-06-25 22:51:38 +08:00
} )
2026-06-29 10:49:07 +08:00
it ( 'returns structured user-interaction errors through tool execution' , async ( ) = > {
const ctx = await setup ( )
const result = await ctx . tools . execute ( {
2026-07-19 23:38:54 +08:00
signal : testToolSignal ,
2026-06-29 10:49:07 +08:00
callId : CallId ( 'ask-no-provider' ) ,
name : 'ask_user_question' ,
2026-07-07 14:57:06 +08:00
arguments : { questions : [ { id : 'continue' , question : 'Continue?' } ] } ,
2026-06-29 10:49:07 +08:00
} )
expect ( result ) . toMatchObject ( {
isError : true ,
2026-07-21 03:08:35 +08:00
error : { info : { name : 'UserInteractionError' , code : 'NO_PROVIDER' } } ,
2026-06-29 10:49:07 +08:00
} )
} )
2026-08-08 15:30:08 +08:00
it ( 'rejects a live runtime-owned agent with a structured DELEGATED_CALLER error' , async ( ) = > {
2026-08-02 11:43:44 +08:00
const ctx = await setup ( )
const seen : AskUserQuestionRequest [ ] = [ ]
ctx . userInteraction . registerProvider ( {
async ask ( request ) {
seen . push ( request )
return { answers : [ { id : 'continue' , selected : [ 'ok' ] } ] }
} ,
} )
2026-08-08 15:30:08 +08:00
const root = stubAgent ( 'root' , 0 )
const child = stubAgent ( 'child' , 0 )
ctx . agents . enter ( root , undefined )
ctx . agents . enter ( child , root )
2026-08-02 11:43:44 +08:00
const result = await ctx . tools . execute ( {
signal : testToolSignal ,
callId : CallId ( 'ask-delegated' ) ,
name : 'ask_user_question' ,
arguments : { questions : [ { id : 'continue' , question : 'Continue?' } ] } ,
2026-08-08 15:30:08 +08:00
agent : child ,
2026-08-02 11:43:44 +08:00
} )
expect ( result ) . toMatchObject ( {
isError : true ,
error : { info : { name : 'UserInteractionError' , code : 'DELEGATED_CALLER' } } ,
2026-08-08 15:30:08 +08:00
content : [ {
type : 'text' ,
text : "Error: human interaction is unavailable while the calling agent is owned by another live agent; include the unresolved question or decision in the child agent's final result" ,
} ] ,
2026-08-02 11:43:44 +08:00
} )
expect ( seen ) . toHaveLength ( 0 )
} )
2026-07-07 14:57:06 +08:00
it ( 'returns a structured error for empty question batches' , async ( ) = > {
2026-06-25 22:51:38 +08:00
const ctx = await setup ( )
const result = await ctx . tools . execute ( {
2026-07-19 23:38:54 +08:00
signal : testToolSignal ,
2026-07-07 14:57:06 +08:00
callId : CallId ( 'ask-empty' ) ,
2026-06-25 22:51:38 +08:00
name : 'ask_user_question' ,
2026-07-07 14:57:06 +08:00
arguments : { questions : [ ] } ,
2026-06-25 22:51:38 +08:00
} )
2026-07-07 14:57:06 +08:00
expect ( result ) . toMatchObject ( {
isError : true ,
2026-07-21 03:08:35 +08:00
error : { info : { name : 'UserInteractionError' , code : 'EMPTY_QUESTIONS' } } ,
2026-06-25 22:51:38 +08:00
} )
} )
it ( 'unregisters the tool when its plugin fiber is disposed' , async ( ) = > {
const ctx = new Context ( )
await ctx . plugin ( SystemPrompt )
await ctx . plugin ( ToolRegistry )
await ctx . plugin ( UserInteractionService )
const fiber = await ctx . plugin ( toolAskUser )
expect ( ctx . tools . get ( 'ask_user_question' ) ) . toBeDefined ( )
await fiber . dispose ( )
expect ( ctx . tools . get ( 'ask_user_question' ) ) . toBeUndefined ( )
} )
} )