fix: detect org-access-denied errors, suppress chat output, and rotate Claude tokens

When a Claude account is suspended, the API returns "Your organization does
not have access to Claude" on the success path or "Failed to authenticate.
API Error: 403 terminated" on the error path. Both are now classified as
'org-access-denied', suppressed from Discord output, and trigger automatic
token rotation. If all tokens are exhausted, enters cooldown without Kimi
fallback (same as usage-exhausted and auth-expired).

Changes:
- agent-error-detection: add isClaudeOrgAccessDeniedMessage(), expand
  classifyClaudeAuthError() and shouldRotateClaudeToken()
- streamed-output-evaluator: detect org-access-denied in success-path chain
- provider-fallback: add NO_FALLBACK_COOLDOWN_REASONS set and
  isPrimaryNoFallbackCooldownActive() helper
- provider-retry: handle org-access-denied in rotation loop
- message-agent-executor / task-scheduler: use generalized no-fallback
  cooldown check
- Tests: +8 test cases across 5 test files (415 total passing)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Eyejoker
2026-03-25 17:24:10 +09:00
parent 3e1e032346
commit 60a9fe86ec
11 changed files with 372 additions and 16 deletions

View File

@@ -5,6 +5,7 @@ import type { AgentOutput } from './agent-runner.js';
// ── Mocks ──────────────────────────────────────────────────────
vi.mock('./agent-error-detection.js', () => ({
isClaudeUsageExhaustedMessage: vi.fn(() => false),
isClaudeOrgAccessDeniedMessage: vi.fn(() => false),
isClaudeAuthExpiredMessage: vi.fn(() => false),
isClaudeAuthError: vi.fn(() => false),
}));
@@ -22,6 +23,7 @@ vi.mock('./codex-token-rotation.js', () => ({
import {
isClaudeUsageExhaustedMessage,
isClaudeOrgAccessDeniedMessage,
isClaudeAuthExpiredMessage,
isClaudeAuthError,
} from './agent-error-detection.js';
@@ -60,6 +62,7 @@ function errorOutput(error: string): AgentOutput {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(isClaudeUsageExhaustedMessage).mockReturnValue(false);
vi.mocked(isClaudeOrgAccessDeniedMessage).mockReturnValue(false);
vi.mocked(isClaudeAuthExpiredMessage).mockReturnValue(false);
vi.mocked(isClaudeAuthError).mockReturnValue(false);
vi.mocked(detectFallbackTrigger).mockReturnValue({
@@ -164,6 +167,25 @@ describe('evaluateStreamedOutput', () => {
});
});
describe('Claude org-access-denied banner', () => {
it('suppresses output and returns newTrigger', () => {
vi.mocked(isClaudeOrgAccessDeniedMessage).mockReturnValue(true);
const result = evaluateStreamedOutput(
successOutput(
'Your organization does not have access to Claude. Please login again or contact your administrator.',
),
freshState(),
claudeOpts,
);
expect(result.shouldForwardOutput).toBe(false);
expect(result.newTrigger).toEqual({ reason: 'org-access-denied' });
expect(result.state.streamedTriggerReason).toEqual({
reason: 'org-access-denied',
});
});
});
describe('duplicate trigger suppression', () => {
it('suppresses output but returns no newTrigger when already triggered', () => {
vi.mocked(isClaudeUsageExhaustedMessage).mockReturnValue(true);