refactor: move retry and recovery paths to structured output helpers
This commit is contained in:
@@ -26,6 +26,16 @@ export function getAgentOutputText(output: {
|
||||
return stringifyLegacyAgentResult(output.result);
|
||||
}
|
||||
|
||||
export function hasAgentOutputPayload(output: {
|
||||
output?: StructuredAgentOutput;
|
||||
result?: string | object | null;
|
||||
}): boolean {
|
||||
if (output.output) {
|
||||
return true;
|
||||
}
|
||||
return output.result !== null && output.result !== undefined;
|
||||
}
|
||||
|
||||
export function isSilentAgentOutput(output: {
|
||||
output?: StructuredAgentOutput;
|
||||
}): boolean {
|
||||
|
||||
@@ -54,7 +54,10 @@ describe('classifySuppressTokenOutput', () => {
|
||||
|
||||
it('treats a truncated structured silent envelope as mixed', () => {
|
||||
expect(
|
||||
classifySuppressTokenOutput('{"ejclaw":{"visibility":"silent"', undefined),
|
||||
classifySuppressTokenOutput(
|
||||
'{"ejclaw":{"visibility":"silent"',
|
||||
undefined,
|
||||
),
|
||||
).toBe('mixed');
|
||||
});
|
||||
|
||||
|
||||
@@ -83,4 +83,42 @@ describe('runClaudeRotationLoop', () => {
|
||||
trigger: { reason: 'success-null-result' },
|
||||
});
|
||||
});
|
||||
|
||||
it('uses structured public output text as the next rotation message', async () => {
|
||||
vi.mocked(getTokenCount).mockReturnValue(2);
|
||||
vi.mocked(rotateToken)
|
||||
.mockReturnValueOnce(true)
|
||||
.mockReturnValueOnce(false);
|
||||
|
||||
let attempts = 0;
|
||||
const outcome = await runClaudeRotationLoop(
|
||||
{ reason: '429' },
|
||||
async () => {
|
||||
attempts += 1;
|
||||
if (attempts === 1) {
|
||||
return {
|
||||
output: {
|
||||
status: 'success',
|
||||
result: null,
|
||||
output: { visibility: 'public', text: 'retry with next token' },
|
||||
},
|
||||
sawOutput: false,
|
||||
streamedTriggerReason: { reason: 'usage-exhausted' },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
output: { status: 'success', result: 'ok' },
|
||||
sawOutput: true,
|
||||
};
|
||||
},
|
||||
{ runId: 'structured-rotation-message' },
|
||||
);
|
||||
|
||||
expect(outcome).toEqual({
|
||||
type: 'error',
|
||||
trigger: { reason: 'usage-exhausted' },
|
||||
});
|
||||
expect(rotateToken).toHaveBeenNthCalledWith(2, 'retry with next token');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
* to eliminate the ~255-line structural duplication.
|
||||
*/
|
||||
|
||||
import type { AgentOutput } from './agent-runner.js';
|
||||
import { getAgentOutputText } from './agent-output.js';
|
||||
import {
|
||||
classifyRotationTrigger,
|
||||
shouldRotateClaudeToken,
|
||||
@@ -26,7 +28,7 @@ export interface TriggerInfo {
|
||||
}
|
||||
|
||||
export interface RotationAttemptResult {
|
||||
output?: { status: string; result?: string | null; error?: string | null };
|
||||
output?: Pick<AgentOutput, 'status' | 'result' | 'output' | 'error'>;
|
||||
thrownError?: unknown;
|
||||
sawOutput: boolean;
|
||||
sawSuccessNullResult?: boolean;
|
||||
@@ -115,8 +117,7 @@ export async function runClaudeRotationLoop(
|
||||
reason: attempt.streamedTriggerReason.reason,
|
||||
retryAfterMs: attempt.streamedTriggerReason.retryAfterMs,
|
||||
};
|
||||
lastRotationMessage =
|
||||
typeof output.result === 'string' ? output.result : undefined;
|
||||
lastRotationMessage = getAgentOutputText(output) ?? undefined;
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,19 @@ describe('shouldResetSessionOnAgentFailure', () => {
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('matches structured public output text too', () => {
|
||||
expect(
|
||||
shouldResetSessionOnAgentFailure({
|
||||
result: null,
|
||||
output: {
|
||||
visibility: 'public',
|
||||
text: 'API Error: 400 {"type":"error","error":{"type":"invalid_request_error","message":"messages.11.content.0: Invalid `signature` in `thinking` block"}}',
|
||||
},
|
||||
error: undefined,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldRetryFreshSessionOnAgentFailure', () => {
|
||||
@@ -75,4 +88,14 @@ describe('shouldRetryFreshSessionOnAgentFailure', () => {
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('does not treat structured silent output as a retryable session failure', () => {
|
||||
expect(
|
||||
shouldRetryFreshSessionOnAgentFailure({
|
||||
result: null,
|
||||
output: { visibility: 'silent' },
|
||||
error: undefined,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getAgentOutputText } from './agent-output.js';
|
||||
import type { AgentOutput } from './agent-runner.js';
|
||||
|
||||
const SESSION_RESET_PATTERNS = [
|
||||
@@ -28,18 +29,24 @@ function toText(value: string | object | null | undefined): string[] {
|
||||
}
|
||||
|
||||
export function shouldResetSessionOnAgentFailure(
|
||||
output: Pick<AgentOutput, 'result' | 'error'>,
|
||||
output: Pick<AgentOutput, 'result' | 'output' | 'error'>,
|
||||
): boolean {
|
||||
const texts = [...toText(output.result), ...toText(output.error)];
|
||||
const texts = [
|
||||
...toText(getAgentOutputText(output)),
|
||||
...toText(output.error),
|
||||
];
|
||||
return texts.some((text) =>
|
||||
SESSION_RESET_PATTERNS.some((pattern) => pattern.test(text)),
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldRetryFreshSessionOnAgentFailure(
|
||||
output: Pick<AgentOutput, 'result' | 'error'>,
|
||||
output: Pick<AgentOutput, 'result' | 'output' | 'error'>,
|
||||
): boolean {
|
||||
const texts = [...toText(output.result), ...toText(output.error)];
|
||||
const texts = [
|
||||
...toText(getAgentOutputText(output)),
|
||||
...toText(output.error),
|
||||
];
|
||||
return texts.some((text) =>
|
||||
SESSION_RETRY_PATTERNS.some((pattern) => pattern.test(text)),
|
||||
);
|
||||
|
||||
@@ -138,6 +138,21 @@ describe('evaluateStreamedOutput', () => {
|
||||
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', () => {
|
||||
@@ -180,6 +195,28 @@ describe('evaluateStreamedOutput', () => {
|
||||
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', () => {
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
getAgentOutputText,
|
||||
hasAgentOutputPayload,
|
||||
isSilentAgentOutput,
|
||||
} from './agent-output.js';
|
||||
import {
|
||||
classifyClaudeAuthError,
|
||||
classifyRotationTrigger,
|
||||
@@ -53,6 +58,8 @@ export function evaluateStreamedOutput(
|
||||
options.agentType === 'codex' && options.provider === 'codex';
|
||||
const countsAsFinalOutput =
|
||||
output.phase === undefined || output.phase === 'final';
|
||||
const outputText = getAgentOutputText(output);
|
||||
const silentOutput = isSilentAgentOutput(output);
|
||||
|
||||
if (
|
||||
isPrimaryClaude &&
|
||||
@@ -71,19 +78,19 @@ export function evaluateStreamedOutput(
|
||||
isPrimaryClaude &&
|
||||
output.status === 'success' &&
|
||||
!state.sawOutput &&
|
||||
typeof output.result === 'string'
|
||||
typeof outputText === 'string'
|
||||
) {
|
||||
const authClassification = classifyClaudeAuthError(output.result);
|
||||
const authClassification = classifyClaudeAuthError(outputText);
|
||||
const triggerReason: AgentTriggerReason | undefined =
|
||||
isClaudeUsageExhaustedMessage(output.result)
|
||||
isClaudeUsageExhaustedMessage(outputText)
|
||||
? 'usage-exhausted'
|
||||
: isClaudeOrgAccessDeniedMessage(output.result) ||
|
||||
: isClaudeOrgAccessDeniedMessage(outputText) ||
|
||||
authClassification.category === 'org-access-denied'
|
||||
? 'org-access-denied'
|
||||
: isClaudeAuthExpiredMessage(output.result) ||
|
||||
: isClaudeAuthExpiredMessage(outputText) ||
|
||||
authClassification.category === 'auth-expired'
|
||||
? 'auth-expired'
|
||||
: detectClaudeProviderFailureMessage(output.result) || undefined;
|
||||
: detectClaudeProviderFailureMessage(outputText) || undefined;
|
||||
|
||||
if (triggerReason) {
|
||||
const newTrigger = nextState.streamedTriggerReason
|
||||
@@ -100,7 +107,7 @@ export function evaluateStreamedOutput(
|
||||
|
||||
if (
|
||||
options.suppressClaudeAuthErrorOutput &&
|
||||
isClaudeAuthError(output.result)
|
||||
isClaudeAuthError(outputText)
|
||||
) {
|
||||
return {
|
||||
state: nextState,
|
||||
@@ -112,15 +119,15 @@ export function evaluateStreamedOutput(
|
||||
|
||||
if (
|
||||
countsAsFinalOutput &&
|
||||
output.result !== null &&
|
||||
output.result !== undefined
|
||||
hasAgentOutputPayload(output)
|
||||
) {
|
||||
nextState.sawOutput = true;
|
||||
} else if (
|
||||
options.trackSuccessNullResult &&
|
||||
isPrimaryClaude &&
|
||||
output.status === 'success' &&
|
||||
!state.sawOutput
|
||||
!state.sawOutput &&
|
||||
!silentOutput
|
||||
) {
|
||||
nextState.sawSuccessNullResultWithoutOutput = true;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user