Przeglądaj źródła

fix(client): fold high-sequence history windows

_Kerman 1 miesiąc temu
rodzic
commit
26742effdd

+ 8 - 17
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<string, CallIndexEntry>()
   const resultViews = new Map<number, ToolResultView>()
   const assistantSteps = new Map<string, AssistantStepMetadata>()
@@ -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,

+ 31 - 0
packages/client/runtime/tests/history-fold.spec.ts

@@ -8,6 +8,37 @@ const at = (seq: number, event: Record<string, unknown>): 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'),

+ 2 - 2
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

+ 1 - 1
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`.
 

+ 1 - 1
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`。
 

+ 34 - 14
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
     }
   }
 }

+ 34 - 0
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({