refactor: move retry and recovery paths to structured output helpers

This commit is contained in:
Eyejoker
2026-03-28 06:17:52 +09:00
parent 47dda19ded
commit 38d96578a4
8 changed files with 144 additions and 18 deletions

View File

@@ -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 {

View File

@@ -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');
});

View File

@@ -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');
});
});

View File

@@ -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;
}

View File

@@ -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);
});
});

View File

@@ -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)),
);

View File

@@ -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', () => {

View File

@@ -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;
}