| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222 |
- import { describe, expect, it, vi } from 'vitest'
- import { Context } from '@deepseek-ai/cordis'
- import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
- 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)
- return { answers: [{ id: request.questions[0]?.id ?? 'missing', selected: [answer] }] }
- },
- }
- }
- 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
- }
- 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)
- const result = await ctx.userInteraction.ask({ questions: [{ id: 'confirm', question: 'Proceed?' }] })
- expect(result).toEqual({ answers: [{ id: 'confirm', selected: ['yes'] }] })
- expect(p.seen).toEqual([{ questions: [{ id: 'confirm', question: 'Proceed?' }] }])
- })
- it('rejects ask requests when no provider is registered', async () => {
- const ctx = new Context()
- await ctx.plugin(UserInteractionService)
- await expect(ctx.userInteraction.ask({ questions: [{ id: 'confirm', question: 'Proceed?' }] }))
- .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()
- await expect(ctx.userInteraction.ask({ questions: [{ id: 'confirm', question: 'Proceed?' }] }))
- .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)
- const p = { ask: vi.fn(async () => ({ answers: [{ id: 'confirm', selected: ['too late'] }] })) }
- ctx.userInteraction.registerProvider(p)
- const controller = new AbortController()
- controller.abort()
- await expect(ctx.userInteraction.ask({ questions: [{ id: 'confirm', question: 'Proceed?' }], signal: controller.signal }))
- .rejects.toMatchObject({ code: 'ASK_ABORTED' })
- expect(p.ask).not.toHaveBeenCalled()
- })
- 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()
- })
- it('rejects a live runtime-owned agent before reaching the provider', 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 root = stubAgent('root', 0)
- const child = stubAgent('child', 0)
- ctx.agents.enter(root, undefined)
- ctx.agents.enter(child, root)
- await expect(ctx.userInteraction.ask({
- questions: [{ id: 'confirm', question: 'Proceed?' }],
- 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",
- })
- expect(p.ask).not.toHaveBeenCalled()
- })
- it('reaches the provider for a lineage-bearing session resumed as a runtime root', async () => {
- const ctx = new Context()
- await ctx.plugin(AgentRegistry)
- await ctx.plugin(UserInteractionService)
- const p = provider('yes')
- ctx.userInteraction.registerProvider(p)
- const agent = stubAgent('resumed-root', 1)
- ctx.agents.enter(agent, undefined)
- const result = await ctx.userInteraction.ask({
- questions: [{ id: 'confirm', question: 'Proceed?' }],
- agent,
- })
- expect(result).toEqual({ answers: [{ id: 'confirm', selected: ['yes'] }] })
- })
- 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()
- })
- 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()
- })
- 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()
- })
- 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)
- })
- })
|