CoolFace
Datasetpublic

basant307/AI_Governance_Project

sourceHugging Faceapache-2.0updated 2mo agoView on Hugging Face
0likes45downloads
planApprovalGate.test.ts446 linesDownload Raw Back to plan-gate
1/**2 * @license3 * Copyright 2025 Qwen4 * SPDX-License-Identifier: Apache-2.05 */6 7import { describe, it, expect, vi, beforeEach } from 'vitest';8import { assignFindingIds, runPlanApprovalGate } from './planApprovalGate.js';9import type { GateAgentResult, EvidenceBundle } from './types.js';10import { type PlanGateState, createPlanGateState } from './state.js';11import type { Config } from '../config/config.js';12 13// ── assignFindingIds unit tests ───────────────────────────────────────14 15describe('assignFindingIds', () => {16  it('should assign stable GF-N ids in order', () => {17    const result: GateAgentResult = {18      agent: 'plan_reviewer',19      decision: 'blocked',20      findings: [21        {22          localId: 'GF-1',23          severity: 'P2',24          issue: 'Missing feature X',25          rationale: 'not addressed',26        },27        {28          localId: 'GF-2',29          severity: 'P3',30          issue: 'Wrong file path',31          rationale: 'file moved',32        },33      ],34    };35    const merged = assignFindingIds(result);36    expect(merged).toHaveLength(2);37    expect(merged[0]!.id).toBe('GF-1');38    expect(merged[1]!.id).toBe('GF-2');39  });40 41  it('should return empty array when agent passes', () => {42    const result: GateAgentResult = {43      agent: 'plan_reviewer',44      decision: 'pass',45      findings: [],46    };47    const merged = assignFindingIds(result);48    expect(merged).toHaveLength(0);49  });50 51  it('should preserve all finding fields', () => {52    const result: GateAgentResult = {53      agent: 'plan_reviewer',54      decision: 'blocked',55      findings: [56        {57          localId: 'GF-1',58          severity: 'P1',59          issue: 'Critical',60          rationale: 'violates request',61          suggestedFix: 'Fix it',62          suggestedQuestion: 'Are you sure?',63        },64      ],65    };66    const merged = assignFindingIds(result);67    expect(merged[0]).toEqual({68      id: 'GF-1',69      severity: 'P1',70      issue: 'Critical',71      rationale: 'violates request',72      suggestedFix: 'Fix it',73      suggestedQuestion: 'Are you sure?',74    });75  });76});77 78// ── runPlanApprovalGate ───────────────────────────────────────────────79 80vi.mock('./gateReviewAgents.js', () => ({81  runGateAgent: vi.fn(),82}));83 84import { runGateAgent } from './gateReviewAgents.js';85 86const mockRunGateAgent = vi.mocked(runGateAgent);87 88function makeResult(overrides: Partial<GateAgentResult> = {}): GateAgentResult {89  return {90    agent: 'plan_reviewer',91    decision: 'pass',92    findings: [],93    ...overrides,94  };95}96 97describe('runPlanApprovalGate', () => {98  let gateState: PlanGateState;99  let mockConfig: Config;100  const signal = new AbortController().signal;101 102  const bundle: EvidenceBundle = {103    originalRequest: 'Add a button',104    plan: 'Step 1: add button component',105  };106 107  beforeEach(() => {108    gateState = createPlanGateState(1);109    mockConfig = {110      getPlanGateState: vi.fn(() => gateState),111      getSubagentManager: vi.fn(),112    } as unknown as Config;113    mockRunGateAgent.mockReset();114  });115 116  it('should return unavailable when no gate state', async () => {117    (mockConfig.getPlanGateState as ReturnType<typeof vi.fn>).mockReturnValue(118      undefined,119    );120 121    const decision = await runPlanApprovalGate(mockConfig, bundle, signal);122    expect(decision.kind).toBe('unavailable');123  });124 125  it('should return approved when agent passes with no findings', async () => {126    mockRunGateAgent.mockResolvedValue(makeResult({ decision: 'pass' }));127 128    const decision = await runPlanApprovalGate(mockConfig, bundle, signal);129    expect(decision.kind).toBe('approved');130  });131 132  it('should return unavailable when agent reports itself as unavailable (even with empty findings)', async () => {133    mockRunGateAgent.mockResolvedValue(134      makeResult({ decision: 'unavailable', findings: [] }),135    );136 137    const decision = await runPlanApprovalGate(mockConfig, bundle, signal);138    expect(decision.kind).toBe('unavailable');139    expect((decision as { reason: string }).reason).toContain('unavailable');140  });141 142  it('should return blocked when agent has P1 findings', async () => {143    mockRunGateAgent.mockResolvedValue(144      makeResult({145        decision: 'blocked',146        findings: [147          {148            localId: 'GF-1',149            severity: 'P1',150            issue: 'Critical flaw',151            rationale: 'violates request',152          },153        ],154      }),155    );156 157    const decision = await runPlanApprovalGate(mockConfig, bundle, signal);158    expect(decision.kind).toBe('blocked');159  });160 161  it('should return needs_user when agent returns needs_user with suggestedQuestion', async () => {162    mockRunGateAgent.mockResolvedValue(163      makeResult({164        decision: 'needs_user',165        findings: [166          {167            localId: 'GF-1',168            severity: 'P2',169            issue: 'Ambiguous scope',170            rationale: 'unclear',171            suggestedQuestion: 'Do you want feature A or B?',172          },173        ],174      }),175    );176 177    const decision = await runPlanApprovalGate(mockConfig, bundle, signal);178    expect(decision.kind).toBe('needs_user');179    expect((decision as { questions: string[] }).questions).toEqual([180      'Do you want feature A or B?',181    ]);182  });183 184  it('should fall through to blocked when needs_user has no suggestedQuestion', async () => {185    mockRunGateAgent.mockResolvedValue(186      makeResult({187        decision: 'needs_user',188        findings: [189          {190            localId: 'GF-1',191            severity: 'P2',192            issue: 'Missing info',193            rationale: 'no question provided',194          },195        ],196      }),197    );198 199    const decision = await runPlanApprovalGate(mockConfig, bundle, signal);200    expect(decision.kind).toBe('blocked');201  });202 203  it('should return cap_escalation when at cap with blocking findings', async () => {204    gateState.reviewCount = 4; // next will be 5 (= CAPPED_REVIEW_LIMIT)205    mockRunGateAgent.mockResolvedValue(206      makeResult({207        decision: 'blocked',208        findings: [209          {210            localId: 'GF-1',211            severity: 'P1',212            issue: 'Still broken',213            rationale: 'unresolved',214          },215        ],216      }),217    );218 219    const decision = await runPlanApprovalGate(mockConfig, bundle, signal);220    expect(decision.kind).toBe('cap_escalation');221  });222 223  it('should approve with non-blocking notes when at cap with only P3 findings', async () => {224    gateState.reviewCount = 4;225    mockRunGateAgent.mockResolvedValue(226      makeResult({227        decision: 'blocked',228        findings: [229          {230            localId: 'GF-1',231            severity: 'P3',232            issue: 'Minor style',233            rationale: 'nit',234          },235        ],236      }),237    );238 239    const decision = await runPlanApprovalGate(mockConfig, bundle, signal);240    expect(decision.kind).toBe('approved');241    expect(242      (decision as { nonBlockingFindings?: unknown[] }).nonBlockingFindings,243    ).toHaveLength(1);244  });245 246  it('should return unavailable when agent exhausts retries', async () => {247    mockRunGateAgent.mockRejectedValue(new Error('network error'));248 249    const decision = await runPlanApprovalGate(mockConfig, bundle, signal);250    expect(decision.kind).toBe('unavailable');251    expect((decision as { reason: string }).reason).toContain('retries');252  });253});254 255// ── Cap logic tests ───────────────────────────────────────────────────256 257describe('runPlanApprovalGate decision edge cases', () => {258  let gateState: PlanGateState;259  let mockConfig: Config;260  const signal = new AbortController().signal;261 262  const bundle: EvidenceBundle = {263    originalRequest: 'Add a button',264    plan: 'Step 1: add button component',265  };266 267  beforeEach(() => {268    gateState = createPlanGateState(1);269    mockConfig = {270      getPlanGateState: vi.fn(() => gateState),271      getSubagentManager: vi.fn(),272    } as unknown as Config;273    mockRunGateAgent.mockReset();274  });275 276  it('should return unavailable when needs_user has empty findings', async () => {277    mockRunGateAgent.mockResolvedValue(278      makeResult({ decision: 'needs_user', findings: [] }),279    );280 281    const decision = await runPlanApprovalGate(mockConfig, bundle, signal);282    expect(decision.kind).toBe('unavailable');283    expect((decision as { reason: string }).reason).toContain('needs_user');284  });285 286  it('should return unavailable when blocked has empty findings', async () => {287    mockRunGateAgent.mockResolvedValue(288      makeResult({ decision: 'blocked', findings: [] }),289    );290 291    const decision = await runPlanApprovalGate(mockConfig, bundle, signal);292    expect(decision.kind).toBe('unavailable');293    expect((decision as { reason: string }).reason).toContain('blocked');294  });295 296  it('should treat pass-with-findings as blocked', async () => {297    mockRunGateAgent.mockResolvedValue(298      makeResult({299        decision: 'pass',300        findings: [301          {302            localId: 'GF-1',303            severity: 'P2',304            issue: 'Anomalous finding',305            rationale: 'should not pass',306          },307        ],308      }),309    );310 311    const decision = await runPlanApprovalGate(mockConfig, bundle, signal);312    expect(decision.kind).toBe('blocked');313  });314 315  it('should return unavailable when pre-aborted signal', async () => {316    const abortController = new AbortController();317    abortController.abort();318    mockRunGateAgent.mockRejectedValue(new Error('aborted'));319 320    const decision = await runPlanApprovalGate(321      mockConfig,322      bundle,323      abortController.signal,324    );325    expect(decision.kind).toBe('unavailable');326  });327 328  it('should succeed after partial retries (fail 2, then succeed)', async () => {329    mockRunGateAgent330      .mockRejectedValueOnce(new Error('transient 1'))331      .mockRejectedValueOnce(new Error('transient 2'))332      .mockResolvedValueOnce(makeResult({ decision: 'pass' }));333 334    const decision = await runPlanApprovalGate(mockConfig, bundle, signal);335    expect(decision.kind).toBe('approved');336    expect(mockRunGateAgent).toHaveBeenCalledTimes(3);337  });338 339  it('should handle uncapped mode (findings still block without cap escalation)', async () => {340    gateState.gateMode = 'uncapped';341    gateState.reviewCount = 10;342    mockRunGateAgent.mockResolvedValue(343      makeResult({344        decision: 'blocked',345        findings: [346          {347            localId: 'GF-1',348            severity: 'P1',349            issue: 'Critical',350            rationale: 'bad',351          },352        ],353      }),354    );355 356    const decision = await runPlanApprovalGate(mockConfig, bundle, signal);357    expect(decision.kind).toBe('blocked');358  });359 360  it('should increment reviewCount on each gate run', async () => {361    expect(gateState.reviewCount).toBe(0);362    mockRunGateAgent.mockResolvedValue(makeResult({ decision: 'pass' }));363 364    await runPlanApprovalGate(mockConfig, bundle, signal);365    expect(gateState.reviewCount).toBe(1);366  });367 368  it('should store findings in gateState.lastFindings', async () => {369    mockRunGateAgent.mockResolvedValue(370      makeResult({371        decision: 'blocked',372        findings: [373          {374            localId: 'GF-1',375            severity: 'P2',376            issue: 'Test issue',377            rationale: 'test',378          },379        ],380      }),381    );382 383    await runPlanApprovalGate(mockConfig, bundle, signal);384    expect(gateState.lastFindings).toHaveLength(1);385    expect(gateState.lastFindings[0]!.id).toBe('GF-1');386  });387 388  it('P3-only at cap approves with nonBlockingFindings', async () => {389    gateState.reviewCount = 4;390    mockRunGateAgent.mockResolvedValue(391      makeResult({392        decision: 'blocked',393        findings: [394          {395            localId: 'GF-1',396            severity: 'P3',397            issue: 'Minor',398            rationale: 'nit',399          },400          {401            localId: 'GF-2',402            severity: 'P3',403            issue: 'Also minor',404            rationale: 'style',405          },406        ],407      }),408    );409 410    const decision = await runPlanApprovalGate(mockConfig, bundle, signal);411    expect(decision.kind).toBe('approved');412    expect(413      (decision as { nonBlockingFindings?: unknown[] }).nonBlockingFindings,414    ).toHaveLength(2);415  });416 417  it('P1 at cap triggers cap_escalation with only blocking findings', async () => {418    gateState.reviewCount = 4;419    mockRunGateAgent.mockResolvedValue(420      makeResult({421        decision: 'blocked',422        findings: [423          {424            localId: 'GF-1',425            severity: 'P1',426            issue: 'Critical',427            rationale: 'bad',428          },429          {430            localId: 'GF-2',431            severity: 'P3',432            issue: 'Minor',433            rationale: 'nit',434          },435        ],436      }),437    );438 439    const decision = await runPlanApprovalGate(mockConfig, bundle, signal);440    expect(decision.kind).toBe('cap_escalation');441    const escalation = decision as { blockingFindings: Array<{ id: string }> };442    expect(escalation.blockingFindings).toHaveLength(1);443    expect(escalation.blockingFindings[0]!.id).toBe('GF-1');444  });445});446 
basant307/AI_Governance_Project · CoolFace