headless.snapshot.ts 30 KB

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