basant307/AI_Governance_Project
048
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 