From 6975cc8f8ad8828bfc79ba350c9a0cd3cb7a7de1 Mon Sep 17 00:00:00 2001 From: Zbigniew Sobiecki Date: Tue, 24 Feb 2026 10:43:59 +0000 Subject: [PATCH] fix(progress): retry once on empty progress model output Gemini Flash Lite via OpenRouter intermittently returns streaming responses with zero text content (~40% failure rate in production). Add a single retry with 1s delay in callProgressModelOnce() so the first empty response no longer immediately falls back to the generic template. Only the failure path adds latency. Update existing empty-output tests to expect both attempts to fail before throwing. Add two new tests verifying retry-then-success for both empty-text and no-events scenarios. Co-Authored-By: Claude Opus 4.6 --- src/backends/progressModel.ts | 47 +++++++----- tests/unit/backends/progressModel.test.ts | 88 ++++++++++++++++++++--- 2 files changed, 105 insertions(+), 30 deletions(-) diff --git a/src/backends/progressModel.ts b/src/backends/progressModel.ts index 84c61381..b1cad680 100644 --- a/src/backends/progressModel.ts +++ b/src/backends/progressModel.ts @@ -129,34 +129,43 @@ export async function callProgressModel( } /** - * Make the actual single-shot LLM call to generate a progress summary. + * Make the LLM call to generate a progress summary. + * Retries once on empty output — Gemini Flash Lite intermittently returns + * streaming responses with zero text content. */ async function callProgressModelOnce( model: string, context: ProgressContext, customModels: ModelSpec[], ): Promise { - const client = new LLMist({ customModels }); - - const builder = new AgentBuilder(client) - .withModel(model) - .withTemperature(0) - .withSystem(PROGRESS_SYSTEM_PROMPT) - .withMaxIterations(1); - - const agent = builder.ask(formatProgressUserPrompt(context)); + for (let attempt = 0; attempt < 2; attempt++) { + const client = new LLMist({ customModels }); + + const builder = new AgentBuilder(client) + .withModel(model) + .withTemperature(0) + .withSystem(PROGRESS_SYSTEM_PROMPT) + .withMaxIterations(1); + + const agent = builder.ask(formatProgressUserPrompt(context)); + + const outputLines: string[] = []; + for await (const event of agent.run()) { + if (event.type === 'text' && event.content) { + outputLines.push(event.content); + } + } - const outputLines: string[] = []; - for await (const event of agent.run()) { - if (event.type === 'text' && event.content) { - outputLines.push(event.content); + const output = outputLines.join('\n').trim(); + if (output) { + return output; } - } - const output = outputLines.join('\n').trim(); - if (!output) { - throw new Error('Progress model returned empty output'); + // First attempt returned empty — retry after brief delay + if (attempt === 0) { + await new Promise((resolve) => setTimeout(resolve, 1_000)); + } } - return output; + throw new Error('Progress model returned empty output'); } diff --git a/tests/unit/backends/progressModel.test.ts b/tests/unit/backends/progressModel.test.ts index 43e1f2ea..3f102e46 100644 --- a/tests/unit/backends/progressModel.test.ts +++ b/tests/unit/backends/progressModel.test.ts @@ -101,40 +101,106 @@ describe('callProgressModel', () => { expect(result).toBe('Valid text output.'); }); - it('throws when LLM returns empty output', async () => { + it('throws when LLM returns empty output on both attempts', async () => { async function* fakeRun() { yield { type: 'text', content: '' }; } + // Both attempts return empty + for (let i = 0; i < 2; i++) { + MockAgentBuilder.mockImplementationOnce(() => ({ + withModel: vi.fn().mockReturnThis(), + withTemperature: vi.fn().mockReturnThis(), + withSystem: vi.fn().mockReturnThis(), + withMaxIterations: vi.fn().mockReturnThis(), + ask: vi.fn().mockReturnValue({ run: fakeRun }), + })); + } + + await expect(callProgressModel('test-model', makeContext(), [])).rejects.toThrow( + 'Progress model returned empty output', + ); + }); + + it('throws when LLM returns no events on both attempts', async () => { + async function* fakeRun() { + // yields nothing + } + + // Both attempts yield nothing + for (let i = 0; i < 2; i++) { + MockAgentBuilder.mockImplementationOnce(() => ({ + withModel: vi.fn().mockReturnThis(), + withTemperature: vi.fn().mockReturnThis(), + withSystem: vi.fn().mockReturnThis(), + withMaxIterations: vi.fn().mockReturnThis(), + ask: vi.fn().mockReturnValue({ run: fakeRun }), + })); + } + + await expect(callProgressModel('test-model', makeContext(), [])).rejects.toThrow( + 'Progress model returned empty output', + ); + }); + + it('retries once on empty output and succeeds', async () => { + async function* emptyRun() { + yield { type: 'text', content: '' }; + } + async function* successRun() { + yield { type: 'text', content: 'Recovered progress update.' }; + } + + // First attempt: empty output MockAgentBuilder.mockImplementationOnce(() => ({ withModel: vi.fn().mockReturnThis(), withTemperature: vi.fn().mockReturnThis(), withSystem: vi.fn().mockReturnThis(), withMaxIterations: vi.fn().mockReturnThis(), - ask: vi.fn().mockReturnValue({ run: fakeRun }), + ask: vi.fn().mockReturnValue({ run: emptyRun }), + })); + // Second attempt: success + MockAgentBuilder.mockImplementationOnce(() => ({ + withModel: vi.fn().mockReturnThis(), + withTemperature: vi.fn().mockReturnThis(), + withSystem: vi.fn().mockReturnThis(), + withMaxIterations: vi.fn().mockReturnThis(), + ask: vi.fn().mockReturnValue({ run: successRun }), })); - await expect(callProgressModel('test-model', makeContext(), [])).rejects.toThrow( - 'Progress model returned empty output', - ); + const result = await callProgressModel('test-model', makeContext(), []); + expect(result).toBe('Recovered progress update.'); + expect(MockAgentBuilder).toHaveBeenCalledTimes(2); }); - it('throws when LLM returns no events', async () => { - async function* fakeRun() { + it('retries once on no events and succeeds', async () => { + async function* noEventsRun() { // yields nothing } + async function* successRun() { + yield { type: 'text', content: 'Recovered after no events.' }; + } + // First attempt: no events MockAgentBuilder.mockImplementationOnce(() => ({ withModel: vi.fn().mockReturnThis(), withTemperature: vi.fn().mockReturnThis(), withSystem: vi.fn().mockReturnThis(), withMaxIterations: vi.fn().mockReturnThis(), - ask: vi.fn().mockReturnValue({ run: fakeRun }), + ask: vi.fn().mockReturnValue({ run: noEventsRun }), + })); + // Second attempt: success + MockAgentBuilder.mockImplementationOnce(() => ({ + withModel: vi.fn().mockReturnThis(), + withTemperature: vi.fn().mockReturnThis(), + withSystem: vi.fn().mockReturnThis(), + withMaxIterations: vi.fn().mockReturnThis(), + ask: vi.fn().mockReturnValue({ run: successRun }), })); - await expect(callProgressModel('test-model', makeContext(), [])).rejects.toThrow( - 'Progress model returned empty output', - ); + const result = await callProgressModel('test-model', makeContext(), []); + expect(result).toBe('Recovered after no events.'); + expect(MockAgentBuilder).toHaveBeenCalledTimes(2); }); it('throws when LLM call times out (races against a slow call)', async () => {