refactor: introduce AgentTriggerReason type union as SSOT for error reason strings

Replace scattered reason string literals with a centralized type hierarchy:
- AgentTriggerReason: all possible trigger reasons
- ClaudeRotationReason, CodexRotationReason, NoFallbackCooldownReason:
  derived subtypes for specific contexts
- AgentErrorClassification, FallbackTriggerResult, CodexRotationTriggerResult:
  discriminated unions for compile-time narrowing

Remove dead export isUsageExhausted() (superseded by
isPrimaryNoFallbackCooldownActive). Replace NO_FALLBACK_COOLDOWN_REASONS
Set with isNoFallbackCooldownReason() type guard.

Add tests for agent-error-detection and provider-retry (423 total passing).

Known limitation: 6 `as CodexRotationReason` casts in Codex rotation paths
where streamed trigger reason is AgentTriggerReason but runtime value is
always CodexRotationReason-compatible.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Eyejoker
2026-03-25 17:46:43 +09:00
parent b86fd1ad89
commit 94eeaa5e3e
10 changed files with 290 additions and 53 deletions

View File

@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';
import {
classifyClaudeAuthError,
isClaudeOrgAccessDeniedMessage,
isNoFallbackCooldownReason,
shouldRotateClaudeToken,
} from './agent-error-detection.js';
describe('agent-error-detection', () => {
it('detects Claude org access denied banners', () => {
expect(
isClaudeOrgAccessDeniedMessage(
'Your organization does not have access to Claude. Please login again or contact your administrator.',
),
).toBe(true);
});
it('classifies org access denied banners as org-access-denied', () => {
expect(
classifyClaudeAuthError(
'Your organization does not have access to Claude. Please login again or contact your administrator.',
),
).toEqual({
category: 'org-access-denied',
reason: 'org-access-denied',
});
});
it('classifies terminated 403 auth failures as org-access-denied', () => {
expect(
classifyClaudeAuthError(
'Failed to authenticate. API Error: 403 terminated',
),
).toEqual({
category: 'org-access-denied',
reason: 'org-access-denied',
});
});
it('marks only Claude quota/auth reasons as Claude rotation reasons', () => {
expect(shouldRotateClaudeToken('429')).toBe(true);
expect(shouldRotateClaudeToken('usage-exhausted')).toBe(true);
expect(shouldRotateClaudeToken('auth-expired')).toBe(true);
expect(shouldRotateClaudeToken('org-access-denied')).toBe(true);
expect(shouldRotateClaudeToken('overloaded')).toBe(false);
expect(shouldRotateClaudeToken('success-null-result')).toBe(false);
});
it('marks only no-fallback cooldown reasons as skip-worthy', () => {
expect(isNoFallbackCooldownReason('usage-exhausted')).toBe(true);
expect(isNoFallbackCooldownReason('auth-expired')).toBe(true);
expect(isNoFallbackCooldownReason('org-access-denied')).toBe(true);
expect(isNoFallbackCooldownReason('429')).toBe(false);
expect(isNoFallbackCooldownReason('success-null-result')).toBe(false);
});
});