Ver código fonte

fix(session): keep independent version fields off the format generation

`SESSION_FORMAT_VERSION` is the only value that names the Session format
generation. Two other version fields had moved to 2 alongside it:

- The `dsh_session_log` request extension's outer schema `version` returns
  to 1 and its Session header projection keeps `seedLength`, derived from
  the logical inherited cut, so the external wire is unchanged in this PR;
  `sessionFormatVersion` alone identifies the embedded generation.
- The `sessionStats` projection unit's `stateVersion` returns to 1: the
  projection cache binds every checkpoint to the format generation, so a
  generation change discards old rows without a unit version bump.

Test doubles and the standalone Client fixture that build a current
header now spell `SESSION_FORMAT_VERSION` instead of the literal.
Tianyi Cui 1 mês atrás
pai
commit
86241a442d

+ 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: 5475f467db7de7f0dfdec29764adf2fdc5c9961b
-2026-09-01-v2-embedded-assistant-streams.zh.md: 60e983e48358bdc5ba25f1e2c4b35cc866b8a1f3
+2026-09-01-v2-embedded-assistant-streams.md: c9d7a66481d4258de8f9f7abdaa12d25f5217510
+2026-09-01-v2-embedded-assistant-streams.zh.md: ff4caceb68c34e470d2dd81fc9cf214555ad676e

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

@@ -39,7 +39,7 @@ The v2 physical header requires `isSeeded` and stores no numeric cut. A seeded a
 
 A fresh subagent child's constructor seed is exactly the inherited parent prefix. `Session` appends the tagged cut marker, then subagent setup appends the child-owned descriptor and delegated policies. The former descriptor-seed helper is removed, so a descriptor is never counted as inherited and cold resume replays the persisted child-owned setup. Historical snapshot fixtures that placed an untagged marker after the descriptor are corrected at their source; current comparison keeps marker count and sequence references visible.
 
-The `dsh_session_log` request extension increments its outer schema to version 2 because its Session header projection replaces `seedLength` with required `isSeeded`. Its `sessionFormatVersion` member continues to identify the embedded logical Session generation.
+The `dsh_session_log` request extension keeps its own outer schema at version 1: its Session header projection still derives `seedLength` from the logical inherited cut, and only its `sessionFormatVersion` member identifies the embedded logical Session generation. Projection units likewise keep their `stateVersion`; the projection cache binds every checkpoint to the Session format generation, so a generation change never needs a unit version bump.
 
 Generation selection and publication follow the [released Session migration decision](2026-08-31-released-session-format-migrations.md): the source path, bytes, and inode remain unchanged, only the final version-named successor is published, and retained predecessors provide neither fallback nor downgrade support.
 

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

@@ -39,7 +39,7 @@ v2 物理 header 要求 `isSeeded`,且不存储数值切点。带 seed 的产
 
 新建 subagent 子项的 constructor seed 与继承的父项前缀完全相同。`Session` 会追加 tagged cut marker,随后 subagent setup 再追加子项持有的 descriptor 与 delegated policy。原 descriptor-seed helper 会被删除,因此 descriptor 绝不会计入继承内容,cold resume 则重放已经持久化的子项 setup。曾把 untagged marker 放在 descriptor 后面的历史 snapshot fixture 会在源处修正;当前比较仍会暴露 marker 数量与序号引用。
 
-`dsh_session_log` request extension 会把外层 schema 升至版本 2,因为它的 Session header 投影会用必需的 `isSeeded` 取代 `seedLength`。其中的 `sessionFormatVersion` 成员仍用于标识嵌入的逻辑 Session generation。
+`dsh_session_log` request extension 的外层 schema 保持版本 1:它的 Session header 投影仍从逻辑 inherited cut 推导 `seedLength`,只有其中的 `sessionFormatVersion` 成员标识嵌入的逻辑 Session generation。projection unit 同样保持各自的 `stateVersion`;projection cache 把每个 checkpoint 绑定到 Session format generation,因此 generation 变化不需要提升 unit 版本。
 
 Generation 选择与发布遵循[已发布 Session 迁移决策](2026-08-31-released-session-format-migrations.zh.md):源路径、字节与 inode 保持不变,只发布最终具名版本 successor;保留 predecessor 不提供 fallback 或 downgrade 支持。
 

+ 2 - 1
packages/api/session-controller/tests/fake-api.client.ts

