headless.snapshot.ts 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140
  1. import { readFile, readdir, writeFile } from 'node:fs/promises'
  2. import { delimiter, dirname, join } from 'node:path'
  3. import { fileURLToPath } from 'node:url'
  4. import {
  5. normalizeSessionLog,
  6. normalizeStdout,
  7. scrubRequestHeaders,
  8. type NormalizeContext,
  9. } from '@deepseek-ai/dsh-acp-snapshot'
  10. import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
  11. import { describe, expect, it } from 'vitest'
  12. const snapshotsDir = join(dirname(fileURLToPath(import.meta.url)), 'snapshots')
  13. const scenarioDir = join(snapshotsDir, 'advanced-toolchain')
  14. const sessionFixture = join(scenarioDir, 'session.jsonl')
  15. const streamExpected = join(scenarioDir, 'stream-json.expected.jsonl')
  16. const configPath = fileURLToPath(new URL('../advanced.cordis.snapshot.yml', import.meta.url))
  17. const binScript = fileURLToPath(new URL('../../../packages/examples/cli-demo/src/bin.ts', import.meta.url))
  18. const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
  19. const refreshing = process.env.DSH_SNAPSHOT === 'refresh'
  20. interface JsonObject {
  21. [key: string]: unknown
  22. }
  23. interface PersistedLog {
  24. readonly content: string
  25. readonly header: JsonObject
  26. }
  27. function parseJsonl(content: string): JsonObject[] {
  28. return content.split('\n')
  29. .filter(line => line.trim().length > 0)
  30. .map(line => JSON.parse(line) as JsonObject)
  31. }
  32. function contextFromLogs(contents: readonly string[]): NormalizeContext {
  33. const headers = contents.map(content => parseJsonl(content)[0])
  34. return {
  35. sessionIds: headers.flatMap(header => typeof header?.id === 'string' ? [header.id] : []),
  36. cwd: typeof headers[0]?.cwd === 'string' ? headers[0].cwd : '\0no-cwd\0',
  37. }
  38. }
  39. function normalizeHeadlessStream(rawStdout: string, cwd: string): string {
  40. const records = parseJsonl(rawStdout)
  41. if (records.length === 0) throw new Error('headless snapshot emitted no stream-json records')
  42. const final = records.at(-1)
  43. if (final?.type !== 'result') throw new Error('headless snapshot did not end with a result record')
  44. if (records.slice(0, -1).some(record => record.type !== 'session_event')) {
  45. throw new Error('headless snapshot emitted a non-event record before its result')
  46. }
  47. const sessionIds = [...new Set(records.flatMap(record => typeof record.sessionId === 'string' ? [record.sessionId] : []))]
  48. if (sessionIds.length !== 1) throw new Error(`headless snapshot streamed ${sessionIds.length} main session ids`)
  49. const context: NormalizeContext = { sessionIds, cwd }
  50. const events = records.slice(0, -1).map((record) => {
  51. if (record.event === null || typeof record.event !== 'object' || Array.isArray(record.event)) {
  52. throw new Error('headless snapshot emitted an invalid session event')
  53. }
  54. return record.event as JsonObject
  55. })
  56. const normalizedEvents = parseJsonl(scrubRequestHeaders(normalizeSessionLog(
  57. `${events.map(event => JSON.stringify(event)).join('\n')}\n`,
  58. context,
  59. )))
  60. const normalizedRecords = records.map((record, index) => index < normalizedEvents.length
  61. ? { ...record, event: normalizedEvents[index] }
  62. : record)
  63. return normalizeStdout(`${normalizedRecords.map(record => JSON.stringify(record)).join('\n')}\n`, context)
  64. }
  65. async function advancedPrompt(): Promise<string> {
  66. const input = JSON.parse(await readFile(join(scenarioDir, 'input.json'), 'utf8')) as {
  67. steps?: { op?: unknown; text?: unknown }[]
  68. }
  69. const prompt = input.steps?.find(step => step.op === 'prompt')?.text
  70. if (typeof prompt !== 'string') throw new Error('advanced-toolchain input has no prompt step')
  71. return prompt
  72. }
  73. async function persistedLogs(cwd: string): Promise<PersistedLog[]> {
  74. const root = join(cwd, '.sessions')
  75. const files = (await readdir(root, { recursive: true })).filter(file => file.endsWith('.jsonl'))
  76. return Promise.all(files.map(async (file) => {
  77. const content = await readFile(join(root, file), 'utf8')
  78. return { content, header: parseJsonl(content)[0] ?? {} }
  79. }))
  80. }
  81. describe('headless stream-json snapshots', () => {
  82. it('replays the advanced toolchain through the one-shot app', async () => {
  83. const prompt = await advancedPrompt()
  84. const expectedSessions = await Promise.all([
  85. sessionFixture,
  86. join(scenarioDir, 'session.1.jsonl'),
  87. join(scenarioDir, 'session.2.jsonl'),
  88. ].map(file => readFile(file, 'utf8')))
  89. let runCwd = ''
  90. const result = await runLoaderSmoke({
  91. label: 'advanced headless stream-json snapshot',
  92. tempDirPrefix: 'headless-snapshot-advanced-',
  93. binScript,
  94. configPath,
  95. binArgs: ['--config', configPath, '--output-format', 'stream-json', prompt],
  96. tsconfigPath,
  97. env: {
  98. DSH_SNAPSHOT: 'replay',
  99. DSH_SNAPSHOT_FILE: sessionFixture,
  100. DSH_SNAPSHOT_CHILD_FILES: [join(scenarioDir, 'session.1.jsonl'), join(scenarioDir, 'session.2.jsonl')].join(delimiter),
  101. NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '),
  102. },
  103. prepare: (cwd) => { runCwd = cwd },
  104. inspect: async (cwd) => {
  105. const logs = await persistedLogs(cwd)
  106. expect(logs).toHaveLength(3)
  107. const parents = logs.filter(log => typeof log.header.parentSession !== 'string')
  108. expect(parents).toHaveLength(1)
  109. const parent = parents[0]
  110. if (parent === undefined) throw new Error('headless snapshot did not persist its main session')
  111. const children = logs.filter(log => typeof log.header.parentSession === 'string')
  112. .sort((left, right) => Number(left.header.createdAt) - Number(right.header.createdAt))
  113. const actualSessions = [parent, ...children]
  114. const actualContext = contextFromLogs(actualSessions.map(log => log.content))
  115. const expectedContext = contextFromLogs(expectedSessions)
  116. for (const [index, actual] of actualSessions.entries()) {
  117. const expected = expectedSessions[index]
  118. if (expected === undefined) throw new Error(`headless snapshot has no fixture for persisted log ${index}`)
  119. expect(scrubRequestHeaders(normalizeSessionLog(actual.content, actualContext)))
  120. .toBe(scrubRequestHeaders(normalizeSessionLog(expected, expectedContext)))
  121. }
  122. },
  123. })
  124. expect(result.stderr).toBe('')
  125. const normalized = normalizeHeadlessStream(result.stdout, runCwd)
  126. if (refreshing) await writeFile(streamExpected, normalized)
  127. expect(normalized).toBe(await readFile(streamExpected, 'utf8'))
  128. }, LOADER_SMOKE_TEST_TIMEOUT_MS)
  129. })