Selaa lähdekoodia

fix(session): preserve v2 migration compatibility

Tianyi Cui 1 viikko sitten
vanhempi
sitoutus
4fb03d0bda

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.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 .agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.md
-2026-09-01-v2-embedded-assistant-streams.md: e50af4848523c92daaac1bd8b1120c26cdf62bdc
-2026-09-01-v2-embedded-assistant-streams.zh.md: 34924a43150c34571a6c5f2e7837d1b662d2ec46
+2026-09-01-v2-embedded-assistant-streams.md: 805e52579be250ed372585c48fad434f7e15aadc
+2026-09-01-v2-embedded-assistant-streams.zh.md: 05e1a76f149a92c35af1ede28babfd2513773352

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.md

@@ -33,7 +33,7 @@ The Web follow adapter opts into these process-local frames and adds the last du
 
 The adjacent migration validates the complete frozen v1 artifact, groups chunks by turn, step, terminal boundary, and exact message provenance, and then substitutes one settlement per attempt. A successful group's chunks move into its message. An unclaimed group becomes `assistant/attempt` at the last consumed chunk's position. Unrelated interleaved events retain their relative order, and survivors receive dense v2 sequence numbers.
 
-The edge remaps the finite declared reference inventory: envelope provenance, surface replacement endpoints, command source events, compaction ranges and shadowed lists, and title message lists. A reference to a consumed chunk refuses migration; it is never redirected to a settlement with different meaning. The edge also refuses an inherited cut that splits an attempt.
+The edge remaps the finite declared reference inventory: envelope provenance, surface replacement endpoints, command source events, compaction ranges and shadowed lists, and title message lists. The model-visible text of a validated `session/title-llm-request` remains byte-identical in the source sequence namespace while its `messageSeqs` field moves to the v2 namespace; target validation therefore does not reconstruct that text from remapped sequences. A reference to a consumed chunk refuses migration; it is never redirected to a settlement with different meaning. The edge also refuses an inherited cut that splits an attempt.
 
 The v2 physical header requires `isSeeded` and stores no numeric cut. A seeded artifact marks its exact cut with `session/end-seed { inherited: true }`; decoding derives the cut from the last tagged marker. The v2 codec writes one durable event per physical row, range-encodes only `sourceEventSeqs`, and validates physical envelopes without freezing ordinary event vocabulary or payload additions. The v1-to-v2 target validator separately freezes the released-v2 inventory, while current restoration uses the installed Session vocabulary. Frozen v0 and v1 codecs retain packed-row decoding for their immutable historical generations.
 