@@ -28,6 +28,7 @@ import {
   type RemoteStreamOptions,
 } from '@deepseek-ai/dsh-api-gateway/client'
 import type { SessionRemotes } from '../src/client/sessions/remotes.ts'
+import { SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session/types'
 import { historyRecordLastSeq } from '../src/client/sessions/history-records.ts'
 
 const AVAILABLE_STREAM_CONNECTION = {
@@ -392,7 +393,7 @@ export class FakeApiClient {
       yield {
         type: 'snapshot',
         header: {
-          version: 2,
+          version: SESSION_FORMAT_VERSION,
           id: sessionId,
           createdAt: 0,
           isSeeded: false,

+ 1 - 1
packages/api/session-controller/tests/session-projections.host.spec.ts

@@ -154,7 +154,7 @@ describe('session.history projections block', () => {
     const snapshot = await opening(remote(ctx), child.id)
 
     expect(snapshot.header).toEqual({
-      version: 2,
+      version: SESSION_FORMAT_VERSION,
       id: child.id,
       createdAt: child.header.createdAt,
       cwd: '/workspace',

+ 3 - 3
packages/api/session-controller/tests/sessions-service.client.spec.ts

@@ -12,7 +12,7 @@ import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
 import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
 import { LlmAttemptId } from '@deepseek-ai/dsh-llm'
 import { RemoteStreamCarrierError } from '@deepseek-ai/dsh-api-gateway/client'
-import { SessionSeq } from '@deepseek-ai/dsh-session/types'
+import { SESSION_FORMAT_VERSION, SessionSeq } from '@deepseek-ai/dsh-session/types'
 import { ClientSessions, SessionCreateError } from '../src/client/sessions/service.ts'
 import { scopeOf } from '../src/client/scope.ts'
 import type { SessionFollowFrame } from '../src/types.ts'
@@ -547,7 +547,7 @@ describe('Agent scope disposal lifecycle', () => {
                     value: {
                       type: 'snapshot',
                       header: {
-                        version: 2,
+                        version: SESSION_FORMAT_VERSION,
                         id: request.address.kind === 'session'
                           ? request.address.sessionId
                           : request.address.childSessionId,
@@ -623,7 +623,7 @@ describe('Agent scope disposal lifecycle', () => {
                     done: false,
                     value: {
                       type: 'snapshot',
-                      header: { version: 2, id: sessionId, createdAt: 0, isSeeded: false },
+                      header: { version: SESSION_FORMAT_VERSION, id: sessionId, createdAt: 0, isSeeded: false },
                       cursor: -1,
                       records: [],
                       hasMore: false,

+ 3 - 2
packages/api/session-controller/tests/transport.client.spec.ts

@@ -6,6 +6,7 @@ import {
 } from '@deepseek-ai/dsh-api-gateway/client'
 import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
 import { LlmAttemptId } from '@deepseek-ai/dsh-llm'
+import { SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session/types'
 import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
 import {
   createSessionControlStream,
@@ -54,7 +55,7 @@ function snapshot(
   return {
     type: 'snapshot',
     header: {
-      version: 2,
+      version: SESSION_FORMAT_VERSION,
       id: ADDRESS.kind === 'session' ? ADDRESS.sessionId : ADDRESS.childSessionId,
       createdAt: 0,
       isSeeded: false,
@@ -181,7 +182,7 @@ describe('Session Client stream adapters', () => {
       frames: [{
         type: 'snapshot',
         header: {
-          version: 2,
+          version: SESSION_FORMAT_VERSION,
           id: ADDRESS.sessionId,
           createdAt: 0,
           isSeeded: false,

+ 2 - 2
packages/client/connection/src/client/fixture.ts

@@ -28,7 +28,7 @@ import type {
   SessionId,
   SessionSeqCursor,
 } from '@deepseek-ai/dsh-session/types'
-import { SessionSeq } from '@deepseek-ai/dsh-session/types'
+import { SESSION_FORMAT_VERSION, SessionSeq } from '@deepseek-ai/dsh-session/types'
 import type { JsonValue } from '@deepseek-ai/dsh-util-values'
 import type { TodoItem } from '@deepseek-ai/dsh-tool-todo/client'
 // Type-only: the brand constructor is host-side; the fixture casts at its
@@ -3328,7 +3328,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
       yield {
         type: 'snapshot',
         header: {
-          version: 2,
+          version: SESSION_FORMAT_VERSION,
           id: sessionId,
           createdAt: summary.updatedAt,
           ...(summary.cwd === undefined ? {} : { cwd: summary.cwd }),

+ 1 - 1
packages/llm/llm-deepseek/tests/loader-composition.spec.ts

@@ -182,7 +182,7 @@ describe('llm-deepseek real dynamic composition', () => {
       }
     }
     expect(request.dsh_session_log).toMatchObject({
-      version: 2,
+      version: 1,
       session: { id: 'extension-composition-enabled' },
       afterSeq: -1,
       throughSeq: 0,

+ 2 - 2
packages/session/session-log-deepseek/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-log-deepseek/README.md
-README.md: 29e6b7860b2654f4ff272c2e9d23d3490527e396
-README.zh.md: ec104bf51c7d9e4589fa19f3fbba2ecc762b1889
+README.md: 047cd8133b88ceacce1fdaafa87a60611532a18c
+README.zh.md: be6b97bc889a037847289d4f5757c12823969a63

+ 1 - 1
packages/session/session-log-deepseek/README.md

@@ -34,7 +34,7 @@ Shipped profiles mount the plugin so an overlay can enable it, but the default c
 <a id="request-field"></a>
 ## Request field
 
-For a request carrying a live `sessionId`, the plugin folds the greatest accepted watermark for that exact Session format generation, snapshots `Session.events`, and sends the contiguous suffix after the watermark. A process-local fold scans each event once and consumes later appends incrementally; restart and HMR rebuild it from the durable log. The version-2 field contains `sessionFormatVersion`, a raw session header with the `isSeeded` lineage bit, numeric `afterSeq` and `throughSeq`, and every complete canonical event translated to raw-number envelope fields. Forked sessions ignore inherited parent watermarks because both the recorded Session id and format generation must match the request source.
+For a request carrying a live `sessionId`, the plugin folds the greatest accepted watermark for that exact Session format generation, snapshots `Session.events`, and sends the contiguous suffix after the watermark. A process-local fold scans each event once and consumes later appends incrementally; restart and HMR rebuild it from the durable log. The version-1 field contains `sessionFormatVersion`, a raw session header (`seedLength` is present only for a seeded Session), numeric `afterSeq` and `throughSeq`, and every complete canonical event translated to raw-number envelope fields. Forked sessions ignore inherited parent watermarks because both the recorded Session id and format generation must match the request source.
 
 <a id="acceptance-and-retry"></a>
 ## Acceptance and retry

+ 1 - 1
packages/session/session-log-deepseek/README.zh.md

@@ -34,7 +34,7 @@ kind: "package-reference"
 <a id="request-field"></a>
 ## 请求字段
 
-对于携带存活 `sessionId` 的请求,插件会折叠该确切会话格式代的最大已接受水位,对 `Session.events` 取快照,并发送水位之后的连续后缀。进程内 fold 会让每条事件只被扫描一次并增量消费后续追加;重启与 HMR 会从持久日志重建它。版本 2 字段包含 `sessionFormatVersion`、带 `isSeeded` 谱系位的原始会话 header、数值型 `afterSeq` 与 `throughSeq`,以及把完整权威事件翻译为原始数值 envelope 字段后的数组元素。只有记录的会话 id 与格式代均匹配请求来源时水位才生效,因此 fork 会话会忽略从父会话继承的水位。
+对于携带存活 `sessionId` 的请求,插件会折叠该确切会话格式代的最大已接受水位,对 `Session.events` 取快照,并发送水位之后的连续后缀。进程内 fold 会让每条事件只被扫描一次并增量消费后续追加;重启与 HMR 会从持久日志重建它。版本 1 字段包含 `sessionFormatVersion`、原始会话 header(仅 seeded Session 携带 `seedLength`)、数值型 `afterSeq` 与 `throughSeq`,以及把完整权威事件翻译为原始数值 envelope 字段后的数组元素。只有记录的会话 id 与格式代均匹配请求来源时水位才生效,因此 fork 会话会忽略从父会话继承的水位。
 
 <a id="acceptance-and-retry"></a>
 ## 接受与重试

+ 2 - 2
packages/session/session-log-deepseek/src/index.ts

@@ -59,7 +59,7 @@ function wireHeader(session: Session): DeepSeekSessionLogWireHeader {
     createdAt: header.createdAt,
     ...header.cwd === undefined ? {} : { cwd: header.cwd },
     ...header.parentSession === undefined ? {} : { parentSession: String(header.parentSession) },
-    isSeeded: header.isSeeded,
+    ...header.isSeeded ? { seedLength: Number(session.inheritedEventCount) } : {},
     ...header.origin === undefined ? {} : { origin: header.origin },
     ...header.delegationDepth === undefined ? {} : { delegationDepth: header.delegationDepth },
     ...header.agentPreset === undefined ? {} : { agentPreset: header.agentPreset },
@@ -147,7 +147,7 @@ export function apply(ctx: Context, config: Config): void {
       if (throughSeq === undefined) return undefined
       const suffix = session.snapshotEvents(SessionLogOffset(afterSeq + 1))
       const value: DeepSeekSessionLogExtension = {
-        version: 2,
+        version: 1,
         sessionFormatVersion: session.header.version,
         session: wireHeader(session),
         afterSeq: Number(afterSeq),

+ 3 - 3
packages/session/session-log-deepseek/src/types.ts

@@ -10,8 +10,8 @@ export interface DeepSeekSessionLogWireHeader {
   readonly createdAt: number
   readonly cwd?: string
   readonly parentSession?: string
-  /** Whether the event stream contains an inherited prefix ending at its tagged marker. */
-  readonly isSeeded: boolean
+  /** Exact inherited prefix length; absent for an unseeded Session. */
+  readonly seedLength?: number
   readonly origin?: 'subagent'
   readonly delegationDepth?: number
   readonly agentPreset?: string
@@ -35,7 +35,7 @@ export interface DeepSeekSessionLogWireEvent {
 
 /** Versioned incremental session-log field carried by an official DeepSeek request. */
 export interface DeepSeekSessionLogExtension {
-  readonly version: 2
+  readonly version: 1
   /** Session format generation represented by this suffix. */
   readonly sessionFormatVersion: number
   readonly session: DeepSeekSessionLogWireHeader

+ 4 - 5
packages/session/session-log-deepseek/tests/upload.spec.ts

@@ -55,8 +55,8 @@ describe('incremental DeepSeek session-log upload', () => {
     expectTypeOf<DeepSeekSessionLogExtension['throughSeq']>().toEqualTypeOf<number>()
     expectTypeOf<DeepSeekSessionLogExtension['events'][number]['seq']>().toEqualTypeOf<number>()
     expectTypeOf<DeepSeekSessionLogExtension['events'][number]['data']>().toEqualTypeOf<JsonValue>()
-    expectTypeOf<DeepSeekSessionLogExtension['session']['isSeeded']>()
-      .toEqualTypeOf<boolean>()
+    expectTypeOf<DeepSeekSessionLogExtension['session']['seedLength']>()
+      .toEqualTypeOf<number | undefined>()
   })
 
   it('does not contribute the session log under its default configuration', async () => {
@@ -82,7 +82,6 @@ describe('incremental DeepSeek session-log upload', () => {
     const first = await ctx.deepseekLlmApiExtensions.prepare({ body: body(), signal: SIGNAL, sessionId: session.id })
     const firstPayload = first.fields.dsh_session_log
     expect(firstPayload).toMatchObject({
-      version: 2,
       sessionFormatVersion: SESSION_FORMAT_VERSION,
       afterSeq: -1,
       throughSeq: 1,
@@ -326,12 +325,12 @@ describe('incremental DeepSeek session-log upload', () => {
       id: 'wire-child',
       cwd: '/wire-workspace',
       parentSession: 'wire-parent',
-      isSeeded: true,
+      seedLength: seed.length,
       origin: 'subagent',
       delegationDepth: 1,
       agentPreset: 'minimal',
     })
-    expect(wire.session).not.toHaveProperty('seedLength')
+    expect(wire.session).not.toHaveProperty('isSeeded')
     expect(typeof wire.afterSeq).toBe('number')
     expect(typeof wire.throughSeq).toBe('number')
     expect(Array.isArray(wire.events)).toBe(true)

+ 1 - 1
packages/session/session-stats/src/projection.ts

@@ -133,7 +133,7 @@ function usageOutputTokens(usage: unknown): number | null {
 /** The `sessionStats` unit registered on `ctx.sessionProjections` (exported for the unit spec). */
 export const sessionStatsProjectionDefinition = {
   key: 'sessionStats',
-  stateVersion: 2,
+  stateVersion: 1,
   stateSchema: sessionStatsStateSchema,
   init: () => ({
     turns: 0,