From 26742effdd63afbf3ad8d83384e34697c294d38b Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 4 Aug 2026 13:57:04 +0800 Subject: [PATCH] fix(client): fold high-sequence history windows --- .../client/session-history/history-fold.ts | 25 ++++------ .../client/runtime/tests/history-fold.spec.ts | 31 ++++++++++++ packages/core/session/README.i18n.yaml | 4 +- packages/core/session/README.md | 2 +- packages/core/session/README.zh.md | 2 +- packages/core/session/src/surface.ts | 48 +++++++++++++------ packages/core/session/tests/surface.spec.ts | 34 +++++++++++++ 7 files changed, 111 insertions(+), 35 deletions(-) diff --git a/packages/client/runtime/src/client/session-history/history-fold.ts b/packages/client/runtime/src/client/session-history/history-fold.ts index c4bc6ed9b5..68c8aebccf 100644 --- a/packages/client/runtime/src/client/session-history/history-fold.ts +++ b/packages/client/runtime/src/client/session-history/history-fold.ts @@ -49,18 +49,10 @@ function assistantStepKey(turn: number, step: number): string { return `${turn}\u0000${step}` } -// Trajectory owns surface-window reconstruction so its immutable ledger does -// not depend on Chat's live fold adapter or Session's mutable state. -/* jscpd:ignore-start */ -function paddingEvent(seq: number): SessionEvent { - return { type: 'noop/padding', seq, time: 0, data: {} } as unknown as SessionEvent -} - function replacementCrossesWindowHead(event: SessionEvent, baseSeq: number): boolean { if (!isSurfaceEvent(event) || event.surfaceOp === 'append') return false return event.surfaceOp.start < baseSeq || event.surfaceOp.end < baseSeq } -/* jscpd:ignore-end */ function contextOriginKind(event: SessionEvent | undefined): ConversationContextOriginKind { if (event?.type !== 'user/message') return 'rewrite' @@ -84,9 +76,12 @@ function isTokenDelta(chunk: SessionEvent<'assistant/chunk'>['data']['chunk']): } } -function foldContexts(events: readonly SessionEvent[]): readonly FoldedContext[] { +function foldContexts( + events: readonly SessionEvent[], + baseSeq: number, +): readonly FoldedContext[] { const replay: SessionEvent[] = [] - const surface = new SurfaceManager(replay) + const surface = new SurfaceManager(replay, baseSeq) const contexts: FoldedContext[] = [] let generation = 0 let originSeq: number | undefined @@ -332,10 +327,6 @@ export function projectConversationHistory( ): ConversationHistoryProjection { const events = entries.map(entry => entry.event) const baseSeq = events[0]?.seq ?? 0 - const padded = [ - ...Array.from({ length: baseSeq }, (_, seq) => paddingEvent(seq)), - ...events, - ] const callIndex = new Map() const resultViews = new Map() const assistantSteps = new Map() @@ -405,7 +396,7 @@ export function projectConversationHistory( const materialize = (seq: number): ConversationNode | undefined => { const cached = nodeCache.get(seq) if (cached !== undefined) return cached - const event = padded[seq] + const event = events[seq - baseSeq] if (event === undefined || !isSurfaceEligibleType(event.type)) return const node = materializeNode( event, @@ -431,7 +422,7 @@ export function projectConversationHistory( }] } else { try { - contexts = foldContexts(padded).map((context): ConversationContext => { + contexts = foldContexts(events, baseSeq).map((context): ConversationContext => { const nodes = context.nodes.flatMap((seq) => { const node = materialize(seq) return node === undefined ? [] : [node] @@ -444,7 +435,7 @@ export function projectConversationHistory( nodes, } } - const originEvent = padded[context.originSeq] + const originEvent = events[context.originSeq - baseSeq] return { id: context.generation, parentId: context.generation - 1, diff --git a/packages/client/runtime/tests/history-fold.spec.ts b/packages/client/runtime/tests/history-fold.spec.ts index 0c5421e491..ff703afa26 100644 --- a/packages/client/runtime/tests/history-fold.spec.ts +++ b/packages/client/runtime/tests/history-fold.spec.ts @@ -8,6 +8,37 @@ const at = (seq: number, event: Record): SessionEvent => ({ seq, time: 1_700_000_000_000 + seq, ...event }) as unknown as SessionEvent describe('projectConversationHistory', () => { + it('projects a high-sequence history window without synthesizing its unloaded prefix', () => { + const baseSeq = 400_000 + const events = [ + ev.user(baseSeq, 'loaded tail'), + at(baseSeq + 1, { + type: 'assistant/message', + surfaceOp: { op: 'replace', start: baseSeq, end: baseSeq }, + sourceEventSeqs: [baseSeq], + data: { + turn: 80, + step: 1, + message: createMessage({ + role: 'assistant', + content: [{ type: 'text', text: 'tail summary' }], + source: { kind: 'model', provider: 'fake', model: 'fake' }, + }), + }, + }), + ] + + const projection = projectConversationHistory(events.map(event => ({ event }))) + expect(projection.eventNodes.map(node => node.seq)).toEqual([baseSeq, baseSeq + 1]) + expect(projection.contexts.map(context => ({ + originSeq: context.originSeq, + nodes: context.nodes.map(node => node.seq), + }))).toEqual([ + { originSeq: undefined, nodes: [baseSeq] }, + { originSeq: baseSeq + 1, nodes: [baseSeq + 1] }, + ]) + }) + it('projects frozen surface generations without widening the core live surface', () => { const events = [ ev.user(0, 'a'), diff --git a/packages/core/session/README.i18n.yaml b/packages/core/session/README.i18n.yaml index 9f0aec4eb8..fa1a5a5fe2 100644 --- a/packages/core/session/README.i18n.yaml +++ b/packages/core/session/README.i18n.yaml @@ -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/core/session/README.md -README.md: d78dc5bcfe1df2edd01280208f3859eb1b2d6763 -README.zh.md: 40c58a539d5027f2619b5b2102b94e76f2c73e23 +README.md: 892be8237d008b85418d8b815325a970b140a163 +README.zh.md: 031239faf0fd14d582988f05590816cd17c329b6 diff --git a/packages/core/session/README.md b/packages/core/session/README.md index d78dc5bcfe..892be8237d 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -58,7 +58,7 @@ Providers stream token-sized deltas, so a raw log stores hundreds of `assistant/ - `SurfaceOp` — how an event entered the ordered surface: `'append'` (normal tail append) or `{ op: 'replace', start, end }` (replace entries from `start` through `end` inclusive — both must be valid surface seqs; `start === end` replaces one entry). Used by compaction to shadow old events without deleting them. - `SurfaceIntent` — `{ surfaceOp: SurfaceOp; sourceEventSeqs?: number[] }`, the required third parameter to `session.append()` for surface-eligible types. - `SessionSurface` — the readonly live `nodes` and `replaceGeneration` projection exposed by `session.surface`; candidate validation remains private to `Session`. -- `foldSurface(events)` — replay the canonical surface contract into detached current event sequences and actual replacement ranges. The same pass rejects non-contiguous seqs, misplaced or malformed metadata, empty or duplicate provenance, non-earlier sources, invalid positional ranges, replacements that fail to cite every shadowed surface entry, and a `tool/result` replacement that changes anything except one current result's `content`; `SurfaceManager` shares the atomic transition while retaining only its incremental sequence cache. +- `foldSurface(events)` — replay the canonical surface contract into detached current event sequences and actual replacement ranges. The same pass rejects non-contiguous seqs, misplaced or malformed metadata, empty or duplicate provenance, non-earlier sources, invalid positional ranges, replacements that fail to cite every shadowed surface entry, and a `tool/result` replacement that changes anything except one current result's `content`; `SurfaceManager` shares the atomic transition while retaining only its incremental sequence cache. Its optional `baseSeq` folds a contiguous loaded window with absolute event sequences and no synthetic prefix; replacements must remain inside that window. - `isSurfaceEvent(event)` / `isSurfaceEligibleType(type)` — the first narrows a `SessionEvent` to a fully formed surface event; the second detects a surface-eligible event missing its marker when validating a seed or loaded log. - `isAppendSurfaceEvent(event)` / `isReplacementSurfaceEvent(event)` — split a formed surface event by marker variant. Append-origin events are the durable source for a human transcript, which is not the model-visible surface: a landed replacement shadows the range it summarizes, so projecting a transcript from `session.surface` erases conversation the reader already saw. Consumers that must send exactly what the model sees keep reading `session.surface`. diff --git a/packages/core/session/README.zh.md b/packages/core/session/README.zh.md index 40c58a539d..031239faf0 100644 --- a/packages/core/session/README.zh.md +++ b/packages/core/session/README.zh.md @@ -58,7 +58,7 @@ - `SurfaceOp`:事件进入有序 surface 的方式,即 `'append'`(正常尾部追加)或 `{ op: 'replace', start, end }`(替换从 `start` 到 `end` 的条目,含两端;二者都必须是有效的 surface 序号;`start === end` 时替换一个条目)。压缩用它遮蔽旧事件而不删除它们。 - `SurfaceIntent`:`{ surfaceOp: SurfaceOp; sourceEventSeqs?: number[] }`,可进入 surface 的类型调用 `session.append()` 时必需的第三个参数。 - `SessionSurface`:实时只读 `nodes` 和 `replaceGeneration` 投影,由 `session.surface` 暴露;候选校验仍由 `Session` 私有。 -- `foldSurface(events)`:回放规范 surface 契约,得到脱离的当前事件序列与实际替换范围。同一趟处理会拒绝不连续序号、错位或畸形元数据、空或重复溯源信息、来源并非更早事件、无效位置范围,以及没有引用所有已遮蔽 surface 条目的替换。如果一个 `tool/result` 替换修改了当前某个结果的 `content` 之外的任何内容,也会被拒绝;`SurfaceManager` 共享该原子状态转换,但只保留自己的增量序列缓存。 +- `foldSurface(events)`:回放规范 surface 契约,得到脱离的当前事件序列与实际替换范围。同一趟处理会拒绝不连续序号、错位或畸形元数据、空或重复溯源信息、来源并非更早事件、无效位置范围,以及没有引用所有已遮蔽 surface 条目的替换。如果一个 `tool/result` 替换修改了当前某个结果的 `content` 之外的任何内容,也会被拒绝;`SurfaceManager` 共享该原子状态转换,但只保留自己的增量序列缓存。其可选 `baseSeq` 可使用绝对事件序号折叠连续的已加载窗口,而无需构造合成前缀;替换范围必须位于该窗口内。 - `isSurfaceEvent(event)`/`isSurfaceEligibleType(type)`:前者将 `SessionEvent` 收窄为形态完整的 surface 事件;后者在校验种子或已加载日志时,检测缺少标记的可进入 surface 事件。 - `isAppendSurfaceEvent(event)`/`isReplacementSurfaceEvent(event)`:按标记变体拆分形态完整的 surface 事件。追加来源的事件是人类可读记录(transcript)的持久来源,而该记录并非模型可见的 surface:已落地的替换会遮蔽它所概括的范围,因此从 `session.surface` 投影记录会抹掉读者已经看到的对话。必须准确发送模型所见内容的消费方仍继续读取 `session.surface`。 diff --git a/packages/core/session/src/surface.ts b/packages/core/session/src/surface.ts index ad3d28127c..775e371ddf 100644 --- a/packages/core/session/src/surface.ts +++ b/packages/core/session/src/surface.ts @@ -242,13 +242,14 @@ function assertToolResultRewrite( event: SessionEvent, shadowedSeqs: readonly number[], events: readonly SessionEvent[], + baseSeq: number, ): void { if (event.type !== 'tool/result') return if (shadowedSeqs.length !== 1) { throw new Error('tool/result surface replacement must rewrite exactly one current node') } for (const originalSeq of shadowedSeqs) { - const original = events[originalSeq] + const original = events[originalSeq - baseSeq] if (original?.type !== 'tool/result') { throw new Error('tool/result surface replacement must target a current tool/result') } @@ -276,6 +277,7 @@ function planSurfaceEvent( event: SessionEvent, expectedSeq: number, events: readonly SessionEvent[], + baseSeq: number, ): SurfacePlan | undefined { if (event.seq !== expectedSeq) { throw new Error(`session event seq ${event.seq} is not contiguous; expected ${expectedSeq}`) @@ -288,7 +290,7 @@ function planSurfaceEvent( } const range = replacementRange(state, surfaceOp) assertProvenance(event, range.shadowedSeqs) - assertToolResultRewrite(event, range.shadowedSeqs, events) + assertToolResultRewrite(event, range.shadowedSeqs, events, baseSeq) return { kind: 'replace', seq: event.seq, @@ -304,8 +306,9 @@ function applySurfaceEvent( event: SessionEvent, expectedSeq: number, events: readonly SessionEvent[], + baseSeq: number, ): SurfaceFoldReplacement | undefined { - const plan = planSurfaceEvent(state, event, expectedSeq, events) + const plan = planSurfaceEvent(state, event, expectedSeq, events, baseSeq) if (plan?.kind === 'append') { state.nodes.push(plan.seq) } else if (plan?.kind === 'replace') { @@ -331,7 +334,7 @@ export function foldSurface(events: readonly SessionEvent[]): SurfaceFoldResult const state = createFoldState() const replacements: SurfaceFoldReplacement[] = [] for (const [index, event] of events.entries()) { - const replacement = applySurfaceEvent(state, event, index, events) + const replacement = applySurfaceEvent(state, event, index, events, 0) if (replacement !== undefined) replacements.push(replacement) } return { nodes: [...state.nodes], replacements } @@ -341,38 +344,55 @@ export function foldSurface(events: readonly SessionEvent[]): SurfaceFoldResult export class SurfaceManager implements SessionSurface { /** Shared transition state; replacement history is not retained. */ private _state = createFoldState() - /** Last processed seq; -1 folds a seeded log on first access. */ - private _lastProcessedSeq = -1 + /** Last processed absolute seq. */ + private _lastProcessedSeq: number - constructor(private log: readonly SessionEvent[]) {} + /** + * @param log - Contiguous complete log or loaded event window. + * @param baseSeq - Absolute sequence of the window's first event. + */ + constructor( + private log: readonly SessionEvent[], + private readonly baseSeq = 0, + ) { + this._lastProcessedSeq = baseSeq - 1 + } /** * Validate the next candidate without mutating the committed surface. * @param event - candidate event that has not entered the log yet. */ validateNext(event: SessionEvent): void { - if (this._lastProcessedSeq < this.log.length - 1) this._processDelta() - planSurfaceEvent(this._state, event, this.log.length, this.log) + if (this._lastProcessedSeq < this.baseSeq + this.log.length - 1) this._processDelta() + planSurfaceEvent( + this._state, + event, + this.baseSeq + this.log.length, + this.log, + this.baseSeq, + ) } /** Monotonic count of folded positional replacements. */ get replaceGeneration(): number { - if (this._lastProcessedSeq < this.log.length - 1) this._processDelta() + if (this._lastProcessedSeq < this.baseSeq + this.log.length - 1) this._processDelta() return this._state.replaceGeneration } /** Surface event sequences in model-visible order. */ get nodes(): readonly number[] { - if (this._lastProcessedSeq < this.log.length - 1) this._processDelta() + if (this._lastProcessedSeq < this.baseSeq + this.log.length - 1) this._processDelta() return this._state.nodes } /** Fold events appended since the previous access. */ private _processDelta(): void { - for (let i = this._lastProcessedSeq + 1; i < this.log.length; i++) { + const tailSeq = this.baseSeq + this.log.length - 1 + for (let seq = this._lastProcessedSeq + 1; seq <= tailSeq; seq++) { + const index = seq - this.baseSeq // oxlint-disable-next-line typescript/no-non-null-assertion -- bounded by the loop condition - applySurfaceEvent(this._state, this.log[i]!, i, this.log) - this._lastProcessedSeq = i + applySurfaceEvent(this._state, this.log[index]!, seq, this.log, this.baseSeq) + this._lastProcessedSeq = seq } } } diff --git a/packages/core/session/tests/surface.spec.ts b/packages/core/session/tests/surface.spec.ts index 017f0e0163..8bc76f2e0b 100644 --- a/packages/core/session/tests/surface.spec.ts +++ b/packages/core/session/tests/surface.spec.ts @@ -9,6 +9,7 @@ import { isSurfaceEligibleType, isSurfaceEvent, } from '@deepseek-ai/dsh-session' +import { SurfaceManager } from '@deepseek-ai/dsh-session/surface' import { createMessage, createToolResultMessage, @@ -239,6 +240,39 @@ describe('foldSurface tool-result rewrites', () => { }) describe('SurfaceManager', () => { + it('folds a contiguous window without materializing earlier event sequences', () => { + const baseSeq = 400_000 + const events = [ + provenanceEvent(baseSeq, undefined), + provenanceEvent(baseSeq + 1, undefined), + { + ...provenanceEvent(baseSeq + 2, [baseSeq]), + surfaceOp: { op: 'replace', start: baseSeq, end: baseSeq }, + }, + ] as SessionEvent[] + + const surface = new SurfaceManager(events, baseSeq) + expect(surface.nodes).toEqual([baseSeq + 2, baseSeq + 1]) + expect(surface.replaceGeneration).toBe(1) + }) + + it('validates tool-result rewrites against a nonzero window offset', () => { + const baseSeq = 400_000 + const original = toolResultEvent(baseSeq, 'call') + const events: SessionEvent[] = [ + original, + { + ...original, + seq: baseSeq + 1, + time: baseSeq + 1, + surfaceOp: { op: 'replace' as const, start: baseSeq, end: baseSeq }, + sourceEventSeqs: [baseSeq], + } as SessionEvent, + ] + + expect(new SurfaceManager(events, baseSeq).nodes).toEqual([baseSeq + 1]) + }) + it('shares ordered entries and nested replacement ranges with foldSurface', () => { const s = new Session(SessionId('shared-fold')) s.append('user/message', createUserMessage({