differential.spec.ts 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237
  1. import { afterEach, describe, expect, it } from 'vitest'
  2. import fc from 'fast-check'
  3. import { Context } from '@deepseek-ai/cordis'
  4. import { mkdtemp, rm } from 'node:fs/promises'
  5. import { tmpdir } from 'node:os'
  6. import { join } from 'node:path'
  7. import { DatabaseSync } from 'node:sqlite'
  8. import { ToolCallId, type StreamChunk } from '@deepseek-ai/dsh-llm'
  9. import SessionStore, { type SessionEvent } from '@deepseek-ai/dsh-session'
  10. import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
  11. import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
  12. import SessionPersistenceSqlite from '@deepseek-ai/dsh-session-persistence-sqlite'
  13. import { meta } from '../../session-persistence/tests/contract.ts'
  14. import { testSql } from './test-sql.ts'
  15. type BackendName = 'jsonl-zstd' | 'sqlite'
  16. interface MountedBackend {
  17. readonly persistence: SessionPersistence
  18. dispose(): Promise<void>
  19. }
  20. const directories: string[] = []
  21. afterEach(async () => {
  22. for (const directory of directories.splice(0)) {
  23. await rm(directory, { recursive: true, force: true })
  24. }
  25. })
  26. async function freshDirectory(prefix: string): Promise<string> {
  27. const directory = await mkdtemp(join(tmpdir(), prefix))
  28. directories.push(directory)
  29. return directory
  30. }
  31. async function mount(name: BackendName, root: string): Promise<MountedBackend> {
  32. const ctx = new Context()
  33. await ctx.plugin(SessionStore)
  34. switch (name) {
  35. case 'jsonl-zstd': {
  36. const fiber = await ctx.plugin(SessionPersistenceJsonl, { root: join(root, 'jsonl') })
  37. return { persistence: ctx.sessionPersistence, dispose: async () => { await fiber.dispose() } }
  38. }
  39. case 'sqlite': {
  40. const fiber = await ctx.plugin(SessionPersistenceSqlite, { path: join(root, 'sessions.db') })
  41. return { persistence: ctx.sessionPersistence, dispose: async () => { await fiber.dispose() } }
  42. }
  43. }
  44. }
  45. function closedChunkLog(
  46. entries: readonly { readonly chunk: StreamChunk; readonly time: number }[],
  47. ): SessionEvent[] {
  48. const chunks = entries.map(({ chunk, time }, index): SessionEvent => ({
  49. type: 'assistant/chunk',
  50. seq: index + 2,
  51. time,
  52. data: { turn: 1, step: 1, chunk },
  53. }))
  54. return [
  55. { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
  56. { type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } },
  57. ...chunks,
  58. { type: 'step/end', seq: chunks.length + 2, time: 3, data: { turn: 1, step: 1 } },
  59. {
  60. type: 'turn/end',
  61. seq: chunks.length + 3,
  62. time: 4,
  63. data: { turn: 1, reason: { kind: 'completed' } },
  64. },
  65. ]
  66. }
  67. function packingMatrixLog(): SessionEvent[] {
  68. const entries: { chunk: StreamChunk; time: number }[] = [
  69. ...Array.from({ length: 5 }, (_, index) => ({
  70. chunk: { type: 'text-delta' as const, index: 0, text: `text-${index}` },
  71. time: 1_000 + index,
  72. })),
  73. ...Array.from({ length: 4 }, (_, index) => ({
  74. chunk: { type: 'reasoning-delta' as const, index: 1, text: `reason-${index}` },
  75. time: 990 - index,
  76. })),
  77. ...Array.from({ length: 4 }, (_, index) => ({
  78. chunk: {
  79. type: 'tool-call-delta' as const,
  80. index: 2,
  81. id: ToolCallId('named-call'),
  82. name: 'write',
  83. argumentsDelta: `{${index}`,
  84. },
  85. time: 2_000 + index,
  86. })),
  87. ...Array.from({ length: 3 }, (_, index) => ({
  88. chunk: {
  89. type: 'tool-call-delta' as const,
  90. index: 3,
  91. id: ToolCallId('unnamed-call'),
  92. argumentsDelta: `${index}}`,
  93. },
  94. time: 3_000 + index,
  95. })),
  96. { chunk: { type: 'block-start', index: 4, blockType: 'text' }, time: 4_000 },
  97. { chunk: { type: 'text-delta', index: 4, text: 'short-a' }, time: 4_001 },
  98. { chunk: { type: 'text-delta', index: 4, text: 'short-b' }, time: 4_002 },
  99. { chunk: { type: 'text-delta', index: 5, text: 'scalar-singleton' }, time: 4_003 },
  100. { chunk: { type: 'finish', reason: { kind: 'stop' } }, time: 4_004 },
  101. ]
  102. return closedChunkLog(entries)
  103. }
  104. function batches(events: readonly SessionEvent[], sizes: readonly number[]): SessionEvent[][] {
  105. const result: SessionEvent[][] = []
  106. let offset = 0
  107. let index = 0
  108. while (offset < events.length) {
  109. const size = sizes[index % sizes.length] as number
  110. result.push(events.slice(offset, offset + size))
  111. offset += size
  112. index += 1
  113. }
  114. return result
  115. }
  116. async function verifyBackend(
  117. name: BackendName,
  118. root: string,
  119. events: readonly SessionEvent[],
  120. sizes: readonly number[],
  121. ): Promise<void> {
  122. const header = { ...meta('differential', '/work'), delegationDepth: 0 }
  123. let mounted = await mount(name, root)
  124. try {
  125. await mounted.persistence.create(header)
  126. for (const batch of batches(events, sizes)) {
  127. await mounted.persistence.append(header.id, batch)
  128. }
  129. expect(await mounted.persistence.inspect(header.id), name).toEqual({ meta: header, events })
  130. expect(await mounted.persistence.list(), name).toEqual([header])
  131. const revision = (await mounted.persistence.listSnapshots())[0]?.revision
  132. for (let fromSeq = 0; fromSeq <= events.length + 1; fromSeq += 1) {
  133. expect((await mounted.persistence.readFrom(header.id, fromSeq)).events, `${name} seq ${fromSeq}`)
  134. .toEqual(events.slice(fromSeq))
  135. }
  136. expect((await mounted.persistence.listSnapshots())[0]?.revision, name).toBe(revision)
  137. } finally {
  138. await mounted.dispose()
  139. }
  140. mounted = await mount(name, root)
  141. try {
  142. expect(await mounted.persistence.inspect(header.id), `${name} reopen`).toEqual({ meta: header, events })
  143. } finally {
  144. await mounted.dispose()
  145. }
  146. }
  147. const streamChunkArbitrary: fc.Arbitrary<StreamChunk> = fc.oneof(
  148. fc.record({ type: fc.constant<'text-delta'>('text-delta'), index: fc.nat(2), text: fc.string() }),
  149. fc.record({ type: fc.constant<'reasoning-delta'>('reasoning-delta'), index: fc.nat(2), text: fc.string() }),
  150. fc.record({
  151. type: fc.constant<'tool-call-delta'>('tool-call-delta'),
  152. index: fc.nat(2),
  153. id: fc.constantFrom(ToolCallId('call-1'), ToolCallId('call-2')),
  154. argumentsDelta: fc.string(),
  155. }),
  156. fc.record({
  157. type: fc.constant<'tool-call-delta'>('tool-call-delta'),
  158. index: fc.nat(2),
  159. id: fc.constantFrom(ToolCallId('call-1'), ToolCallId('call-2')),
  160. name: fc.constantFrom('read', 'write'),
  161. argumentsDelta: fc.string(),
  162. }),
  163. fc.record({
  164. type: fc.constant<'block-start'>('block-start'),
  165. index: fc.nat(2),
  166. blockType: fc.constant<'text'>('text'),
  167. }),
  168. fc.record({ type: fc.constant<'finish'>('finish'), reason: fc.constant({ kind: 'stop' as const }) }),
  169. )
  170. const randomWorkload = fc.record({
  171. entries: fc.array(fc.record({
  172. chunk: streamChunkArbitrary,
  173. time: fc.oneof(
  174. { weight: 4, arbitrary: fc.integer({ min: 0, max: 10_000 }) },
  175. { weight: 1, arbitrary: fc.integer({ min: Number.MIN_SAFE_INTEGER, max: Number.MAX_SAFE_INTEGER }) },
  176. ),
  177. }), { maxLength: 30 }),
  178. batchSizes: fc.array(fc.integer({ min: 1, max: 8 }), { minLength: 1, maxLength: 8 }),
  179. }).map(({ entries, batchSizes }) => ({
  180. events: JSON.parse(JSON.stringify(closedChunkLog(entries))) as SessionEvent[],
  181. batchSizes,
  182. }))
  183. describe('SQLite cross-backend differential behavior', () => {
  184. it('matches JSONL/Zstandard for every packed kind, scalar fallback, suffix, partition, and reopen', async () => {
  185. const events = packingMatrixLog()
  186. for (const [partitionIndex, sizes] of [[events.length], [1], [2, 1, 5, 3]].entries()) {
  187. const directory = await freshDirectory(`dsh-sqlite-matrix-${partitionIndex}-`)
  188. for (const name of ['jsonl-zstd', 'sqlite'] as const) {
  189. const root = join(directory, name)
  190. await verifyBackend(name, root, events, sizes)
  191. if (name === 'sqlite') {
  192. const db = new DatabaseSync(join(root, 'sessions.db'), { readOnly: true })
  193. try {
  194. expect(db.prepare(testSql('count-physical-types')).all()).toEqual([
  195. [
  196. { type: 'reasoning-chunks', count: 1 },
  197. { type: 'text-chunks', count: 1 },
  198. { type: 'tool-call-chunks', count: 2 },
  199. ],
  200. [],
  201. [
  202. { type: 'reasoning-chunks', count: 1 },
  203. { type: 'text-chunks', count: 1 },
  204. { type: 'tool-call-chunks', count: 1 },
  205. ],
  206. ][partitionIndex])
  207. } finally {
  208. db.close()
  209. }
  210. }
  211. }
  212. }
  213. }, 30_000)
  214. it('matches JSONL/Zstandard across randomized logical logs and append partitions', async () => {
  215. await fc.assert(fc.asyncProperty(randomWorkload, async ({ events, batchSizes }) => {
  216. const directory = await freshDirectory('dsh-sqlite-property-')
  217. for (const name of ['jsonl-zstd', 'sqlite'] as const) {
  218. await verifyBackend(name, join(directory, name), events, batchSizes)
  219. }
  220. }), { numRuns: 100, seed: 0x5A17E })
  221. }, 60_000)
  222. })