headless.snapshot.ts 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618
  1. import { readFile, readdir, writeFile } from 'node:fs/promises'
  2. import { createServer } from 'node:http'
  3. import type { IncomingMessage, ServerResponse } from 'node:http'
  4. import { delimiter, dirname, join } from 'node:path'
  5. import { fileURLToPath } from 'node:url'
  6. import {
  7. normalizeSessionLog,
  8. normalizeStdout,
  9. refreshFixtureReplacements,
  10. scrubRequestHeaders,
  11. stabilizeRefreshLog,
  12. tokenizeSessionFixtureCwd,
  13. type HarvestedLog,
  14. type NormalizeContext,
  15. } from '@deepseek-ai/dsh-acp-snapshot'
  16. import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
  17. import { describe, expect, it } from 'vitest'
  18. const snapshotsDir = join(dirname(fileURLToPath(import.meta.url)), 'snapshots')
  19. const advancedScenarioDir = join(snapshotsDir, 'advanced-toolchain')
  20. const advancedSessionFixture = join(advancedScenarioDir, 'session.jsonl')
  21. const advancedStreamExpected = join(advancedScenarioDir, 'stream-json.expected.jsonl')
  22. const advancedConfigPath = fileURLToPath(new URL('../advanced.cordis.snapshot.yml', import.meta.url))
  23. const ptyScenarioDir = join(snapshotsDir, 'pty-tools')
  24. const ptySessionFixture = join(ptyScenarioDir, 'session.jsonl')
  25. const ptyStreamExpected = join(ptyScenarioDir, 'stream-json.expected.jsonl')
  26. const ptyConfigPath = fileURLToPath(new URL('../pty.cordis.snapshot.yml', import.meta.url))
  27. const goalScenarioDir = join(snapshotsDir, 'goal-tools')
  28. const goalConfigPath = fileURLToPath(new URL('../goal.cordis.snapshot.yml', import.meta.url))
  29. const retryScenarioDir = join(snapshotsDir, 'provider-retry')
  30. const retryConfigPath = fileURLToPath(new URL('../retry.cordis.snapshot.yml', import.meta.url))
  31. const credentialsScenarioDir = join(snapshotsDir, 'missing-credential')
  32. const credentialsConfigPath = fileURLToPath(new URL('../credentials.cordis.snapshot.yml', import.meta.url))
  33. const ralphScenarioDir = join(snapshotsDir, 'ralph-loop')
  34. const ralphConfigPath = fileURLToPath(new URL('../ralph.cordis.snapshot.yml', import.meta.url))
  35. const startupFailureConfigPath = fileURLToPath(new URL('./fixtures/startup-activation-error/cordis.yml', import.meta.url))
  36. const startupFailureExpected = join(snapshotsDir, 'startup-activation-error', 'stderr.expected.txt')
  37. const binScript = fileURLToPath(new URL('../../../packages/examples/cli-demo/src/bin.ts', import.meta.url))
  38. const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
  39. const reasoningConfigPath = fileURLToPath(new URL('./fixtures/cli.cordis.yml', import.meta.url))
  40. const deepseekDefaultsConfigPath = fileURLToPath(new URL('./fixtures/deepseek-defaults.cordis.yml', import.meta.url))
  41. const refreshing = process.env.DSH_SNAPSHOT === 'refresh'
  42. interface JsonObject {
  43. [key: string]: unknown
  44. }
  45. interface PersistedLog {
  46. readonly content: string
  47. readonly header: JsonObject
  48. }
  49. interface DeepSeekDefaultsServer {
  50. readonly url: string
  51. readonly requests: JsonObject[]
  52. close(): Promise<void>
  53. }
  54. /** Serve one deterministic DeepSeek-compatible response while retaining its request body. */
  55. async function deepseekDefaultsServer(): Promise<DeepSeekDefaultsServer> {
  56. const requests: JsonObject[] = []
  57. const server = createServer((request: IncomingMessage, response: ServerResponse) => {
  58. let body = ''
  59. request.setEncoding('utf8')
  60. request.on('data', (chunk: string) => { body += chunk })
  61. request.on('end', () => {
  62. requests.push(JSON.parse(body) as JsonObject)
  63. response.writeHead(200, { 'content-type': 'text/event-stream' })
  64. response.end([
  65. 'data: {"choices":[{"delta":{"content":"DEFAULTS_OK"}}]}',
  66. 'data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}',
  67. 'data: [DONE]',
  68. '',
  69. ].join('\n\n'))
  70. })
  71. })
  72. await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
  73. const address = server.address()
  74. if (address === null || typeof address === 'string') throw new Error('DeepSeek defaults snapshot server has no port')
  75. return {
  76. url: `http://127.0.0.1:${address.port}`,
  77. requests,
  78. close: () => new Promise(resolve => server.close(() => { resolve() })),
  79. }
  80. }
  81. function parseJsonl(content: string): JsonObject[] {
  82. return content.split('\n')
  83. .filter(line => line.trim().length > 0)
  84. .map(line => JSON.parse(line) as JsonObject)
  85. }
  86. function contextFromLogs(contents: readonly string[]): NormalizeContext {
  87. const headers = contents.map(content => parseJsonl(content)[0])
  88. return {
  89. sessionIds: headers.flatMap(header => typeof header?.id === 'string' ? [header.id] : []),
  90. cwd: typeof headers[0]?.cwd === 'string' ? headers[0].cwd : '\0no-cwd\0',
  91. }
  92. }
  93. function normalizeHeadlessStream(rawStdout: string, cwd: string): string {
  94. const records = parseJsonl(rawStdout)
  95. if (records.length === 0) throw new Error('headless snapshot emitted no stream-json records')
  96. const final = records.at(-1)
  97. if (final?.type !== 'result') throw new Error('headless snapshot did not end with a result record')
  98. if (records.slice(0, -1).some(record => record.type !== 'session_event')) {
  99. throw new Error('headless snapshot emitted a non-event record before its result')
  100. }
  101. const sessionIds = [...new Set(records.flatMap(record => typeof record.sessionId === 'string' ? [record.sessionId] : []))]
  102. if (sessionIds.length !== 1) throw new Error(`headless snapshot streamed ${sessionIds.length} main session ids`)
  103. const context: NormalizeContext = { sessionIds, cwd }
  104. const events = records.slice(0, -1).map((record) => {
  105. if (record.event === null || typeof record.event !== 'object' || Array.isArray(record.event)) {
  106. throw new Error('headless snapshot emitted an invalid session event')
  107. }
  108. return record.event as JsonObject
  109. })
  110. const normalizedEvents = parseJsonl(scrubRequestHeaders(normalizeSessionLog(
  111. `${events.map(event => JSON.stringify(event)).join('\n')}\n`,
  112. context,
  113. )))
  114. const normalizedRecords = records.map((record, index) => index < normalizedEvents.length
  115. ? { ...record, event: normalizedEvents[index] }
  116. : record)
  117. return normalizeStdout(`${normalizedRecords.map(record => JSON.stringify(record)).join('\n')}\n`, context)
  118. }
  119. /** Zero durable goal timestamps inside both metadata records and rendered XML JSON. */
  120. function normalizeGoalTimestamps(value: unknown): unknown {
  121. if (typeof value === 'string') {
  122. return value.replace(/("(?:createdAt|updatedAt|clearedAt)":)\d+/g, '$10')
  123. }
  124. if (Array.isArray(value)) return value.map(normalizeGoalTimestamps)
  125. if (value !== null && typeof value === 'object') {
  126. return Object.fromEntries(Object.entries(value).map(([key, item]) => [
  127. key,
  128. ['createdAt', 'updatedAt', 'clearedAt'].includes(key) && typeof item === 'number'
  129. ? 0
  130. : normalizeGoalTimestamps(item),
  131. ]))
  132. }
  133. return value
  134. }
  135. /** Normalize the stream's durable goal timestamps after the shared scrubbers. */
  136. function normalizeGoalStream(rawStdout: string, cwd: string): string {
  137. return parseJsonl(normalizeHeadlessStream(rawStdout, cwd))
  138. .map(record => JSON.stringify(normalizeGoalTimestamps(record)))
  139. .join('\n') + '\n'
  140. }
  141. async function scenarioPrompt(dir: string, label: string): Promise<string> {
  142. const input = JSON.parse(await readFile(join(dir, 'input.json'), 'utf8')) as {
  143. steps?: { op?: unknown; text?: unknown }[]
  144. }
  145. const prompt = input.steps?.find(step => step.op === 'prompt')?.text
  146. if (typeof prompt !== 'string') throw new Error(`${label} input has no prompt step`)
  147. return prompt
  148. }
  149. async function persistedLogs(cwd: string): Promise<PersistedLog[]> {
  150. const root = join(cwd, '.sessions')
  151. const files = (await readdir(root, { recursive: true })).filter(file => file.endsWith('.jsonl'))
  152. return Promise.all(files.map(async (file) => {
  153. const content = await readFile(join(root, file), 'utf8')
  154. return { content, header: parseJsonl(content)[0] ?? {} }
  155. }))
  156. }
  157. describe('headless stream-json snapshots', () => {
  158. it('prints the original Loader activation error through the assembled one-shot app', async () => {
  159. const result = await runLoaderSmoke({
  160. label: 'headless startup activation error snapshot',
  161. tempDirPrefix: 'headless-snapshot-startup-error-',
  162. binScript,
  163. configPath: startupFailureConfigPath,
  164. binArgs: ['--config', startupFailureConfigPath, '--output-format', 'stream-json', 'unreachable task'],
  165. tsconfigPath,
  166. expectedExitCode: 1,
  167. })
  168. expect(result.stdout).toBe('')
  169. await expect(result.stderr).toMatchFileSnapshot(startupFailureExpected)
  170. }, LOADER_SMOKE_TEST_TIMEOUT_MS)
  171. it('retries a transient provider failure through the one-shot app', async () => {
  172. const prompt = await scenarioPrompt(retryScenarioDir, 'provider-retry')
  173. const streamExpected = join(retryScenarioDir, 'stream-json.expected.jsonl')
  174. let runCwd = ''
  175. const result = await runLoaderSmoke({
  176. label: 'provider retry headless stream-json snapshot',
  177. tempDirPrefix: 'headless-snapshot-provider-retry-',
  178. binScript,
  179. configPath: retryConfigPath,
  180. binArgs: ['--config', retryConfigPath, '--output-format', 'stream-json', prompt],
  181. tsconfigPath,
  182. env: {
  183. DSH_SNAPSHOT: 'replay',
  184. NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '),
  185. },
  186. prepare: (cwd) => { runCwd = cwd },
  187. inspect: async (cwd) => {
  188. const logs = await persistedLogs(cwd)
  189. expect(logs).toHaveLength(1)
  190. const records = parseJsonl(logs[0]?.content ?? '')
  191. const retries = records.filter(record => record.type === 'llm/retry')
  192. expect(retries).toHaveLength(1)
  193. expect(retries[0]?.data).toMatchObject({
  194. provider: 'deepseek-official',
  195. mode: 'normal',
  196. policyKey: '["normal",1,["RATE_LIMIT"],1,1,0]',
  197. retry: 1,
  198. maxRetries: 1,
  199. delayMs: 1,
  200. failure: { message: 'snapshot transient failure', code: 'RATE_LIMIT', status: 429 },
  201. })
  202. },
  203. })
  204. expect(result.stderr).toBe('')
  205. const normalized = normalizeHeadlessStream(result.stdout, runCwd)
  206. if (refreshing) await writeFile(streamExpected, normalized)
  207. expect(normalized).toBe(await readFile(streamExpected, 'utf8'))
  208. }, LOADER_SMOKE_TEST_TIMEOUT_MS)
  209. it('surfaces actionable missing-credential guidance through the one-shot app', async () => {
  210. const streamExpected = join(credentialsScenarioDir, 'stream-json.expected.jsonl')
  211. let runCwd = ''
  212. const result = await runLoaderSmoke({
  213. label: 'missing-credential headless stream-json snapshot',
  214. tempDirPrefix: 'headless-snapshot-missing-credential-',
  215. binScript,
  216. configPath: credentialsConfigPath,
  217. binArgs: ['--config', credentialsConfigPath, '--output-format', 'stream-json', 'say pong'],
  218. tsconfigPath,
  219. env: {
  220. // First-run posture: no key in the environment, none under ./.dsh.
  221. DEEPSEEK_API_KEY: '',
  222. DEEPSEEK_BASE_URL: '',
  223. NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '),
  224. },
  225. // The designed failure surface: the one-shot app reports the failed turn.
  226. expectedExitCode: 1,
  227. prepare: (cwd) => { runCwd = cwd },
  228. })
  229. // The guidance leads with the credential store — the path that keeps the
  230. // secret out of configuration files — and offers a literal key last.
  231. expect(result.stderr).toBe(
  232. 'dsh-cli-demo: turn 1 failed at step 1: llm-deepseek: no API key for provider route "deepseek-official";'
  233. + ' store DEEPSEEK_API_KEY through the credentials service (the web Models page writes it),'
  234. + ' export DEEPSEEK_API_KEY in the launching environment, or — as a last resort — set a literal'
  235. + ' "apiKey" in the llm-deepseek settings section\n',
  236. )
  237. const normalized = normalizeHeadlessStream(result.stdout, runCwd)
  238. if (refreshing) await writeFile(streamExpected, normalized)
  239. expect(normalized).toBe(await readFile(streamExpected, 'utf8'))
  240. }, LOADER_SMOKE_TEST_TIMEOUT_MS)
  241. it('logs the model default and a dynamic next-step reasoning effort', async () => {
  242. const result = await runLoaderSmoke({
  243. label: 'reasoning effort headless stream-json snapshot',
  244. tempDirPrefix: 'headless-snapshot-reasoning-effort-',
  245. binScript,
  246. configPath: reasoningConfigPath,
  247. binArgs: ['--config', reasoningConfigPath, '--output-format', 'stream-json', 'prove dynamic reasoning effort'],
  248. tsconfigPath,
  249. })
  250. expect(result.stderr).toBe('')
  251. const headers = parseJsonl(result.stdout)
  252. .map(record => record.event)
  253. .filter((event): event is JsonObject => (
  254. event !== null
  255. && typeof event === 'object'
  256. && !Array.isArray(event)
  257. && 'type' in event
  258. && event.type === 'request/header'
  259. ))
  260. .map((event) => {
  261. const data = event.data as JsonObject
  262. return (data.header as JsonObject).config
  263. })
  264. expect(headers).toMatchInlineSnapshot(`
  265. [
  266. {
  267. "model": "cli-mock",
  268. "provider": "cli-mock",
  269. "reasoningEffort": "high",
  270. },
  271. {
  272. "model": "cli-mock",
  273. "provider": "cli-mock",
  274. "reasoningEffort": "off",
  275. },
  276. ]
  277. `)
  278. }, LOADER_SMOKE_TEST_TIMEOUT_MS)
  279. it('logs and sends the DeepSeek adapter maxTokens default through the one-shot app', async () => {
  280. const server = await deepseekDefaultsServer()
  281. try {
  282. const result = await runLoaderSmoke({
  283. label: 'DeepSeek adapter defaults headless stream-json snapshot',
  284. tempDirPrefix: 'headless-snapshot-deepseek-defaults-',
  285. binScript,
  286. configPath: deepseekDefaultsConfigPath,
  287. binArgs: [
  288. '--config',
  289. deepseekDefaultsConfigPath,
  290. '--output-format',
  291. 'stream-json',
  292. 'return the deterministic response',
  293. ],
  294. tsconfigPath,
  295. env: {
  296. DSH_SNAPSHOT_BASE_URL: server.url,
  297. NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '),
  298. },
  299. })
  300. expect(result.stderr).toBe('')
  301. expect(server.requests).toHaveLength(1)
  302. expect(server.requests[0]?.max_tokens).toBe(256_000)
  303. const header = (parseJsonl(result.stdout)
  304. .map(record => record.event)
  305. .find((event): event is JsonObject => (
  306. event !== null
  307. && typeof event === 'object'
  308. && !Array.isArray(event)
  309. && 'type' in event
  310. && event.type === 'request/header'
  311. ))?.data as JsonObject | undefined)?.header as JsonObject | undefined
  312. expect(header?.config).toMatchInlineSnapshot(`
  313. {
  314. "maxTokens": 256000,
  315. "model": "deepseek-v4-flash",
  316. "provider": "deepseek-official",
  317. "reasoningEffort": "off",
  318. }
  319. `)
  320. expect(header?.adapterDefaults).toEqual({
  321. maxTokens: true,
  322. reasoningEffort: true,
  323. })
  324. } finally {
  325. await server.close()
  326. }
  327. }, LOADER_SMOKE_TEST_TIMEOUT_MS)
  328. it('replays the advanced toolchain through the one-shot app', async () => {
  329. const prompt = await scenarioPrompt(advancedScenarioDir, 'advanced-toolchain')
  330. const fixtureFiles = [
  331. advancedSessionFixture,
  332. join(advancedScenarioDir, 'session.1.jsonl'),
  333. join(advancedScenarioDir, 'session.2.jsonl'),
  334. ]
  335. let expectedSessions = await Promise.all(fixtureFiles.map(file => readFile(file, 'utf8')))
  336. let runCwd = ''
  337. const result = await runLoaderSmoke({
  338. label: 'advanced headless stream-json snapshot',
  339. tempDirPrefix: 'headless-snapshot-advanced-',
  340. binScript,
  341. configPath: advancedConfigPath,
  342. binArgs: ['--config', advancedConfigPath, '--output-format', 'stream-json', prompt],
  343. tsconfigPath,
  344. env: {
  345. DSH_SNAPSHOT: 'replay',
  346. DSH_SNAPSHOT_FILE: advancedSessionFixture,
  347. DSH_SNAPSHOT_CHILD_FILES: [
  348. join(advancedScenarioDir, 'session.1.jsonl'),
  349. join(advancedScenarioDir, 'session.2.jsonl'),
  350. ].join(delimiter),
  351. NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '),
  352. },
  353. prepare: (cwd) => { runCwd = cwd },
  354. inspect: async (cwd) => {
  355. const logs = await persistedLogs(cwd)
  356. expect(logs).toHaveLength(3)
  357. const parents = logs.filter(log => typeof log.header.parentSession !== 'string')
  358. expect(parents).toHaveLength(1)
  359. const parent = parents[0]
  360. if (parent === undefined) throw new Error('headless snapshot did not persist its main session')
  361. const children = logs.filter(log => typeof log.header.parentSession === 'string')
  362. .sort((left, right) => Number(left.header.createdAt) - Number(right.header.createdAt))
  363. const actualSessions = [parent, ...children]
  364. const actualContext = contextFromLogs(actualSessions.map(log => log.content))
  365. if (refreshing) {
  366. const harvested = actualSessions.map((log): HarvestedLog => ({
  367. id: String(log.header.id),
  368. createdAt: Number(log.header.createdAt),
  369. ...typeof log.header.parentSession === 'string'
  370. ? { parentSession: log.header.parentSession }
  371. : {},
  372. content: log.content,
  373. }))
  374. const replacements = refreshFixtureReplacements(harvested, expectedSessions)
  375. expectedSessions = await Promise.all(actualSessions.map(async (actual, index) => {
  376. const existing = expectedSessions[index]
  377. const file = fixtureFiles[index]
  378. if (existing === undefined || file === undefined) {
  379. throw new Error(`headless snapshot has no fixture for persisted log ${index}`)
  380. }
  381. const stable = tokenizeSessionFixtureCwd(
  382. stabilizeRefreshLog(actual.content, existing, replacements, actualContext),
  383. )
  384. await writeFile(file, stable)
  385. return stable
  386. }))
  387. }
  388. const expectedContext = contextFromLogs(expectedSessions)
  389. for (const [index, actual] of actualSessions.entries()) {
  390. const expected = expectedSessions[index]
  391. if (expected === undefined) throw new Error(`headless snapshot has no fixture for persisted log ${index}`)
  392. expect(scrubRequestHeaders(normalizeSessionLog(actual.content, actualContext)))
  393. .toBe(scrubRequestHeaders(normalizeSessionLog(expected, expectedContext)))
  394. }
  395. },
  396. })
  397. expect(result.stderr).toBe('')
  398. const normalized = normalizeHeadlessStream(result.stdout, runCwd)
  399. if (refreshing) await writeFile(advancedStreamExpected, normalized)
  400. expect(normalized).toBe(await readFile(advancedStreamExpected, 'utf8'))
  401. }, LOADER_SMOKE_TEST_TIMEOUT_MS)
  402. it('replays persisted goal tools through the one-shot app', async () => {
  403. const prompt = await scenarioPrompt(goalScenarioDir, 'goal-tools')
  404. const streamExpected = join(goalScenarioDir, 'stream-json.expected.jsonl')
  405. let runCwd = ''
  406. const result = await runLoaderSmoke({
  407. label: 'goal tools headless stream-json snapshot',
  408. tempDirPrefix: 'headless-snapshot-goal-tools-',
  409. binScript,
  410. configPath: goalConfigPath,
  411. binArgs: ['--config', goalConfigPath, '--output-format', 'stream-json', prompt],
  412. tsconfigPath,
  413. env: {
  414. DSH_SNAPSHOT: 'replay',
  415. DSH_SNAPSHOT_FILE: join(goalScenarioDir, 'session.jsonl'),
  416. DSH_SNAPSHOT_OVERRIDE: join(goalScenarioDir, 'replay.override.json'),
  417. NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '),
  418. },
  419. prepare: (cwd) => { runCwd = cwd },
  420. inspect: async (cwd) => {
  421. const logs = await persistedLogs(cwd)
  422. expect(logs).toHaveLength(1)
  423. const records = parseJsonl(logs[0]?.content ?? '')
  424. const calls = records.filter(record => record.type === 'tool/call')
  425. .map(record => (record.data as JsonObject | undefined)?.name)
  426. expect(calls).toEqual(['update_goal', 'create_goal', 'get_goal'])
  427. const probeResult = records.find((record) => {
  428. if (record.type !== 'tool/result') return false
  429. const data = record.data as JsonObject | undefined
  430. const message = data?.message as JsonObject | undefined
  431. const source = message?.source as JsonObject | undefined
  432. return source?.callId === 'call_goal_probe'
  433. })
  434. const probeData = probeResult?.data as JsonObject | undefined
  435. const probeMessage = probeData?.message as JsonObject | undefined
  436. const probeContent = probeMessage?.content as JsonObject[] | undefined
  437. expect(probeContent?.[0]?.isError).toBe(true)
  438. expect((probeData?.error as JsonObject | undefined)?.code).toBe('GOAL_NOT_FOUND')
  439. const goalChanges = records.filter((record) => {
  440. if (record.type !== 'user/message') return false
  441. const data = record.data as JsonObject | undefined
  442. const source = data?.source as JsonObject | undefined
  443. const change = source?.change as JsonObject | undefined
  444. return source?.kind === 'goal' && change?.kind === 'goal/change'
  445. })
  446. expect(goalChanges).toHaveLength(1)
  447. const data = goalChanges[0]?.data as JsonObject | undefined
  448. const source = data?.source as JsonObject | undefined
  449. const change = source?.change as JsonObject | undefined
  450. const goal = change?.goal as JsonObject | undefined
  451. expect(change?.operation).toBe('create')
  452. expect(goal).toMatchObject({
  453. objective: 'Finish the headless goal-tool snapshot proof',
  454. phase: 'active',
  455. maxGoalRounds: 7,
  456. })
  457. },
  458. })
  459. expect(result.stderr).toBe('')
  460. const normalized = normalizeGoalStream(result.stdout, runCwd)
  461. if (refreshing) await writeFile(streamExpected, normalized)
  462. expect(normalized).toBe(await readFile(streamExpected, 'utf8'))
  463. }, LOADER_SMOKE_TEST_TIMEOUT_MS)
  464. it('replays two fresh Ralph rounds through the one-shot app', async () => {
  465. const prompt = await scenarioPrompt(ralphScenarioDir, 'ralph-loop')
  466. const streamExpected = join(ralphScenarioDir, 'stream-json.expected.jsonl')
  467. let runCwd = ''
  468. const result = await runLoaderSmoke({
  469. label: 'Ralph loop headless stream-json snapshot',
  470. tempDirPrefix: 'headless-snapshot-ralph-loop-',
  471. binScript,
  472. configPath: ralphConfigPath,
  473. binArgs: ['--config', ralphConfigPath, '--output-format', 'stream-json', prompt],
  474. tsconfigPath,
  475. env: {
  476. DSH_SNAPSHOT: 'replay',
  477. DSH_SNAPSHOT_FILE: join(ralphScenarioDir, 'session.jsonl'),
  478. DSH_SNAPSHOT_OVERRIDE: join(ralphScenarioDir, 'replay.override.json'),
  479. DSH_SNAPSHOT_CHILD_FILES: [
  480. join(ralphScenarioDir, 'session.1.jsonl'),
  481. join(ralphScenarioDir, 'session.2.jsonl'),
  482. ].join(delimiter),
  483. NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '),
  484. },
  485. prepare: (cwd) => { runCwd = cwd },
  486. inspect: async (cwd) => {
  487. const logs = await persistedLogs(cwd)
  488. expect(logs).toHaveLength(3)
  489. const parent = logs.find(log => typeof log.header.parentSession !== 'string')
  490. if (parent === undefined) throw new Error('Ralph snapshot did not persist its parent session')
  491. const parentId = parent.header.id
  492. expect(typeof parentId).toBe('string')
  493. const children = logs.filter(log => typeof log.header.parentSession === 'string')
  494. .sort((left, right) => Number(left.header.createdAt) - Number(right.header.createdAt))
  495. expect(children).toHaveLength(2)
  496. expect(children.map(child => child.header.parentSession)).toEqual([parentId, parentId])
  497. expect(children.map(child => child.header.cwd)).toEqual([parent.header.cwd, parent.header.cwd])
  498. expect(parent.header.delegationDepth).toBe(0)
  499. expect(children.map(child => child.header.delegationDepth)).toEqual([1, 1])
  500. expect(children.map(child => child.header.seedLength)).toEqual([undefined, undefined])
  501. expect(new Set(children.map(child => child.header.id)).size).toBe(2)
  502. const parentRecords = parseJsonl(parent.content)
  503. const parentCalls = parentRecords.filter(record => record.type === 'tool/call')
  504. expect(parentCalls.map(record => (record.data as JsonObject | undefined)?.name)).toEqual(['ralph'])
  505. const parentResult = parentRecords.find(record => record.type === 'tool/result')
  506. const parentResultData = parentResult?.data as JsonObject | undefined
  507. const parentMessage = parentResultData?.message as JsonObject | undefined
  508. const parentContent = parentMessage?.content as JsonObject[] | undefined
  509. expect(parentContent?.[0]?.isError).toBe(false)
  510. expect(JSON.stringify(parentContent?.[0]?.content)).toContain('reported completion after 2 rounds')
  511. const childRecords = children.map(child => parseJsonl(child.content))
  512. const childPrompts = childRecords.map((records) => {
  513. const message = records.find(record => record.type === 'user/message')
  514. return JSON.stringify((message?.data as JsonObject | undefined)?.content)
  515. })
  516. expect(childPrompts[0]).toContain('Ralph round: 1 of 2.')
  517. expect(childPrompts[0]).toContain('(none — this is the first round)')
  518. expect(childPrompts[0]).not.toContain('ROUND_ONE_HANDOFF')
  519. expect(childPrompts[1]).toContain('Ralph round: 2 of 2.')
  520. expect(childPrompts[1]).toContain('ROUND_ONE_HANDOFF')
  521. for (const childPrompt of childPrompts) {
  522. expect(childPrompt).toContain('Prove two fresh Ralph rounds through the shipped headless app.')
  523. expect(childPrompt).not.toContain('Run a two-round fresh-agent Ralph loop')
  524. }
  525. for (const records of childRecords) {
  526. const calls = records.filter(record => record.type === 'tool/call')
  527. expect(calls.map(record => (record.data as JsonObject | undefined)?.name))
  528. .toEqual(['structured_output'])
  529. }
  530. },
  531. })
  532. expect(result.stderr).toBe('')
  533. const normalized = normalizeHeadlessStream(result.stdout, runCwd)
  534. if (refreshing) await writeFile(streamExpected, normalized)
  535. expect(normalized).toBe(await readFile(streamExpected, 'utf8'))
  536. }, LOADER_SMOKE_TEST_TIMEOUT_MS)
  537. it('replays persistent PTY tools through the one-shot app', async () => {
  538. const input = JSON.parse(await readFile(join(ptyScenarioDir, 'input.json'), 'utf8')) as {
  539. steps?: { op?: unknown; text?: unknown }[]
  540. }
  541. const prompt = input.steps?.find(step => step.op === 'prompt')?.text
  542. if (typeof prompt !== 'string') throw new Error('pty-tools input has no prompt step')
  543. let expectedSession = await readFile(ptySessionFixture, 'utf8')
  544. let runCwd = ''
  545. const result = await runLoaderSmoke({
  546. label: 'headless persistent PTY snapshot',
  547. tempDirPrefix: 'headless-snapshot-pty-',
  548. binScript,
  549. configPath: ptyConfigPath,
  550. binArgs: ['--config', ptyConfigPath, '--output-format', 'stream-json', prompt],
  551. tsconfigPath,
  552. env: {
  553. DSH_SNAPSHOT: 'replay',
  554. DSH_SNAPSHOT_FILE: ptySessionFixture,
  555. NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '),
  556. },
  557. prepare: (cwd) => { runCwd = cwd },
  558. inspect: async (cwd) => {
  559. const logs = await persistedLogs(cwd)
  560. expect(logs).toHaveLength(1)
  561. const actual = logs[0]
  562. if (actual === undefined) throw new Error('headless PTY snapshot did not persist its session')
  563. const actualContext = contextFromLogs([actual.content])
  564. if (refreshing) {
  565. const harvested: HarvestedLog = {
  566. id: String(actual.header.id),
  567. createdAt: Number(actual.header.createdAt),
  568. content: actual.content,
  569. }
  570. const replacements = refreshFixtureReplacements([harvested], [expectedSession])
  571. expectedSession = tokenizeSessionFixtureCwd(
  572. stabilizeRefreshLog(actual.content, expectedSession, replacements, actualContext),
  573. )
  574. await writeFile(ptySessionFixture, expectedSession)
  575. }
  576. const expectedContext = contextFromLogs([expectedSession])
  577. expect(scrubRequestHeaders(normalizeSessionLog(actual.content, actualContext)))
  578. .toBe(scrubRequestHeaders(normalizeSessionLog(expectedSession, expectedContext)))
  579. },
  580. })
  581. expect(result.stderr).toBe('')
  582. const normalized = normalizeHeadlessStream(result.stdout, runCwd)
  583. if (refreshing) await writeFile(ptyStreamExpected, normalized)
  584. expect(normalized).toBe(await readFile(ptyStreamExpected, 'utf8'))
  585. }, LOADER_SMOKE_TEST_TIMEOUT_MS)
  586. })