differential.spec.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273
  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 { CallId, 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; readonly ignorable?: true }[],
  47. ): SessionEvent[] {
  48. const chunks = entries.map(({ chunk, time, ignorable }, index): SessionEvent => ({
  49. type: 'assistant/chunk',
  50. seq: index + 2,
  51. time,
  52. data: { turn: 1, step: 1, chunk },
  53. ...ignorable === true ? { ignorable } : {},
  54. }))
  55. return [
  56. { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
  57. { type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } },
  58. ...chunks,
  59. { type: 'step/end', seq: chunks.length + 2, time: 3, data: { turn: 1, step: 1 } },
  60. {
  61. type: 'turn/end',
  62. seq: chunks.length + 3,
  63. time: 4,
  64. data: { turn: 1, reason: { kind: 'completed' } },
  65. },
  66. ]
  67. }
  68. function packingMatrixLog(): SessionEvent[] {
  69. const entries: { chunk: StreamChunk; time: number; ignorable?: true }[] = [
  70. ...Array.from({ length: 5 }, (_, index) => ({
  71. chunk: { type: 'text-delta' as const, index: 0, text: `text-${index}` },
  72. time: 1_000 + index,
  73. })),
  74. ...Array.from({ length: 4 }, (_, index) => ({
  75. chunk: { type: 'reasoning-delta' as const, index: 1, text: `reason-${index}` },
  76. time: 990 - index,
  77. })),
  78. ...Array.from({ length: 4 }, (_, index) => ({
  79. chunk: {
  80. type: 'tool-call-delta' as const,
  81. index: 2,
  82. id: CallId('named-call'),
  83. name: 'write',
  84. argumentsDelta: `{${index}`,
  85. },
  86. time: 2_000 + index,
  87. })),
  88. ...Array.from({ length: 3 }, (_, index) => ({
  89. chunk: {
  90. type: 'tool-call-delta' as const,
  91. index: 3,
  92. id: CallId('unnamed-call'),
  93. argumentsDelta: `${index}}`,
  94. },
  95. time: 3_000 + index,
  96. })),
  97. { chunk: { type: 'block-start', index: 4, blockType: 'text' }, time: 4_000 },
  98. { chunk: { type: 'text-delta', index: 4, text: 'short-a' }, time: 4_001 },
  99. { chunk: { type: 'text-delta', index: 4, text: 'short-b' }, time: 4_002 },
  100. { chunk: { type: 'text-delta', index: 5, text: 'scalar-envelope' }, time: 4_003, ignorable: true },
  101. { chunk: { type: 'finish', reason: { kind: 'stop' } }, time: 4_004 },
  102. ]
  103. return closedChunkLog(entries)
  104. }
  105. function storageTagCollisionLog(): SessionEvent[] {
  106. return [
  107. { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
  108. ...['text-chunks', 'reasoning-chunks', 'tool-call-chunks'].map((type, index) => ({
  109. type,
  110. seq: index + 1,
  111. time: index + 2,
  112. data: { future: true },
  113. ignorable: true as const,
  114. }) as unknown as SessionEvent),
  115. { type: 'turn/end', seq: 4, time: 5, data: { turn: 1, reason: { kind: 'completed' } } },
  116. ]
  117. }
  118. function batches(events: readonly SessionEvent[], sizes: readonly number[]): SessionEvent[][] {
  119. const result: SessionEvent[][] = []
  120. let offset = 0
  121. let index = 0
  122. while (offset < events.length) {
  123. const size = sizes[index % sizes.length] as number
  124. result.push(events.slice(offset, offset + size))
  125. offset += size
  126. index += 1
  127. }
  128. return result
  129. }
  130. async function verifyBackend(
  131. name: BackendName,
  132. root: string,
  133. events: readonly SessionEvent[],
  134. sizes: readonly number[],
  135. ): Promise<void> {
  136. const header = { ...meta('differential', '/work'), delegationDepth: 0 }
  137. let mounted = await mount(name, root)
  138. try {
  139. await mounted.persistence.create(header)
  140. for (const batch of batches(events, sizes)) {
  141. await mounted.persistence.append(header.id, batch)
  142. }
  143. expect(await mounted.persistence.inspect(header.id), name).toEqual({ meta: header, events })
  144. expect(await mounted.persistence.list(), name).toEqual([header])
  145. const revision = (await mounted.persistence.listSnapshots())[0]?.revision
  146. for (let fromSeq = 0; fromSeq <= events.length + 1; fromSeq += 1) {
  147. expect((await mounted.persistence.readFrom(header.id, fromSeq)).events, `${name} seq ${fromSeq}`)
  148. .toEqual(events.slice(fromSeq))
  149. }
  150. expect((await mounted.persistence.listSnapshots())[0]?.revision, name).toBe(revision)
  151. } finally {
  152. await mounted.dispose()
  153. }
  154. mounted = await mount(name, root)
  155. try {
  156. expect(await mounted.persistence.inspect(header.id), `${name} reopen`).toEqual({ meta: header, events })
  157. } finally {
  158. await mounted.dispose()
  159. }
  160. }
  161. const streamChunkArbitrary: fc.Arbitrary<StreamChunk> = fc.oneof(
  162. fc.record({ type: fc.constant<'text-delta'>('text-delta'), index: fc.nat(2), text: fc.string() }),
  163. fc.record({ type: fc.constant<'reasoning-delta'>('reasoning-delta'), index: fc.nat(2), text: fc.string() }),
  164. fc.record({
  165. type: fc.constant<'tool-call-delta'>('tool-call-delta'),
  166. index: fc.nat(2),
  167. id: fc.constantFrom(CallId('call-1'), CallId('call-2')),
  168. argumentsDelta: fc.string(),
  169. }),
  170. fc.record({
  171. type: fc.constant<'tool-call-delta'>('tool-call-delta'),
  172. index: fc.nat(2),
  173. id: fc.constantFrom(CallId('call-1'), CallId('call-2')),
  174. name: fc.constantFrom('read', 'write'),
  175. argumentsDelta: fc.string(),
  176. }),
  177. fc.record({
  178. type: fc.constant<'block-start'>('block-start'),
  179. index: fc.nat(2),
  180. blockType: fc.constant<'text'>('text'),
  181. }),
  182. fc.record({ type: fc.constant<'finish'>('finish'), reason: fc.constant({ kind: 'stop' as const }) }),
  183. )
  184. const randomWorkload = fc.record({
  185. entries: fc.array(fc.record({
  186. chunk: streamChunkArbitrary,
  187. time: fc.oneof(
  188. { weight: 4, arbitrary: fc.integer({ min: 0, max: 10_000 }) },
  189. { weight: 1, arbitrary: fc.integer({ min: Number.MIN_SAFE_INTEGER, max: Number.MAX_SAFE_INTEGER }) },
  190. ),
  191. ignorable: fc.option(fc.constant<true>(true), { nil: undefined }),
  192. }), { maxLength: 30 }),
  193. batchSizes: fc.array(fc.integer({ min: 1, max: 8 }), { minLength: 1, maxLength: 8 }),
  194. }).map(({ entries, batchSizes }) => ({
  195. events: JSON.parse(JSON.stringify(closedChunkLog(entries.map(({ chunk, time, ignorable }) => ({
  196. chunk,
  197. time,
  198. ...ignorable === true ? { ignorable } : {},
  199. }))))) as SessionEvent[],
  200. batchSizes,
  201. }))
  202. describe('SQLite cross-backend differential behavior', () => {
  203. it('preserves ignorable logical events whose names match physical storage tags', async () => {
  204. const events = storageTagCollisionLog()
  205. const directory = await freshDirectory('dsh-sqlite-storage-tag-collision-')
  206. const root = join(directory, 'sqlite')
  207. await verifyBackend('sqlite', root, events, [2, 1])
  208. const db = new DatabaseSync(join(root, 'sessions.db'), { readOnly: true })
  209. try {
  210. expect(db.prepare(testSql('count-physical-types')).all()).toEqual([])
  211. expect(db.prepare(testSql('count-ignorable-events')).get()).toEqual({ count: 3 })
  212. } finally {
  213. db.close()
  214. }
  215. })
  216. it('matches JSONL/Zstandard for every packed kind, scalar fallback, suffix, partition, and reopen', async () => {
  217. const events = packingMatrixLog()
  218. for (const [partitionIndex, sizes] of [[events.length], [1], [2, 1, 5, 3]].entries()) {
  219. const directory = await freshDirectory(`dsh-sqlite-matrix-${partitionIndex}-`)
  220. for (const name of ['jsonl-zstd', 'sqlite'] as const) {
  221. const root = join(directory, name)
  222. await verifyBackend(name, root, events, sizes)
  223. if (name === 'sqlite') {
  224. const db = new DatabaseSync(join(root, 'sessions.db'), { readOnly: true })
  225. try {
  226. expect(db.prepare(testSql('count-physical-types')).all()).toEqual([
  227. [
  228. { type: 'reasoning-chunks', count: 1 },
  229. { type: 'text-chunks', count: 1 },
  230. { type: 'tool-call-chunks', count: 2 },
  231. ],
  232. [],
  233. [
  234. { type: 'reasoning-chunks', count: 1 },
  235. { type: 'text-chunks', count: 1 },
  236. { type: 'tool-call-chunks', count: 1 },
  237. ],
  238. ][partitionIndex])
  239. expect(db.prepare(testSql('count-ignorable-events')).get())
  240. .toEqual({ count: 1 })
  241. } finally {
  242. db.close()
  243. }
  244. }
  245. }
  246. }
  247. }, 30_000)
  248. it('matches JSONL/Zstandard across randomized logical logs and append partitions', async () => {
  249. await fc.assert(fc.asyncProperty(randomWorkload, async ({ events, batchSizes }) => {
  250. const directory = await freshDirectory('dsh-sqlite-property-')
  251. for (const name of ['jsonl-zstd', 'sqlite'] as const) {
  252. await verifyBackend(name, join(directory, name), events, batchSizes)
  253. }
  254. }), { numRuns: 100, seed: 0x5A17E })
  255. }, 60_000)
  256. })