import { beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('./agent-error-detection.js', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, classifyRotationTrigger: vi.fn(() => ({ shouldRetry: false, reason: '', })), }; }); vi.mock('./codex-token-rotation.js', () => ({ detectCodexRotationTrigger: vi.fn(() => ({ shouldRotate: false, reason: '', })), })); import { classifyRotationTrigger } from './agent-error-detection.js'; import { detectCodexRotationTrigger } from './codex-token-rotation.js'; import { isRetryableClaudeSessionFailureAttempt, resolveAttemptRetryAction, resolveClaudeRetryTrigger, resolveCodexRetryTrigger, resolvePairedFollowUpQueueAction, } from './message-agent-executor-rules.js'; describe('message-agent-executor-rules', () => { beforeEach(() => { vi.clearAllMocks(); }); it('resolves a Claude retry trigger from streamed trigger state', () => { expect( resolveClaudeRetryTrigger({ canRetryClaudeCredentials: true, provider: 'claude', attempt: { sawOutput: false, streamedTriggerReason: { reason: 'auth-expired', retryAfterMs: 5000 }, }, }), ).toEqual({ reason: 'auth-expired', retryAfterMs: 5000, }); }); it('returns null for Claude retry when output was already visible', () => { expect( resolveClaudeRetryTrigger({ canRetryClaudeCredentials: true, provider: 'claude', attempt: { sawOutput: true }, fallbackMessage: '429 rate limit', }), ).toBeNull(); expect(classifyRotationTrigger).not.toHaveBeenCalled(); }); it('resolves a Codex retry trigger from the codex detector', () => { vi.mocked(detectCodexRotationTrigger).mockReturnValue({ shouldRotate: true, reason: 'auth-expired', }); expect( resolveCodexRetryTrigger({ canRetryCodex: true, attempt: {}, rotationMessage: '401 oauth token has expired', }), ).toEqual({ reason: 'auth-expired' }); }); it('ignores streamed reasons that are not valid Codex rotation triggers', () => { expect( resolveCodexRetryTrigger({ canRetryCodex: true, attempt: { streamedTriggerReason: { reason: 'usage-exhausted' }, }, }), ).toBeNull(); expect(detectCodexRotationTrigger).not.toHaveBeenCalled(); }); it('resolves a shared Claude retry action from fallback output text', () => { vi.mocked(classifyRotationTrigger).mockReturnValue({ shouldRetry: true, reason: '429', retryAfterMs: 30000, }); expect( resolveAttemptRetryAction({ provider: 'claude', canRetryClaudeCredentials: true, canRetryCodex: false, attempt: { sawOutput: false }, rotationMessage: '429 rate limit', }), ).toEqual({ kind: 'claude', trigger: { reason: '429', retryAfterMs: 30000 }, rotationMessage: '429 rate limit', }); }); it('resolves a shared Codex retry action from streamed state', () => { expect( resolveAttemptRetryAction({ provider: 'codex', canRetryClaudeCredentials: false, canRetryCodex: true, attempt: { sawOutput: false, streamedTriggerReason: { reason: 'auth-expired' }, }, rotationMessage: 'oauth token expired', }), ).toEqual({ kind: 'codex', trigger: { reason: 'auth-expired' }, rotationMessage: 'oauth token expired', }); }); it('detects retryable Claude session failures from either flag or classifier', () => { expect( isRetryableClaudeSessionFailureAttempt({ attempt: { sawOutput: false, retryableSessionFailureDetected: true, }, isClaudeCodeAgent: true, provider: 'claude', shouldRetryFreshSessionOnAgentFailure: vi.fn(() => false), }), ).toBe(true); const shouldRetryFreshSessionOnAgentFailure = vi.fn(() => true); expect( isRetryableClaudeSessionFailureAttempt({ attempt: { sawOutput: false, error: new Error('stale session'), }, isClaudeCodeAgent: true, provider: 'claude', shouldRetryFreshSessionOnAgentFailure, }), ).toBe(true); expect(shouldRetryFreshSessionOnAgentFailure).toHaveBeenCalledWith({ result: null, error: 'stale session', }); }); it('resolves pending reviewer follow-up requeue from task state', () => { expect( resolvePairedFollowUpQueueAction({ completedRole: 'reviewer', executionStatus: 'failed', sawOutput: false, taskStatus: 'review_ready', }), ).toBe('pending'); }); it('requeues owner follow-up after a silent arbiter Codex account failure returns the task active', () => { expect( resolvePairedFollowUpQueueAction({ completedRole: 'arbiter', executionStatus: 'failed', sawOutput: false, taskStatus: 'active', outputSummary: 'Your access token could not be refreshed because your refresh token was already used. Please log out and sign in again.\nExecution completed without a visible terminal verdict.', }), ).toBe('pending'); }); it('resolves pending arbiter follow-up requeue after repeated owner execution failures', () => { expect( resolvePairedFollowUpQueueAction({ completedRole: 'owner', executionStatus: 'failed', sawOutput: false, taskStatus: 'arbiter_requested', }), ).toBe('pending'); }); it('resolves pending owner retry after a silent owner failure leaves the task active', () => { expect( resolvePairedFollowUpQueueAction({ completedRole: 'owner', executionStatus: 'failed', sawOutput: false, taskStatus: 'active', }), ).toBe('pending'); }); it('requeues a silent owner failure caused by Codex pool unavailability', () => { expect( resolvePairedFollowUpQueueAction({ completedRole: 'owner', executionStatus: 'failed', sawOutput: false, taskStatus: 'active', outputSummary: 'auth-expired: All Codex rotation accounts unavailable; re-auth required before launching Codex\nExecution completed without a visible terminal verdict.', }), ).toBe('pending'); }); it('does not request an executor-side follow-up after successful owner output moved the task to review_ready', () => { expect( resolvePairedFollowUpQueueAction({ completedRole: 'owner', executionStatus: 'succeeded', sawOutput: true, taskStatus: 'review_ready', }), ).toBe('none'); }); it('does not request an executor-side follow-up when reviewer success moved the task to merge_ready', () => { expect( resolvePairedFollowUpQueueAction({ completedRole: 'reviewer', executionStatus: 'succeeded', sawOutput: true, taskStatus: 'merge_ready', }), ).toBe('none'); }); it('does not request a generic follow-up after successful reviewer output moved the task back to active', () => { expect( resolvePairedFollowUpQueueAction({ completedRole: 'reviewer', executionStatus: 'succeeded', sawOutput: true, taskStatus: 'active', }), ).toBe('none'); }); it('does not request a duplicate executor-side follow-up after successful owner STEP_DONE output', () => { expect( resolvePairedFollowUpQueueAction({ completedRole: 'owner', executionStatus: 'succeeded', sawOutput: true, taskStatus: 'active', outputSummary: 'STEP_DONE\nkeep going', }), ).toBe('none'); }); it('returns none after successful output when no next-turn action is needed', () => { expect( resolvePairedFollowUpQueueAction({ completedRole: 'owner', executionStatus: 'succeeded', sawOutput: true, taskStatus: 'active', }), ).toBe('none'); }); });