| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331 |
- import { describe, expect, it } from 'vitest'
- import { Context } from '@deepseek-ai/cordis'
- import { ToolCallId } from '@deepseek-ai/dsh-llm'
- import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
- import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
- import ToolRuntime from '@deepseek-ai/dsh-tools'
- import UserQuestionService, {
- type AskUserQuestionAnswer,
- type AskUserQuestionRequest,
- } from '@deepseek-ai/dsh-user-questions'
- import * as toolAskUser from '@deepseek-ai/dsh-tool-ask-user'
- const testToolSignal = new AbortController().signal
- interface QuestionAnswerer {
- ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer>
- }
- function registerQuestionAnswerer(ctx: Context, answerer: QuestionAnswerer): () => void {
- return ctx.on('user-questions/request', request => answerer.ask(request))
- }
- interface OptionSchemaShape {
- properties: {
- questions: {
- items: {
- properties: {
- options: {
- items: {
- properties: Record<string, { type: string }>
- }
- }
- } & Record<string, unknown>
- }
- }
- }
- }
- async function setup() {
- const ctx = new Context()
- await ctx.plugin(AgentRegistry)
- await ctx.plugin(SystemPrompt)
- await ctx.plugin(ToolRuntime)
- await ctx.plugin(UserQuestionService)
- await ctx.plugin(toolAskUser)
- return ctx
- }
- 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('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: {
- questions: { type: 'array' },
- },
- required: ['questions'],
- },
- })
- const parameters = schema?.parameters as unknown as OptionSchemaShape
- 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' },
- description: { type: 'string' },
- })
- 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')
- })
- it('asks the registered user-questions provider and projects structured answers to text', async () => {
- const ctx = await setup()
- const seen: AskUserQuestionRequest[] = []
- registerQuestionAnswerer(ctx, {
- async ask(request) {
- seen.push(request)
- return { answers: [{ id: 'pkg', selected: ['pnpm'] }] }
- },
- })
- const result = await ctx.tools.execute({
- signal: testToolSignal,
- callId: ToolCallId('ask-1'),
- name: 'ask_user_question',
- arguments: {
- questions: [{
- id: 'pkg',
- question: 'Which package manager should I use?',
- options: [{ label: 'pnpm', description: 'Use pnpm workspaces.' }],
- }],
- },
- })
- expect(result).toMatchObject({
- isError: false,
- content: [{ type: 'text', text: '{"answers":[{"id":"pkg","selected":["pnpm"]}]}' }],
- })
- expect(seen).toMatchObject([{
- questions: [{
- id: 'pkg',
- question: 'Which package manager should I use?',
- options: [{ label: 'pnpm', description: 'Use pnpm workspaces.' }],
- }],
- }])
- })
- it('passes recommended option labels through without adding schema fields', async () => {
- const ctx = await setup()
- const seen: AskUserQuestionRequest[] = []
- registerQuestionAnswerer(ctx, {
- async ask(request) {
- seen.push(request)
- return { answers: [{ id: 'pkg', selected: ['pnpm (Recommended)'] }] }
- },
- })
- await ctx.tools.execute({
- signal: testToolSignal,
- callId: ToolCallId('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' },
- ])
- })
- it('projects custom answers and multi-select choices', async () => {
- const ctx = await setup()
- registerQuestionAnswerer(ctx, {
- async ask() {
- return {
- answers: [
- { id: 'targets', selected: ['tests', 'docs'], custom: 'release notes' },
- { id: 'labels-only', selected: ['tests'] },
- { id: 'notes', selected: [], custom: 'ship today' },
- ],
- }
- },
- })
- const result = await ctx.tools.execute({
- signal: testToolSignal,
- callId: ToolCallId('ask-multi'),
- name: 'ask_user_question',
- arguments: {
- questions: [
- {
- id: 'targets',
- question: 'What should I update?',
- options: [{ label: 'tests' }, { label: 'docs' }],
- multi_select: true,
- },
- {
- id: 'labels-only',
- question: 'Which labels should I keep?',
- options: [{ label: 'tests' }, { label: 'docs' }],
- multi_select: true,
- },
- { id: 'notes', question: 'Any note?' },
- ],
- },
- })
- expect(result.isError).toBe(false)
- if (result.isError) throw new Error('expected ask_user_question success')
- expect(result.value).toEqual({
- answers: [
- { id: 'targets', selected: ['tests', 'docs'], custom: 'release notes' },
- { id: 'labels-only', selected: ['tests'] },
- { id: 'notes', selected: [], custom: 'ship today' },
- ],
- })
- expect(result.content).toEqual([{
- type: 'text',
- text: '{"answers":[{"id":"targets","selected":["tests","docs"],"custom":"release notes"},{"id":"labels-only","selected":["tests"]},{"id":"notes","selected":[],"custom":"ship today"}]}',
- }])
- })
- it('passes the tool abort signal to the user-questions request', async () => {
- const ctx = await setup()
- const seen: AskUserQuestionRequest[] = []
- registerQuestionAnswerer(ctx, {
- async ask(request) {
- seen.push(request)
- return { answers: [{ id: 'continue', selected: ['ok'] }] }
- },
- })
- const controller = new AbortController()
- await ctx.tools.execute({
- callId: ToolCallId('ask-2'),
- name: 'ask_user_question',
- arguments: { questions: [{ id: 'continue', question: 'Continue?' }] },
- signal: controller.signal,
- })
- expect(seen[0]?.signal).toBe(controller.signal)
- })
- it('passes optional header and a resumed runtime root through to the user-questions request', async () => {
- const ctx = await setup()
- const seen: AskUserQuestionRequest[] = []
- registerQuestionAnswerer(ctx, {
- async ask(request) {
- seen.push(request)
- return { answers: [{ id: 'continue', selected: ['ok'] }] }
- },
- })
- const agent = stubAgent('resumed-root', 1)
- ctx.agents.enter(agent, undefined)
- const result = await ctx.tools.execute({
- signal: testToolSignal,
- callId: ToolCallId('ask-3'),
- name: 'ask_user_question',
- arguments: { questions: [{ id: 'continue', header: 'Confirm', question: 'Continue?' }] },
- agent,
- })
- expect(result.content).toEqual([{ type: 'text', text: '{"answers":[{"id":"continue","selected":["ok"]}]}' }])
- expect(seen[0]).toMatchObject({ questions: [{ id: 'continue', header: 'Confirm', question: 'Continue?' }], agent })
- })
- it('returns structured user-questions errors through tool execution', async () => {
- const ctx = await setup()
- const result = await ctx.tools.execute({
- signal: testToolSignal,
- callId: ToolCallId('ask-no-provider'),
- name: 'ask_user_question',
- arguments: { questions: [{ id: 'continue', question: 'Continue?' }] },
- })
- expect(result).toMatchObject({
- isError: true,
- error: { info: { name: 'UserQuestionError', code: 'NO_PROVIDER' } },
- })
- })
- it('rejects a live runtime-owned agent with a structured DELEGATED_CALLER error', async () => {
- const ctx = await setup()
- const seen: AskUserQuestionRequest[] = []
- registerQuestionAnswerer(ctx, {
- async ask(request) {
- seen.push(request)
- return { answers: [{ id: 'continue', selected: ['ok'] }] }
- },
- })
- const root = stubAgent('root', 0)
- const child = stubAgent('child', 0)
- ctx.agents.enter(root, undefined)
- ctx.agents.enter(child, root)
- const result = await ctx.tools.execute({
- signal: testToolSignal,
- callId: ToolCallId('ask-delegated'),
- name: 'ask_user_question',
- arguments: { questions: [{ id: 'continue', question: 'Continue?' }] },
- agent: child,
- })
- expect(result).toMatchObject({
- isError: true,
- error: { info: { name: 'UserQuestionError', code: 'DELEGATED_CALLER' } },
- 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",
- }],
- })
- expect(seen).toHaveLength(0)
- })
- it('returns a structured error for empty question batches', async () => {
- const ctx = await setup()
- const result = await ctx.tools.execute({
- signal: testToolSignal,
- callId: ToolCallId('ask-empty'),
- name: 'ask_user_question',
- arguments: { questions: [] },
- })
- expect(result).toMatchObject({
- isError: true,
- error: { info: { name: 'UserQuestionError', code: 'EMPTY_QUESTIONS' } },
- })
- })
- it('unregisters the tool when its plugin fiber is disposed', async () => {
- const ctx = new Context()
- await ctx.plugin(SystemPrompt)
- await ctx.plugin(ToolRuntime)
- await ctx.plugin(UserQuestionService)
- 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()
- })
- })
|