headless.snapshot.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379
  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. refreshFixtureReplacements,
  8. scrubRequestHeaders,
  9. stabilizeRefreshLog,
  10. type HarvestedLog,
  11. type NormalizeContext,
  12. } from '@deepseek-ai/dsh-acp-snapshot'
  13. import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
  14. import { describe, expect, it } from 'vitest'
  15. const snapshotsDir = join(dirname(fileURLToPath(import.meta.url)), 'snapshots')
  16. const advancedScenarioDir = join(snapshotsDir, 'advanced-toolchain')
  17. const advancedSessionFixture = join(advancedScenarioDir, 'session.jsonl')
  18. const advancedStreamExpected = join(advancedScenarioDir, 'stream-json.expected.jsonl')
  19. const advancedConfigPath = fileURLToPath(new URL('../advanced.cordis.snapshot.yml', import.meta.url))
  20. const ptyScenarioDir = join(snapshotsDir, 'pty-tools')
  21. const ptySessionFixture = join(ptyScenarioDir, 'session.jsonl')
  22. const ptyStreamExpected = join(ptyScenarioDir, 'stream-json.expected.jsonl')
  23. const ptyConfigPath = fileURLToPath(new URL('../pty.cordis.snapshot.yml', import.meta.url))
  24. const goalScenarioDir = join(snapshotsDir, 'goal-tools')
  25. const goalConfigPath = fileURLToPath(new URL('../goal.cordis.snapshot.yml', import.meta.url))
  26. const ralphScenarioDir = join(snapshotsDir, 'ralph-loop')
  27. const ralphConfigPath = fileURLToPath(new URL('../ralph.cordis.snapshot.yml', import.meta.url))
  28. const binScript = fileURLToPath(new URL('../../../packages/examples/cli-demo/src/bin.ts', import.meta.url))
  29. const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
  30. const refreshing = process.env.DSH_SNAPSHOT === 'refresh'
  31. interface JsonObject {
  32. [key: string]: unknown
  33. }
  34. interface PersistedLog {
  35. readonly content: string
  36. readonly header: JsonObject
  37. }
  38. function parseJsonl(content: string): JsonObject[] {
  39. return content.split('\n')
  40. .filter(line => line.trim().length > 0)
  41. .map(line => JSON.parse(line) as JsonObject)
  42. }
  43. function contextFromLogs(contents: readonly string[]): NormalizeContext {
  44. const headers = contents.map(content => parseJsonl(content)[0])
  45. return {
  46. sessionIds: headers.flatMap(header => typeof header?.id === 'string' ? [header.id] : []),
  47. cwd: typeof headers[0]?.cwd === 'string' ? headers[0].cwd : '\0no-cwd\0',
  48. }
  49. }
  50. function normalizeHeadlessStream(rawStdout: string, cwd: string): string {
  51. const records = parseJsonl(rawStdout)
  52. if (records.length === 0) throw new Error('headless snapshot emitted no stream-json records')
  53. const final = records.at(-1)
  54. if (final?.type !== 'result') throw new Error('headless snapshot did not end with a result record')
  55. if (records.slice(0, -1).some(record => record.type !== 'session_event')) {
  56. throw new Error('headless snapshot emitted a non-event record before its result')
  57. }
  58. const sessionIds = [...new Set(records.flatMap(record => typeof record.sessionId === 'string' ? [record.sessionId] : []))]
  59. if (sessionIds.length !== 1) throw new Error(`headless snapshot streamed ${sessionIds.length} main session ids`)
  60. const context: NormalizeContext = { sessionIds, cwd }
  61. const events = records.slice(0, -1).map((record) => {
  62. if (record.event === null || typeof record.event !== 'object' || Array.isArray(record.event)) {
  63. throw new Error('headless snapshot emitted an invalid session event')
  64. }
  65. return record.event as JsonObject
  66. })
  67. const normalizedEvents = parseJsonl(scrubRequestHeaders(normalizeSessionLog(
  68. `${events.map(event => JSON.stringify(event)).join('\n')}\n`,
  69. context,
  70. )))
  71. const normalizedRecords = records.map((record, index) => index < normalizedEvents.length
  72. ? { ...record, event: normalizedEvents[index] }
  73. : record)
  74. return normalizeStdout(`${normalizedRecords.map(record => JSON.stringify(record)).join('\n')}\n`, context)
  75. }
  76. /** Zero durable goal timestamps inside both metadata records and rendered XML JSON. */
  77. function normalizeGoalTimestamps(value: unknown): unknown {
  78. if (typeof value === 'string') {
  79. return value.replace(/("(?:createdAt|updatedAt|clearedAt)":)\d+/g, '$10')
  80. }
  81. if (Array.isArray(value)) return value.map(normalizeGoalTimestamps)
  82. if (value !== null && typeof value === 'object') {
  83. return Object.fromEntries(Object.entries(value).map(([key, item]) => [
  84. key,
  85. ['createdAt', 'updatedAt', 'clearedAt'].includes(key) && typeof item === 'number'
  86. ? 0
  87. : normalizeGoalTimestamps(item),
  88. ]))
  89. }
  90. return value
  91. }
  92. /** Normalize the stream's durable goal timestamps after the shared scrubbers. */
  93. function normalizeGoalStream(rawStdout: string, cwd: string): string {
  94. return parseJsonl(normalizeHeadlessStream(rawStdout, cwd))
  95. .map(record => JSON.stringify(normalizeGoalTimestamps(record)))
  96. .join('\n') + '\n'
  97. }
  98. async function scenarioPrompt(dir: string, label: string): Promise<string> {
  99. const input = JSON.parse(await readFile(join(dir, 'input.json'), 'utf8')) as {
  100. steps?: { op?: unknown; text?: unknown }[]
  101. }
  102. const prompt = input.steps?.find(step => step.op === 'prompt')?.text
  103. if (typeof prompt !== 'string') throw new Error(`${label} input has no prompt step`)
  104. return prompt
  105. }
  106. async function persistedLogs(cwd: string): Promise<PersistedLog[]> {
  107. const root = join(cwd, '.sessions')
  108. const files = (await readdir(root, { recursive: true })).filter(file => file.endsWith('.jsonl'))
  109. return Promise.all(files.map(async (file) => {
  110. const content = await readFile(join(root, file), 'utf8')
  111. return { content, header: parseJsonl(content)[0] ?? {} }
  112. }))
  113. }
  114. describe('headless stream-json snapshots', () => {
  115. it('replays the advanced toolchain through the one-shot app', async () => {
  116. const prompt = await scenarioPrompt(advancedScenarioDir, 'advanced-toolchain')
  117. const fixtureFiles = [
  118. advancedSessionFixture,
  119. join(advancedScenarioDir, 'session.1.jsonl'),
  120. join(advancedScenarioDir, 'session.2.jsonl'),
  121. ]
  122. let expectedSessions = await Promise.all(fixtureFiles.map(file => readFile(file, 'utf8')))
  123. let runCwd = ''
  124. const result = await runLoaderSmoke({
  125. label: 'advanced headless stream-json snapshot',
  126. tempDirPrefix: 'headless-snapshot-advanced-',
  127. binScript,
  128. configPath: advancedConfigPath,
  129. binArgs: ['--config', advancedConfigPath, '--output-format', 'stream-json', prompt],
  130. tsconfigPath,
  131. env: {
  132. DSH_SNAPSHOT: 'replay',
  133. DSH_SNAPSHOT_FILE: advancedSessionFixture,
  134. DSH_SNAPSHOT_CHILD_FILES: [
  135. join(advancedScenarioDir, 'session.1.jsonl'),
  136. join(advancedScenarioDir, 'session.2.jsonl'),
  137. ].join(delimiter),
  138. NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '),
  139. },
  140. prepare: (cwd) => { runCwd = cwd },
  141. inspect: async (cwd) => {
  142. const logs = await persistedLogs(cwd)
  143. expect(logs).toHaveLength(3)
  144. const parents = logs.filter(log => typeof log.header.parentSession !== 'string')
  145. expect(parents).toHaveLength(1)
  146. const parent = parents[0]
  147. if (parent === undefined) throw new Error('headless snapshot did not persist its main session')
  148. const children = logs.filter(log => typeof log.header.parentSession === 'string')
  149. .sort((left, right) => Number(left.header.createdAt) - Number(right.header.createdAt))
  150. const actualSessions = [parent, ...children]
  151. if (refreshing) {
  152. const harvested = actualSessions.map((log): HarvestedLog => ({
  153. id: String(log.header.id),
  154. createdAt: Number(log.header.createdAt),
  155. ...typeof log.header.parentSession === 'string'
  156. ? { parentSession: log.header.parentSession }
  157. : {},
  158. content: log.content,
  159. }))
  160. const replacements = refreshFixtureReplacements(harvested, expectedSessions)
  161. expectedSessions = await Promise.all(actualSessions.map(async (actual, index) => {
  162. const existing = expectedSessions[index]
  163. const file = fixtureFiles[index]
  164. if (existing === undefined || file === undefined) {
  165. throw new Error(`headless snapshot has no fixture for persisted log ${index}`)
  166. }
  167. const stable = stabilizeRefreshLog(actual.content, existing, replacements)
  168. await writeFile(file, stable)
  169. return stable
  170. }))
  171. }
  172. const actualContext = contextFromLogs(actualSessions.map(log => log.content))
  173. const expectedContext = contextFromLogs(expectedSessions)
  174. for (const [index, actual] of actualSessions.entries()) {
  175. const expected = expectedSessions[index]
  176. if (expected === undefined) throw new Error(`headless snapshot has no fixture for persisted log ${index}`)
  177. expect(scrubRequestHeaders(normalizeSessionLog(actual.content, actualContext)))
  178. .toBe(scrubRequestHeaders(normalizeSessionLog(expected, expectedContext)))
  179. }
  180. },
  181. })
  182. expect(result.stderr).toBe('')
  183. const normalized = normalizeHeadlessStream(result.stdout, runCwd)
  184. if (refreshing) await writeFile(advancedStreamExpected, normalized)
  185. expect(normalized).toBe(await readFile(advancedStreamExpected, 'utf8'))
  186. }, LOADER_SMOKE_TEST_TIMEOUT_MS)
  187. it('replays persisted goal tools through the one-shot app', async () => {
  188. const prompt = await scenarioPrompt(goalScenarioDir, 'goal-tools')
  189. const streamExpected = join(goalScenarioDir, 'stream-json.expected.jsonl')
  190. let runCwd = ''
  191. const result = await runLoaderSmoke({
  192. label: 'goal tools headless stream-json snapshot',
  193. tempDirPrefix: 'headless-snapshot-goal-tools-',
  194. binScript,
  195. configPath: goalConfigPath,
  196. binArgs: ['--config', goalConfigPath, '--output-format', 'stream-json', prompt],
  197. tsconfigPath,
  198. env: {
  199. DSH_SNAPSHOT: 'replay',
  200. DSH_SNAPSHOT_FILE: join(goalScenarioDir, 'session.jsonl'),
  201. DSH_SNAPSHOT_OVERRIDE: join(goalScenarioDir, 'replay.override.json'),
  202. NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '),
  203. },
  204. prepare: (cwd) => { runCwd = cwd },
  205. inspect: async (cwd) => {
  206. const logs = await persistedLogs(cwd)
  207. expect(logs).toHaveLength(1)
  208. const records = parseJsonl(logs[0]?.content ?? '')
  209. const calls = records.filter(record => record.type === 'tool/call')
  210. .map(record => (record.data as JsonObject | undefined)?.name)
  211. expect(calls).toEqual(['update_goal', 'create_goal', 'get_goal'])
  212. const probeResult = records.find(record => record.type === 'tool/result'
  213. && (record.data as JsonObject | undefined)?.callId === 'call_goal_probe')
  214. const probeData = probeResult?.data as JsonObject | undefined
  215. expect(probeData?.isError).toBe(true)
  216. expect((probeData?.error as JsonObject | undefined)?.code).toBe('GOAL_NOT_FOUND')
  217. const goalChanges = records.filter((record) => {
  218. if (record.type !== 'user/message') return false
  219. const data = record.data as JsonObject | undefined
  220. const meta = data?.meta as JsonObject | undefined
  221. return meta?.kind === 'goal/change'
  222. })
  223. expect(goalChanges).toHaveLength(1)
  224. const data = goalChanges[0]?.data as JsonObject | undefined
  225. const meta = data?.meta as JsonObject | undefined
  226. const goal = meta?.goal as JsonObject | undefined
  227. expect(meta?.operation).toBe('create')
  228. expect(goal).toMatchObject({
  229. objective: 'Finish the headless goal-tool snapshot proof',
  230. phase: 'active',
  231. maxGoalRounds: 7,
  232. })
  233. },
  234. })
  235. expect(result.stderr).toBe('')
  236. const normalized = normalizeGoalStream(result.stdout, runCwd)
  237. if (refreshing) await writeFile(streamExpected, normalized)
  238. expect(normalized).toBe(await readFile(streamExpected, 'utf8'))
  239. }, LOADER_SMOKE_TEST_TIMEOUT_MS)
  240. it('replays two fresh Ralph rounds through the one-shot app', async () => {
  241. const prompt = await scenarioPrompt(ralphScenarioDir, 'ralph-loop')
  242. const streamExpected = join(ralphScenarioDir, 'stream-json.expected.jsonl')
  243. let runCwd = ''
  244. const result = await runLoaderSmoke({
  245. label: 'Ralph loop headless stream-json snapshot',
  246. tempDirPrefix: 'headless-snapshot-ralph-loop-',
  247. binScript,
  248. configPath: ralphConfigPath,
  249. binArgs: ['--config', ralphConfigPath, '--output-format', 'stream-json', prompt],
  250. tsconfigPath,
  251. env: {
  252. DSH_SNAPSHOT: 'replay',
  253. DSH_SNAPSHOT_FILE: join(ralphScenarioDir, 'session.jsonl'),
  254. DSH_SNAPSHOT_OVERRIDE: join(ralphScenarioDir, 'replay.override.json'),
  255. DSH_SNAPSHOT_CHILD_FILES: [
  256. join(ralphScenarioDir, 'session.1.jsonl'),
  257. join(ralphScenarioDir, 'session.2.jsonl'),
  258. ].join(delimiter),
  259. NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '),
  260. },
  261. prepare: (cwd) => { runCwd = cwd },
  262. inspect: async (cwd) => {
  263. const logs = await persistedLogs(cwd)
  264. expect(logs).toHaveLength(3)
  265. const parent = logs.find(log => typeof log.header.parentSession !== 'string')
  266. if (parent === undefined) throw new Error('Ralph snapshot did not persist its parent session')
  267. const parentId = parent.header.id
  268. expect(typeof parentId).toBe('string')
  269. const children = logs.filter(log => typeof log.header.parentSession === 'string')
  270. .sort((left, right) => Number(left.header.createdAt) - Number(right.header.createdAt))
  271. expect(children).toHaveLength(2)
  272. expect(children.map(child => child.header.parentSession)).toEqual([parentId, parentId])
  273. expect(children.map(child => child.header.cwd)).toEqual([parent.header.cwd, parent.header.cwd])
  274. expect(parent.header.delegationDepth).toBe(0)
  275. expect(children.map(child => child.header.delegationDepth)).toEqual([1, 1])
  276. expect(children.map(child => child.header.seedLength)).toEqual([undefined, undefined])
  277. expect(new Set(children.map(child => child.header.id)).size).toBe(2)
  278. const parentRecords = parseJsonl(parent.content)
  279. const parentCalls = parentRecords.filter(record => record.type === 'tool/call')
  280. expect(parentCalls.map(record => (record.data as JsonObject | undefined)?.name)).toEqual(['ralph'])
  281. const parentResult = parentRecords.find(record => record.type === 'tool/result')
  282. const parentResultData = parentResult?.data as JsonObject | undefined
  283. expect(parentResultData?.isError).toBe(false)
  284. expect(JSON.stringify(parentResultData?.content)).toContain('reported completion after 2 rounds')
  285. const childRecords = children.map(child => parseJsonl(child.content))
  286. const childPrompts = childRecords.map((records) => {
  287. const message = records.find(record => record.type === 'user/message')
  288. return JSON.stringify((message?.data as JsonObject | undefined)?.content)
  289. })
  290. expect(childPrompts[0]).toContain('Ralph round: 1 of 2.')
  291. expect(childPrompts[0]).toContain('(none — this is the first round)')
  292. expect(childPrompts[0]).not.toContain('ROUND_ONE_HANDOFF')
  293. expect(childPrompts[1]).toContain('Ralph round: 2 of 2.')
  294. expect(childPrompts[1]).toContain('ROUND_ONE_HANDOFF')
  295. for (const childPrompt of childPrompts) {
  296. expect(childPrompt).toContain('Prove two fresh Ralph rounds through the shipped headless app.')
  297. expect(childPrompt).not.toContain('Run a two-round fresh-agent Ralph loop')
  298. }
  299. for (const records of childRecords) {
  300. const calls = records.filter(record => record.type === 'tool/call')
  301. expect(calls.map(record => (record.data as JsonObject | undefined)?.name))
  302. .toEqual(['structured_output'])
  303. }
  304. },
  305. })
  306. expect(result.stderr).toBe('')
  307. const normalized = normalizeHeadlessStream(result.stdout, runCwd)
  308. if (refreshing) await writeFile(streamExpected, normalized)
  309. expect(normalized).toBe(await readFile(streamExpected, 'utf8'))
  310. }, LOADER_SMOKE_TEST_TIMEOUT_MS)
  311. it('replays persistent PTY tools through the one-shot app', async () => {
  312. const input = JSON.parse(await readFile(join(ptyScenarioDir, 'input.json'), 'utf8')) as {
  313. steps?: { op?: unknown; text?: unknown }[]
  314. }
  315. const prompt = input.steps?.find(step => step.op === 'prompt')?.text
  316. if (typeof prompt !== 'string') throw new Error('pty-tools input has no prompt step')
  317. let expectedSession = await readFile(ptySessionFixture, 'utf8')
  318. let runCwd = ''
  319. const result = await runLoaderSmoke({
  320. label: 'headless persistent PTY snapshot',
  321. tempDirPrefix: 'headless-snapshot-pty-',
  322. binScript,
  323. configPath: ptyConfigPath,
  324. binArgs: ['--config', ptyConfigPath, '--output-format', 'stream-json', prompt],
  325. tsconfigPath,
  326. env: {
  327. DSH_SNAPSHOT: 'replay',
  328. DSH_SNAPSHOT_FILE: ptySessionFixture,
  329. NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '),
  330. },
  331. prepare: (cwd) => { runCwd = cwd },
  332. inspect: async (cwd) => {
  333. const logs = await persistedLogs(cwd)
  334. expect(logs).toHaveLength(1)
  335. const actual = logs[0]
  336. if (actual === undefined) throw new Error('headless PTY snapshot did not persist its session')
  337. if (refreshing) {
  338. const harvested: HarvestedLog = {
  339. id: String(actual.header.id),
  340. createdAt: Number(actual.header.createdAt),
  341. content: actual.content,
  342. }
  343. const replacements = refreshFixtureReplacements([harvested], [expectedSession])
  344. expectedSession = stabilizeRefreshLog(actual.content, expectedSession, replacements)
  345. await writeFile(ptySessionFixture, expectedSession)
  346. }
  347. const actualContext = contextFromLogs([actual.content])
  348. const expectedContext = contextFromLogs([expectedSession])
  349. expect(scrubRequestHeaders(normalizeSessionLog(actual.content, actualContext)))
  350. .toBe(scrubRequestHeaders(normalizeSessionLog(expectedSession, expectedContext)))
  351. },
  352. })
  353. expect(result.stderr).toBe('')
  354. const normalized = normalizeHeadlessStream(result.stdout, runCwd)
  355. if (refreshing) await writeFile(ptyStreamExpected, normalized)
  356. expect(normalized).toBe(await readFile(ptyStreamExpected, 'utf8'))
  357. }, LOADER_SMOKE_TEST_TIMEOUT_MS)
  358. })