fix(client): address trajectory review findings

This commit is contained in:
_Kerman
2026-07-28 15:07:44 +08:00
parent 64b9535c31
commit 9096dbb29d
9 changed files with 244 additions and 60 deletions

View File

@@ -15,20 +15,22 @@ export interface SessionHistoryInspection {
/**
* Create a lazy inspection projection over an immutable history window.
* Conversation consumers retain the cheap wrapper; only Trajectory reads the
* getters that replay event order and request lifecycle state.
* @param entries - Contiguous raw history entries in sequence order.
* Conversation consumers retain the cheap wrapper; only Trajectory snapshots
* the entries and replays event order and request lifecycle state.
* @param loadEntries - Lazily snapshots contiguous raw entries in sequence order.
* @returns Lazy, memoized inspection fields for that exact window.
*/
export function createHistoryInspection(
entries: readonly HistoryEntry[],
loadEntries: () => readonly HistoryEntry[],
): SessionHistoryInspection {
let entries: readonly HistoryEntry[] | undefined
let conversation: ReturnType<typeof projectConversationHistory> | undefined
let requests: ReturnType<typeof inspectRequests> | undefined
const historyEntries = () => entries ??= loadEntries()
const conversationProjection = () =>
conversation ??= projectConversationHistory(entries)
conversation ??= projectConversationHistory(historyEntries())
const requestProjection = () =>
requests ??= inspectRequests(entries)
requests ??= inspectRequests(historyEntries())
return {
get eventNodes() {
return conversationProjection().eventNodes

View File

@@ -111,13 +111,13 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
private codeDispatches = new Map<string, readonly CodeSubCall[]>()
private dispatchesRev = 0
private dispatchesCache: { rev: number; value: ReadonlyMap<string, readonly CodeSubCall[]> } | null = null
/** Raw history revision; published entries are copied so later live appends never mutate a prior snapshot. */
/** Raw history revision; inspection wrappers capture the exact array window and length. */
private historyRev = 0
private historyEntriesCache: { rev: number; value: readonly HistoryEntry[] } | null = null
private historyInspectionCache: {
rev: number
value: SessionHistoryInspection
} | null = null
private loadOlderPromise: Promise<void> | null = null
private running = false
/**
* Sticky send marker, private input of the composerPhase derivation: set
@@ -252,42 +252,56 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
return promise
}
/** 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
/**
* Page up: pull one earlier page with the window's first seq as beforeSeq and prepend (§D.2).
* Concurrent callers share the active page so complete-history readers can continue afterward.
* @returns When the active or newly started page request settles.
*/
loadOlder(): Promise<void> {
if (this.loadOlderPromise !== null) return this.loadOlderPromise
if (this.openState !== 'open' || !this.hasMore) return Promise.resolve()
this.loadingOlder = true
this.notifier.markDirty()
try {
const { result } = await this.api.sessions.history({
sessionId: this.sessionId, beforeSeq: this.baseSeq, maxMessages: PAGE_MESSAGES,
})
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) {
const generation = this.openGeneration
const operation = (async () => {
try {
const { result } = await this.api.sessions.history({
sessionId: this.sessionId, beforeSeq: this.baseSeq, maxMessages: PAGE_MESSAGES,
})
if (generation !== this.openGeneration || this.openState !== 'open') 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) {
this.hasMore = result.value.hasMore
return
}
const tail = older[older.length - 1]
if (tail === undefined || tail.event.seq + 1 !== this.baseSeq) {
// §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 ${this.baseSeq}`)
this.hasMore = false
return
}
this.events = [...older.map(e => e.event), ...this.events]
this.views = [...older.map(e => e.view), ...this.views]
this.historyRev++
/* v8 ignore next -- the ?? arm needs older[0] undefined, but the empty-page branch above already returned. */
this.baseSeq = older[0]?.event.seq ?? this.baseSeq
this.hasMore = result.value.hasMore
return
this.foldAdapter.reset(this.events, this.baseSeq, this.views) // prepend forces a rebuild (sentinel count changed)
this.rebuildDerivedFromWindow()
} catch (error) {
console.error('[web-runtime] loadOlder failed:', error)
}
const tail = older[older.length - 1]
if (tail === undefined || tail.event.seq + 1 !== this.baseSeq) {
// §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 ${this.baseSeq}`)
this.hasMore = false
return
}
this.events = [...older.map(e => e.event), ...this.events]
this.views = [...older.map(e => e.view), ...this.views]
this.historyRev++
/* v8 ignore next -- the ?? arm needs older[0] undefined, but the empty-page branch above already returned. */
this.baseSeq = older[0]?.event.seq ?? this.baseSeq
this.hasMore = result.value.hasMore
this.foldAdapter.reset(this.events, this.baseSeq, this.views) // prepend forces a rebuild (sentinel count changed)
this.rebuildDerivedFromWindow()
} catch (error) {
console.error('[web-runtime] loadOlder failed:', error)
} finally {
})()
const settled = operation.finally(() => {
if (this.loadOlderPromise !== settled) return
this.loadOlderPromise = null
this.loadingOlder = false
this.notifier.markDirty()
}
})
this.loadOlderPromise = settled
return settled
}
/**
@@ -297,7 +311,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
* @returns When the available history has been exhausted or paging stops making progress.
*/
async loadAllHistory(): Promise<void> {
while (this.openState === 'open' && this.hasMore && !this.loadingOlder) {
while (this.openState === 'open' && this.hasMore) {
const previousBaseSeq = this.baseSeq
await this.loadOlder()
if (this.baseSeq === previousBaseSeq) return
@@ -844,24 +858,27 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
}
}
/** Build the lazy history inspection wrapper without leaking mutable window arrays. */
/** Build a lazy inspection wrapper for the exact current history window. */
private buildHistoryInspection(): SessionHistoryInspection {
if (this.historyEntriesCache === null || this.historyEntriesCache.rev !== this.historyRev) {
this.historyEntriesCache = {
rev: this.historyRev,
value: this.events.map((event, index) => {
const view = this.views[index]
return view === undefined ? { event } : { event, view }
}),
}
}
if (
this.historyInspectionCache === null
|| this.historyInspectionCache.rev !== this.historyRev
) {
const events = this.events
const views = this.views
const length = events.length
this.historyInspectionCache = {
rev: this.historyRev,
value: createHistoryInspection(this.historyEntriesCache.value),
value: createHistoryInspection(() =>
Array.from({ length }, (_, index) => {
const event = events[index]
if (event === undefined) {
throw new Error('captured history window changed before inspection')
}
const view = views[index]
return view === undefined ? { event } : { event, view }
}),
),
}
}
return this.historyInspectionCache.value

View File

@@ -124,6 +124,21 @@ describe('live event path', () => {
expect((last as { interrupted?: true }).interrupted).toBeUndefined()
})
it('keeps a lazily inspected snapshot pinned to its original history window', async () => {
const { session } = await opened()
const before = session.getSnapshot()
session.handleMuxEnvelope('r' as never, {
type: 'session/event',
sessionId: SID,
event: ev.user(6, 'later'),
})
expect(before.inspection?.eventNodes.map(node => node.seq)).toEqual([1, 3])
expect(session.getSnapshot().inspection?.eventNodes.map(node => node.seq))
.toEqual([1, 3, 6])
})
it('freezes an unfinalized partial into an interrupted node on turn/end (cancel path)', async () => {
const { session } = await opened()
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
@@ -292,6 +307,36 @@ describe('paging', () => {
expect(session.getSnapshot().hasMore).toBe(true)
})
it('continues complete-history loading after an already active page', async () => {
const pages = [
plainTurn(0, 0, '最早问', '最早答'),
plainTurn(6, 1, '中间问', '中间答'),
plainTurn(12, 2, '最新问', '最新答'),
]
const middle = deferred<Awaited<ReturnType<FakeApiClient['onHistory']>>>()
const { api, session } = makeSession()
api.onHistory = (payload) => {
if (payload.beforeSeq === undefined) return histResponse(pages[2]!, true)
if (payload.beforeSeq === 12) return middle.promise
return histResponse(pages[0]!, false)
}
await session.open()
const activePage = session.loadOlder()
const completeHistory = session.loadAllHistory()
middle.resolve(ok({
events: entries(pages[1]!) as never[],
hasMore: true,
modelTarget: { provider: 'deepseek', model: 'deepseek-v4-flash' },
}))
await Promise.all([activePage, completeHistory])
expect(api.callsOf('session.history')).toHaveLength(3)
expect(session.getSnapshot().hasMore).toBe(false)
expect(session.getSnapshot().nodes.map(node => node.seq))
.toEqual([1, 3, 7, 9, 13, 15])
})
it('drops a discontinuous older page fail-soft (window unchanged, hasMore cleared)', async () => {
const { api, session } = makeSession()
api.onHistory = payload => payload.beforeSeq === undefined