@@ -45,7 +45,7 @@ Generation selection and publication follow the [released Session migration deci
 
 ## Verification
 
-The compact-stream tests pin exact accumulation and expansion for text, reasoning, tool arguments, raw chunks, timestamp gaps, malformed records, and detached snapshots. The v1-to-v2 tests cover successful and failed attempts, interleaving, dense sequence and reference remapping, seed-cut insertion and split refusal, strict source and target validation, one-row v2 encoding, provenance ranges, raw and Zstandard publication, and no-write current reads.
+The compact-stream tests pin exact accumulation and expansion for text, reasoning, tool arguments, raw chunks, timestamp gaps, malformed records, and detached snapshots. The v1-to-v2 tests cover successful and failed attempts, interleaving, dense sequence and reference remapping, source-sequence title framing, seed-cut insertion and split refusal, strict source and target validation, one-row v2 encoding, backend-compatible provenance ranges, raw and Zstandard publication, and no-write current reads.
 
 The manual performance acceptance measures static catalog-routing overhead against direct released-v2 restoration of the same already parsed physical rows across three runs, 100 warmup pairs, and 600 measured pairs. It does not compare v1 with v2 or time backend I/O. Every pooled median and p95 regression must remain within 5%; the accepted run's worst p95 regression was 3.150%. `--smoke` reports a non-gating diagnostic sample.
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.zh.md

@@ -33,7 +33,7 @@ Web follow adapter 显式选择接收这些进程本地 frame,并为每个 sta
 
 相邻迁移会校验完整的冻结 v1 产物,按 turn、step、terminal boundary 与精确 message provenance 对 chunk 分组,再为每个 attempt 替换一个 settlement。成功分组的 chunk 移入其 message。未被认领的分组会在最后一个被消费 chunk 的位置变成 `assistant/attempt`。无关的交错事件保持相对顺序,存活事件获得密集 v2 序号。
 
-该迁移边会重映射有限的已声明引用清单:信封 provenance、surface replacement 端点、command source event、compaction range 与 shadowed list,以及 title message list。指向被消费 chunk 的引用会使迁移失败;它绝不会被重定向到含义不同的 settlement。该迁移边也会拒绝切开 attempt 的继承切点。
+该迁移边会重映射有限的已声明引用清单:信封 provenance、surface replacement 端点、command source event、compaction range 与 shadowed list,以及 title message list。经过校验的 `session/title-llm-request` 模型可见文本会在源序号命名空间中保持逐字节不变,而它的 `messageSeqs` 字段会迁移到 v2 命名空间;因此目标校验不会根据重映射后的序号重建该文本。指向被消费 chunk 的引用会使迁移失败;它绝不会被重定向到含义不同的 settlement。该迁移边也会拒绝切开 attempt 的继承切点。
 
 v2 物理 header 要求 `isSeeded`,且不存储数值切点。带 seed 的产物用 `session/end-seed { inherited: true }` 标记其精确切点;解码从最后一个 tagged marker 推导切点。v2 编解码器为每个持久事件写一条物理行,只对 `sourceEventSeqs` 做范围编码,并在不冻结普通事件词汇或 payload 新增项的前提下校验物理 envelope。v1-to-v2 target validator 会另行冻结 released-v2 清单,current restoration 则使用 installed Session 词汇。冻结的 v0 与 v1 编解码器继续为不可变历史 generation 解码 packed row。
 
@@ -45,7 +45,7 @@ Generation 选择与发布遵循[已发布 Session 迁移决策](2026-08-31-rele
 
 ## 验证
 
-紧凑 stream 测试固定 text、reasoning、tool argument、raw chunk、时间戳间隔、格式错误 record 与分离 snapshot 的精确累积和展开。v1 到 v2 测试覆盖成功与失败 attempt、交错、密集序号与引用重映射、seed 切点插入与切分拒绝、严格源与目标校验、每行一个事件的 v2 编码、provenance range、原始与 Zstandard 发布,以及无写入的当前读取。
+紧凑 stream 测试固定 text、reasoning、tool argument、raw chunk、时间戳间隔、格式错误 record 与分离 snapshot 的精确累积和展开。v1 到 v2 测试覆盖成功与失败 attempt、交错、密集序号与引用重映射、源序号 title framing、seed 切点插入与切分拒绝、严格源与目标校验、每行一个事件的 v2 编码、与 backend 兼容的 provenance range、原始与 Zstandard 发布,以及无写入的当前读取。
 
 手工 performance acceptance 会在三轮、100 组 warmup pair 与 600 组 measured pair 下,针对同一批已经解析的物理 row,把静态 catalog routing 与直接 released-v2 restoration 比较;它不比较 v1 与 v2,也不计入 backend I/O。每个 pooled median 与 p95 regression 都必须保持在 5% 以内;已接受运行的最差 p95 regression 为 3.150%。`--smoke` 报告不参与 gate 的诊断 sample。
 

+ 11 - 2
packages/session/session-format-v0-to-v1/src/relationships.ts

@@ -31,6 +31,8 @@ interface ToolLifecycle {
 export interface ReleasedRelationshipExtensions {
   /** Event types that must occur inside the current open step. */
   readonly stepEvents?: ReadonlySet<string>
+  /** Title-request model input was source-validated and preserved across sequence remapping. */
+  readonly preservedSourceTitleRequestText?: true
 }
 
 /**
@@ -221,7 +223,12 @@ export function assertReleasedArtifactRelationships(
       }
       case 'session/title':
       case 'session/title-llm-request':
-        assertTitleSources(artifact.events, event, data)
+        assertTitleSources(
+          artifact.events,
+          event,
+          data,
+          extensions.preservedSourceTitleRequestText !== true,
+        )
         break
       case 'command/run': {
         const id = data['commandId'] as string
@@ -408,6 +415,7 @@ function assertTitleSources(
   events: readonly SessionFormatEvent[],
   event: SessionFormatEvent,
   data: Record<string, SessionFormatJsonValue>,
+  validateFramedText: boolean,
 ): void {
   const seqs = data['messageSeqs'] as readonly number[]
   if (event.type === 'session/title') {
@@ -444,7 +452,8 @@ function assertTitleSources(
       throw new SessionFormatError('session/title-llm-request messages do not represent messageSeqs')
     }
     const framed = content[0]
-    if (framed === undefined || framed['type'] !== 'text' || framed['text'] !== expected) {
+    if (framed === undefined || framed['type'] !== 'text'
+      || validateFramedText && framed['text'] !== expected) {
       throw new SessionFormatError('session/title-llm-request messages do not represent messageSeqs')
     }
   }

+ 2 - 2
packages/session/session-format-v1-to-v2/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/session/session-format-v1-to-v2/README.md
-README.md: 5ead8532b2cce52a0cbf4c39bfe9e54ef14002f1
-README.zh.md: 1f41d24007f95f799a9667910acb93d494a2aa2e
+README.md: 0e2661259b89cdbf4d5e138fe6d7a8fdfeaaea56
+README.zh.md: c066b994a7fe63fa2628fe0d5b4ae950f67da72b

+ 1 - 1
packages/session/session-format-v1-to-v2/README.md

@@ -40,7 +40,7 @@ const migratedV2 = sessionFormatV1ToV2.migrate(decodedV1)
 
 A successful v1 `assistant/message` must cite its complete ordered attempt. The migration removes the cited top-level chunks and obsolete message provenance, compacts the chunks without joining token boundaries, and stores the stream on that message. An unclaimed attempt becomes one log-only `assistant/attempt` at its final chunk position. Unrelated interleaved events keep their relative order.
 
-The migration refuses a reference to a consumed chunk instead of redirecting it to a different semantic event. It remaps declared event provenance, surface replacements, command source events, compaction ranges and lists, and title message lists. A seeded source also refuses an inherited cut that splits an Assistant attempt; the target marks the exact cut with `session/end-seed { inherited: true }`.
+The migration refuses a reference to a consumed chunk instead of redirecting it to a different semantic event. It remaps declared event provenance, surface replacements, command source events, compaction ranges and lists, and title message lists. The already model-visible `session/title-llm-request.messages` text remains byte-identical after source validation, so target validation does not reinterpret the old sequence numbers embedded in that prompt. A seeded source also refuses an inherited cut that splits an Assistant attempt; the target marks the exact cut with `session/end-seed { inherited: true }`.
 
 The v2 physical header requires `isSeeded` and does not store a numeric cut. The codec derives the cut from the last inherited end-seed marker, writes one event per row, range-encodes only `sourceEventSeqs`, and remains neutral to ordinary event vocabulary and payload growth. Strict migration-target validation freezes the released-v2 inventory and rejects unknown types or members. Current restoration instead admits event types known to the installed Session package plus unknown events carrying `ignorable: true`, then delegates payload and stream semantics to the installed current restorer. All paths retain strict header, event-envelope, sequence, and inherited-cut validation.
 

+ 1 - 1
packages/session/session-format-v1-to-v2/README.zh.md

@@ -40,7 +40,7 @@ const migratedV2 = sessionFormatV1ToV2.migrate(decodedV1)
 
 成功的 v1 `assistant/message` 必须引用其完整有序 attempt。迁移会移除这些顶层 chunk 和已停用的 message provenance,在不合并 token 边界的前提下压缩 chunk,并把 stream 存到该 message 上。未被 message 认领的 attempt 会在其最后一个 chunk 的位置变成一个仅日志可见的 `assistant/attempt`。无关的交错事件保持相对顺序。
 
-如果引用指向被消费的 chunk,迁移会失败,而不会把它重定向到语义不同的事件。它会重映射已声明的事件 provenance、surface replacement、command source event、compaction range 与 list,以及 title message list。带 seed 的源若让继承切点切开一个 Assistant attempt,也会迁移失败;目标会用 `session/end-seed { inherited: true }` 标出精确切点。
+如果引用指向被消费的 chunk,迁移会失败,而不会把它重定向到语义不同的事件。它会重映射已声明的事件 provenance、surface replacement、command source event、compaction range 与 list,以及 title message list。已经对模型可见的 `session/title-llm-request.messages` 文本会在源校验后保持逐字节不变,因此目标校验不会重新解释该 prompt 中嵌入的旧序号。带 seed 的源若让继承切点切开一个 Assistant attempt,也会迁移失败;目标会用 `session/end-seed { inherited: true }` 标出精确切点。
 
 v2 物理 header 要求 `isSeeded`,且不存储数值切点。编解码器从最后一个 inherited end-seed marker 推导切点,每行写入一个事件,只对 `sourceEventSeqs` 做范围编码,并对普通事件词汇与 payload 扩展保持中立。严格的迁移目标校验会冻结 released-v2 清单并拒绝未知 type 或 member。当前恢复则准入 installed Session package 已知的事件 type,以及携带 `ignorable: true` 的未知事件,再把 payload 与 stream 语义交给 installed current restorer。所有路径仍严格校验 header、event envelope、sequence 与 inherited cut。
 

+ 6 - 0
packages/session/session-format-v1-to-v2/src/codec.ts

@@ -168,6 +168,7 @@ function encodeProvenance(event: SessionFormatEvent): SessionFormatJsonObject {
 function decodeSeqRanges(value: SessionFormatJsonValue, maxEntries: number): readonly number[] {
   if (!Array.isArray(value)) throw new SessionFormatError('sourceEventSeqs must be an array')
   const output: number[] = []
+  let hasRange = false
   for (const entry of value) {
     if (!Array.isArray(entry)) {
       output.push(sessionFormatCount(entry, 'sourceEventSeqs member'))
@@ -180,6 +181,7 @@ function decodeSeqRanges(value: SessionFormatJsonValue, maxEntries: number): rea
       throw new SessionFormatError('sourceEventSeqs range exceeds its event seq')
     }
     for (let current = start; current <= end; current += 1) output.push(current)
+    hasRange = true
   }
   const seen = new Set<number>()
   for (const source of output) {
@@ -188,10 +190,14 @@ function decodeSeqRanges(value: SessionFormatJsonValue, maxEntries: number): rea
     }
     seen.add(source)
   }
+  if (hasRange && output.some((source, index) => index > 0 && source <= (output[index - 1] as number))) {
+    throw new SessionFormatError('sourceEventSeqs ranges must be strictly increasing')
+  }
   return output
 }
 
 function encodeSeqRanges(values: readonly number[]): readonly SessionFormatJsonValue[] {
+  if (values.some((value, index) => index > 0 && value <= (values[index - 1] as number))) return [...values]
   const output: SessionFormatJsonValue[] = []
   for (let index = 0; index < values.length;) {
     const start = values[index] as number

+ 1 - 0
packages/session/session-format-v1-to-v2/src/validation.ts

@@ -31,6 +31,7 @@ const LOG_OPTIONAL = ['ignorable'] as const
 const RELEASED_V2_EVENT_TYPE_SET = new Set(RELEASED_V2_EVENT_TYPES)
 const RELEASED_V2_RELATIONSHIP_EXTENSIONS = {
   stepEvents: new Set(['assistant/attempt']),
+  preservedSourceTitleRequestText: true,
 } as const
 
 /**

+ 17 - 0
packages/session/session-format-v1-to-v2/tests/codec.spec.ts

@@ -4,6 +4,7 @@ import type {
   SessionFormatEvent,
   SessionFormatJsonObject,
 } from '@deepseek-ai/dsh-session-format'
+import { decodeSeqRanges as decodeCurrentSeqRanges } from '@deepseek-ai/dsh-session'
 import { releasedV2SessionFormatCodec } from '@deepseek-ai/dsh-session-format-v1-to-v2'
 
 const minimalPhysicalHeader = {
@@ -126,6 +127,21 @@ describe('releasedV2SessionFormatCodec rows', () => {
     expect(releasedV2SessionFormatCodec.decodeArtifact(encoded.header, encoded.rows)).toStrictEqual(source)
   })
 
+  it('keeps non-monotonic provenance scalar-only for the current backend reader', () => {
+    const sourceEventSeqs = [4, 5, 1, 2, 3]
+    const source = artifact([
+      feedback(0), feedback(1), feedback(2), feedback(3), feedback(4), feedback(5),
+      userMessage(6, sourceEventSeqs),
+    ])
+
+    const encoded = releasedV2SessionFormatCodec.encodeArtifact(source)
+    const stored = encoded.rows[6]?.['sourceEventSeqs']
+
+    expect(stored).toStrictEqual(sourceEventSeqs)
+    expect(decodeCurrentSeqRanges(stored, 6)).toStrictEqual(sourceEventSeqs)
+    expect(releasedV2SessionFormatCodec.decodeArtifact(encoded.header, encoded.rows)).toStrictEqual(source)
+  })
+
   it('keeps the v2 physical codec vocabulary-neutral for current growth and a future source freeze', () => {
     const source = artifact([
       { type: 'external/required', seq: 0, time: 1, data: { retained: true } },
@@ -164,6 +180,7 @@ describe('releasedV2SessionFormatCodec rows', () => {
     ['scalar at the event', [4], /unique earlier/],
     ['duplicate scalars', [0, 0], /unique earlier/],
     ['overlapping range and scalar', [[0, 1], 1], /unique earlier/],
+    ['non-monotonic range', [3, [0, 2]], /strictly increasing/],
   ])('refuses malformed stored provenance: %s', (_name, sourceEventSeqs, message) => {
     const rows = [feedback(0), feedback(1), feedback(2), feedback(3), {
       ...userMessage(4), sourceEventSeqs,

+ 46 - 0
packages/session/session-format-v1-to-v2/tests/migration.spec.ts

@@ -650,6 +650,52 @@ describe('sessionFormatV1ToV2', () => {
     })
   })
 
+  it('preserves source-sequence text in a title request while remapping its references', () => {
+    const framed = 'Generate the session title from this JSON array of human messages:\n'
+      + JSON.stringify([{ seq: 6, text: 'question' }])
+    const source: SessionFormatArtifact = {
+      header: {
+        version: 1, id: 'v1-title-source-seq', createdAt: 1,
+        isSeeded: false, delegationDepth: 0,
+      },
+      inheritedEventCount: 0,
+      events: [
+        event('turn/start', 0, 1, { turn: 1 }),
+        event('step/start', 1, 2, { turn: 1, step: 1 }),
+        event('assistant/chunk', 2, 3, {
+          turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'hello' },
+        }),
+        event('assistant/chunk', 3, 4, {
+          turn: 1, step: 1, chunk: { type: 'finish', reason: { kind: 'stop' } },
+        }),
+        {
+          ...event('assistant/message', 4, 5, { turn: 1, step: 1, message }),
+          sourceEventSeqs: [2, 3], surfaceOp: 'append',
+        },
+        event('step/end', 5, 6, { turn: 1, step: 1 }),
+        { ...event('user/message', 6, 7, userMessage), surfaceOp: 'append' },
+        event('session/title-llm-request', 7, 8, {
+          titleProvider: 'title-1', messageSeqs: [6], route: { provider: 'mock', model: 'mock' },
+          system: 'title',
+          messages: [{
+            id: 'title-request', role: 'user', content: [{ type: 'text', text: framed }],
+            source: { kind: 'plugin', plugin: 'dsh-session-title-llm' },
+          }],
+          maxTokens: 20,
+        }),
+        event('turn/end', 8, 9, { turn: 1, reason: { kind: 'completed' } }),
+      ],
+    }
+
+    const migrated = sessionFormatV1ToV2.migrate(source)
+    const titleRequest = migrated.events.find(event => event.type === 'session/title-llm-request')
+
+    expect(titleRequest?.data).toMatchObject({
+      messageSeqs: [4],
+      messages: [{ content: [{ text: framed }] }],
+    })
+  })
+
   it('remaps compaction summaries and closes prior-turn groups independently', () => {
     const source: SessionFormatArtifact = {
       header: {