Files
EJClaw/src/message-agent-executor-rules.test.ts
Eyejoker 3d36e15f67 Fix recurring Codex unavailable in paired rooms (#212)
* fix: avoid codex leases for claude readonly sessions

* fix: restore codex lease quality budget

* fix: preserve owner finalization on codex failure

* fix: wake owner after arbiter codex failure

* chore: format readonly codex lease fix
2026-06-03 01:14:39 +08:00

279 lines
8.0 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('./agent-error-detection.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('./agent-error-detection.js')>();
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');
});
});