import { describe, expect, it, vi, beforeEach } from 'vitest'; import type { AgentOutput } from './agent-runner.js'; // ── Mocks ────────────────────────────────────────────────────── vi.mock('./agent-error-detection.js', () => ({ classifyClaudeAuthError: vi.fn(() => ({ category: 'none', reason: '' })), classifyRotationTrigger: vi.fn(() => ({ shouldRetry: false, reason: '' })), detectClaudeProviderFailureMessage: vi.fn(() => ''), isClaudeUsageExhaustedMessage: vi.fn(() => false), isClaudeOrgAccessDeniedMessage: vi.fn(() => false), isClaudeAuthExpiredMessage: vi.fn(() => false), isClaudeAuthError: vi.fn(() => false), })); vi.mock('./codex-token-rotation.js', () => ({ detectCodexRotationTrigger: vi.fn(() => ({ shouldRotate: false, reason: '', })), })); vi.mock('./session-recovery.js', () => ({ shouldRetryFreshCodexSessionOnAgentFailure: vi.fn(() => false), shouldRetryFreshSessionOnAgentFailure: vi.fn(() => false), })); import { classifyClaudeAuthError, classifyRotationTrigger, detectClaudeProviderFailureMessage, isClaudeUsageExhaustedMessage, isClaudeOrgAccessDeniedMessage, isClaudeAuthExpiredMessage, isClaudeAuthError, } from './agent-error-detection.js'; import { detectCodexRotationTrigger } from './codex-token-rotation.js'; import { shouldRetryFreshCodexSessionOnAgentFailure, shouldRetryFreshSessionOnAgentFailure, } from './session-recovery.js'; import { evaluateStreamedOutput, type StreamedOutputState, type EvaluateStreamedOutputOptions, } from './streamed-output-evaluator.js'; // ── Helpers ──────────────────────────────────────────────────── function freshState(): StreamedOutputState { return { sawOutput: false, sawVisibleOutput: false, sawSuccessNullResultWithoutOutput: false, }; } const claudeOpts: EvaluateStreamedOutputOptions = { agentType: 'claude-code', provider: 'claude', }; const codexOpts: EvaluateStreamedOutputOptions = { agentType: 'codex', provider: 'codex', }; function successOutput( result: string | null, phase?: AgentOutput['phase'], ): AgentOutput { return { status: 'success', result, ...(phase ? { phase } : {}) }; } function errorOutput(error: string): AgentOutput { return { status: 'error', result: null, error }; } beforeEach(() => { vi.clearAllMocks(); vi.mocked(classifyClaudeAuthError).mockReturnValue({ category: 'none', reason: '', }); vi.mocked(classifyRotationTrigger).mockReturnValue({ shouldRetry: false, reason: '', }); vi.mocked(detectClaudeProviderFailureMessage).mockReturnValue(''); vi.mocked(isClaudeUsageExhaustedMessage).mockReturnValue(false); vi.mocked(isClaudeOrgAccessDeniedMessage).mockReturnValue(false); vi.mocked(isClaudeAuthExpiredMessage).mockReturnValue(false); vi.mocked(isClaudeAuthError).mockReturnValue(false); vi.mocked(detectCodexRotationTrigger).mockReturnValue({ shouldRotate: false, reason: '', }); vi.mocked(shouldRetryFreshCodexSessionOnAgentFailure).mockReturnValue(false); vi.mocked(shouldRetryFreshSessionOnAgentFailure).mockReturnValue(false); }); // ── Tests ────────────────────────────────────────────────────── describe('evaluateStreamedOutput', () => { describe('normal output forwarding', () => { it('forwards success output and sets sawOutput', () => { const result = evaluateStreamedOutput( successOutput('Hello'), freshState(), claudeOpts, ); expect(result.shouldForwardOutput).toBe(true); expect(result.state.sawOutput).toBe(true); expect(result.newTrigger).toBeUndefined(); }); it('forwards output for non-primary agent type', () => { const result = evaluateStreamedOutput( successOutput('Hello'), freshState(), { agentType: 'claude-code', provider: 'fallback' }, ); expect(result.shouldForwardOutput).toBe(true); expect(result.state.sawOutput).toBe(true); }); it('does not set sawOutput for null result', () => { const result = evaluateStreamedOutput( successOutput(null), freshState(), claudeOpts, ); expect(result.shouldForwardOutput).toBe(true); expect(result.state.sawOutput).toBe(false); }); it('does not set sawOutput for progress output', () => { const result = evaluateStreamedOutput( successOutput('대화 요약 중...', 'progress'), freshState(), claudeOpts, ); expect(result.shouldForwardOutput).toBe(true); expect(result.state.sawOutput).toBe(false); }); it('treats structured silent final output as output, not success-null-result', () => { const result = evaluateStreamedOutput( { status: 'success', result: null, output: { visibility: 'silent' }, }, freshState(), { ...claudeOpts, trackSuccessNullResult: true }, ); expect(result.shouldForwardOutput).toBe(true); expect(result.state.sawOutput).toBe(true); expect(result.state.sawSuccessNullResultWithoutOutput).toBe(false); }); }); describe('Claude usage-exhausted banner', () => { it('suppresses output and returns newTrigger', () => { vi.mocked(isClaudeUsageExhaustedMessage).mockReturnValue(true); const result = evaluateStreamedOutput( successOutput("You're out of extra usage. Resets at 5pm."), freshState(), claudeOpts, ); expect(result.shouldForwardOutput).toBe(false); expect(result.newTrigger).toEqual({ reason: 'usage-exhausted' }); expect(result.state.streamedTriggerReason).toEqual({ reason: 'usage-exhausted', }); }); it('does not fire on non-primary Claude', () => { vi.mocked(isClaudeUsageExhaustedMessage).mockReturnValue(true); const result = evaluateStreamedOutput( successOutput('banner text'), freshState(), { agentType: 'claude-code', provider: 'fallback' }, ); // Non-primary: skip banner check, forward normally expect(result.shouldForwardOutput).toBe(true); expect(result.newTrigger).toBeUndefined(); }); it('does not fire when output was already seen', () => { vi.mocked(isClaudeUsageExhaustedMessage).mockReturnValue(true); const result = evaluateStreamedOutput( successOutput('banner text'), { ...freshState(), sawOutput: true }, claudeOpts, ); expect(result.shouldForwardOutput).toBe(true); expect(result.newTrigger).toBeUndefined(); }); it('detects a structured public usage-exhausted banner too', () => { vi.mocked(isClaudeUsageExhaustedMessage).mockReturnValue(true); const result = evaluateStreamedOutput( { status: 'success', result: null, output: { visibility: 'public', text: "You're out of extra usage. Resets at 5pm.", }, }, freshState(), claudeOpts, ); expect(result.shouldForwardOutput).toBe(false); expect(result.newTrigger).toEqual({ reason: 'usage-exhausted' }); expect(result.state.streamedTriggerReason).toEqual({ reason: 'usage-exhausted', }); }); }); describe('Claude retryable session failure suppression', () => { it('suppresses retryable session failures before any visible output', () => { vi.mocked(shouldRetryFreshSessionOnAgentFailure).mockReturnValue(true); const result = evaluateStreamedOutput( successOutput( 'API Error: 400 {"type":"error","error":{"type":"invalid_request_error","message":"messages.11.content.0: Invalid `signature` in `thinking` block"}}', 'intermediate', ), freshState(), claudeOpts, ); expect(result.shouldForwardOutput).toBe(false); expect(result.suppressedRetryableSessionFailure).toBe(true); expect(result.state.retryableSessionFailureDetected).toBe(true); expect(result.state.sawOutput).toBe(false); }); it('does not suppress retryable session failures after visible output', () => { vi.mocked(shouldRetryFreshSessionOnAgentFailure).mockReturnValue(true); const result = evaluateStreamedOutput( successOutput('API Error: 400 invalid thinking block', 'final'), { ...freshState(), sawOutput: true }, claudeOpts, ); expect(result.shouldForwardOutput).toBe(true); expect(result.suppressedRetryableSessionFailure).toBeUndefined(); }); }); describe('Codex retryable session failure suppression', () => { it('suppresses remote compact failures before any visible output', () => { vi.mocked(shouldRetryFreshCodexSessionOnAgentFailure).mockReturnValue( true, ); const result = evaluateStreamedOutput( successOutput( "Error running remote compact task: Unknown parameter: 'prompt_cache_retention'", 'intermediate', ), freshState(), codexOpts, ); expect(result.shouldForwardOutput).toBe(false); expect(result.suppressedRetryableSessionFailure).toBe(true); expect(result.state.retryableSessionFailureDetected).toBe(true); expect(result.state.sawOutput).toBe(false); }); }); describe('Claude auth-expired banner', () => { it('suppresses output and returns newTrigger', () => { vi.mocked(isClaudeAuthExpiredMessage).mockReturnValue(true); const result = evaluateStreamedOutput( successOutput('Failed to authenticate...'), freshState(), claudeOpts, ); expect(result.shouldForwardOutput).toBe(false); expect(result.newTrigger).toEqual({ reason: 'auth-expired' }); }); }); describe('Claude provider failure banner', () => { it('suppresses Cloudflare 502 HTML and returns newTrigger', () => { vi.mocked(detectClaudeProviderFailureMessage).mockReturnValue( 'overloaded', ); const result = evaluateStreamedOutput( successOutput( 'API Error: 502