fix(client): fold high-sequence history windows
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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`。
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user