2026-06-25 22:51:38 +08:00
import { describe , expect , it , vi } from 'vitest'
2026-08-10 22:04:06 +08:00
import { Context } from '@deepseek-ai/cordis'
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 UserInteractionService , {
UserInteractionError ,
type AskUserQuestionRequest ,
type UserInteractionProvider ,
} from '@deepseek-ai/dsh-user-interaction'
function provider ( answer = 'approved' ) : UserInteractionProvider & { seen : AskUserQuestionRequest [ ] } {
const seen : AskUserQuestionRequest [ ] = [ ]
return {
seen ,
async ask ( request ) {
seen . push ( request )
2026-07-07 14:57:06 +08:00
return { answers : [ { id : request.questions [ 0 ] ? . id ? ? 'missing' , selected : [ answer ] } ] }
2026-06-25 22:51:38 +08:00
} ,
}
}
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 ( 'UserInteractionService' , ( ) = > {
it ( 'delegates ask requests to the registered provider' , async ( ) = > {
const ctx = new Context ( )
await ctx . plugin ( UserInteractionService )
const p = provider ( 'yes' )
ctx . userInteraction . registerProvider ( p )
2026-07-07 14:57:06 +08:00
const result = await ctx . userInteraction . ask ( { questions : [ { id : 'confirm' , question : 'Proceed?' } ] } )
2026-06-25 22:51:38 +08:00
2026-07-07 14:57:06 +08:00
expect ( result ) . toEqual ( { answers : [ { id : 'confirm' , selected : [ 'yes' ] } ] } )
expect ( p . seen ) . toEqual ( [ { questions : [ { id : 'confirm' , question : 'Proceed?' } ] } ] )
2026-06-25 22:51:38 +08:00
} )
it ( 'rejects ask requests when no provider is registered' , async ( ) = > {
const ctx = new Context ( )
await ctx . plugin ( UserInteractionService )
2026-07-07 14:57:06 +08:00
await expect ( ctx . userInteraction . ask ( { questions : [ { id : 'confirm' , question : 'Proceed?' } ] } ) )
2026-06-25 22:51:38 +08:00
. rejects . toMatchObject ( { name : 'UserInteractionError' , code : 'NO_PROVIDER' } )
} )
it ( 'registers providers with HMR-safe disposal' , async ( ) = > {
const ctx = new Context ( )
await ctx . plugin ( UserInteractionService )
const p = provider ( )
const dispose = ctx . userInteraction . registerProvider ( p )
dispose ( )
dispose ( )
2026-07-07 14:57:06 +08:00
await expect ( ctx . userInteraction . ask ( { questions : [ { id : 'confirm' , question : 'Proceed?' } ] } ) )
2026-06-25 22:51:38 +08:00
. rejects . toMatchObject ( { code : 'NO_PROVIDER' } )
} )
it ( 'rejects duplicate providers instead of replacing the active UI' , async ( ) = > {
const ctx = new Context ( )
await ctx . plugin ( UserInteractionService )
ctx . userInteraction . registerProvider ( provider ( 'first' ) )
expect ( ( ) = > ctx . userInteraction . registerProvider ( provider ( 'second' ) ) )
. toThrow ( UserInteractionError )
} )
it ( 'fails before reaching the provider when the signal is already aborted' , async ( ) = > {
const ctx = new Context ( )
await ctx . plugin ( UserInteractionService )
2026-07-07 14:57:06 +08:00
const p = { ask : vi.fn ( async ( ) = > ( { answers : [ { id : 'confirm' , selected : [ 'too late' ] } ] } ) ) }
2026-06-25 22:51:38 +08:00
ctx . userInteraction . registerProvider ( p )
const controller = new AbortController ( )
controller . abort ( )
2026-07-07 14:57:06 +08:00
await expect ( ctx . userInteraction . ask ( { questions : [ { id : 'confirm' , question : 'Proceed?' } ] , signal : controller.signal } ) )
2026-06-25 22:51:38 +08:00
. rejects . toMatchObject ( { code : 'ASK_ABORTED' } )
expect ( p . ask ) . not . toHaveBeenCalled ( )
} )
2026-07-07 14:57:06 +08:00
it ( 'rejects empty question batches before reaching the provider' , async ( ) = > {
const ctx = new Context ( )
await ctx . plugin ( UserInteractionService )
const p = { ask : vi.fn ( async ( ) = > ( { answers : [ ] } ) ) }
ctx . userInteraction . registerProvider ( p )
await expect ( ctx . userInteraction . ask ( { questions : [ ] } ) )
. rejects . toMatchObject ( { name : 'UserInteractionError' , code : 'EMPTY_QUESTIONS' } )
expect ( p . ask ) . not . toHaveBeenCalled ( )
} )
2026-07-30 19:09:19 +08:00
2026-08-08 15:30:08 +08:00
it ( 'rejects a live runtime-owned agent before reaching the provider' , async ( ) = > {
2026-08-02 11:43:44 +08:00
const ctx = new Context ( )
2026-08-08 15:30:08 +08:00
await ctx . plugin ( AgentRegistry )
2026-08-02 11:43:44 +08:00
await ctx . plugin ( UserInteractionService )
const p = { ask : vi.fn ( async ( ) = > ( { answers : [ ] } ) ) }
ctx . userInteraction . registerProvider ( p )
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
await expect ( ctx . userInteraction . ask ( {
questions : [ { id : 'confirm' , question : 'Proceed?' } ] ,
2026-08-08 15:30:08 +08:00
agent : child ,
} ) ) . rejects . toMatchObject ( {
name : 'UserInteractionError' ,
code : 'DELEGATED_CALLER' ,
message : "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 ( p . ask ) . not . toHaveBeenCalled ( )
} )
2026-08-08 15:30:08 +08:00
it ( 'reaches the provider for a lineage-bearing session resumed as a runtime root' , async ( ) = > {
2026-08-02 11:43:44 +08:00
const ctx = new Context ( )
2026-08-08 15:30:08 +08:00
await ctx . plugin ( AgentRegistry )
2026-08-02 11:43:44 +08:00
await ctx . plugin ( UserInteractionService )
const p = provider ( 'yes' )
ctx . userInteraction . registerProvider ( p )
2026-08-08 15:30:08 +08:00
const agent = stubAgent ( 'resumed-root' , 1 )
ctx . agents . enter ( agent , undefined )
2026-08-02 11:43:44 +08:00
const result = await ctx . userInteraction . ask ( {
questions : [ { id : 'confirm' , question : 'Proceed?' } ] ,
agent ,
} )
expect ( result ) . toEqual ( { answers : [ { id : 'confirm' , selected : [ 'yes' ] } ] } )
} )
2026-08-08 15:30:08 +08:00
it ( 'rejects a supplied agent when no live registry can attest it' , async ( ) = > {
const ctx = new Context ( )
await ctx . plugin ( UserInteractionService )
const p = { ask : vi.fn ( async ( ) = > ( { answers : [ ] } ) ) }
ctx . userInteraction . registerProvider ( p )
await expect ( ctx . userInteraction . ask ( {
questions : [ { id : 'confirm' , question : 'Proceed?' } ] ,
agent : stubAgent ( 'unattested' ) ,
} ) ) . rejects . toMatchObject ( { name : 'UserInteractionError' , code : 'CALLER_NOT_LIVE' } )
expect ( p . ask ) . not . toHaveBeenCalled ( )
} )
it ( 'rejects a stale agent object that reuses a live id' , async ( ) = > {
const ctx = new Context ( )
await ctx . plugin ( AgentRegistry )
await ctx . plugin ( UserInteractionService )
const p = { ask : vi.fn ( async ( ) = > ( { answers : [ ] } ) ) }
ctx . userInteraction . registerProvider ( p )
const live = stubAgent ( 'same-id' )
ctx . agents . enter ( live , undefined )
await expect ( ctx . userInteraction . ask ( {
questions : [ { id : 'confirm' , question : 'Proceed?' } ] ,
agent : stubAgent ( 'same-id' ) ,
} ) ) . rejects . toMatchObject ( { name : 'UserInteractionError' , code : 'CALLER_NOT_LIVE' } )
expect ( p . ask ) . not . toHaveBeenCalled ( )
} )
2026-07-30 19:09:19 +08:00
it ( 'rejects an intent whose approve label names none of its own options' , async ( ) = > {
const ctx = new Context ( )
await ctx . plugin ( UserInteractionService )
const p = { ask : vi.fn ( async ( ) = > ( { answers : [ ] } ) ) }
ctx . userInteraction . registerProvider ( p )
const question = { id : 'plan-review' , question : 'Approve?' , detail : '# Plan' }
// A wrong label among offered options, and no options offered at all.
for ( const options of [ [ { label : 'Approve' } ] , undefined ] ) {
await expect ( ctx . userInteraction . ask ( {
questions : [ {
. . . question ,
. . . ( options === undefined ? { } : { options } ) ,
intent : { kind : 'plan-review' , approve : 'Ship it' } ,
} ] ,
} ) ) . rejects . toMatchObject ( { name : 'UserInteractionError' , code : 'BAD_INTENT' } )
}
expect ( p . ask ) . not . toHaveBeenCalled ( )
} )
2026-07-30 19:38:18 +08:00
it ( 'rejects a plan-review intent on a question carrying no plan to review' , async ( ) = > {
const ctx = new Context ( )
await ctx . plugin ( UserInteractionService )
const p = { ask : vi.fn ( async ( ) = > ( { answers : [ ] } ) ) }
ctx . userInteraction . registerProvider ( p )
// Detail IS the plan for this intent, so a UI honouring it would ask the
// user to approve something they cannot see.
await expect ( ctx . userInteraction . ask ( {
questions : [ {
id : 'plan-review' , question : 'Approve?' ,
options : [ { label : 'Approve' } , { label : 'Keep planning' } ] ,
intent : { kind : 'plan-review' , approve : 'Approve' } ,
} ] ,
} ) ) . rejects . toMatchObject ( { name : 'UserInteractionError' , code : 'BAD_INTENT' } )
expect ( p . ask ) . not . toHaveBeenCalled ( )
} )
2026-07-30 19:09:19 +08:00
it ( 'passes an intent through once its approve label names an offered option' , async ( ) = > {
const ctx = new Context ( )
await ctx . plugin ( UserInteractionService )
const p = provider ( 'Approve' )
ctx . userInteraction . registerProvider ( p )
const intent = { kind : 'plan-review' , approve : 'Approve' } as const
const result = await ctx . userInteraction . ask ( {
questions : [
{ id : 'plain' , question : 'Proceed?' } ,
{
id : 'plan-review' , question : 'Approve?' , detail : '# Plan' ,
options : [ { label : 'Approve' } , { label : 'Keep planning' } ] , intent ,
} ,
] ,
} )
expect ( result . answers ) . toEqual ( [ { id : 'plain' , selected : [ 'Approve' ] } ] )
expect ( p . seen [ 0 ] ? . questions [ 1 ] ? . intent ) . toEqual ( intent )
} )
2026-06-25 22:51:38 +08:00
} )