v2-ptc-migration.spec.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206
  1. /** Real JSONL publication and provider-neutral message preservation across the V2 PTC rename. */
  2. import { Context } from '@deepseek-ai/cordis'
  3. import { Session, SessionId } from '@deepseek-ai/dsh-session'
  4. import type { SessionFormatEvent } from '@deepseek-ai/dsh-session-format'
  5. import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
  6. import { createHash } from 'node:crypto'
  7. import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
  8. import { tmpdir } from 'node:os'
  9. import { dirname, join } from 'node:path'
  10. import { afterEach, beforeEach, describe, expect, it } from 'vitest'
  11. import { generationLogPath } from '../src/format.ts'
  12. const userMessage = {
  13. id: 'tools-code-mode:user', role: 'user', source: { kind: 'user' },
  14. content: [{ type: 'text', text: 'Keep tools-code-mode and tool/code-dispatch in this text. 图片' }],
  15. }
  16. const titleMessage = {
  17. id: 'tools-code-mode:title-input', role: 'user',
  18. source: { kind: 'plugin', plugin: 'dsh-session-title-llm' },
  19. content: [{ type: 'text', text: 'Generate the session title from this JSON array of human messages:\n[{"seq":2,"text":"Keep tools-code-mode and tool/code-dispatch in this text. 图片"}]' }],
  20. }
  21. const toolCall = {
  22. type: 'tool-call', id: 'tools-code-mode:root-call', name: 'run_code',
  23. arguments: '{"code":"return tools.read_image(input)","literal":"tools-code-mode"}',
  24. }
  25. const replayState = {
  26. response: { id: 'tools-code-mode:response', opaque: 'tool/code-dispatch' },
  27. blocks: [{ signature: 'tools-code-mode:signature' }],
  28. }
  29. const assistantMessage = {
  30. id: 'tools-code-mode:assistant', role: 'assistant', content: [toolCall],
  31. source: { kind: 'model', provider: 'historical', model: 'historical-model', replayState },
  32. }
  33. const toolMessage = {
  34. id: 'tools-code-mode:result', role: 'user',
  35. source: { kind: 'tool', callId: toolCall.id },
  36. content: [{
  37. type: 'tool-result', toolCallId: toolCall.id, isError: false,
  38. content: [{ type: 'text', text: 'tools-code-mode: image attached; tool/code-dispatch' }],
  39. }],
  40. }
  41. const imageMessage = {
  42. id: 'tools-code-mode:root-call:image-context', role: 'user',
  43. source: { kind: 'plugin', plugin: 'tools-code-mode', form: 'notice', summary: 'Image from tools-code-mode' },
  44. content: [
  45. { type: 'text', text: 'Image from tools-code-mode:child-call (tool/code-dispatch)' },
  46. { type: 'image', attachment: {
  47. attachmentId: 'tools-code-mode:image', mediaType: 'image/png', bytes: 68,
  48. width: 1, height: 1, name: 'tools-code-mode.png',
  49. originalDimensions: { width: 2, height: 2 },
  50. } },
  51. ],
  52. }
  53. const currentImageMessage = {
  54. ...imageMessage, source: { ...imageMessage.source, plugin: 'tools-ptc' },
  55. }
  56. const dispatch = {
  57. rootCallId: toolCall.id, parentCallId: toolCall.id, subCallId: 'tools-code-mode:child-call',
  58. name: 'read_image', arguments: { path: 'tools-code-mode.png', literal: 'tool/code-dispatch-start' },
  59. }
  60. /** Historical rows are literal V2 data, independent of the current event-name vocabulary. */
  61. function releasedV2Events(): SessionFormatEvent[] {
  62. return [
  63. { type: 'turn/start', seq: 0, time: 1001, data: { turn: 1 } },
  64. { type: 'step/start', seq: 1, time: 1002, data: { turn: 1, step: 1 } },
  65. { type: 'user/message', seq: 2, time: 1003, data: userMessage, surfaceOp: 'append' },
  66. { type: 'assistant/message', seq: 3, time: 1005, surfaceOp: 'append', data: {
  67. turn: 1, step: 1, message: assistantMessage,
  68. stream: [
  69. { type: 'tool-call-chunks', time0: 1004, index: 0, id: toolCall.id,
  70. name: toolCall.name, dt: [], args: [toolCall.arguments] },
  71. { type: 'chunk', time: 1005, chunk: { type: 'finish', reason: { kind: 'tool-calls' }, replayState } },
  72. ],
  73. } },
  74. { type: 'tool/call', seq: 4, time: 1006, data: {
  75. turn: 1, step: 1, callId: toolCall.id, name: toolCall.name, arguments: toolCall.arguments,
  76. } },
  77. { type: 'tool/code-dispatch-start', seq: 5, time: 1007, data: dispatch },
  78. { type: 'tool/code-dispatch', seq: 6, time: 1008, data: {
  79. ...dispatch, isError: false, content: imageMessage.content,
  80. } },
  81. { type: 'tool/result', seq: 7, time: 1009, surfaceOp: 'append', data: {
  82. turn: 1, step: 1, message: toolMessage,
  83. } },
  84. { type: 'agent/inbox/spliced', seq: 8, time: 1010, data: {
  85. target: 'next-step', start: 0, removedCount: 0, inserted: [imageMessage],
  86. } },
  87. { type: 'user/message', seq: 9, time: 1011, data: imageMessage, surfaceOp: 'append' },
  88. { type: 'session/title-llm-request', seq: 10, time: 1012, data: {
  89. titleProvider: 'tools-code-mode:title', messageSeqs: [2],
  90. route: { provider: 'historical', model: 'historical-model' },
  91. system: 'Keep tools-code-mode literal.', maxTokens: 32,
  92. messages: [titleMessage],
  93. } },
  94. { type: 'step/end', seq: 11, time: 1013, data: { turn: 1, step: 1 } },
  95. { type: 'turn/end', seq: 12, time: 1014, data: { turn: 1, reason: { kind: 'completed' } } },
  96. ]
  97. }
  98. describe('JSONL V2 PTC publication and restore', () => {
  99. let root: string | undefined
  100. let ctx: Context | undefined
  101. beforeEach(async () => {
  102. root = await mkdtemp(join(tmpdir(), 'dsh-jsonl-v2-ptc-'))
  103. ctx = new Context()
  104. await ctx.plugin(JsonlSessionPersistence, { root, compression: 'none' })
  105. })
  106. afterEach(async () => {
  107. try {
  108. await ctx?.fiber.dispose()
  109. } finally {
  110. if (root !== undefined) await rm(root, { recursive: true, force: true })
  111. }
  112. })
  113. it('publishes V3 without changing V2 bytes and preserves restored IDs, roles, content, and replay state', async () => {
  114. if (root === undefined || ctx === undefined) throw new Error('persistence fixture is not initialized')
  115. const id = SessionId('tools-code-mode:session')
  116. const header = { type: 'session', version: 2, id, createdAt: 1000, isSeeded: false, delegationDepth: 0 }
  117. const events = releasedV2Events()
  118. const source = Buffer.from([header, ...events].map(row => JSON.stringify(row)).join('\n') + '\n')
  119. const predecessor = generationLogPath(root, undefined, id, 2, 'none')
  120. const successor = join(dirname(predecessor), 'session.v3.jsonl')
  121. await mkdir(dirname(predecessor), { recursive: true })
  122. await writeFile(predecessor, source)
  123. const sourceStat = await stat(predecessor)
  124. const reader = await ctx.sessionPersistence.open(id, 'read')
  125. try {
  126. expect(reader.header).toEqual({ version: 3, id, createdAt: 1000, isSeeded: false, delegationDepth: 0 })
  127. expect((await reader.read()).events.map(event => event.type)).toEqual([
  128. 'turn/start', 'step/start', 'system/message', 'user/message', 'assistant/message', 'tool/call',
  129. 'tool/ptc-dispatch-start', 'tool/ptc-dispatch', 'tool/result', 'agent/inbox/spliced',
  130. 'user/message', 'session/title-llm-request', 'step/end', 'turn/end',
  131. ])
  132. } finally {
  133. await reader.close()
  134. }
  135. expect(await readFile(predecessor)).toEqual(source)
  136. expect(await stat(predecessor)).toMatchObject({ dev: sourceStat.dev, ino: sourceStat.ino })
  137. await expect(readFile(successor)).rejects.toMatchObject({ code: 'ENOENT' })
  138. const writer = await ctx.sessionPersistence.open(id, 'write')
  139. try {
  140. await writer.flush()
  141. } finally {
  142. await writer.close()
  143. }
  144. const published = await readFile(successor)
  145. const [publishedHeader, ...publishedEvents] = published.toString('utf8').trimEnd().split('\n')
  146. .map((row): unknown => JSON.parse(row))
  147. expect(publishedHeader).toEqual({ ...header, version: 3 })
  148. const expectedEvents: SessionFormatEvent[] = events.map(event => ({ ...event, seq: event.seq < 2 ? event.seq : event.seq + 1 }))
  149. expectedEvents[5] = { ...expectedEvents[5], type: 'tool/ptc-dispatch-start' } as SessionFormatEvent
  150. expectedEvents[6] = { ...expectedEvents[6], type: 'tool/ptc-dispatch' } as SessionFormatEvent
  151. expectedEvents[8] = { ...expectedEvents[8], data: {
  152. target: 'next-step', start: 0, removedCount: 0, inserted: [currentImageMessage],
  153. } } as SessionFormatEvent
  154. expectedEvents[9] = { ...expectedEvents[9], data: currentImageMessage } as SessionFormatEvent
  155. expectedEvents[10] = { ...expectedEvents[10], data: {
  156. ...(events[10]?.data as Record<string, unknown>), messageSeqs: [3],
  157. } } as SessionFormatEvent
  158. const systemMessage = {
  159. id: 'v2-to-v3-system-' + createHash('sha256')
  160. .update(JSON.stringify(['session-format-v2-to-v3', id, 1, 'step/start'])).digest('hex'),
  161. role: 'system', source: { kind: 'plugin', plugin: '@deepseek-ai/dsh-system-prompt' }, content: [],
  162. }
  163. expectedEvents.splice(2, 0, {
  164. type: 'system/message', seq: 2, time: 1002, surfaceOp: 'append',
  165. data: { turn: 1, step: 1, message: systemMessage },
  166. })
  167. expect(publishedEvents).toEqual(expectedEvents)
  168. expect(await readFile(predecessor)).toEqual(source)
  169. expect(await stat(predecessor)).toMatchObject({ dev: sourceStat.dev, ino: sourceStat.ino })
  170. await ctx.fiber.dispose()
  171. ctx = new Context()
  172. await ctx.plugin(JsonlSessionPersistence, { root, compression: 'none' })
  173. const reloaded = await ctx.sessionPersistence.open(id, 'read')
  174. try {
  175. const restored = await reloaded.read()
  176. expect(restored.events).toEqual(expectedEvents)
  177. const session = Session.fromRestore(id, restored.events, reloaded.header, reloaded.inheritedEventCount, restored.eventState)
  178. const messages = session.deriveMessages()
  179. expect(messages).toEqual([userMessage, assistantMessage, toolMessage, currentImageMessage])
  180. // Provider-neutral input evidence: no provider converter is a declared dependency here.
  181. expect(messages.map(({ id, role, content }) => ({ id, role, content }))).toEqual(
  182. [userMessage, assistantMessage, toolMessage, imageMessage].map(({ id, role, content }) => ({ id, role, content })),
  183. )
  184. expect(messages[1]?.source).toEqual(assistantMessage.source)
  185. expect(messages[2]?.source).toEqual({ kind: 'tool', callId: 'tools-code-mode:root-call' })
  186. expect(messages[3]?.source).toEqual(currentImageMessage.source)
  187. } finally {
  188. await reloaded.close()
  189. }
  190. expect(await readFile(predecessor)).toEqual(source)
  191. expect(await stat(predecessor)).toMatchObject({ dev: sourceStat.dev, ino: sourceStat.ino })
  192. expect(await readFile(successor)).toEqual(published)
  193. expect((await readdir(dirname(predecessor))).filter(name => name.endsWith('.jsonl')).sort())
  194. .toEqual(['session.v2.jsonl', 'session.v3.jsonl'])
  195. })
  196. })