diff --git a/src/agent-output.ts b/src/agent-output.ts index cd2b4b9..5be30b4 100644 --- a/src/agent-output.ts +++ b/src/agent-output.ts @@ -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 { diff --git a/src/output-suppression.test.ts b/src/output-suppression.test.ts index aaac6fc..ee6009a 100644 --- a/src/output-suppression.test.ts +++ b/src/output-suppression.test.ts @@ -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'); }); diff --git a/src/provider-retry.test.ts b/src/provider-retry.test.ts index ff85294..056e60a 100644 --- a/src/provider-retry.test.ts +++ b/src/provider-retry.test.ts @@ -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'); + }); }); diff --git a/src/provider-retry.ts b/src/provider-retry.ts index 49f3261..e374ed4 100644 --- a/src/provider-retry.ts +++ b/src/provider-retry.ts @@ -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; 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; } diff --git a/src/session-recovery.test.ts b/src/session-recovery.test.ts index 32656fb..349d1f9 100644 --- a/src/session-recovery.test.ts +++ b/src/session-recovery.test.ts @@ -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); + }); }); diff --git a/src/session-recovery.ts b/src/session-recovery.ts index fbaeeb1..a098f3a 100644 --- a/src/session-recovery.ts +++ b/src/session-recovery.ts @@ -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, + output: Pick, ): 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, + output: Pick, ): 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)), ); diff --git a/src/streamed-output-evaluator.test.ts b/src/streamed-output-evaluator.test.ts index fbf9c30..faf065b 100644 --- a/src/streamed-output-evaluator.test.ts +++ b/src/streamed-output-evaluator.test.ts @@ -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', () => { diff --git a/src/streamed-output-evaluator.ts b/src/streamed-output-evaluator.ts index 2705dc0..4729944 100644 --- a/src/streamed-output-evaluator.ts +++ b/src/streamed-output-evaluator.ts @@ -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; }