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,98 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('./logger.js', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
vi.mock('./provider-fallback.js', () => ({
detectFallbackTrigger: vi.fn(() => ({ shouldFallback: false, reason: '' })),
markPrimaryCooldown: vi.fn(),
}));
vi.mock('./token-rotation.js', () => ({
rotateToken: vi.fn(() => false),
getTokenCount: vi.fn(() => 1),
markTokenHealthy: vi.fn(),
}));
import { markPrimaryCooldown } from './provider-fallback.js';
import { runClaudeRotationLoop } from './provider-retry.js';
import {
getTokenCount,
markTokenHealthy,
rotateToken,
} from './token-rotation.js';
describe('runClaudeRotationLoop', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getTokenCount).mockReturnValue(1);
vi.mocked(rotateToken).mockReturnValue(false);
});
it('rotates and succeeds after an org-access-denied trigger', async () => {
vi.mocked(getTokenCount).mockReturnValue(2);
vi.mocked(rotateToken).mockReturnValueOnce(true);
const outcome = await runClaudeRotationLoop(
{ reason: 'org-access-denied' },
async () => ({
output: { status: 'success', result: 'ok' },
sawOutput: true,
}),
{ runId: 'rotate-org-access' },
);
expect(outcome).toEqual({ type: 'success' });
expect(rotateToken).toHaveBeenCalledTimes(1);
expect(markTokenHealthy).toHaveBeenCalledTimes(1);
expect(markPrimaryCooldown).not.toHaveBeenCalled();
});
it('marks no-fallback cooldown when all Claude tokens are org-access-denied', async () => {
vi.mocked(getTokenCount).mockReturnValue(2);
const outcome = await runClaudeRotationLoop(
{ reason: 'org-access-denied' },
async () => ({
output: { status: 'success', result: 'should not run' },
sawOutput: true,
}),
{ runId: 'no-fallback-org-access' },
);
expect(outcome).toEqual({
type: 'no-fallback',
trigger: { reason: 'org-access-denied' },
});
expect(markPrimaryCooldown).toHaveBeenCalledWith(
'org-access-denied',
undefined,
);
});
it('returns success-null-result as a fallback trigger after rotation', async () => {
vi.mocked(getTokenCount).mockReturnValue(2);
vi.mocked(rotateToken).mockReturnValueOnce(true);
const outcome = await runClaudeRotationLoop(
{ reason: '429' },
async () => ({
output: { status: 'success', result: null },
sawOutput: false,
sawSuccessNullResult: true,
}),
{ runId: 'success-null-result' },
);
expect(outcome).toEqual({
type: 'needs-fallback',
trigger: { reason: 'success-null-result' },
});
expect(markPrimaryCooldown).not.toHaveBeenCalled();
});
});