Prechádzať zdrojové kódy

fix(session): preserve unsupported V3 events during tail recovery

Tianyi Cui 2 týždňov pred
rodič
commit
5895f46b34

+ 1 - 1
packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts

@@ -2030,7 +2030,7 @@ describe('built-in conversation node Definitions', () => {
     expect(node(snapshot(value), 'compaction')).toBeUndefined()
   })
 
-  it('ignores legacy retry and code-dispatch events without correlation ids', () => {
+  it('ignores legacy retry and PTC dispatch events without correlation ids', () => {
     const value = assembler([
       at(10, 'llm/retry', {
         turn: 1,

+ 1 - 1
packages/core/tools/src/invariant.ts

@@ -29,7 +29,7 @@ function validateResult(
   }
 }
 
-/** Install monotonic pipeline, final-snapshot, and code-dispatch enclosure checks. */
+/** Install monotonic pipeline, final-snapshot, and PTC dispatch enclosure checks. */
 const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
   const stages = new WeakMap<object, ToolStage>()
   const openTurns = new WeakMap<Session, number | null>()

+ 0 - 2
packages/session/session-format-catalog/tests/catalog.spec.ts

@@ -111,7 +111,6 @@ describe('first-party Session format catalog', () => {
     expect(JSON.stringify({ header, rows })).toBe(before)
   })
 
-
   it.each([0, 1, 2])('migrates frozen v%i PTC records and reopens the actual current representation without rewriting IDs', (version) => {
     const sourceHeader = deepFreeze({
       type: 'session', version, id: 'tools-code-mode:session', createdAt: 1, delegationDepth: 0,
@@ -168,7 +167,6 @@ describe('first-party Session format catalog', () => {
     expect(JSON.stringify({ currentHeader, currentRows })).toBe(encodedBefore)
   })
 
-
   it.each(['current', 'transformed'] as const)('rejects native v3 obsolete required tags with %s validation and retains ignorable tags', (validation) => {
     const header = deepFreeze({ type: 'session', version: 3, id: 'native-ptc', createdAt: 1, isSeeded: false, delegationDepth: 0 })
     for (const type of ['tool/code-dispatch-start', 'tool/code-dispatch']) {

+ 0 - 1
packages/session/session-format-v2-to-v3/tests/migration.spec.ts

@@ -226,7 +226,6 @@ describe('v2 to v3 PTC migration', () => {
     expect(context.values).toEqual([first])
   })
 
-
   it.each(['tool/code-dispatch-start', 'tool/code-dispatch'])('keeps %s in the frozen v2 codec but rejects it as required native v3 input', (type) => {
     const row = deepFreeze({ type, seq: 0, time: 1, data: { text: 'tools-code-mode' } })
     const v2 = releasedV2SessionFormatCodec.createDecoder(releasedV2SessionFormatCodec.encodeHeader(header, 0), 'strict')

+ 4 - 1
packages/session/session-persistence-jsonl/src/format.ts

@@ -19,7 +19,7 @@ import type {
   SessionId,
   SessionLogOffset as SessionLogOffsetType,
 } from '@deepseek-ai/dsh-session'
-import { parseSessionFormatLogFilename, sessionFormatLogFilename } from '@deepseek-ai/dsh-session-format'
+import { parseSessionFormatLogFilename, sessionFormatLogFilename, SessionFormatUnsupportedMigrationError } from '@deepseek-ai/dsh-session-format'
 import type { SessionFormatEvent } from '@deepseek-ai/dsh-session-format'
 import type { SessionFormatRecovery, SessionFormatRestore } from '@deepseek-ai/dsh-session-format'
 import { sessionFormatCatalog } from '@deepseek-ai/dsh-session-format-catalog'
@@ -492,6 +492,9 @@ export class SessionLogScanner {
     try {
       this.restore.decodeRow(decoded)
     } catch (error: unknown) {
+      if (error instanceof SessionFormatUnsupportedMigrationError) {
+        throw new SessionFormatUnsupportedError(error.message)
+      }
       /* v8 ignore next -- every production Session format decoder rejects with Error. */
       const detail = error instanceof Error ? error.message : String(error)
       const issue = new Error(`corrupt session log: invalid committed event at line ${this.eventLine}: ${detail}`, {

+ 101 - 0
packages/session/session-persistence-jsonl/tests/v3-event-admission.spec.ts

@@ -0,0 +1,101 @@
+import { Context } from '@deepseek-ai/cordis'
+import { SessionId, SessionSeq } from '@deepseek-ai/dsh-session'
+import { SessionFormatUnsupportedError } from '@deepseek-ai/dsh-session-persistence'
+import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
+import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { dirname, join } from 'node:path'
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { generationLogPath, scanLog } from '../src/format.ts'
+
+const id = SessionId('v3-admission')
+const header = { type: 'session', version: 3, id, createdAt: 1000, isSeeded: false, delegationDepth: 0 }
+const start = { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }
+const prefix = [header, start].map(row => JSON.stringify(row)).join('\n') + '\n'
+const obsoleteTypes = ['tool/code-dispatch-start', 'tool/code-dispatch'] as const
+
+function obsoleteEvent(type: string, ignorable?: true) {
+  return {
+    type, seq: 1, time: 2,
+    data: { rootCallId: 'root', parentCallId: 'root', subCallId: 'child', name: 'read', arguments: {} },
+    ...(ignorable ? { ignorable } : {}),
+  }
+}
+
+describe('native V3 event admission at EOF', () => {
+  let root: string
+  let ctx: Context
+
+  beforeEach(async () => {
+    root = await mkdtemp(join(tmpdir(), 'dsh-v3-admission-'))
+    ctx = new Context()
+    await ctx.plugin(JsonlSessionPersistence, { root, compression: 'none' })
+  })
+
+  afterEach(async () => {
+    try {
+      await ctx?.fiber.dispose()
+    } finally {
+      if (root !== undefined) await rm(root, { recursive: true, force: true })
+    }
+  })
+
+  async function store(bytes: Buffer): Promise<string> {
+    const path = generationLogPath(root, undefined, id, 3, 'none')
+    await mkdir(dirname(path), { recursive: true })
+    await writeFile(path, bytes)
+    return path
+  }
+
+  it.each(obsoleteTypes)('scanLog refuses a complete required %s EOF row', (type) => {
+    const bytes = Buffer.from(prefix + JSON.stringify(obsoleteEvent(type)) + '\n')
+    expect(() => scanLog(bytes)).toThrow(SessionFormatUnsupportedError)
+    expect(() => scanLog(bytes)).toThrow('format v3 contains unknown event type')
+  })
+
+  it.each(obsoleteTypes)('read and write opens refuse required %s without changing bytes', async (type) => {
+    const bytes = Buffer.from(prefix + JSON.stringify(obsoleteEvent(type)) + '\n')
+    const path = await store(bytes)
+    for (const access of ['read', 'write'] as const) {
+      const opened = ctx.sessionPersistence.open(id, access).then(async (handle) => {
+        await handle.close()
+      })
+      await expect(opened).rejects.toThrow(SessionFormatUnsupportedError)
+      expect(await readFile(path)).toEqual(bytes)
+    }
+  })
+
+  it.each(obsoleteTypes)('retains ignorable %s through scanning and a provider append', async (type) => {
+    const event = obsoleteEvent(type, true)
+    const bytes = Buffer.from(prefix + JSON.stringify(event) + '\n')
+    expect(scanLog(bytes)).toMatchObject({ events: [start, event], committedBytes: bytes.length })
+    const path = await store(bytes)
+    const writer = await ctx.sessionPersistence.open(id, 'write')
+    try {
+      expect((await writer.read()).events).toEqual([start, event])
+      await writer.append([{
+        type: 'turn/end', seq: SessionSeq(2), time: 3, data: { turn: 1, reason: { kind: 'completed' } },
+      }])
+    } finally {
+      await writer.close()
+    }
+    expect((await readFile(path)).subarray(0, bytes.length)).toEqual(bytes)
+  })
+
+  it.each(['{not json', 'null'])('still recovers an ordinary malformed EOF row: %s', async (tail) => {
+    const bytes = Buffer.from(prefix + tail + '\n')
+    expect(scanLog(bytes)).toMatchObject({ events: [start], committedBytes: Buffer.byteLength(prefix) })
+    const path = await store(bytes)
+    const writer = await ctx.sessionPersistence.open(id, 'write')
+    const end = {
+      type: 'turn/end' as const, seq: SessionSeq(1), time: 3, data: { turn: 1, reason: { kind: 'completed' as const } },
+    }
+    try {
+      expect((await writer.read()).events).toEqual([start])
+      await writer.append([end])
+    } finally {
+      await writer.close()
+    }
+    expect(await readFile(path, 'utf8')).toBe(prefix + JSON.stringify(end) + '\n')
+  })
+})