CoolFace
Datasetpublic

basant307/AI_Governance_Project

sourceHugging Faceapache-2.0updated 2mo agoView on Hugging Face
0likes48downloads
askUserQuestion.test.ts666 linesDownload Raw Back to tools
1/**2 * @license3 * Copyright 2025 Qwen4 * SPDX-License-Identifier: Apache-2.05 */6 7import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';8import { AskUserQuestionTool } from './askUserQuestion.js';9import type { Config } from '../config/config.js';10import { ApprovalMode } from '../config/config.js';11import { ToolConfirmationOutcome } from './tools.js';12 13describe('AskUserQuestionTool', () => {14  let mockConfig: Config;15  let tool: AskUserQuestionTool;16 17  beforeEach(() => {18    mockConfig = {19      isInteractive: vi.fn().mockReturnValue(true),20      getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT),21      getTargetDir: vi.fn().mockReturnValue('/mock/dir'),22      getChatRecordingService: vi.fn(),23      getExperimentalZedIntegration: vi.fn().mockReturnValue(false),24      getInputFormat: vi.fn().mockReturnValue(undefined),25      getPlanGateState: vi.fn().mockReturnValue(undefined),26    } as unknown as Config;27 28    tool = new AskUserQuestionTool(mockConfig);29  });30 31  describe('tool registration flags', () => {32    it('is not deferred — must remain visible in the initial tool list', () => {33      // shouldDefer=true would hide the schema behind ToolSearch and force the34      // model to discover the tool by name before using it. The model then35      // tends to skip the structured clarification UX and ask in plain prose.36      expect(tool.shouldDefer).toBe(false);37    });38  });39 40  describe('validateToolParams', () => {41    it('should accept valid params with single question', () => {42      const params = {43        questions: [44          {45            question: 'What is your favorite color?',46            header: 'Color',47            options: [48              { label: 'Red', description: 'The color red' },49              { label: 'Blue', description: 'The color blue' },50            ],51            multiSelect: false,52          },53        ],54      };55 56      const result = tool.validateToolParams(params);57      expect(result).toBeNull();58    });59 60    it('should reject params with too many questions', () => {61      const params = {62        questions: Array(5).fill({63          question: 'Test?',64          header: 'Test',65          options: [66            { label: 'A', description: 'Option A' },67            { label: 'B', description: 'Option B' },68          ],69          multiSelect: false,70        }),71      };72 73      const result = tool.validateToolParams(params);74      expect(result).toContain('between 1 and 4 questions');75    });76 77    it('should reject question with header too long', () => {78      const params = {79        questions: [80          {81            question: 'Test question?',82            header: 'ThisHeaderIsTooLong',83            options: [84              { label: 'A', description: 'Option A' },85              { label: 'B', description: 'Option B' },86            ],87            multiSelect: false,88          },89        ],90      };91 92      const result = tool.validateToolParams(params);93      expect(result).toContain('12 characters or less');94    });95 96    it('should reject question with too few options', () => {97      const params = {98        questions: [99          {100            question: 'Test question?',101            header: 'Test',102            options: [{ label: 'A', description: 'Only one option' }],103            multiSelect: false,104          },105        ],106      };107 108      const result = tool.validateToolParams(params);109      expect(result).toContain('between 2 and 4 options');110    });111 112    it('should accept params with multiSelect omitted', () => {113      const params = {114        questions: [115          {116            question: 'Pick a framework?',117            header: 'Framework',118            options: [119              { label: 'React', description: 'A JavaScript library' },120              { label: 'Vue', description: 'Progressive framework' },121            ],122          },123        ],124      };125 126      expect(tool.validateToolParams(params)).toBeNull();127      expect(() => tool.build(params)).not.toThrow();128    });129 130    it('should reject params where multiSelect is not a boolean', () => {131      const params = {132        questions: [133          {134            question: 'Pick a framework?',135            header: 'Framework',136            options: [137              { label: 'React', description: 'A JavaScript library' },138              { label: 'Vue', description: 'Progressive framework' },139            ],140            multiSelect: 'yes' as unknown as boolean,141          },142        ],143      };144 145      const result = tool.validateToolParams(params);146      expect(result).toBe('Question 1: "multiSelect" must be a boolean.');147    });148  });149 150  describe('getDefaultPermission and getConfirmationDetails', () => {151    it('should return ask permission and confirmation details in interactive mode', async () => {152      const params = {153        questions: [154          {155            question: 'Pick a framework?',156            header: 'Framework',157            options: [158              { label: 'React', description: 'A JavaScript library' },159              { label: 'Vue', description: 'Progressive framework' },160            ],161            multiSelect: false,162          },163        ],164      };165 166      const invocation = tool.build(params);167      const permission = await invocation.getDefaultPermission();168      expect(permission).toBe('ask');169 170      const confirmation = await invocation.getConfirmationDetails(171        new AbortController().signal,172      );173      expect(confirmation.type).toBe('ask_user_question');174      if (confirmation.type === 'ask_user_question') {175        expect(confirmation.questions).toEqual(params.questions);176        expect(confirmation.onConfirm).toBeDefined();177      }178    });179 180    it('should return allow permission in non-interactive mode', async () => {181      (mockConfig.isInteractive as Mock).mockReturnValue(false);182 183      const params = {184        questions: [185          {186            question: 'Test?',187            header: 'Test',188            options: [189              { label: 'A', description: 'Option A' },190              { label: 'B', description: 'Option B' },191            ],192            multiSelect: false,193          },194        ],195      };196 197      const invocation = tool.build(params);198      const permission = await invocation.getDefaultPermission();199      expect(permission).toBe('allow');200    });201  });202 203  describe('execute', () => {204    it('should return error in non-interactive mode', async () => {205      (mockConfig.isInteractive as Mock).mockReturnValue(false);206 207      const params = {208        questions: [209          {210            question: 'Test?',211            header: 'Test',212            options: [213              { label: 'A', description: 'Option A' },214              { label: 'B', description: 'Option B' },215            ],216            multiSelect: false,217          },218        ],219      };220 221      const invocation = tool.build(params);222      const result = await invocation.execute(new AbortController().signal);223 224      expect(result.llmContent).toContain('non-interactive mode');225      expect(result.returnDisplay).toContain('non-interactive mode');226    });227 228    it('should return cancellation message when user declines', async () => {229      const params = {230        questions: [231          {232            question: 'Test?',233            header: 'Test',234            options: [235              { label: 'A', description: 'Option A' },236              { label: 'B', description: 'Option B' },237            ],238            multiSelect: false,239          },240        ],241      };242 243      const invocation = tool.build(params);244      const confirmation = await invocation.getConfirmationDetails(245        new AbortController().signal,246      );247 248      // Simulate user cancellation249      await confirmation.onConfirm(ToolConfirmationOutcome.Cancel);250 251      const result = await invocation.execute(new AbortController().signal);252      expect(result.llmContent).toContain('declined to answer');253    });254 255    it('should return formatted answers when user provides them', async () => {256      const params = {257        questions: [258          {259            question: 'Pick a framework?',260            header: 'Framework',261            options: [262              { label: 'React', description: 'A JavaScript library' },263              { label: 'Vue', description: 'Progressive framework' },264            ],265            multiSelect: false,266          },267          {268            question: 'Pick a language?',269            header: 'Language',270            options: [271              { label: 'TypeScript', description: 'Typed JavaScript' },272              { label: 'JavaScript', description: 'Plain JS' },273            ],274            multiSelect: false,275          },276        ],277      };278 279      const invocation = tool.build(params);280      const confirmation = await invocation.getConfirmationDetails(281        new AbortController().signal,282      );283 284      // Simulate user providing answers285      await confirmation.onConfirm(ToolConfirmationOutcome.ProceedOnce, {286        answers: {287          '0': 'React',288          '1': 'TypeScript',289        },290      });291 292      const result = await invocation.execute(new AbortController().signal);293 294      expect(result.llmContent).toContain('Framework**: React');295      expect(result.llmContent).toContain('Language**: TypeScript');296      expect(result.returnDisplay).toContain(297        'has provided the following answers:',298      );299    });300 301    it('should ignore answers with malformed question indexes', async () => {302      const params = {303        questions: [304          {305            question: 'Pick a framework?',306            header: 'Framework',307            options: [308              { label: 'React', description: 'A JavaScript library' },309              { label: 'Vue', description: 'Progressive framework' },310            ],311            multiSelect: false,312          },313        ],314      };315 316      const invocation = tool.build(params);317      const confirmation = await invocation.getConfirmationDetails(318        new AbortController().signal,319      );320 321      await confirmation.onConfirm(ToolConfirmationOutcome.ProceedOnce, {322        answers: {323          '0junk': 'React',324        },325      });326 327      const result = await invocation.execute(new AbortController().signal);328 329      expect(result.llmContent).not.toContain('Framework**: React');330      expect(result.llmContent).toContain('No valid answers were provided.');331    });332 333    it('should ignore non-canonical decimal answer indexes', async () => {334      const params = {335        questions: [336          {337            question: 'Pick a framework?',338            header: 'Framework',339            options: [340              { label: 'React', description: 'A JavaScript library' },341              { label: 'Vue', description: 'Progressive framework' },342            ],343            multiSelect: false,344          },345          {346            question: 'Pick a language?',347            header: 'Language',348            options: [349              { label: 'TypeScript', description: 'Typed JavaScript' },350              { label: 'Python', description: 'General purpose language' },351            ],352            multiSelect: false,353          },354        ],355      };356 357      const invocation = tool.build(params);358      const confirmation = await invocation.getConfirmationDetails(359        new AbortController().signal,360      );361 362      await confirmation.onConfirm(ToolConfirmationOutcome.ProceedOnce, {363        answers: {364          '01': 'TypeScript',365        },366      });367 368      const result = await invocation.execute(new AbortController().signal);369 370      expect(result.llmContent).not.toContain('Language**: TypeScript');371      expect(result.llmContent).toContain('No valid answers were provided.');372    });373 374    it('should ignore answers with out-of-range question indexes', async () => {375      const params = {376        questions: [377          {378            question: 'Pick a framework?',379            header: 'Framework',380            options: [381              { label: 'React', description: 'A JavaScript library' },382              { label: 'Vue', description: 'Progressive framework' },383            ],384            multiSelect: false,385          },386        ],387      };388 389      const invocation = tool.build(params);390      const confirmation = await invocation.getConfirmationDetails(391        new AbortController().signal,392      );393 394      await confirmation.onConfirm(ToolConfirmationOutcome.ProceedOnce, {395        answers: {396          '1': 'TypeScript',397        },398      });399 400      const result = await invocation.execute(new AbortController().signal);401 402      expect(result.llmContent).not.toContain('Question 2**: TypeScript');403      expect(result.llmContent).toContain('No valid answers were provided.');404    });405  });406 407  describe('applyPlanGateMetadata', () => {408    const gateState = {409      entryId: 1,410      reviewCount: 3,411      gateMode: 'capped' as const,412      lastFindings: [],413      capEscalationPending: true,414      needsUserPending: false,415    };416 417    beforeEach(() => {418      (mockConfig.getPlanGateState as ReturnType<typeof vi.fn>).mockReturnValue(419        gateState,420      );421      gateState.gateMode = 'capped';422      gateState.reviewCount = 3;423      gateState.capEscalationPending = true;424      gateState.needsUserPending = false;425    });426 427    it('should set gateMode to uncapped on CONTINUE answer', async () => {428      const { CAP_ESCALATION_LABELS } = await import('../plan-gate/types.js');429      const params = {430        questions: [431          {432            question: 'Cap reached',433            header: 'Gate',434            options: [435              {436                label: CAP_ESCALATION_LABELS.CONTINUE,437                description: 'Keep going',438              },439              {440                label: CAP_ESCALATION_LABELS.APPROVE,441                description: 'Skip gate',442              },443            ],444          },445        ],446        metadata: { source: 'plan_gate_cap' },447      };448 449      const invocation = tool.build(params);450      const details = await invocation.getConfirmationDetails(451        new AbortController().signal,452      );453      await details.onConfirm(ToolConfirmationOutcome.ProceedOnce, {454        answers: { '0': CAP_ESCALATION_LABELS.CONTINUE },455      });456      await invocation.execute(new AbortController().signal);457 458      expect(gateState.gateMode).toBe('uncapped');459      expect(gateState.capEscalationPending).toBe(false);460    });461 462    it('should set gateMode to user_override on APPROVE answer', async () => {463      const { CAP_ESCALATION_LABELS } = await import('../plan-gate/types.js');464      const params = {465        questions: [466          {467            question: 'Cap reached',468            header: 'Gate',469            options: [470              {471                label: CAP_ESCALATION_LABELS.CONTINUE,472                description: 'Keep going',473              },474              {475                label: CAP_ESCALATION_LABELS.APPROVE,476                description: 'Skip gate',477              },478            ],479          },480        ],481        metadata: { source: 'plan_gate_cap' },482      };483 484      const invocation = tool.build(params);485      const details = await invocation.getConfirmationDetails(486        new AbortController().signal,487      );488      await details.onConfirm(ToolConfirmationOutcome.ProceedOnce, {489        answers: { '0': CAP_ESCALATION_LABELS.APPROVE },490      });491      await invocation.execute(new AbortController().signal);492 493      expect(gateState.gateMode).toBe('user_override');494    });495 496    it('should set gateMode to user_takeover on free-text answer', async () => {497      const params = {498        questions: [499          {500            question: 'Cap reached',501            header: 'Gate',502            options: [503              { label: 'Continue editing plan', description: 'Keep going' },504              { label: 'Approve execution', description: 'Skip gate' },505            ],506          },507        ],508        metadata: { source: 'plan_gate_cap' },509      };510 511      const invocation = tool.build(params);512      const details = await invocation.getConfirmationDetails(513        new AbortController().signal,514      );515      await details.onConfirm(ToolConfirmationOutcome.ProceedOnce, {516        answers: { '0': 'I want to change the approach entirely' },517      });518      await invocation.execute(new AbortController().signal);519 520      expect(gateState.gateMode).toBe('user_takeover');521    });522 523    it('should reset reviewCount on plan_gate_needs_user', async () => {524      gateState.needsUserPending = true;525      const params = {526        questions: [527          {528            question: 'What DB?',529            header: 'DB',530            options: [531              { label: 'Postgres', description: 'PG' },532              { label: 'MySQL', description: 'My' },533            ],534          },535        ],536        metadata: { source: 'plan_gate_needs_user' },537      };538 539      const invocation = tool.build(params);540      const details = await invocation.getConfirmationDetails(541        new AbortController().signal,542      );543      await details.onConfirm(ToolConfirmationOutcome.ProceedOnce, {544        answers: { '0': 'Postgres' },545      });546      await invocation.execute(new AbortController().signal);547 548      expect(gateState.reviewCount).toBe(0);549    });550 551    it('should ignore plan_gate_cap when capEscalationPending is false', async () => {552      gateState.capEscalationPending = false;553      const params = {554        questions: [555          {556            question: 'Cap reached',557            header: 'Gate',558            options: [559              { label: 'Continue editing plan', description: 'Keep going' },560              { label: 'Approve execution', description: 'Skip gate' },561            ],562          },563        ],564        metadata: { source: 'plan_gate_cap' },565      };566 567      const invocation = tool.build(params);568      const details = await invocation.getConfirmationDetails(569        new AbortController().signal,570      );571      await details.onConfirm(ToolConfirmationOutcome.ProceedOnce, {572        answers: { '0': 'Approve execution' },573      });574      await invocation.execute(new AbortController().signal);575 576      // gateMode should NOT change because capEscalationPending was false577      expect(gateState.gateMode).toBe('capped');578    });579 580    it('should reset reviewCount on plan_gate_needs_user when needsUserPending is true', async () => {581      gateState.needsUserPending = true;582      const params = {583        questions: [584          {585            question: 'What DB?',586            header: 'DB',587            options: [588              { label: 'Postgres', description: 'PG' },589              { label: 'MySQL', description: 'My' },590            ],591          },592        ],593        metadata: { source: 'plan_gate_needs_user' },594      };595 596      const invocation = tool.build(params);597      const details = await invocation.getConfirmationDetails(598        new AbortController().signal,599      );600      await details.onConfirm(ToolConfirmationOutcome.ProceedOnce, {601        answers: { '0': 'Postgres' },602      });603      await invocation.execute(new AbortController().signal);604 605      expect(gateState.reviewCount).toBe(0);606      expect(gateState.needsUserPending).toBe(false);607    });608 609    it('should ignore plan_gate_needs_user when needsUserPending is false', async () => {610      gateState.needsUserPending = false;611      const params = {612        questions: [613          {614            question: 'What DB?',615            header: 'DB',616            options: [617              { label: 'Postgres', description: 'PG' },618              { label: 'MySQL', description: 'My' },619            ],620          },621        ],622        metadata: { source: 'plan_gate_needs_user' },623      };624 625      const invocation = tool.build(params);626      const details = await invocation.getConfirmationDetails(627        new AbortController().signal,628      );629      await details.onConfirm(ToolConfirmationOutcome.ProceedOnce, {630        answers: { '0': 'Postgres' },631      });632      await invocation.execute(new AbortController().signal);633 634      // reviewCount should NOT be reset because needsUserPending was false635      expect(gateState.reviewCount).toBe(3);636    });637 638    it('should not mutate state when no metadata source', async () => {639      const params = {640        questions: [641          {642            question: 'Pick?',643            header: 'Choice',644            options: [645              { label: 'A', description: 'a' },646              { label: 'B', description: 'b' },647            ],648          },649        ],650      };651 652      const invocation = tool.build(params);653      const details = await invocation.getConfirmationDetails(654        new AbortController().signal,655      );656      await details.onConfirm(ToolConfirmationOutcome.ProceedOnce, {657        answers: { '0': 'A' },658      });659      await invocation.execute(new AbortController().signal);660 661      expect(gateState.gateMode).toBe('capped');662      expect(gateState.reviewCount).toBe(3);663    });664  });665});666 
basant307/AI_Governance_Project · CoolFace