diff --git a/packages/llm/llm-retry/src/history.ts b/packages/llm/llm-retry/src/history.ts index 2b86a77e03..d31fe3f201 100644 --- a/packages/llm/llm-retry/src/history.ts +++ b/packages/llm/llm-retry/src/history.ts @@ -4,8 +4,9 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session' /** * Find the provider in force when one step closed, excluding later recovery mutations. - * A preceding retry is also a route marker because every provider change - * requires a newer full request-header snapshot. + * Request headers remain effective across turn boundaries until a newer full + * snapshot changes them. A preceding retry in the same turn is also a route + * marker because every provider change requires a newer full snapshot. * @param events - session events containing the closed step. * @param turn - turn that owns the failed step. * @param step - failed step whose provider is required. @@ -32,7 +33,6 @@ export function providerForClosedStep( && event.data.step < step) { return event.data.provider } - if (event.type === 'turn/start' || event.type === 'turn/end') return undefined } return undefined } diff --git a/packages/llm/llm-retry/tests/invariant.spec.ts b/packages/llm/llm-retry/tests/invariant.spec.ts index c9c5d2c564..aa4030a886 100644 --- a/packages/llm/llm-retry/tests/invariant.spec.ts +++ b/packages/llm/llm-retry/tests/invariant.spec.ts @@ -38,7 +38,7 @@ describe('llm-retry invariants', () => { }] as never, 1, 1)).toBeUndefined() }) - it('does not inherit a provider across a turn boundary', () => { + it('inherits the latest provider across a turn boundary when the header is unchanged', () => { expect(providerForClosedStep([ { type: 'turn/start', data: { turn: 1 } }, { @@ -48,7 +48,7 @@ describe('llm-retry invariants', () => { { type: 'turn/end', data: { turn: 1 } }, { type: 'turn/start', data: { turn: 2 } }, { type: 'step/end', data: { turn: 2, step: 1 } }, - ] as never, 2, 1)).toBeUndefined() + ] as never, 2, 1)).toBe('prior') }) it('accepts increasing retry records for successive closed steps and ignores unrelated events', async () => { @@ -226,7 +226,7 @@ describe('llm-retry invariants', () => { }).toThrow(/does not match the failed request provider mock/) }) - it('rejects a current-turn retry without a current-turn provider route', async () => { + it('accepts a current-turn retry under an unchanged prior provider route', async () => { const ctx = await setup() const session = closeStep(ctx, 'retry-invariant-prior-route') session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) @@ -243,7 +243,7 @@ describe('llm-retry invariants', () => { delayMs: 1, failure, }) - }).toThrow(/does not match the failed request provider undefined/) + }).not.toThrow() }) it('rejects non-numeric durable delays', async () => { diff --git a/packages/llm/llm-retry/tests/retry.spec.ts b/packages/llm/llm-retry/tests/retry.spec.ts index 1c83d3bacc..56f785f38c 100644 --- a/packages/llm/llm-retry/tests/retry.spec.ts +++ b/packages/llm/llm-retry/tests/retry.spec.ts @@ -207,6 +207,45 @@ describe('provider-routed retry policy', () => { }) }) + it('retries a later turn under its unchanged provider header', async () => { + vi.useFakeTimers() + const adapter = new ScriptedAdapter([ + textResponse('first turn'), + new LlmError('busy on second turn', 'RATE_LIMIT'), + textResponse('second turn recovered'), + ]) + ;({ ctx: context } = await harness(adapter, { mock: normalConfig({ + backoff: { initialDelayMs: 1, maxDelayMs: 1 }, + }) })) + const agent = context.agentLoop.create(SessionId('retry-later-turn'), { + provider: 'mock', + model: 'mock', + }) + + const firstIdle = waitForIdle(context, agent) + agent.followup([{ type: 'text', text: 'first' }]) + await firstIdle + expect(agent.session.events.filter(event => event.type === 'request/header')).toHaveLength(1) + + const scheduled = waitForRetry(context, agent, 1) + agent.followup([{ type: 'text', text: 'second' }]) + expect((await scheduled).data).toMatchObject({ + turn: 2, + step: 1, + provider: 'mock', + }) + const secondIdle = waitForIdle(context, agent) + await vi.advanceTimersByTimeAsync(1) + await secondIdle + + expect(adapter.requests).toHaveLength(3) + expect(agent.session.events.filter(event => event.type === 'request/header')).toHaveLength(1) + expect(agent.session.deriveMessages().at(-1)).toMatchObject({ + role: 'assistant', + content: [{ type: 'text', text: 'second turn recovered' }], + }) + }) + it('leaves partial failed chunks on their step without committing a message or tool side effect', async () => { vi.useFakeTimers() const adapter = new ScriptedAdapter([ diff --git a/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts b/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts index 9fcfe23de8..a032e1add7 100644 --- a/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts +++ b/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts @@ -26,6 +26,7 @@ const WORKSPACE_CLOSURE = [ 'packages/sandbox/sandbox', 'packages/llm/llm', 'packages/util/brand', + 'packages/util/timeout', 'packages/support/invariants', ]