fix(schedule): preserve late views during pagination

This commit is contained in:
pku-xht
2026-08-06 04:47:31 +08:00
committed by Tianyi Cui
parent 75d9676a77
commit acbeaf2d74
6 changed files with 143 additions and 64 deletions

View File

@@ -98,6 +98,12 @@ function assertSameEvent(left: SessionEvent, right: SessionEvent): void {
}
}
/** One in-flight older-page request and the late sidecars that may belong to its result. */
interface OlderPageLoad {
readonly beforeSeq: number
readonly views: Map<number, { event: SessionEvent; view: SessionEventView }>
}
/**
* Owns a session's event window, derived conversation state, and observable
* snapshot. React bindings remain outside this data layer. Features see only
@@ -119,7 +125,7 @@ export class Session implements SessionFace {
* a pre-disconnect open whose history request is already doomed (audit S4). Stale doOpen
* passes drop all writes once the generation moves on. */
private openGeneration = 0
private loadingOlder = false
private loadingOlder: OlderPageLoad | null = null
private readonly transcript = new TranscriptAdapter()
private partial: PartialAccumulator | null = null
private openCalls = new Map<string, RunningToolCall>()
@@ -390,14 +396,13 @@ export class Session implements SessionFace {
/** Page up: pull one earlier page with the window's first seq as beforeSeq and prepend (§D.2). */
async loadOlder(): Promise<void> {
if (this.openState !== 'open' || !this.hasMore || this.loadingOlder) return
const generation = this.openGeneration
const requestedBaseSeq = this.baseSeq
this.loadingOlder = true
if (this.openState !== 'open' || !this.hasMore || this.loadingOlder !== null) return
const loading: OlderPageLoad = { beforeSeq: this.baseSeq, views: new Map() }
this.loadingOlder = loading
this.notifier.markDirty()
try {
const { result } = await this.history({ beforeSeq: this.baseSeq, maxMessages: PAGE_MESSAGES })
if (generation !== this.openGeneration) return
const { result } = await this.history({ beforeSeq: loading.beforeSeq, maxMessages: PAGE_MESSAGES })
if (this.loadingOlder !== loading) return
if (!result.ok) return // keep the window as-is; do not overwrite openError (open already succeeded)
const older = result.value.events
if (older.length === 0) {
@@ -405,26 +410,39 @@ export class Session implements SessionFace {
return
}
const tail = older[older.length - 1]
if (tail === undefined || tail.event.seq + 1 !== requestedBaseSeq) {
if (tail === undefined || tail.event.seq + 1 !== loading.beforeSeq) {
// §D.2 continuity assertion: on violation drop the page fail-soft rather than render an out-of-order stream.
console.error(`[web-runtime] history page discontinuous: tail seq ${tail?.event.seq} vs baseSeq ${requestedBaseSeq}`)
console.error(`[web-runtime] history page discontinuous: tail seq ${tail?.event.seq} vs baseSeq ${loading.beforeSeq}`)
this.hasMore = false
return
}
this.events = [...older.map(entry => entry.event), ...this.events]
this.views = [...older.map(entry => entry.view), ...this.views]
let settled: HistoryEntry[]
try {
settled = older.map((entry): HistoryEntry => {
const late = loading.views.get(entry.event.seq)
if (late === undefined) return entry
assertSameEvent(entry.event, late.event)
return { ...entry, view: late.view }
})
} catch (error) {
console.error('[web-runtime] older-page session event failed identity validation:', error)
void this.resync()
return
}
this.events = [...settled.map(entry => entry.event), ...this.events]
this.views = [...settled.map(entry => entry.view), ...this.views]
/* v8 ignore next -- the empty-page branch returned above. */
this.baseSeq = older[0]?.event.seq ?? this.baseSeq
this.hasMore = result.value.hasMore
this.transcript.reset(this.events, this.views)
this.rebuildDerivedFromWindow()
} catch (error) {
if (generation === this.openGeneration) {
if (this.loadingOlder === loading) {
console.error('[web-runtime] loadOlder failed:', error)
}
} finally {
if (generation === this.openGeneration) {
this.loadingOlder = false
if (this.loadingOlder === loading) {
this.loadingOlder = null
if (this.liveBuffer.length > 0) void this.repairGap()
this.notifier.markDirty()
}
@@ -455,7 +473,7 @@ export class Session implements SessionFace {
this.pendingRev++
this.subscribedLastSeq = null
this.liveBuffer = []
this.loadingOlder = false
this.loadingOlder = null
this.stitching = false
this.notifier.markDirty()
await this.open()
@@ -836,11 +854,12 @@ export class Session implements SessionFace {
this.queueRev++
}
/** Land a live session/event (open/repair in flight -> buffer; overlapping seq -> drop;
* a seq gap -> buffer + tail-page repull instead of appending a hole (audit S3: a gap is an
* expected reconnect-window artifact, repaired by refetch). The window stays one contiguous
* raw range, which is what lets the transcript render every event between its ends and lets a
* compaction checkpoint find its cited summary event. */
/** Land a live session/event (open/repair in flight -> buffer; retained overlap -> validate
* and upgrade; an overlap below the window waits only for its in-flight older page). A seq gap
* buffers and repulls the tail instead of appending a hole (audit S3: a gap is an expected
* reconnect-window artifact, repaired by refetch). The window stays one contiguous raw range,
* 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 {
if (this.openState === 'loading' || this.stitching) {
this.liveBuffer.push({ event, view })
@@ -850,6 +869,15 @@ export class Session implements SessionFace {
const tailSeq = this.windowTailSeq()
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)
if (changed) this.notifier.markDirty()
} catch (error) {
@@ -860,12 +888,12 @@ export class Session implements SessionFace {
}
if (tailSeq !== null && event.seq > tailSeq + 1) {
this.liveBuffer.push({ event, view })
if (!this.loadingOlder) void this.repairGap()
if (this.loadingOlder === null) void this.repairGap()
return
}
if (tailSeq === null && event.seq !== 0) {
this.liveBuffer.push({ event, view })
if (!this.loadingOlder) void this.repairGap()
if (this.loadingOlder === null) void this.repairGap()
return
}
this.appendLive(event, view)
@@ -1148,7 +1176,7 @@ export class Session implements SessionFace {
openState: this.openState,
openError: this.openError,
hasMore: this.hasMore,
loadingOlder: this.loadingOlder,
loadingOlder: this.loadingOlder !== null,
promptError: this.promptError,
blank: this.blankBit,
lastAgentError: this.lastAgentError,

View File

@@ -186,43 +186,94 @@ describe('late event views', () => {
})
})
it.each(['success', 'rejection', 'empty', 'discontinuous'] as const)(
'settles a late overlap after loadOlder %s',
async (outcome) => {
const { api, session } = makeSession()
const newer = logRange(6, 12)
const target = newer[3]!
api.onHistory = () => histResponse(newer, true)
await session.open()
it('upgrades a retained view during loadOlder and preserves it across prepend', async () => {
const { api, session } = makeSession()
const target = reminderEvent(9, 'schedule-loading-older')
const newer = [...logRange(6, 9), target, ...logRange(10, 12)]
api.onHistory = () => histResponse(newer, true)
await session.open()
const gate = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
api.onHistory = () => gate.promise
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
try {
const loading = session.loadOlder()
session.handleMuxEnvelope('late' as never, {
type: 'session/event', sessionId: SID, event: target,
view: reminderView(`schedule-${outcome}`),
})
if (outcome === 'success') {
gate.resolve(ok({ events: entries(logRange(0, 6)) as never[], hasMore: false }))
} else if (outcome === 'rejection') {
gate.resolve(err({ code: 'internal', message: 'page rejected', details: {} }))
} else if (outcome === 'empty') {
gate.resolve(ok({ events: [], hasMore: false }))
} else {
gate.resolve(ok({ events: entries(logRange(0, 2)) as never[], hasMore: true }))
}
await loading
expect(session.getSnapshot().nodes).toMatchObject([{
kind: 'presented-event', seq: target.seq,
view: { id: `schedule-${outcome}` },
}])
} finally {
errorSpy.mockRestore()
}
},
)
const gate = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
api.onHistory = () => gate.promise
const loading = session.loadOlder()
session.handleMuxEnvelope('late' as never, {
type: 'session/event', sessionId: SID, event: target,
view: reminderView('schedule-loading-older'),
})
expect(session.getSnapshot().nodes).toMatchObject([{
kind: 'presented-event', seq: target.seq,
view: { id: 'schedule-loading-older' },
}])
gate.resolve(ok({ events: entries(logRange(0, 6)) as never[], hasMore: false }))
await loading
expect(session.getSnapshot().nodes).toMatchObject([{
kind: 'presented-event', seq: target.seq,
view: { id: 'schedule-loading-older' },
}])
})
it('keeps a late view for the raw event returned by an in-flight older page', async () => {
const { api, session } = makeSession()
const target = reminderEvent(3, 'schedule-older-page')
const older = [...logRange(0, 3), target, ...logRange(4, 6)]
api.onHistory = () => histResponse(logRange(6, 12), true)
await session.open()
const gate = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
api.onHistory = () => gate.promise
const loading = session.loadOlder()
session.handleMuxEnvelope('late' as never, {
type: 'session/event', sessionId: SID, event: target,
view: reminderView('schedule-older-page'),
})
session.handleMuxEnvelope('later' as never, {
type: 'session/event', sessionId: SID, event: target,
view: reminderView('schedule-older-page', '检查更新'),
})
expect(session.getSnapshot().nodes).toEqual([])
gate.resolve(ok({ events: entries(older) as never[], hasMore: false }))
await loading
expect(session.getSnapshot().nodes).toMatchObject([{
kind: 'presented-event', seq: target.seq,
view: { id: 'schedule-older-page', prompt: '检查更新' },
}])
})
it('resyncs when an older page disagrees with its buffered late event identity', async () => {
const { api, session } = makeSession()
const newer = logRange(6, 12)
const pageEvent = reminderEvent(3, 'schedule-page')
const delivered = reminderEvent(3, 'schedule-delivered')
const older = [...logRange(0, 3), pageEvent, ...logRange(4, 6)]
api.onHistory = () => histResponse(newer, true)
await session.open()
const gate = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
api.onHistory = () => gate.promise
const loading = session.loadOlder()
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 {
gate.resolve(ok({ events: entries(older) as never[], hasMore: false }))
await loading
await vi.waitFor(() => {
expect(api.callsOf('session.history')).toHaveLength(3)
expect(session.getSnapshot().openState).toBe('open')
})
expect(errorSpy).toHaveBeenCalledWith(
'[web-runtime] older-page session event failed identity validation:',
expect.objectContaining({ message: 'session event identity mismatch at seq 3' }),
)
} finally {
errorSpy.mockRestore()
}
})
})

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-schedule/README.md
README.md: 7a37baf92c6e050e628d97d5926b08bae295137b
README.zh.md: 8acc33cc397f8e0ac4bee016007d635d0021c309
README.md: c7b1934cd5a8e6ac3e3cc022ec67a948ec538786
README.zh.md: 8ba09550e1506ad9233827ec4e95fc35fa8d7c0d

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
用于渲染持久 Schedule 提醒回执的纯浏览器插件。插件在会话拥有的 `conversation.chat.eventview` slot 中注册持久事件类型 `schedule/change`。通用 runtime 继续携带持久事件身份与 Host 计算的 JSON sidecar本包只拥有 Schedule 卡片。
用于渲染持久 Schedule 提醒回执的纯浏览器插件。插件在会话拥有的 `conversation.chat.eventview` slot 中注册持久事件类型 `schedule/change`。通用运行时继续携带持久事件身份与 Host 计算的 JSON sidecar本包只拥有 Schedule 卡片。
卡片显示提醒原文、Session 内的 Schedule ID、精确 UTC 发生时刻,以及 `session-local` 交付边界。若 sidecar 损坏或版本不兼容,组件会显示受控的不可用回执,而不会让会话崩溃。卸载插件只会移除该键控 renderer`ui-conversation` 随后仍会为同一个持久事件显示通用且可见的 JSON fallback。