fix(web): use exact turn boundaries for run time

This commit is contained in:
imccyu
2026-08-04 15:55:07 +08:00
parent 2a828f09da
commit cc435a9c0c
26 changed files with 146 additions and 116 deletions

View File

@@ -331,6 +331,8 @@ export interface ConversationSnapshot {
sessionId: SessionId
/** Human transcript plus retry notices and interrupted-turn terminal nodes in event order. */
nodes: readonly ConversationNode[]
/** Exact in-window `turn/start` time and optional matching `turn/end` time. */
turnTimings: ReadonlyMap<number, { readonly startTime: number; readonly endTime?: number }>
/** In-window completed turn number -> its `turn/end` event seq. */
turnEnds: ReadonlyMap<number, number>
partial: PartialAssistant | null

View File

@@ -113,6 +113,11 @@ export class Session implements SessionFace {
private pendingCache: { rev: number; value: PendingInteraction[] } | null = null
private derivedRev = 0
private nodesCache: { projected: readonly ConversationNode[]; derivedRev: number; value: readonly ConversationNode[] } | null = null
/** Exact turn timing retained from the raw window so presentation never
* infers elapsed time from transcript content. */
private turnTimings = new Map<number, { startTime: number; endTime?: number }>()
private turnTimingsRev = 0
private turnTimingsCache: { rev: number; value: ConversationSnapshot['turnTimings'] } | null = null
/** Completed turn boundaries retained from the raw window so presentation
* actions never infer a safe fork point from transcript content alone. */
private turnEnds = new Map<number, number>()
@@ -799,6 +804,8 @@ export class Session implements SessionFace {
}
switch (event.type) {
case 'turn/start': {
this.turnTimings.set(event.data.turn, { startTime: event.time })
this.turnTimingsRev++
if (event.data.trigger.kind === 'retry') this.settleScheduledRetry('started')
return
}
@@ -830,6 +837,11 @@ export class Session implements SessionFace {
return
}
case 'turn/end': {
const timing = this.turnTimings.get(event.data.turn)
if (timing !== undefined) {
this.turnTimings.set(event.data.turn, { ...timing, endTime: event.time })
this.turnTimingsRev++
}
this.turnEnds.set(event.data.turn, event.seq)
this.turnEndsRev++
if (event.data.reason.kind === 'aborted' || event.data.reason.kind === 'disposed') {
@@ -922,6 +934,8 @@ export class Session implements SessionFace {
this.callsRev++
this.derivedNodes = []
this.derivedRev++
this.turnTimings = new Map()
this.turnTimingsRev++
this.turnEnds = new Map()
this.turnEndsRev++
this.codeDispatches = new Map()
@@ -955,6 +969,9 @@ export class Session implements SessionFace {
if (this.callsCache === null || this.callsCache.rev !== this.callsRev) {
this.callsCache = { rev: this.callsRev, value: [...this.openCalls.values()] }
}
if (this.turnTimingsCache === null || this.turnTimingsCache.rev !== this.turnTimingsRev) {
this.turnTimingsCache = { rev: this.turnTimingsRev, value: new Map(this.turnTimings) }
}
if (this.turnEndsCache === null || this.turnEndsCache.rev !== this.turnEndsRev) {
this.turnEndsCache = { rev: this.turnEndsRev, value: new Map(this.turnEnds) }
}
@@ -971,6 +988,7 @@ export class Session implements SessionFace {
return {
sessionId: this.sessionId,
nodes,
turnTimings: this.turnTimingsCache.value,
turnEnds: this.turnEndsCache.value,
partial,
runningCalls: this.callsCache.value,

View File

@@ -46,6 +46,11 @@ describe('open', () => {
expect(snapshot.openState).toBe('open')
expect(snapshot.hasMore).toBe(true)
expect(snapshot.nodes.map(n => n.kind)).toEqual(['user', 'assistant'])
expect(snapshot.turnTimings.get(3)).toEqual({
startTime: 1_700_000_000_010,
endTime: 1_700_000_000_015,
})
expect(snapshot.turnEnds.get(3)).toBe(15)
})
it('is idempotent: concurrent opens share one history call, reopening when open is a no-op', async () => {
@@ -253,11 +258,16 @@ describe('live event path', () => {
expect(snapshot.nodes.some(node => node.kind === 'turn-error')).toBe(false)
expect(snapshot.nodes.at(-2)).toMatchObject({ kind: 'model-retry', retryState: 'started' })
expect(snapshot.nodes.at(-1)).toMatchObject({ kind: 'assistant', blocks: [{ kind: 'text', text: '完整回复' }] })
expect([...snapshot.turnTimings]).toEqual([
[1, { startTime: 1_700_000_000_006, endTime: 1_700_000_000_013 }],
[2, { startTime: 1_700_000_000_014, endTime: 1_700_000_000_018 }],
])
const replay = makeSession()
replay.api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), ...retryTurn])
await replay.session.open()
expect(replay.session.getSnapshot().nodes).toEqual(snapshot.nodes)
expect(replay.session.getSnapshot().turnTimings).toEqual(snapshot.turnTimings)
expect(replay.session.getSnapshot().partial).toBeNull()
})
@@ -1254,6 +1264,7 @@ describe('reference stability (the memo contract)', () => {
expect(after).not.toBe(before)
expect(after.runningCalls).toBe(before.runningCalls)
expect(after.pending).toBe(before.pending)
expect(after.turnTimings).toBe(before.turnTimings)
expect(after.turnEnds).toBe(before.turnEnds)
// And a mutation on the tracked domain swaps that array.
feed(ev.toolResult(11, 1, 'c1', 'ECHO'))