migration-refusal.spec.ts 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199
  1. /** Durable EOF refusals preserve historical generations and never fall back from native V3. */
  2. import { Context } from '@deepseek-ai/cordis'
  3. import { SessionId } from '@deepseek-ai/dsh-session'
  4. import type { SessionFormatJsonObject } from '@deepseek-ai/dsh-session-format'
  5. import { SessionFormatUnsupportedError } from '@deepseek-ai/dsh-session-persistence'
  6. import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
  7. import { createHash } from 'node:crypto'
  8. import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
  9. import { tmpdir } from 'node:os'
  10. import { basename, dirname, join } from 'node:path'
  11. import { afterEach, beforeEach, describe, expect, it } from 'vitest'
  12. import { generationLogPath, type JsonlCompression } from '../src/format.ts'
  13. import { compressZstdFrame } from '../src/zstd.ts'
  14. const id = SessionId('migration-refusal')
  15. const config = { provider: 'historical', model: 'historical-model' }
  16. const question = {
  17. id: 'question', role: 'user', source: { kind: 'user' },
  18. content: [{ type: 'text', text: 'Read the saved migration audit.' }],
  19. }
  20. const dispatch = {
  21. rootCallId: 'root-call', parentCallId: 'root-call', subCallId: 'read-call',
  22. name: 'read', arguments: { file_path: 'migration-audit.txt' },
  23. }
  24. const prefix: readonly SessionFormatJsonObject[] = [
  25. { type: 'turn/start', data: { turn: 1 } },
  26. { type: 'step/start', data: { turn: 1, step: 1 } },
  27. { type: 'user/message', data: question, surfaceOp: 'append' },
  28. { type: 'request/header', data: { header: { config, system: 'Inspect the durable audit.' }, reason: 'initial' } },
  29. ]
  30. const nativePrefix: readonly SessionFormatJsonObject[] = [
  31. ...prefix.slice(0, 2),
  32. { type: 'system/message', surfaceOp: 'append', data: {
  33. turn: 1, step: 1, message: {
  34. id: 'native-system', role: 'system', source: { kind: 'plugin', plugin: '@deepseek-ai/dsh-system-prompt' },
  35. content: [{ type: 'text', text: 'Inspect the durable audit.' }],
  36. },
  37. } },
  38. { type: 'user/message', data: question, surfaceOp: 'append' },
  39. { type: 'request/header', data: { header: { config }, reason: 'initial' } },
  40. ]
  41. function ptcRow(type: string): SessionFormatJsonObject {
  42. return { type, data: type.endsWith('-start') ? dispatch : {
  43. ...dispatch, isError: false, content: [{ type: 'text', text: 'Audit is intact.' }],
  44. } }
  45. }
  46. const migrationRefusals = [
  47. ...['tool/ptc-dispatch-start', 'tool/ptc-dispatch'].flatMap(type => [false, true].map(ignorable => ({
  48. name: type + (ignorable ? ' (ignorable)' : ' (required)'),
  49. tail: { ...ptcRow(type), ...(ignorable ? { ignorable: true } : {}) },
  50. diagnostic: 'format v2 to v3 cannot safely transform unclassified event ' + type,
  51. }))),
  52. {
  53. name: 'delivery activation claiming V3',
  54. tail: { type: 'session-log-deepseek/delivery-accepted', data: {
  55. sessionId: id, throughSeq: prefix.length - 1, sessionFormatVersion: 3,
  56. } },
  57. diagnostic: '@deepseek-ai/dsh-session-format-v2-to-v3 refuses this format v2 Session: format v2 delivery marker claims target format v3',
  58. },
  59. {
  60. name: 'source message colliding with the generated system ID',
  61. tail: { type: 'user/message', surfaceOp: 'append', data: {
  62. ...question,
  63. id: 'v2-to-v3-system-' + createHash('sha256')
  64. .update(JSON.stringify(['session-format-v2-to-v3', id, 1, 'step/start'])).digest('hex'),
  65. } },
  66. diagnostic: 'source message id collides with a generated system message id',
  67. },
  68. ] satisfies readonly { name: string; tail: SessionFormatJsonObject; diagnostic: string }[]
  69. const nativeRefusals = [
  70. ...['tool/code-dispatch-start', 'tool/code-dispatch'].map(type => ({
  71. name: type,
  72. tail: ptcRow(type),
  73. diagnostic: 'format v3 contains unknown event type ' + JSON.stringify(type) + ' at seq ' + String(nativePrefix.length),
  74. })),
  75. {
  76. name: 'retired request/header.system',
  77. tail: { type: 'request/header', data: {
  78. header: { config, system: 'This prompt must not be discarded.' }, reason: 'change',
  79. } },
  80. diagnostic: 'format v3 request/header rejects retired header.system',
  81. },
  82. ] satisfies readonly { name: string; tail: SessionFormatJsonObject; diagnostic: string }[]
  83. const modes = (['none', 'zstd'] as const).flatMap(compression =>
  84. (['read', 'write'] as const).map(access => ({ compression, access })),
  85. )
  86. let root: string
  87. const contexts: Context[] = []
  88. beforeEach(async () => {
  89. root = await mkdtemp(join(tmpdir(), 'dsh-migration-refusal-'))
  90. })
  91. afterEach(async () => {
  92. try {
  93. for (const ctx of contexts.splice(0).reverse()) await ctx.fiber.dispose()
  94. } finally {
  95. await rm(root, { recursive: true, force: true })
  96. }
  97. })
  98. async function mount(compression: JsonlCompression): Promise<Context> {
  99. const ctx = new Context()
  100. contexts.push(ctx)
  101. await ctx.plugin(JsonlSessionPersistence, { root, compression })
  102. return ctx
  103. }
  104. function line(value: unknown): string {
  105. return JSON.stringify(value) + '\n'
  106. }
  107. async function store(version: 2 | 3, compression: JsonlCompression, rows: readonly SessionFormatJsonObject[]) {
  108. const path = generationLogPath(root, undefined, id, version, compression)
  109. const header = { type: 'session', version, id, createdAt: 1000, isSeeded: false, delegationDepth: 0 }
  110. const events = rows.map((row, seq) => ({ ...row, seq, time: 1001 + seq }))
  111. // The offending EOF row occupies its own complete frame, not a torn compressed suffix.
  112. const chunks = [line(header), events.slice(0, -1).map(line).join(''), line(events.at(-1))]
  113. const bytes = compression === 'none' ? Buffer.from(chunks.join(''))
  114. : Buffer.concat(await Promise.all(chunks.map(chunk => compressZstdFrame(chunk))))
  115. await mkdir(dirname(path), { recursive: true })
  116. await writeFile(path, bytes)
  117. return path
  118. }
  119. async function observe(path: string) {
  120. const identity = await stat(path, { bigint: true })
  121. return {
  122. bytes: await readFile(path), dev: identity.dev, ino: identity.ino,
  123. size: identity.size, mtimeNs: identity.mtimeNs, ctimeNs: identity.ctimeNs,
  124. }
  125. }
  126. async function expectRefusal(ctx: Context, access: 'read' | 'write', path: string, message: string) {
  127. // Close an unexpectedly successful open before the rejection assertion fails.
  128. const opened = ctx.sessionPersistence.open(id, access).then(async (handle) => { await handle.close() })
  129. await expect(opened).rejects.toBeInstanceOf(SessionFormatUnsupportedError)
  130. await expect(opened).rejects.toMatchObject({ message, location: { kind: 'jsonl', path } })
  131. }
  132. async function expectOnlyGenerations(paths: readonly string[]) {
  133. const directory = dirname(paths[0]!)
  134. // A released write lease keeps session.lock; every other extra entry is forbidden.
  135. expect((await readdir(directory)).filter(name => name !== 'session.lock').sort())
  136. .toEqual(paths.map(path => basename(path)).sort())
  137. }
  138. describe.each(modes)('EOF migration refusal ($compression, $access)', ({ compression, access }) => {
  139. it.each(migrationRefusals)('refuses V2 $name without publishing or discarding a tail', async ({ tail, diagnostic }) => {
  140. const path = await store(2, compression, [...prefix, tail])
  141. const original = await observe(path)
  142. const message = diagnostic + '; source v2 artifact remains unchanged (raw log: ' + path + ')'
  143. const ctx = await mount(compression)
  144. for (let attempt = 0; attempt < 2; attempt += 1) {
  145. await expectRefusal(ctx, access, path, message)
  146. expect(await observe(path)).toEqual(original)
  147. await expectOnlyGenerations([path])
  148. for (const targetCompression of ['none', 'zstd'] as const) {
  149. await expect(stat(generationLogPath(root, undefined, id, 3, targetCompression)))
  150. .rejects.toMatchObject({ code: 'ENOENT' })
  151. }
  152. }
  153. })
  154. it.each(nativeRefusals)('refuses native V3 $name instead of falling back to readable V2', async ({ tail, diagnostic }) => {
  155. const lowerPath = await store(2, compression, prefix)
  156. const lower = await observe(lowerPath)
  157. const ctx = await mount(compression)
  158. const reader = await ctx.sessionPersistence.open(id, 'read')
  159. try {
  160. expect(reader.header.version).toBe(3)
  161. const restored = await reader.read()
  162. expect(restored.events.map(event => event.type)).toEqual([
  163. 'turn/start', 'step/start', 'system/message', 'user/message', 'system/message', 'request/header',
  164. ])
  165. expect(restored.events.find(event => event.type === 'user/message')?.data).toEqual(question)
  166. } finally {
  167. await reader.close()
  168. }
  169. expect(await observe(lowerPath)).toEqual(lower)
  170. await expectOnlyGenerations([lowerPath])
  171. const path = await store(3, compression, [...nativePrefix, tail])
  172. const original = await observe(path)
  173. const message = diagnostic + ' (raw log: ' + path + ')'
  174. for (let attempt = 0; attempt < 2; attempt += 1) {
  175. await expectRefusal(ctx, access, path, message)
  176. expect(await observe(path)).toEqual(original)
  177. expect(await observe(lowerPath)).toEqual(lower)
  178. await expectOnlyGenerations([lowerPath, path])
  179. }
  180. })
  181. })