diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 1471aae91e..2eec667e46 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -309,11 +309,10 @@ export class Session implements ObservableSnapshot { * in-flight open first — its history request rode the dead connection and must not settle * the fresh generation into 'error' (audit S4). */ async resync(): Promise { - // The queue mirror is NOT cleared here: onConnected (which drives resync) - // races the mux frames — the fresh generation's baseline may have landed - // already, and the host never resends it. The mirror re-baselines on the - // session/subscribed frame instead (same stream as the queue snapshot - // that follows it, so ordering is guaranteed). + // Queue, metrics, and request capacity are NOT cleared here: onConnected + // (which drives resync) races the mux frames — fresh-generation state may + // have landed already, and the host never resends it. session/subscribed + // owns the generation reset before the queue snapshot and metrics frames. if (this.openState === 'cold') return // never opened: no window to rebuild (doOpen flips to 'loading' synchronously, so cold implies no in-flight open) this.openGeneration++ this.openPromise = null @@ -322,7 +321,6 @@ export class Session implements ObservableSnapshot { this.events = [] this.views = [] this.baseSeq = 0 - this.metrics = null // Superseded, not settled: the baseline replay re-sends still-pending requested frames verbatim // (same rpcId), re-minting fresh waits; a stale reference's respond() still reaches the host. this.pending.clear() diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index 5f01dc9cc2..cbce270597 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -813,6 +813,40 @@ describe('remaining branches', () => { }) describe('resync', () => { + it('preserves fresh-generation metrics that arrive before a failing history refresh', async () => { + const { api, session } = makeSession() + const oldMetrics = metrics(8, 10) + api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'), false, undefined, oldMetrics) + await session.open() + expect(session.getSnapshot().metrics).toBe(oldMetrics) + + session.handleMuxEnvelope('sub' as never, { + type: 'session/subscribed', + sessionId: SID, + lastSeq: 5, + }) + expect(session.getSnapshot().metrics).toBeNull() + + const freshMetrics = metrics(0, 10, { contextTokens: 20 }) + session.handleMuxEnvelope('fresh-metrics' as never, { + type: 'session/metrics', + sessionId: SID, + metrics: freshMetrics, + }) + api.onHistory = () => Promise.resolve(err({ + code: 'internal', + message: 'history refresh failed', + details: {}, + })) + + await session.resync() + + expect(session.getSnapshot()).toMatchObject({ + openState: 'error', + metrics: freshMetrics, + }) + }) + it('rebuilds the window and clears pending; cold instances no-op', async () => { const { api, session } = makeSession() api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b'))