From c3058e8d4651c2fb01fe964f437e7eccdf1adb09 Mon Sep 17 00:00:00 2001 From: pku-xht Date: Thu, 6 Aug 2026 05:14:08 +0800 Subject: [PATCH] fix(client): preserve paged event views during gap repair --- .../runtime/src/client/sessions/session.ts | 18 ++-- packages/client/runtime/tests/session.spec.ts | 82 +++++++++++++++++++ 2 files changed, 94 insertions(+), 6 deletions(-) diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 8a4f62f939..15e9825dfe 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -861,6 +861,18 @@ export class Session implements SessionFace { * which lets the transcript render every event between its ends and a compaction checkpoint * find its cited summary event. */ private acceptLiveEvent(event: SessionEvent, view?: SessionEventView): void { + const loading = this.loadingOlder + if (loading !== null && view !== undefined && event.seq < loading.beforeSeq) { + try { + const retained = loading.views.get(event.seq) + if (retained !== undefined) assertSameEvent(retained.event, event) + loading.views.set(event.seq, { event, view }) + } catch (error) { + console.error('[web-runtime] older-page late session event failed identity validation:', error) + void this.resync() + } + return + } if (this.openState === 'loading' || this.stitching) { this.liveBuffer.push({ event, view }) return @@ -870,12 +882,6 @@ export class Session implements SessionFace { if (tailSeq !== null && event.seq <= tailSeq) { try { if (event.seq < this.baseSeq) { - const loading = this.loadingOlder - if (loading !== null && view !== undefined && event.seq < loading.beforeSeq) { - const retained = loading.views.get(event.seq) - if (retained !== undefined) assertSameEvent(retained.event, event) - loading.views.set(event.seq, { event, view }) - } return } const changed = this.upgradeLiveView(event, view) diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index 5c6c493f07..5cadcab058 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -241,6 +241,88 @@ describe('late event views', () => { }]) }) + it('keeps an older-page late view while a gap repair is also in flight', async () => { + const { api, session } = makeSession() + api.onHistory = () => histResponse(logRange(6, 12), true) + await session.open() + + const repair = deferred>>() + const page = deferred>>() + api.onHistory = payload => payload.beforeSeq === undefined ? repair.promise : page.promise + const gapTail = ev.user(15, '修复后的尾部') + session.handleMuxEnvelope('gap' as never, { + type: 'session/event', sessionId: SID, event: gapTail, + }) + await vi.waitFor(() => { + expect(api.callsOf('session.history')).toHaveLength(2) + }) + + const loading = session.loadOlder() + const target = reminderEvent(3, 'schedule-overlapping-repairs') + session.handleMuxEnvelope('late' as never, { + type: 'session/event', sessionId: SID, event: target, + view: reminderView('schedule-overlapping-repairs'), + }) + + repair.resolve(ok({ + events: entries([...logRange(6, 15), gapTail]) as never[], + hasMore: true, + })) + await vi.waitFor(() => { + expect(session.getSnapshot().nodes).toMatchObject([{ kind: 'user', seq: 15 }]) + }) + page.resolve(ok({ + events: entries([...logRange(0, 3), target, ...logRange(4, 6)]) as never[], + hasMore: false, + })) + await loading + + expect(session.getSnapshot().nodes).toMatchObject([ + { + kind: 'presented-event', seq: target.seq, + view: { id: 'schedule-overlapping-repairs' }, + }, + { kind: 'user', seq: 15 }, + ]) + }) + + it('resyncs when repeated older-page late views disagree on event identity', async () => { + const { api, session } = makeSession() + const newer = logRange(6, 12) + api.onHistory = () => histResponse(newer, true) + await session.open() + + const page = deferred>>() + api.onHistory = () => page.promise + const loading = session.loadOlder() + const delivered = reminderEvent(3, 'schedule-delivered') + session.handleMuxEnvelope('late' as never, { + type: 'session/event', sessionId: SID, event: delivered, + view: reminderView('schedule-delivered'), + }) + + api.onHistory = () => histResponse(newer) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + try { + session.handleMuxEnvelope('drifted' as never, { + type: 'session/event', sessionId: SID, event: reminderEvent(3, 'schedule-drifted'), + view: reminderView('schedule-drifted'), + }) + await vi.waitFor(() => { + expect(api.callsOf('session.history')).toHaveLength(3) + expect(session.getSnapshot().openState).toBe('open') + }) + expect(errorSpy).toHaveBeenCalledWith( + '[web-runtime] older-page late session event failed identity validation:', + expect.objectContaining({ message: 'session event identity mismatch at seq 3' }), + ) + } finally { + errorSpy.mockRestore() + page.resolve(ok({ events: entries(logRange(0, 6)) as never[], hasMore: false })) + await loading + } + }) + it('resyncs when an older page disagrees with its buffered late event identity', async () => { const { api, session } = makeSession() const newer = logRange(6, 12)