suite.spec.ts 66 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571
  1. import { cpSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
  2. import { rm } from 'node:fs/promises'
  3. import { tmpdir } from 'node:os'
  4. import { join } from 'node:path'
  5. import { fileURLToPath } from 'node:url'
  6. import { afterAll, describe, expect, it } from 'vitest'
  7. import {
  8. assertPersistedSessionVersion,
  9. assertSessionFixtureVersion,
  10. defineAcpSnapshotSuite,
  11. latestPersistedSessionPaths,
  12. parsePersistedSessionFilename,
  13. parseSessionFixtureName,
  14. persistedSessionFilename,
  15. sessionFixtureName,
  16. sessionFixtureNames,
  17. sessionHeaderVersion,
  18. writerSnapshotName,
  19. stabilizeFixtureMessageIds,
  20. tokenizeSessionFixtureCwd,
  21. type HarvestedLog,
  22. type Scenario,
  23. } from '../src/index.ts'
  24. import {
  25. assertChildSystemPromptSnapshot,
  26. assertUniqueSnapshotContents,
  27. claimSharedSnapshot,
  28. fixtureContext,
  29. formatSystemPromptSnapshot,
  30. headerChangeCount,
  31. formatToolSchemasSnapshot,
  32. normalizedHeaders,
  33. normalizedSystemPrompts,
  34. normalizedToolSchemas,
  35. parseSystemPromptSnapshot,
  36. parseToolSchemasSnapshot,
  37. refreshFixtureReplacements,
  38. scenarioSkipped,
  39. restorePinnedToolSchemas,
  40. type SharedSnapshotClaim,
  41. stabilizeRefreshLog,
  42. stdoutExpectedVariants,
  43. systemPromptPrecedesRequests,
  44. unknownToolCallIds,
  45. } from '../src/suite.ts'
  46. /**
  47. * Unit tests for the suite factory, by running it: two synthetic suites over the scripted fake
  48. * ACP bin (./fixtures/fake-acp-agent.ts) register real describe/it trees at collection time,
  49. * so every factory path — expected-output and log comparisons, the per-suite header pin and its uniformity
  50. * guard, record-mode fixture write-back, skip semantics, and the fixture guard block —
  51. * executes as an ordinary green test.
  52. *
  53. * Record tests use a temp copy. To intentionally rebuild their committed fixtures, run this
  54. * spec once with `ACP_SNAPSHOT_SPEC_BOOTSTRAP=1`, then review and commit the resulting tree.
  55. */
  56. const fakeAgent = fileURLToPath(new URL('./fixtures/fake-acp-agent.ts', import.meta.url))
  57. const AGENT = {
  58. binScript: fakeAgent,
  59. libBinScript: fakeAgent,
  60. configPath: fakeAgent,
  61. tsconfigPath: fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)),
  62. }
  63. const REPLAY_DIR = fileURLToPath(new URL('./fixtures/suite', import.meta.url))
  64. function stabilize(
  65. fresh: string,
  66. existing: string,
  67. replacements: Parameters<typeof stabilizeRefreshLog>[2] = [],
  68. freshContext: Parameters<typeof stabilizeRefreshLog>[3] = fixtureContext(fresh),
  69. ): string {
  70. return stabilizeRefreshLog(fresh, existing, replacements, freshContext)
  71. }
  72. const RECORD_SRC = fileURLToPath(new URL('./fixtures/record-suite', import.meta.url))
  73. // Replay pins explicit header classes; recording covers the default fallback.
  74. const REPLAY_SCENARIOS: Scenario[] = [
  75. {
  76. name: 'pin-turn',
  77. hasModelTurn: true,
  78. recorded: true,
  79. pinsHeader: true,
  80. expectedHeaderChanges: 1,
  81. expectedPromptChanges: 1,
  82. headerClass: 'main',
  83. },
  84. {
  85. name: 'shared-pin',
  86. hasModelTurn: true,
  87. recorded: true,
  88. pinsHeader: true,
  89. expectedHeaderChanges: 1,
  90. expectedPromptChanges: 1,
  91. headerClass: 'shared',
  92. systemPromptSource: 'pin-turn',
  93. toolSchemasSource: 'pin-turn',
  94. },
  95. {
  96. name: 'plain-turn',
  97. hasModelTurn: true,
  98. recorded: true,
  99. headerClass: 'main',
  100. env: { DSH_PERMISSION_MODE: 'never' },
  101. configPath: AGENT.configPath,
  102. workspaceParent: tmpdir(),
  103. pinsChildToolSchemas: [1],
  104. pinsChildSystemPrompts: [1],
  105. prepareWorkspace: (cwd) => {
  106. writeFileSync(join(cwd, 'seed.txt'), 'prepared at runtime')
  107. },
  108. },
  109. { name: 'no-model', hasModelTurn: false, recorded: false, headerClass: 'main' },
  110. { name: 'blocked-log', hasModelTurn: false, comparesLog: true, recorded: false, headerClass: 'main' },
  111. { name: 'authored-error', hasModelTurn: true, recorded: false, overridden: true, headerClass: 'main' },
  112. ]
  113. const RECORD_SCENARIOS: Scenario[] = [
  114. { name: 'rec-pin', hasModelTurn: true, recorded: true, pinsHeader: true },
  115. { name: 'rec-child', hasModelTurn: true, recorded: true, pinsChildToolSchemas: [1] },
  116. // recorded:false in record mode → registered but skipped (never re-recorded).
  117. { name: 'rec-skip', hasModelTurn: true, recorded: false, overridden: true },
  118. ]
  119. // Record/refresh modes mutate their snapshots dir, so run them on throwaway
  120. // copies — except record's documented bootstrap knob, which regenerates the
  121. // committed record fixtures and expected outputs in place.
  122. const BOOTSTRAP = process.env.ACP_SNAPSHOT_SPEC_BOOTSTRAP === '1'
  123. const recordDir = BOOTSTRAP ? RECORD_SRC : mkdtempSync(join(tmpdir(), 'acp-snap-record-suite-'))
  124. const retiredChildFixture = readFileSync(join(RECORD_SRC, 'rec-child', 'session.1.v3.jsonl'), 'utf8')
  125. if (!BOOTSTRAP) {
  126. cpSync(RECORD_SRC, recordDir, { recursive: true })
  127. // Record mode owns its output inventory: a new scenario has no primary yet,
  128. // while a changed child count can leave old numbered fixtures behind.
  129. rmSync(join(recordDir, 'rec-pin', 'session.jsonl'))
  130. rmSync(join(recordDir, 'rec-pin', 'session.v3.jsonl'))
  131. writeFileSync(join(recordDir, 'rec-child', 'session.2.v3.jsonl'), retiredChildFixture)
  132. }
  133. const refreshDir = mkdtempSync(join(tmpdir(), 'acp-snap-refresh-suite-'))
  134. cpSync(REPLAY_DIR, refreshDir, { recursive: true })
  135. staleRefreshFixtures(refreshDir)
  136. afterAll(async () => {
  137. if (!BOOTSTRAP) await rm(recordDir, { recursive: true, force: true })
  138. await rm(refreshDir, { recursive: true, force: true })
  139. })
  140. function staleRefreshFixtures(dir: string): void {
  141. writeFileSync(join(dir, 'plain-turn', 'stdout.expected.jsonl'), 'stale stdout\n')
  142. writeFileSync(join(dir, 'pin-turn', 'system-prompt.expected.md'), 'STALE PROMPT\n')
  143. writeFileSync(join(dir, 'pin-turn', 'tool-schemas.expected.json'), '{"initial":[{"name":"stale"}],"changes":[]}\n')
  144. writeFileSync(join(dir, 'plain-turn', 'tool-schemas.1.expected.json'), '{"initial":[{"name":"stale-child"}],"changes":[]}\n')
  145. writeFileSync(join(dir, 'plain-turn', 'system-prompt.1.expected.md'), 'STALE CHILD PROMPT\n')
  146. const plainBehaviorFile = join(dir, 'plain-turn', 'behavior.json')
  147. const plainBehavior = JSON.parse(readFileSync(plainBehaviorFile, 'utf8')) as Record<string, unknown>
  148. plainBehavior.echoEnv = true
  149. writeFileSync(plainBehaviorFile, `${JSON.stringify(plainBehavior, null, 2)}\n`)
  150. writeFileSync(join(dir, 'blocked-log', 'session.jsonl'), [
  151. '{"type":"session","id":"99999999-8888-4777-8666-555555555555","createdAt":13,"cwd":"/rec/blocked-cwd","delegationDepth":0}',
  152. '{"type":"hook/result","seq":0,"time":13,"data":{"decision":"stale","durationMs":99}}',
  153. '',
  154. ].join('\n'))
  155. writeFileSync(join(dir, 'authored-error', 'session.jsonl'), [
  156. '{"type":"session","id":"77777777-8888-4777-8666-555555555555","createdAt":13,"cwd":"/rec/error-cwd","delegationDepth":0}',
  157. '{"type":"turn/end","seq":0,"time":9,"data":{"error":"stale"}}',
  158. '',
  159. ].join('\n'))
  160. }
  161. describe('defineAcpSnapshotSuite: replay mode', () => {
  162. defineAcpSnapshotSuite({ agent: AGENT, snapshotsDir: REPLAY_DIR, scenarios: REPLAY_SCENARIOS, mode: 'replay' })
  163. })
  164. // The record suite's tests run in registration order: rec-pin re-records the
  165. // pinned fixture FIRST, so rec-child's uniformity guard reads the fresh pin.
  166. describe('defineAcpSnapshotSuite: record mode', () => {
  167. defineAcpSnapshotSuite({ agent: AGENT, snapshotsDir: recordDir, scenarios: RECORD_SCENARIOS, mode: 'record' })
  168. })
  169. describe('defineAcpSnapshotSuite: refresh mode', () => {
  170. defineAcpSnapshotSuite({ agent: AGENT, snapshotsDir: refreshDir, scenarios: REPLAY_SCENARIOS, mode: 'refresh' })
  171. })
  172. describe('defineAcpSnapshotSuite: refresh write-back', () => {
  173. it('rewrites stdout and comparable logs from a replay-mode child run', () => {
  174. const stdout = readFileSync(join(refreshDir, 'plain-turn', 'stdout.expected.jsonl'), 'utf8')
  175. expect(stdout).not.toContain('stale stdout')
  176. expect(stdout).toContain('env:{\\"mode\\":\\"replay\\"')
  177. expect(stdout).not.toContain('\\"mode\\":\\"refresh\\"')
  178. // The scenario's own env layer reached the subprocess.
  179. expect(stdout).toContain('\\"permissionMode\\":\\"never\\"')
  180. const blocked = readFileSync(join(refreshDir, 'blocked-log', 'session.v3.jsonl'), 'utf8')
  181. expect(blocked).toContain('"decision":"block"')
  182. expect(blocked).not.toContain('"decision":"stale"')
  183. const authored = readFileSync(join(refreshDir, 'authored-error', 'session.v3.jsonl'), 'utf8')
  184. expect(authored).toContain('"message":"model exploded"')
  185. expect(authored).not.toContain('"error":"stale"')
  186. expect(readFileSync(join(refreshDir, 'pin-turn', 'system-prompt.expected.md'), 'utf8')).toBe([
  187. 'SYS PROMPT',
  188. '',
  189. '<!-- system/message change 1 -->',
  190. '',
  191. 'SYS PROMPT',
  192. '',
  193. 'NEW PROMPT LINE',
  194. '',
  195. ].join('\n'))
  196. const schemas = parseToolSchemasSnapshot(readFileSync(join(refreshDir, 'pin-turn', 'tool-schemas.expected.json'), 'utf8'))
  197. expect(schemas.initial.map(tool => (tool as { name: string }).name)).toEqual(['t1'])
  198. expect(schemas.changes.map(set => set.map(tool => (tool as { name: string }).name))).toEqual([['t1', 't2']])
  199. const childSchemas = readFileSync(join(refreshDir, 'plain-turn', 'tool-schemas.1.expected.json'), 'utf8')
  200. expect(childSchemas).toContain('"name": "child-only"')
  201. expect(childSchemas).not.toContain('stale-child')
  202. const childPrompt = readFileSync(join(refreshDir, 'plain-turn', 'system-prompt.1.expected.md'), 'utf8')
  203. expect(childPrompt).toBe('SYS PROMPT\n\nCHILD GUIDANCE\n')
  204. const pinSession = readFileSync(join(refreshDir, 'pin-turn', 'session.v3.jsonl'), 'utf8')
  205. expect(pinSession).toContain('"cwd":"{{cwd}}"')
  206. expect(readFileSync(join(refreshDir, 'pin-turn', 'session.jsonl'), 'utf8'))
  207. .not.toContain('"version"')
  208. expect(pinSession.match(/"text":"{{system}}"/g)).toHaveLength(2)
  209. expect(pinSession).toContain('"surfaceOp":{"op":"replace","startSeq":2,"endSeq":2}')
  210. expect(pinSession).not.toContain('NEW PROMPT LINE')
  211. })
  212. })
  213. describe('defineAcpSnapshotSuite: record inventory write-back', () => {
  214. it('uses complete UUIDs for the shared parent and child system message', () => {
  215. const behavior = JSON.parse(readFileSync(join(RECORD_SRC, 'rec-child', 'behavior.json'), 'utf8')) as {
  216. logs: { lines: { type: string; data?: { message?: { id: string } } }[] }[]
  217. }
  218. const ids = behavior.logs.map(log => log.lines.find(event => event.type === 'system/message')?.data?.message?.id)
  219. expect(ids).toHaveLength(2)
  220. for (const id of ids) expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/)
  221. expect(ids[0]).toBe(ids[1])
  222. })
  223. it('creates a missing primary fixture and preserves generations for a retired child role', () => {
  224. const fixture = readFileSync(join(recordDir, 'rec-pin', 'session.v3.jsonl'), 'utf8')
  225. expect(fixture).toContain('"type":"session"')
  226. expect(fixture).toContain('"cwd":"{{cwd}}"')
  227. if (!BOOTSTRAP) {
  228. expect(readFileSync(join(recordDir, 'rec-child', 'session.2.v3.jsonl'), 'utf8')).toBe(retiredChildFixture)
  229. }
  230. expect(readFileSync(join(recordDir, 'rec-child', 'tool-schemas.1.expected.json'), 'utf8'))
  231. .toContain('"name": "t1"')
  232. })
  233. it('retains an unchanged message relationship across the recorded parent and child fixtures', () => {
  234. const existingMessageId = '22222222-2222-4222-8222-222222222222'
  235. const freshMessageId = '11111111-1111-4111-8111-111111111111'
  236. const fixtures = ['session.v3.jsonl', 'session.1.v3.jsonl']
  237. .map(file => readFileSync(join(recordDir, 'rec-child', file), 'utf8'))
  238. for (const fixture of fixtures) {
  239. expect(fixture).toContain('"id":"{{message:1}}"')
  240. expect(fixture).not.toContain(existingMessageId)
  241. expect(fixture).not.toContain(freshMessageId)
  242. }
  243. })
  244. })
  245. describe('defineAcpSnapshotSuite: registration contract', () => {
  246. it("throws when a scenario's header class has no pinning scenario", () => {
  247. expect(() => {
  248. defineAcpSnapshotSuite({
  249. agent: AGENT,
  250. snapshotsDir: REPLAY_DIR,
  251. scenarios: [{ name: 'pinless', hasModelTurn: true, recorded: true }],
  252. mode: 'replay',
  253. })
  254. }).toThrow(/no scenario pins the request-header content of class "default"/)
  255. // A pinned class does not cover a DIFFERENT class's members.
  256. expect(() => {
  257. defineAcpSnapshotSuite({
  258. agent: AGENT,
  259. snapshotsDir: REPLAY_DIR,
  260. scenarios: [
  261. { name: 'pinned', hasModelTurn: true, recorded: true, pinsHeader: true },
  262. { name: 'classless-orphan', hasModelTurn: true, recorded: true, headerClass: 'other' },
  263. ],
  264. mode: 'replay',
  265. })
  266. }).toThrow(/class "other" \(needed by classless-orphan\)/)
  267. })
  268. it('throws when two scenarios pin the same header class', () => {
  269. expect(() => {
  270. defineAcpSnapshotSuite({
  271. agent: AGENT,
  272. snapshotsDir: REPLAY_DIR,
  273. scenarios: [
  274. { name: 'first-pin', hasModelTurn: true, recorded: true, pinsHeader: true },
  275. { name: 'second-pin', hasModelTurn: true, recorded: true, pinsHeader: true },
  276. ],
  277. mode: 'replay',
  278. })
  279. }).toThrow(/header class "default" pinned by both first-pin and second-pin/)
  280. })
  281. it('throws when scenario names are duplicated', () => {
  282. expect(() => {
  283. defineAcpSnapshotSuite({
  284. agent: AGENT,
  285. snapshotsDir: REPLAY_DIR,
  286. scenarios: [
  287. { name: 'duplicate', hasModelTurn: true, recorded: true, pinsHeader: true },
  288. { name: 'duplicate', hasModelTurn: true, recorded: true },
  289. ],
  290. mode: 'replay',
  291. })
  292. }).toThrow(/duplicate scenario name "duplicate"/)
  293. })
  294. it.each([
  295. ['systemPromptSource', 'owner'],
  296. ['toolSchemasSource', 'owner'],
  297. ['expectedHeaderChanges', 1],
  298. ['expectedPromptChanges', 1],
  299. ] as const)(
  300. 'rejects %s away from a header pin',
  301. (field, value) => {
  302. expect(() => {
  303. defineAcpSnapshotSuite({
  304. agent: AGENT,
  305. snapshotsDir: REPLAY_DIR,
  306. scenarios: [{
  307. name: 'plain',
  308. hasModelTurn: true,
  309. recorded: true,
  310. [field]: value,
  311. }],
  312. mode: 'replay',
  313. })
  314. }).toThrow(new RegExp(`plain\\.${field} is only valid on a header-pinning scenario`))
  315. },
  316. )
  317. it('rejects an unknown or non-pinning sidecar source', () => {
  318. expect(() => {
  319. defineAcpSnapshotSuite({
  320. agent: AGENT,
  321. snapshotsDir: REPLAY_DIR,
  322. scenarios: [{
  323. name: 'pin',
  324. hasModelTurn: true,
  325. recorded: true,
  326. pinsHeader: true,
  327. systemPromptSource: 'missing',
  328. }],
  329. mode: 'replay',
  330. })
  331. }).toThrow(/pin names unknown system-prompt snapshot source "missing"/)
  332. expect(() => {
  333. defineAcpSnapshotSuite({
  334. agent: AGENT,
  335. snapshotsDir: REPLAY_DIR,
  336. scenarios: [
  337. {
  338. name: 'pin',
  339. hasModelTurn: true,
  340. recorded: true,
  341. pinsHeader: true,
  342. toolSchemasSource: 'plain',
  343. },
  344. { name: 'plain', hasModelTurn: true, recorded: true },
  345. ],
  346. mode: 'replay',
  347. })
  348. }).toThrow(/pin names non-pinning tool-schema snapshot source "plain"/)
  349. })
  350. it('rejects a sidecar source that redirects the same artifact', () => {
  351. expect(() => {
  352. defineAcpSnapshotSuite({
  353. agent: AGENT,
  354. snapshotsDir: REPLAY_DIR,
  355. scenarios: [
  356. { name: 'owner', hasModelTurn: true, recorded: true, pinsHeader: true },
  357. {
  358. name: 'redirect',
  359. hasModelTurn: true,
  360. recorded: true,
  361. pinsHeader: true,
  362. headerClass: 'redirect',
  363. systemPromptSource: 'owner',
  364. },
  365. {
  366. name: 'consumer',
  367. hasModelTurn: true,
  368. recorded: true,
  369. pinsHeader: true,
  370. headerClass: 'consumer',
  371. systemPromptSource: 'redirect',
  372. },
  373. ],
  374. mode: 'replay',
  375. })
  376. }).toThrow(/consumer names system-prompt snapshot source "redirect", which does not own its sidecar/)
  377. })
  378. it('rejects shared sidecars with different header-change counts', () => {
  379. expect(() => {
  380. defineAcpSnapshotSuite({
  381. agent: AGENT,
  382. snapshotsDir: REPLAY_DIR,
  383. scenarios: [
  384. {
  385. name: 'owner',
  386. hasModelTurn: true,
  387. recorded: true,
  388. pinsHeader: true,
  389. expectedHeaderChanges: 1,
  390. },
  391. {
  392. name: 'consumer',
  393. hasModelTurn: true,
  394. recorded: true,
  395. pinsHeader: true,
  396. headerClass: 'consumer',
  397. toolSchemasSource: 'owner',
  398. },
  399. ],
  400. mode: 'replay',
  401. })
  402. }).toThrow(/consumer and owner declare different expectedHeaderChanges counts for shared tool-schema snapshot/)
  403. })
  404. it('rejects shared prompt sidecars with different prompt-change counts', () => {
  405. expect(() => {
  406. defineAcpSnapshotSuite({
  407. agent: AGENT,
  408. snapshotsDir: REPLAY_DIR,
  409. scenarios: [
  410. {
  411. name: 'owner',
  412. hasModelTurn: true,
  413. recorded: true,
  414. pinsHeader: true,
  415. expectedPromptChanges: 1,
  416. },
  417. {
  418. name: 'consumer',
  419. hasModelTurn: true,
  420. recorded: true,
  421. pinsHeader: true,
  422. headerClass: 'consumer',
  423. systemPromptSource: 'owner',
  424. },
  425. ],
  426. mode: 'replay',
  427. })
  428. }).toThrow(/consumer and owner declare different expectedPromptChanges counts for shared system-prompt snapshot/)
  429. })
  430. })
  431. describe('shared snapshot content', () => {
  432. it('accepts identical claims and rejects order-dependent shared output', () => {
  433. const claims = new Map<string, SharedSnapshotClaim>()
  434. claimSharedSnapshot(claims, 'shared/system-prompt.expected.md', 'first', 'prompt\n')
  435. claimSharedSnapshot(claims, 'shared/system-prompt.expected.md', 'second', 'prompt\n')
  436. expect(claims.get('shared/system-prompt.expected.md')).toEqual({
  437. scenario: 'first',
  438. content: 'prompt\n',
  439. })
  440. expect(() => {
  441. claimSharedSnapshot(claims, 'shared/system-prompt.expected.md', 'third', 'different\n')
  442. }).toThrow(/diverged between first and third/)
  443. })
  444. it('rejects identical committed content under different paths', () => {
  445. assertUniqueSnapshotContents('prompt', [
  446. { path: 'one/system-prompt.expected.md', content: 'one\n' },
  447. { path: 'two/system-prompt.expected.md', content: 'two\n' },
  448. ])
  449. expect(() => {
  450. assertUniqueSnapshotContents('prompt', [
  451. { path: 'one/system-prompt.expected.md', content: 'same\n' },
  452. { path: 'two/system-prompt.expected.md', content: 'same\n' },
  453. ])
  454. }).toThrow(/identical prompt snapshots appear in one\/system-prompt\.expected\.md and two\/system-prompt\.expected\.md/)
  455. })
  456. })
  457. describe('writerSnapshotName', () => {
  458. it('keeps native writer expectations outside historical replay selection', () => {
  459. expect(writerSnapshotName(0)).toBe('writer.expected.jsonl')
  460. expect(writerSnapshotName(2)).toBe('writer.2.expected.jsonl')
  461. expect(sessionFixtureNames(['session.v1.jsonl', writerSnapshotName(0), writerSnapshotName(1)]))
  462. .toEqual(['session.v1.jsonl'])
  463. })
  464. it.each([-1, -0, 0.5, Number.MAX_SAFE_INTEGER + 1])('rejects invalid role %s', (index) => {
  465. expect(() => writerSnapshotName(index)).toThrow('writer snapshot index must be a non-negative safe integer')
  466. })
  467. })
  468. describe('sessionFixtureNames', () => {
  469. it('selects the highest generation for each contiguous role while ignoring other files', () => {
  470. expect(sessionFixtureNames([
  471. 'stdout.expected.jsonl',
  472. 'session.2.jsonl',
  473. 'session.jsonl',
  474. 'session.1.jsonl',
  475. 'session.v2.jsonl',
  476. 'session.v1.jsonl',
  477. 'session.1.v3.jsonl',
  478. 'input.json',
  479. ])).toEqual(['session.v2.jsonl', 'session.1.v3.jsonl', 'session.2.jsonl'])
  480. })
  481. it('accepts a primary-only scenario', () => {
  482. expect(sessionFixtureNames(['session.jsonl'])).toEqual(['session.jsonl'])
  483. })
  484. it('rejects a directory without the primary fixture', () => {
  485. expect(() => sessionFixtureNames(['session.1.jsonl'])).toThrow('missing parent session fixture')
  486. })
  487. it('rejects gapped child fixtures', () => {
  488. expect(() => sessionFixtureNames(['session.jsonl', 'session.2.jsonl']))
  489. .toThrow('expected index 1, found session.2.jsonl')
  490. })
  491. it.each([
  492. 'session.0.jsonl',
  493. 'session.child.jsonl',
  494. 'session.01.jsonl',
  495. 'session.v0.jsonl',
  496. 'session.V1.jsonl',
  497. 'session.v01.jsonl',
  498. 'session.1.v0.jsonl',
  499. ])(
  500. 'rejects invalid fixture name %s',
  501. (name) => {
  502. expect(() => sessionFixtureNames(['session.jsonl', name]))
  503. .toThrow(`invalid session fixture name: ${name}`)
  504. },
  505. )
  506. it('rejects duplicate role generations', () => {
  507. expect(() => sessionFixtureNames(['session.jsonl', 'session.1.jsonl', 'session.1.jsonl']))
  508. .toThrow('duplicate session fixture generation: session.1.jsonl')
  509. })
  510. })
  511. describe('Session generation filename helpers', () => {
  512. it('renders canonical parent, child, raw, and compressed names', () => {
  513. expect(sessionFixtureName(0, 0)).toBe('session.jsonl')
  514. expect(sessionFixtureName(0, 2)).toBe('session.v2.jsonl')
  515. expect(sessionFixtureName(3, 0)).toBe('session.3.jsonl')
  516. expect(sessionFixtureName(3, 2)).toBe('session.3.v2.jsonl')
  517. expect(persistedSessionFilename(0)).toBe('session.jsonl')
  518. expect(persistedSessionFilename(2, 'zstd')).toBe('session.v2.jsonl.zstd')
  519. })
  520. it.each([
  521. () => sessionFixtureName(-1, 0),
  522. () => sessionFixtureName(-0, 0),
  523. () => sessionFixtureName(0.5, 0),
  524. () => sessionFixtureName(0, -1),
  525. () => persistedSessionFilename(Number.MAX_SAFE_INTEGER + 1),
  526. ])('rejects invalid numeric filename components', (render) => {
  527. expect(render).toThrow(/non-negative safe integer/)
  528. })
  529. it('parses canonical fixture and persistence names without admitting lookalikes', () => {
  530. expect(parseSessionFixtureName('session.2.v3.jsonl')).toEqual({ index: 2, version: 3, name: 'session.2.v3.jsonl' })
  531. expect(parseSessionFixtureName('notes.jsonl')).toBeUndefined()
  532. expect(() => parseSessionFixtureName(`session.${'9'.repeat(400)}.jsonl`))
  533. .toThrow('invalid session fixture name')
  534. expect(parsePersistedSessionFilename('session.jsonl')).toEqual({ version: 0, compression: 'raw', name: 'session.jsonl' })
  535. expect(parsePersistedSessionFilename('session.v4.jsonl.zstd')).toEqual({ version: 4, compression: 'zstd', name: 'session.v4.jsonl.zstd' })
  536. expect(parsePersistedSessionFilename('session.1.jsonl')).toBeUndefined()
  537. expect(parsePersistedSessionFilename(`session.v${'9'.repeat(400)}.jsonl`)).toBeUndefined()
  538. })
  539. it('selects one highest persisted generation per directory and compression', () => {
  540. const paths = [
  541. 'b/session.v1.jsonl',
  542. 'b/session.jsonl',
  543. 'a/session.jsonl',
  544. 'a/session.v2.jsonl',
  545. 'a/session.v3.jsonl.zstd',
  546. 'a/session.v2.20260101.backup.jsonl',
  547. 'noise.jsonl',
  548. ]
  549. expect(latestPersistedSessionPaths(paths)).toEqual(['a/session.v2.jsonl', 'b/session.v1.jsonl'])
  550. expect(latestPersistedSessionPaths(paths, 'zstd')).toEqual(['a/session.v3.jsonl.zstd'])
  551. })
  552. it('validates filename and header generation equality', () => {
  553. const v0 = '{"type":"session","version":0}\n'
  554. const v1 = '{"type":"session","version":1}\n'
  555. expect(sessionHeaderVersion(v1, 'fixture')).toBe(1)
  556. expect(assertSessionFixtureVersion('session.jsonl', v0)).toBe(0)
  557. expect(assertPersistedSessionVersion('session.v1.jsonl', v1)).toBe(1)
  558. expect(() => assertSessionFixtureVersion('notes.jsonl', v0)).toThrow('not a session fixture name')
  559. expect(assertSessionFixtureVersion('session.jsonl', '{"type":"session"}\n')).toBe(0)
  560. expect(() => assertSessionFixtureVersion('session.v1.jsonl', '{"type":"session"}\n'))
  561. .toThrow('a versionless projected Session header is format v0')
  562. expect(() => assertSessionFixtureVersion('session.jsonl', '')).toThrow('session fixture is empty')
  563. expect(() => assertSessionFixtureVersion('session.jsonl', '{')).toThrow('session header contains invalid JSON')
  564. expect(() => assertPersistedSessionVersion('session.1.jsonl', v0)).toThrow('not a canonical Session persistence filename')
  565. expect(() => assertSessionFixtureVersion('session.v1.jsonl', v0))
  566. .toThrow('filename declares Session format v1, header declares v0')
  567. expect(() => assertPersistedSessionVersion('session.jsonl', v1))
  568. .toThrow('filename declares Session format v0, header declares v1')
  569. })
  570. it.each([
  571. ['', 'session fixture is empty'],
  572. ['{', 'session header contains invalid JSON'],
  573. ['[]', 'first record must be a Session header'],
  574. ['{"type":"event","version":0}', 'first record must be a Session header'],
  575. ['{"type":"session"}', 'version must be a non-negative safe integer'],
  576. ['{"type":"session","version":-1}', 'version must be a non-negative safe integer'],
  577. ['{"type":"session","version":-0}', 'version must be a non-negative safe integer'],
  578. ['{"type":"session","version":1.5}', 'version must be a non-negative safe integer'],
  579. ])('rejects malformed header %j', (content, message) => {
  580. expect(() => sessionHeaderVersion(content, 'bad.jsonl')).toThrow(message)
  581. })
  582. })
  583. describe('stdoutExpectedVariants', () => {
  584. const scenario: Scenario = {
  585. name: 'windows-native',
  586. hasModelTurn: true,
  587. recorded: true,
  588. pinsNativeWindowsStdout: true,
  589. }
  590. it('adds the native sidecar after the shared golden on Windows', () => {
  591. expect(stdoutExpectedVariants(scenario, 'win32')).toEqual([
  592. { file: 'stdout.expected.jsonl', cwdPathMode: 'canonical' },
  593. { file: 'stdout.expected.windows.jsonl', cwdPathMode: 'native' },
  594. ])
  595. })
  596. it('keeps only the shared golden on other platforms or without the declaration', () => {
  597. expect(stdoutExpectedVariants(scenario, 'linux')).toEqual([
  598. { file: 'stdout.expected.jsonl', cwdPathMode: 'canonical' },
  599. ])
  600. expect(stdoutExpectedVariants({ ...scenario, pinsNativeWindowsStdout: false }, 'win32')).toEqual([
  601. { file: 'stdout.expected.jsonl', cwdPathMode: 'canonical' },
  602. ])
  603. })
  604. })
  605. describe('scenarioSkipped', () => {
  606. const authored: Scenario = { name: 'authored', hasModelTurn: true, recorded: false }
  607. const posix: Scenario = { name: 'posix-cancel', hasModelTurn: true, recorded: false, posixOnly: true }
  608. const pwsh: Scenario = { name: 'pwsh-tool', hasModelTurn: true, recorded: false, pwshOnly: true }
  609. const retained: Scenario = {
  610. name: 'retained-v0',
  611. hasModelTurn: true,
  612. recorded: true,
  613. sessionFormat: { version: 0, coverage: ['multi-hop'] },
  614. }
  615. it('skips authored scenarios only while recording', () => {
  616. expect(scenarioSkipped(authored, true, 'linux')).toBe(true)
  617. expect(scenarioSkipped(authored, false, 'linux')).toBe(false)
  618. })
  619. it('skips a retained historical generation while recording but replays and refreshes it', () => {
  620. expect(scenarioSkipped(retained, true, 'linux')).toBe(true)
  621. expect(scenarioSkipped(retained, false, 'linux')).toBe(false)
  622. })
  623. it('skips posixOnly scenarios on Windows and nowhere else', () => {
  624. expect(scenarioSkipped(posix, false, 'win32')).toBe(true)
  625. expect(scenarioSkipped(posix, false, 'linux')).toBe(false)
  626. expect(scenarioSkipped(posix, false, 'darwin')).toBe(false)
  627. expect(scenarioSkipped(authored, false, 'win32')).toBe(false)
  628. })
  629. it('skips pwshOnly scenarios when the host lacks pwsh, and runs them otherwise', () => {
  630. expect(scenarioSkipped(pwsh, false, 'linux', false)).toBe(true)
  631. expect(scenarioSkipped(pwsh, false, 'win32', true)).toBe(false)
  632. expect(scenarioSkipped(pwsh, false, 'linux', true)).toBe(false)
  633. expect(scenarioSkipped(authored, false, 'linux', false)).toBe(false)
  634. })
  635. })
  636. describe('fixtureContext', () => {
  637. it('reads the fixture header id and cwd', () => {
  638. const ctx = fixtureContext('{"type":"session","id":"abc","cwd":"/rec"}\n{"type":"turn/start"}\n')
  639. expect(ctx).toEqual({ sessionIds: ['abc'], cwd: '/rec' })
  640. })
  641. it('yields no session ids for a header without a string id', () => {
  642. expect(fixtureContext('{"type":"session","cwd":"/rec"}\n').sessionIds).toEqual([])
  643. })
  644. it('falls back to an impossible sentinel cwd (never the empty string)', () => {
  645. const ctx = fixtureContext('{"type":"session","id":"abc"}\n')
  646. expect(ctx.cwd).toBe('\0no-cwd\0')
  647. expect(ctx.cwd).not.toBe('')
  648. })
  649. it('treats an empty fixture as an empty header', () => {
  650. expect(fixtureContext('')).toEqual({ sessionIds: [], cwd: '\0no-cwd\0' })
  651. })
  652. })
  653. describe('normalizedHeaders', () => {
  654. const header = (tool: string): string => JSON.stringify({
  655. type: 'request/header', seq: 0, time: 9, data: { header: { config: { model: 'm' }, tools: [{ name: tool }] }, reason: 'initial' },
  656. })
  657. it('extracts every request/header payload in log order, normalized', () => {
  658. const id = '11111111-2222-4333-8444-555555555555'
  659. const log = `${JSON.stringify({ type: 'session', id, createdAt: 5, cwd: '/w' })}\n${header('one')}\n`
  660. + `${JSON.stringify({ type: 'turn/start', seq: 1, time: 9, data: { turn: 1 } })}\n${header('two')}\n`
  661. const headers = normalizedHeaders(log, { sessionIds: [id], cwd: '/w' })
  662. expect(headers).toEqual([
  663. { config: { model: 'm' }, tools: [{ name: 'one' }] },
  664. { config: { model: 'm' }, tools: [{ name: 'two' }] },
  665. ])
  666. })
  667. it('yields nothing for a log without header events', () => {
  668. const log = `${JSON.stringify({ type: 'session', id: 'a', createdAt: 5 })}\n`
  669. expect(normalizedHeaders(log, { sessionIds: [], cwd: '/w' })).toEqual([])
  670. })
  671. })
  672. describe('normalizedSystemPrompts', () => {
  673. const systemMessage = (seq: number, data: unknown): string => JSON.stringify({ type: 'system/message', seq, time: 9, data })
  674. it('extracts every system/message text in log order, normalized, and reads empty content as no prompt', () => {
  675. const log = [
  676. '{"type":"session","id":"a","createdAt":5,"cwd":"/w"}',
  677. systemMessage(0, { turn: 1, step: 1, message: { role: 'system', content: [{ type: 'text', text: 'work in /w' }] } }),
  678. '{"type":"request/header","seq":1,"time":9,"data":{"header":{"tools":[]},"reason":"initial"}}',
  679. systemMessage(2, { turn: 1, step: 2, message: { role: 'system', content: [] } }),
  680. systemMessage(3, { turn: 1, step: 3, message: { role: 'system', content: [{ type: 'text', text: 'replaced' }] } }),
  681. '',
  682. ].join('\n')
  683. expect(normalizedSystemPrompts(log, { sessionIds: [], cwd: '/w' })).toEqual(['work in {{cwd}}', '', 'replaced'])
  684. })
  685. it('omits system/message records without a text block', () => {
  686. const log = [
  687. '{"type":"session","id":"a","createdAt":5,"cwd":"/w"}',
  688. systemMessage(0, { turn: 1, step: 1 }),
  689. systemMessage(1, { turn: 1, step: 1, message: { role: 'system', content: 'text' } }),
  690. systemMessage(2, { turn: 1, step: 1, message: { role: 'system', content: [{ type: 'image', data: 'x' }] } }),
  691. systemMessage(3, { turn: 1, step: 1, message: { role: 'system', content: [{ type: 'text', text: 'kept' }] } }),
  692. '',
  693. ].join('\n')
  694. expect(normalizedSystemPrompts(log, { sessionIds: [], cwd: '/w' })).toEqual(['kept'])
  695. })
  696. })
  697. describe('systemPromptPrecedesRequests', () => {
  698. const session = '{"type":"session","id":"a","createdAt":5,"cwd":"/w"}'
  699. const system = '{"type":"system/message","seq":0,"time":9,"data":{"turn":1,"step":1,"message":{"role":"system","content":[]}}}'
  700. const header = '{"type":"request/header","seq":1,"time":9,"data":{"header":{"tools":[]},"reason":"initial"}}'
  701. it('accepts a log whose first request/header follows a system/message', () => {
  702. expect(systemPromptPrecedesRequests(`${session}\n${system}\n${header}\n${header}\n`)).toBe(true)
  703. })
  704. it('accepts a log without any request/header', () => {
  705. expect(systemPromptPrecedesRequests(`${session}\n`)).toBe(true)
  706. expect(systemPromptPrecedesRequests(`${session}\n${system}\n`)).toBe(true)
  707. })
  708. it('rejects a request/header with no preceding system/message', () => {
  709. expect(systemPromptPrecedesRequests(`${session}\n${header}\n`)).toBe(false)
  710. expect(systemPromptPrecedesRequests(`${session}\n${header}\n${system}\n`)).toBe(false)
  711. })
  712. })
  713. describe('normalizedToolSchemas', () => {
  714. it('extracts normalized schema arrays and omits absent or non-array fields', () => {
  715. const log = [
  716. '{"type":"session","id":"a","createdAt":5,"cwd":"/w"}',
  717. '{"type":"request/header","seq":0,"time":9,"data":{"header":{"tools":[{"name":"read","description":"work in /w"}]}}}',
  718. '{"type":"request/header","seq":1,"time":9,"data":{"header":{}}}',
  719. '{"type":"request/header","seq":2,"time":9,"data":{"header":{"tools":null}}}',
  720. '{"type":"request/header","seq":3,"time":9,"data":{"header":null}}',
  721. '{"type":"request/header","seq":4,"time":9,"data":{"header":"invalid"}}',
  722. '',
  723. ].join('\n')
  724. expect(normalizedToolSchemas(log, { sessionIds: [], cwd: '/w' })).toEqual([
  725. [{ name: 'read', description: 'work in {{cwd}}' }],
  726. ])
  727. })
  728. })
  729. describe('formatSystemPromptSnapshot', () => {
  730. it('adds a missing terminal newline without changing an existing one', () => {
  731. expect(formatSystemPromptSnapshot('prompt')).toBe('prompt\n')
  732. expect(formatSystemPromptSnapshot('prompt\n')).toBe('prompt\n')
  733. })
  734. it('renders readable changed-prompt sections', () => {
  735. expect(formatSystemPromptSnapshot('prompt', ['new\nlines']))
  736. .toBe('prompt\n\n<!-- system/message change 1 -->\n\nnew\nlines\n')
  737. })
  738. it('does not double the newline of a changed prompt', () => {
  739. expect(formatSystemPromptSnapshot('prompt\n', ['changed\n']))
  740. .toBe('prompt\n\n<!-- system/message change 1 -->\n\nchanged\n')
  741. })
  742. })
  743. describe('parseSystemPromptSnapshot', () => {
  744. it('splits a formatted sidecar back into its initial prompt and each replacement', () => {
  745. const snapshot = formatSystemPromptSnapshot('initial\n', ['first change', 'second\nchange\n'])
  746. expect(parseSystemPromptSnapshot(snapshot)).toEqual({
  747. initial: 'initial\n',
  748. changes: ['first change\n', 'second\nchange\n'],
  749. })
  750. })
  751. it('reads a single-prompt sidecar as having no changes', () => {
  752. expect(parseSystemPromptSnapshot('prompt\n')).toEqual({ initial: 'prompt\n', changes: [] })
  753. })
  754. })
  755. describe('assertChildSystemPromptSnapshot', () => {
  756. const label = 'plain-turn/system-prompt.1.expected.md'
  757. it('accepts a distinct non-empty canonical child prompt', () => {
  758. expect(() => {
  759. assertChildSystemPromptSnapshot('SYS PROMPT\n\nCHILD GUIDANCE\n', 'SYS PROMPT\n', label)
  760. }).not.toThrow()
  761. })
  762. it('rejects an empty or non-canonical child prompt', () => {
  763. expect(() => { assertChildSystemPromptSnapshot('\n', 'SYS PROMPT\n', label) }).toThrow(/non-empty prompt/)
  764. expect(() => {
  765. assertChildSystemPromptSnapshot('CHILD GUIDANCE', 'SYS PROMPT\n', label)
  766. }).toThrow(/end in a newline/)
  767. })
  768. it('rejects a child prompt that duplicates its class pin', () => {
  769. const classSnapshot = readFileSync(join(REPLAY_DIR, 'pin-turn', 'system-prompt.expected.md'), 'utf8')
  770. const parsed = parseSystemPromptSnapshot(classSnapshot)
  771. expect(parsed.changes).toHaveLength(1)
  772. const initialClassPin = parsed.initial
  773. expect(() => {
  774. assertChildSystemPromptSnapshot(initialClassPin, initialClassPin, label)
  775. }).toThrow(/must differ from its class pin/)
  776. })
  777. })
  778. describe('headerChangeCount', () => {
  779. it('counts changed request headers, ignoring anchors, blanks, and other lines', () => {
  780. const change = JSON.stringify({ type: 'request/header', seq: 2, time: 9, data: { reason: 'change' } })
  781. const anchor = JSON.stringify({ type: 'request/header', seq: 0, time: 9, data: { reason: 'initial' } })
  782. const other = JSON.stringify({ type: 'turn/start', seq: 1, time: 9, data: {} })
  783. expect(headerChangeCount(`${anchor}\n${other}\n\n${change}\n${change}\n`)).toBe(2)
  784. expect(headerChangeCount(`${anchor}\n`)).toBe(0)
  785. })
  786. })
  787. describe('tool-schema snapshots', () => {
  788. const snapshot = {
  789. initial: [{ name: 'read', description: 'Read a file.' }],
  790. changes: [[{ name: 'grep', description: 'Search files.' }]],
  791. }
  792. it('formats and parses canonical structured JSON', () => {
  793. const formatted = formatToolSchemasSnapshot(snapshot.initial, snapshot.changes)
  794. expect(formatted).toBe(`${JSON.stringify(snapshot, null, 2)}\n`)
  795. expect(parseToolSchemasSnapshot(formatted)).toEqual(snapshot)
  796. })
  797. it('rejects invalid top-level and field shapes', () => {
  798. expect(() => parseToolSchemasSnapshot('null')).toThrow(/must be an object/)
  799. expect(() => parseToolSchemasSnapshot('"invalid"')).toThrow(/must be an object/)
  800. expect(() => parseToolSchemasSnapshot('[]')).toThrow(/must be an object/)
  801. expect(() => parseToolSchemasSnapshot('{"initial":{},"changes":[]}')).toThrow(/array-valued/)
  802. expect(() => parseToolSchemasSnapshot('{"initial":[],"changes":{}}')).toThrow(/array-valued/)
  803. expect(() => parseToolSchemasSnapshot('{"initial":[],"changes":[{}]}')).toThrow(/array-valued/)
  804. })
  805. it('restores initial schemas into the pinned header token', () => {
  806. expect(restorePinnedToolSchemas({ system: '{{system}}', tools: '{{tools}}' }, snapshot.initial))
  807. .toEqual({ system: '{{system}}', tools: snapshot.initial })
  808. })
  809. it('rejects invalid headers and a missing tool token', () => {
  810. expect(() => restorePinnedToolSchemas(null, snapshot.initial)).toThrow(/must be an object/)
  811. expect(() => restorePinnedToolSchemas('invalid', snapshot.initial)).toThrow(/must be an object/)
  812. expect(() => restorePinnedToolSchemas([], snapshot.initial)).toThrow(/must be an object/)
  813. expect(() => restorePinnedToolSchemas({ tools: [] }, snapshot.initial)).toThrow(/must equal/)
  814. })
  815. })
  816. describe('unknownToolCallIds', () => {
  817. it('returns structured UNKNOWN_TOOL call ids and ignores other results', () => {
  818. const log = [
  819. '{"type":"tool/result","data":{"message":{"source":{"kind":"tool","callId":"missing"}},"error":{"code":"UNKNOWN_TOOL"}}}',
  820. '{"type":"tool/result","data":{"message":{"source":{"kind":"tool","callId":"failed"}},"error":{"code":"EXECUTION_FAILED"}}}',
  821. '{"type":"tool/result","data":null}',
  822. '{"type":"tool/result","data":"invalid"}',
  823. '{"type":"tool/result","data":{"error":null}}',
  824. '{"type":"tool/result","data":{"error":"invalid"}}',
  825. '{"type":"assistant/message","data":{"error":{"code":"UNKNOWN_TOOL"}}}',
  826. '{"type":"tool/result","data":{"error":{"code":"UNKNOWN_TOOL"}}}',
  827. '',
  828. ].join('\n')
  829. expect(unknownToolCallIds(log)).toEqual(['missing', '<missing callId>'])
  830. })
  831. it('returns no failures for ordinary tool results', () => {
  832. expect(unknownToolCallIds('{"type":"tool/result","data":{"message":{"source":{"kind":"tool","callId":"ok"}}}}\n')).toEqual([])
  833. })
  834. })
  835. describe('stabilizeFixtureMessageIds', () => {
  836. it('reuses one committed message UUID across fixture-ready parent and child logs', () => {
  837. const freshId = '11111111-1111-4111-8111-111111111111'
  838. const existingId = '22222222-2222-4222-8222-222222222222'
  839. const log = (session: string, id: string): string => [
  840. JSON.stringify({ type: 'session', id: session, cwd: '{{cwd}}' }),
  841. JSON.stringify({
  842. type: 'user/message',
  843. data: { role: 'user', content: [{ type: 'text', text: 'same' }], source: { kind: 'user' }, id },
  844. }),
  845. '',
  846. ].join('\n')
  847. const fresh = [log('fresh-parent', freshId), log('fresh-child', freshId)]
  848. const existing = [log('old-parent', existingId), log('old-child', existingId)]
  849. const stable = stabilizeFixtureMessageIds(fresh, existing)
  850. expect(stable).toHaveLength(2)
  851. for (const fixture of stable) {
  852. expect(fixture).toContain(`"id":"${existingId}"`)
  853. expect(fixture).not.toContain(freshId)
  854. }
  855. })
  856. it('rewrites only complete messages carried by surface events or durable inbox splices', () => {
  857. const ids = {
  858. freshUser: '11111111-1111-4111-8111-111111111111',
  859. oldUser: '22222222-2222-4222-8222-222222222222',
  860. freshAssistant: '33333333-3333-4333-8333-333333333333',
  861. oldAssistant: '44444444-4444-4444-8444-444444444444',
  862. freshTool: '55555555-5555-4555-8555-555555555555',
  863. oldTool: '66666666-6666-4666-8666-666666666666',
  864. oldMalformed: '77777777-7777-4777-8777-777777777777',
  865. } as const
  866. const message = (id: string, role: string, text: string): Record<string, unknown> => ({
  867. id,
  868. role,
  869. content: [{ type: 'text', text }],
  870. source: { kind: role === 'user' ? 'user' : 'model' },
  871. })
  872. const log = (userId: string, assistantId: string, toolId: string, malformedId: string): string => [
  873. JSON.stringify({ type: 'session', id: 'same', cwd: '{{cwd}}' }),
  874. JSON.stringify({
  875. type: 'agent/inbox/spliced',
  876. data: {
  877. inserted: [
  878. message(userId, 'user', 'user'),
  879. { ...message(userId, 'user', 'malformed inbox'), source: null },
  880. ],
  881. },
  882. }),
  883. JSON.stringify({ type: 'user/message', data: message(userId, 'user', 'user') }),
  884. JSON.stringify({ type: 'assistant/message', data: { message: message(assistantId, 'assistant', 'assistant') } }),
  885. JSON.stringify({ type: 'tool/result', data: { message: message(toolId, 'tool', 'tool') } }),
  886. JSON.stringify({ type: 'turn/start', data: { id: userId } }),
  887. JSON.stringify({ type: 'steering/message', data: message(userId, 'user', 'obsolete') }),
  888. JSON.stringify({ type: 'user/message', data: { ...message(userId, 'user', 'malformed'), source: null } }),
  889. JSON.stringify({ type: 'user/message', data: message(malformedId, 'user', 'non-UUID') }),
  890. JSON.stringify({ type: 'assistant/message', data: null }),
  891. JSON.stringify({ type: 42, data: message(userId, 'user', 'non-string type') }),
  892. '',
  893. ].join('\n')
  894. const stable = stabilizeFixtureMessageIds(
  895. [log(ids.freshUser, ids.freshAssistant, ids.freshTool, 'not-a-uuid')],
  896. [log(ids.oldUser, ids.oldAssistant, ids.oldTool, ids.oldMalformed)],
  897. )[0] as string
  898. const records = stable.trim().split('\n').map(line => JSON.parse(line) as Record<string, unknown>)
  899. const inserted = ((records[1]?.data as { inserted: Array<{ id: string }> }).inserted)
  900. expect(inserted[0]?.id).toBe(ids.oldUser)
  901. expect(inserted[1]?.id).toBe(ids.freshUser)
  902. expect((records[2]?.data as { id: string }).id).toBe(ids.oldUser)
  903. expect((records[3]?.data as { message: { id: string } }).message.id).toBe(ids.oldAssistant)
  904. expect((records[4]?.data as { message: { id: string } }).message.id).toBe(ids.oldTool)
  905. expect((records[5]?.data as { id: string }).id).toBe(ids.freshUser)
  906. expect((records[6]?.data as { id: string }).id).toBe(ids.freshUser)
  907. expect((records[7]?.data as { id: string }).id).toBe(ids.freshUser)
  908. expect((records[8]?.data as { id: string }).id).toBe('not-a-uuid')
  909. })
  910. it('matches cwd-bearing messages only after the fresh log reaches fixture-ready form', () => {
  911. const freshId = '11111111-1111-4111-8111-111111111111'
  912. const existingId = '22222222-2222-4222-8222-222222222222'
  913. const freshCwd = '/tmp/acp-snapshot-fresh-cwd'
  914. const message = (id: string, path: string): Record<string, unknown> => ({
  915. type: 'user/message',
  916. data: {
  917. id,
  918. role: 'user',
  919. content: [{ type: 'text', text: `read ${path}/input.txt` }],
  920. source: { kind: 'user' },
  921. },
  922. })
  923. const fresh = tokenizeSessionFixtureCwd([
  924. JSON.stringify({ type: 'session', id: 'fresh', cwd: freshCwd }),
  925. JSON.stringify(message(freshId, freshCwd)),
  926. '',
  927. ].join('\n'))
  928. const existing = [
  929. JSON.stringify({ type: 'session', id: 'old', cwd: '{{cwd}}' }),
  930. JSON.stringify(message(existingId, '{{cwd}}')),
  931. '',
  932. ].join('\n')
  933. expect(stabilizeFixtureMessageIds([fresh], [existing])[0]).toContain(`"id":"${existingId}"`)
  934. })
  935. it('rejects a fingerprint connected to an id that also identifies different content', () => {
  936. const freshId = '11111111-1111-4111-8111-111111111111'
  937. const conflictingId = '22222222-2222-4222-8222-222222222222'
  938. const competingId = '33333333-3333-4333-8333-333333333333'
  939. const message = (id: string, text: string): string => JSON.stringify({
  940. type: 'user/message',
  941. data: { id, role: 'user', content: [{ type: 'text', text }], source: { kind: 'user' } },
  942. })
  943. const fresh = `${message(freshId, 'shared')}\n`
  944. const existing = [
  945. message(conflictingId, 'shared'),
  946. message(conflictingId, 'different'),
  947. message(competingId, 'shared'),
  948. '',
  949. ].join('\n')
  950. expect(stabilizeFixtureMessageIds([fresh], [existing])).toEqual([fresh])
  951. })
  952. it('leaves fresh fixtures unchanged when no committed counterpart exists', () => {
  953. const fresh = '{"type":"session","id":"new"}\n'
  954. expect(stabilizeFixtureMessageIds([fresh], [''])).toEqual([fresh])
  955. })
  956. })
  957. describe('refreshFixtureReplacements', () => {
  958. it('maps fresh ids and cwd values to the existing fixture values, skipping non-replacements', () => {
  959. const log = (content: string): HarvestedLog => ({ id: 'diagnostic', createdAt: 1, content })
  960. const logs = [
  961. log('{"type":"session","id":"","cwd":"/same"}\n'),
  962. log('{"type":"session","id":"new-parent","cwd":"/new"}\n'),
  963. log('{"type":"session","id":"new-child","cwd":"/new"}\n'),
  964. ]
  965. const fixtures = [
  966. '{"type":"session","id":"","cwd":"/same"}\n',
  967. '{"type":"session","id":"old-parent","cwd":"/old"}\n',
  968. ]
  969. expect(refreshFixtureReplacements(logs, fixtures)).toEqual([
  970. { from: 'new-parent', to: 'old-parent' },
  971. { from: '/new', to: '/old' },
  972. ])
  973. })
  974. it('stabilizes moved snapshot spill paths by filename while skipping unchanged or unmatched names', () => {
  975. const spill = (session: string, hash: string, name: string): string =>
  976. `/tmp/dsh-acp-snapshot-spill/session-${session}/${hash}-${name}`
  977. const record = (text: string): string =>
  978. `${JSON.stringify({ type: 'session', id: 'same', cwd: '/same' })}\n`
  979. + `${JSON.stringify({ type: 'tool/result', data: { content: [{ type: 'text', text: `stored at: ${text} ` }] } })}\n`
  980. const freshBash = spill('aaaaaaaaaaaa', 'bbbbbbbbbbbb', 'bash.txt')
  981. const oldBash = spill('cccccccccccc', 'dddddddddddd', 'bash.txt')
  982. const shared = spill('eeeeeeeeeeee', 'ffffffffffff', 'grep.txt')
  983. const orphan = spill('111111111111', '222222222222', 'orphan.txt')
  984. const logs: HarvestedLog[] = [{
  985. id: 'diagnostic',
  986. createdAt: 1,
  987. content: record(`${freshBash} and ${shared}`),
  988. }]
  989. const fixtures = [record(`${oldBash} and ${shared} and ${orphan}`)]
  990. // bash.txt moved → replaced; grep.txt is identical and orphan.txt has no
  991. // fresh counterpart → both skipped.
  992. expect(refreshFixtureReplacements(logs, fixtures)).toEqual([
  993. { from: freshBash, to: oldBash },
  994. ])
  995. })
  996. it('leaves complete message ids out of the literal refresh replacement list', () => {
  997. const freshMessageId = '11111111-1111-4111-8111-111111111111'
  998. const existingMessageId = '22222222-2222-4222-8222-222222222222'
  999. const log = (sessionId: string, messageId: string): string => [
  1000. JSON.stringify({ type: 'session', id: sessionId, cwd: '/same' }),
  1001. JSON.stringify({
  1002. type: 'user/message',
  1003. data: {
  1004. id: messageId,
  1005. role: 'user',
  1006. content: [{ type: 'text', text: 'same' }],
  1007. source: { kind: 'user' },
  1008. },
  1009. }),
  1010. '',
  1011. ].join('\n')
  1012. const replacements = refreshFixtureReplacements(
  1013. [{ id: 'diagnostic', createdAt: 1, content: log('fresh', freshMessageId) }],
  1014. [log('old', existingMessageId)],
  1015. )
  1016. expect(replacements).toEqual([{ from: 'fresh', to: 'old' }])
  1017. })
  1018. })
  1019. describe('stabilizeRefreshLog', () => {
  1020. it('preserves unpacked member times when refresh first packs a chunk run', () => {
  1021. const fresh = [
  1022. '{"type":"session","id":"same","createdAt":200}',
  1023. '{"type":"reasoning-chunks","seq0":2,"time0":200,"data":{"turn":1,"step":1,"index":0,"dt":[5,7],"texts":["new",""," split"]}}',
  1024. '{"type":"assistant/message","seq":5,"time":220,"data":{}}',
  1025. '',
  1026. ].join('\n')
  1027. const existing = [
  1028. '{"type":"session","id":"same","createdAt":100}',
  1029. '{"type":"assistant/chunk","seq":2,"time":100,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"old"}}}',
  1030. '{"type":"assistant/chunk","seq":3,"time":101,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"chunk"}}}',
  1031. '{"type":"assistant/chunk","seq":4,"time":103,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"shape"}}}',
  1032. '{"type":"assistant/message","seq":5,"time":104,"data":{}}',
  1033. '',
  1034. ].join('\n')
  1035. expect(stabilize(fresh, existing)).toBe([
  1036. '{"type":"session","id":"same","createdAt":100}',
  1037. '{"type":"reasoning-chunks","seq0":2,"time0":100,"data":{"turn":1,"step":1,"index":0,"dt":[1,2],"texts":["new",""," split"]}}',
  1038. '{"type":"assistant/message","seq":5,"time":104,"data":{}}',
  1039. '',
  1040. ].join('\n'))
  1041. })
  1042. it('preserves packed member times without flattening fresh chunk boundaries', () => {
  1043. const fresh = [
  1044. '{"type":"session","id":"same","createdAt":200}',
  1045. '{"type":"text-chunks","seq0":2,"time0":200,"data":{"turn":1,"step":1,"index":0,"dt":[5,7],"texts":["new",""," split"]}}',
  1046. '',
  1047. ].join('\n')
  1048. const existing = [
  1049. '{"type":"session","id":"same","createdAt":100}',
  1050. '{"type":"text-chunks","seq0":2,"time0":100,"data":{"turn":1,"step":1,"index":0,"dt":[1,2],"texts":["old","chunk","shape"]}}',
  1051. '',
  1052. ].join('\n')
  1053. expect(stabilize(fresh, existing)).toBe([
  1054. '{"type":"session","id":"same","createdAt":100}',
  1055. '{"type":"text-chunks","seq0":2,"time0":100,"data":{"turn":1,"step":1,"index":0,"dt":[1,2],"texts":["new",""," split"]}}',
  1056. '',
  1057. ].join('\n'))
  1058. })
  1059. it('reads projected packed fixtures while retaining their data gaps', () => {
  1060. const fresh = [
  1061. '{"type":"session","id":"same","createdAt":200}',
  1062. '{"type":"text-chunks","seq0":2,"time0":200,"data":{"turn":1,"step":1,"index":0,"dt":[5,7],"texts":["new",""," split"]}}',
  1063. '',
  1064. ].join('\n')
  1065. const existing = [
  1066. '{"type":"session","id":"same","createdAt":100}',
  1067. '{"type":"text-chunks","data":{"turn":1,"step":1,"index":0,"dt":[1,2],"texts":["old","chunk","shape"]}}',
  1068. '',
  1069. ].join('\n')
  1070. expect(stabilize(fresh, existing)).toBe([
  1071. '{"type":"session","id":"same","createdAt":100}',
  1072. '{"type":"text-chunks","seq0":2,"time0":0,"data":{"turn":1,"step":1,"index":0,"dt":[1,2],"texts":["new",""," split"]}}',
  1073. '',
  1074. ].join('\n'))
  1075. })
  1076. it.each([
  1077. ['the old run is absent', [], 200],
  1078. ['the old run is shorter', [100, 101], 100],
  1079. ['a later old time is invalid', [100, 'invalid', 103], 100],
  1080. ['an old gap is not exactly representable', [Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER - 1, Number.MAX_SAFE_INTEGER - 1], Number.MIN_SAFE_INTEGER],
  1081. ])('keeps fresh packed gaps when %s', (_case, existingTimes, expectedTime0) => {
  1082. const freshRow = {
  1083. type: 'reasoning-chunks',
  1084. seq0: 2,
  1085. time0: 200,
  1086. data: { turn: 1, step: 1, index: 0, dt: [5, 7], texts: ['new', '', ' split'] },
  1087. }
  1088. const existingRows = existingTimes.map((time, index) => ({
  1089. type: 'assistant/chunk',
  1090. seq: index + 2,
  1091. time,
  1092. data: {},
  1093. }))
  1094. const output = stabilize(
  1095. `${JSON.stringify({ type: 'session', id: 'same', createdAt: 200 })}\n${JSON.stringify(freshRow)}\n`,
  1096. `${JSON.stringify({ type: 'session', id: 'same', createdAt: 100 })}\n${existingRows.map(row => JSON.stringify(row)).join('\n')}\n`,
  1097. ).trim().split('\n').map(line => JSON.parse(line) as Record<string, unknown>)
  1098. expect(output[1]).toStrictEqual({ ...freshRow, time0: expectedTime0 })
  1099. })
  1100. it('aligns volatile times across a newly inserted log event', () => {
  1101. const fresh = [
  1102. '{"type":"session","id":"same","createdAt":200}',
  1103. '{"type":"turn/start","seq":0,"time":21}',
  1104. '{"type":"user/message","seq":1,"time":22}',
  1105. '{"type":"session/title","seq":2,"time":999}',
  1106. '{"type":"step/start","seq":3,"time":1000}',
  1107. '{"type":"request/header","seq":4,"time":1001}',
  1108. '',
  1109. ].join('\n')
  1110. const existing = [
  1111. '{"type":"session","id":"same","createdAt":100}',
  1112. '{"type":"turn/start","seq":0,"time":11}',
  1113. '{"type":"user/message","seq":1,"time":12}',
  1114. '{"type":"step/start","seq":2,"time":13}',
  1115. '{"type":"request/header","seq":3,"time":14}',
  1116. '',
  1117. ].join('\n')
  1118. expect(stabilize(fresh, existing)).toBe([
  1119. '{"type":"session","id":"same","createdAt":100}',
  1120. '{"type":"turn/start","seq":0,"time":11}',
  1121. '{"type":"user/message","seq":1,"time":12}',
  1122. '{"type":"session/title","seq":2,"time":12}',
  1123. '{"type":"step/start","seq":3,"time":13}',
  1124. '{"type":"request/header","seq":4,"time":14}',
  1125. '',
  1126. ].join('\n'))
  1127. })
  1128. it('retains unchanged message ids across an unrelated inserted event', () => {
  1129. const freshUserId = '11111111-1111-4111-8111-111111111111'
  1130. const existingUserId = '22222222-2222-4222-8222-222222222222'
  1131. const freshAssistantId = '33333333-3333-4333-8333-333333333333'
  1132. const existingAssistantId = '44444444-4444-4444-8444-444444444444'
  1133. const user = (id: string): Record<string, unknown> => ({
  1134. type: 'user/message',
  1135. data: { role: 'user', content: [{ type: 'text', text: 'same user' }], source: { kind: 'user' }, id },
  1136. })
  1137. const assistant = (id: string): Record<string, unknown> => ({
  1138. type: 'assistant/message',
  1139. data: {
  1140. turn: 1,
  1141. step: 1,
  1142. message: {
  1143. role: 'assistant',
  1144. content: [{ type: 'text', text: 'same assistant' }],
  1145. source: { kind: 'model', provider: 'fake', model: 'fake' },
  1146. id,
  1147. },
  1148. },
  1149. })
  1150. const lines = (records: Record<string, unknown>[]): string => [
  1151. JSON.stringify({ type: 'session', id: 'same', createdAt: 1, cwd: '/same' }),
  1152. ...records.map(record => JSON.stringify(record)),
  1153. '',
  1154. ].join('\n')
  1155. const fresh = lines([
  1156. user(freshUserId),
  1157. { type: 'session/inherited', data: {} },
  1158. assistant(freshAssistantId),
  1159. ])
  1160. const existing = lines([user(existingUserId), assistant(existingAssistantId)])
  1161. const replacements = refreshFixtureReplacements(
  1162. [{ id: 'diagnostic', createdAt: 1, content: fresh }],
  1163. [existing],
  1164. )
  1165. const refreshed = stabilize(fresh, existing, replacements)
  1166. const intermediate = refreshed.trim().split('\n')
  1167. .map(line => JSON.parse(line) as Record<string, unknown>)
  1168. expect((intermediate[1]?.data as { id: string }).id).toBe(freshUserId)
  1169. expect(((intermediate[3]?.data as { message: { id: string } }).message).id).toBe(freshAssistantId)
  1170. const output = (stabilizeFixtureMessageIds([refreshed], [existing])[0] as string).trim().split('\n')
  1171. .map(line => JSON.parse(line) as Record<string, unknown>)
  1172. expect((output[1]?.data as { id: string }).id).toBe(existingUserId)
  1173. expect(((output[3]?.data as { message: { id: string } }).message).id).toBe(existingAssistantId)
  1174. })
  1175. it('leaves an aligned complete message id to the fixture-ready structural pass', () => {
  1176. const freshId = '11111111-1111-4111-8111-111111111111'
  1177. const existingId = '22222222-2222-4222-8222-222222222222'
  1178. const log = (id: string): string => [
  1179. JSON.stringify({ type: 'session', id: 'same', createdAt: 1, cwd: '/same' }),
  1180. JSON.stringify({
  1181. type: 'user/message',
  1182. data: { id, role: 'user', content: [{ type: 'text', text: 'same' }], source: { kind: 'user' } },
  1183. }),
  1184. '',
  1185. ].join('\n')
  1186. const fresh = log(freshId)
  1187. const existing = log(existingId)
  1188. const refreshed = stabilize(fresh, existing)
  1189. expect(refreshed).toContain(`"id":"${freshId}"`)
  1190. expect(stabilizeFixtureMessageIds([refreshed], [existing])[0]).toContain(`"id":"${existingId}"`)
  1191. })
  1192. it('keeps volatile fixture fields while preserving fresh meaningful payloads', () => {
  1193. const fresh = [
  1194. '{"type":"session","id":"new-child","createdAt":200,"cwd":"/new","parentSession":"new-parent","seedLength":1}',
  1195. '{"type":"hook/result","seq":1,"time":22,"data":{"decision":"block","durationMs":37}}',
  1196. '{"type":"turn/end","seq":2,"time":33,"data":{"error":"fresh error"}}',
  1197. '{"type":"tool/result","seq":3,"time":44,"data":{"text":"new-parent in /new"}}',
  1198. '{"type":"hook/result","seq":4,"time":55,"data":{"decision":"allow","durationMs":5}}',
  1199. '',
  1200. ].join('\n')
  1201. const existing = [
  1202. '{"type":"session","id":"old-child","createdAt":100,"cwd":"/old","parentSession":"old-parent","seedLength":5}',
  1203. '{"type":"hook/result","seq":1,"time":11,"data":{"decision":"stale","durationMs":99}}',
  1204. '{"type":"turn/end","seq":2,"time":12,"data":{"error":"stale"}}',
  1205. '{"type":"assistant/message","seq":3,"time":12,"data":{"text":"different type"}}',
  1206. '{"type":"hook/result","seq":4,"time":13,"data":{"decision":"stale"}}',
  1207. '',
  1208. ].join('\n')
  1209. expect(stabilize(fresh, existing, [
  1210. { from: 'new-parent', to: 'old-parent' },
  1211. { from: 'new-child', to: 'old-child' },
  1212. { from: '/new', to: '/old' },
  1213. ])).toBe([
  1214. '{"type":"session","id":"old-child","createdAt":100,"cwd":"/old","parentSession":"old-parent","seedLength":1}',
  1215. '{"type":"hook/result","seq":1,"time":11,"data":{"decision":"block","durationMs":99}}',
  1216. '{"type":"turn/end","seq":2,"time":12,"data":{"error":"fresh error"}}',
  1217. '{"type":"tool/result","seq":3,"time":44,"data":{"text":"old-parent in /old"}}',
  1218. '{"type":"hook/result","seq":4,"time":13,"data":{"decision":"allow","durationMs":5}}',
  1219. '',
  1220. ].join('\n'))
  1221. })
  1222. it('preserves normalized volatile fields while accepting fresh semantic fields', () => {
  1223. const freshApprovalId = '11111111-1111-4111-8111-111111111111'
  1224. const existingApprovalId = '22222222-2222-4222-8222-222222222222'
  1225. const freshSpill = '/tmp/dsh-acp-snap-012345678/session-111111111111/222222222222-bash.txt'
  1226. const existingSpill = '/tmp/dsh-acp-snap-012345678/session-aaaaaaaaaaaa/bbbbbbbbbbbb-bash.txt'
  1227. const freshEventRead = [
  1228. 'Session main — title',
  1229. 'Target event seq 4:',
  1230. '```json',
  1231. '{',
  1232. ' "time": 1785000000000,',
  1233. ' "data": {}',
  1234. '}',
  1235. '```',
  1236. '',
  1237. `(Omitted 40000 bytes. Full formatted result stored at: ${freshSpill}. Use read with offset/limit, or grep this path to search within it.)`,
  1238. ].join('\n')
  1239. const existingEventRead = freshEventRead
  1240. .replace('1785000000000', '1784000000000')
  1241. .replace('40000 bytes', '30000 bytes')
  1242. .replace(freshSpill, existingSpill)
  1243. const fresh = [
  1244. '{"type":"session","id":"same","createdAt":200,"cwd":"/old"}',
  1245. JSON.stringify({
  1246. type: 'approval/asked',
  1247. seq: 1,
  1248. time: 22,
  1249. data: {
  1250. id: freshApprovalId,
  1251. outcome: 'fresh',
  1252. aliases: [freshApprovalId, 'fresh'],
  1253. resized: [freshApprovalId, 'new'],
  1254. shape: { shared: freshApprovalId, added: true },
  1255. },
  1256. }),
  1257. JSON.stringify({
  1258. type: 'tool/result',
  1259. seq: 2,
  1260. time: 23,
  1261. data: {
  1262. spill: `Full formatted result stored at: ${freshSpill}. Use read with offset/limit, or grep this path to search within it.`,
  1263. path: '/private/old/result.txt',
  1264. eventRead: freshEventRead,
  1265. },
  1266. }),
  1267. '',
  1268. ].join('\n')
  1269. const existing = [
  1270. '{"type":"session","id":"same","createdAt":100,"cwd":"/old"}',
  1271. JSON.stringify({
  1272. type: 'approval/asked',
  1273. seq: 1,
  1274. time: 11,
  1275. data: {
  1276. id: existingApprovalId,
  1277. outcome: 'stale',
  1278. aliases: [existingApprovalId, 'stale'],
  1279. resized: [existingApprovalId],
  1280. shape: { shared: existingApprovalId },
  1281. },
  1282. }),
  1283. JSON.stringify({
  1284. type: 'tool/result',
  1285. seq: 2,
  1286. time: 12,
  1287. data: {
  1288. spill: `Full formatted result stored at: ${existingSpill}. Use read with offset/limit, or grep this path to search within it.`,
  1289. path: '/old/result.txt',
  1290. eventRead: existingEventRead,
  1291. },
  1292. }),
  1293. '',
  1294. ].join('\n')
  1295. const output = stabilize(fresh, existing).trim().split('\n')
  1296. .map(line => JSON.parse(line) as Record<string, unknown>)
  1297. expect(output).toEqual([
  1298. { type: 'session', id: 'same', createdAt: 100, cwd: '/old' },
  1299. {
  1300. type: 'approval/asked',
  1301. seq: 1,
  1302. time: 11,
  1303. data: {
  1304. id: existingApprovalId,
  1305. outcome: 'fresh',
  1306. aliases: [existingApprovalId, 'fresh'],
  1307. resized: [freshApprovalId, 'new'],
  1308. shape: { shared: existingApprovalId, added: true },
  1309. },
  1310. },
  1311. {
  1312. type: 'tool/result',
  1313. seq: 2,
  1314. time: 12,
  1315. data: {
  1316. spill: `Full formatted result stored at: ${existingSpill}. Use read with offset/limit, or grep this path to search within it.`,
  1317. path: '/old/result.txt',
  1318. eventRead: existingEventRead,
  1319. },
  1320. },
  1321. ])
  1322. })
  1323. it('normalizes fresh cwd aliases before reusing existing paths', () => {
  1324. const freshCwd = String.raw`C:\Users\RUNNER~1\AppData\Local\Temp\acp-snap-cwd-new`
  1325. const freshAlias = String.raw`C:\Users\runneradmin\AppData\Local\Temp\acp-snap-cwd-new`
  1326. const existingCwd = String.raw`C:\Users\RUNNER~1\AppData\Local\Temp\acp-snap-cwd-old`
  1327. const fresh = [
  1328. JSON.stringify({ type: 'session', id: 'same', createdAt: 200, cwd: freshCwd }),
  1329. JSON.stringify({ type: 'tool/result', data: { path: `${freshAlias}\\result.txt` } }),
  1330. '',
  1331. ].join('\n')
  1332. const existing = [
  1333. JSON.stringify({ type: 'session', id: 'same', createdAt: 100, cwd: existingCwd }),
  1334. JSON.stringify({ type: 'tool/result', data: { path: `${existingCwd}\\result.txt` } }),
  1335. '',
  1336. ].join('\n')
  1337. const freshContext = { ...fixtureContext(fresh), cwdAliases: [freshAlias] }
  1338. expect(stabilize(
  1339. fresh,
  1340. existing,
  1341. [{ from: freshCwd, to: existingCwd }],
  1342. freshContext,
  1343. )).toBe([
  1344. JSON.stringify({ type: 'session', id: 'same', createdAt: 100, cwd: existingCwd }),
  1345. JSON.stringify({ type: 'tool/result', data: { path: `${existingCwd}\\result.txt` } }),
  1346. '',
  1347. ].join('\n'))
  1348. })
  1349. it('preserves one correlated volatile id through a consistent log-wide mapping', () => {
  1350. const freshId = '11111111-1111-4111-8111-111111111111'
  1351. const existingId = '22222222-2222-4222-8222-222222222222'
  1352. const fresh = [
  1353. '{"type":"session","id":"same","createdAt":200,"cwd":"/old"}',
  1354. JSON.stringify({ type: 'approval/asked', data: { id: freshId } }),
  1355. JSON.stringify({ type: 'approval/decided', data: { id: freshId, outcome: 'allowed-once' } }),
  1356. '',
  1357. ].join('\n')
  1358. const existing = [
  1359. '{"type":"session","id":"same","createdAt":100,"cwd":"/old"}',
  1360. JSON.stringify({ type: 'approval/asked', data: { id: existingId } }),
  1361. JSON.stringify({ type: 'approval/decided', data: { id: existingId, outcome: 'rejected' } }),
  1362. '',
  1363. ].join('\n')
  1364. expect(stabilize(fresh, existing)).toBe([
  1365. '{"type":"session","id":"same","createdAt":100,"cwd":"/old"}',
  1366. JSON.stringify({ type: 'approval/asked', data: { id: existingId } }),
  1367. JSON.stringify({ type: 'approval/decided', data: { id: existingId, outcome: 'allowed-once' } }),
  1368. '',
  1369. ].join('\n'))
  1370. })
  1371. it('keeps fresh correlated ids when record alignment is structurally ambiguous', () => {
  1372. const firstFreshId = '11111111-1111-4111-8111-111111111111'
  1373. const secondFreshId = '22222222-2222-4222-8222-222222222222'
  1374. const existingId = '33333333-3333-4333-8333-333333333333'
  1375. const fresh = [
  1376. '{"type":"session","id":"same","createdAt":200,"cwd":"/old"}',
  1377. JSON.stringify({ type: 'approval/asked', data: { id: firstFreshId } }),
  1378. JSON.stringify({ type: 'approval/asked', data: { id: secondFreshId } }),
  1379. JSON.stringify({ type: 'approval/decided', data: { id: firstFreshId } }),
  1380. JSON.stringify({ type: 'approval/decided', data: { id: secondFreshId } }),
  1381. '',
  1382. ].join('\n')
  1383. const existing = [
  1384. '{"type":"session","id":"same","createdAt":100,"cwd":"/old"}',
  1385. JSON.stringify({ type: 'approval/asked', data: { id: existingId } }),
  1386. JSON.stringify({ type: 'approval/decided', data: { id: existingId } }),
  1387. '',
  1388. ].join('\n')
  1389. const ids = stabilize(fresh, existing).trim().split('\n').slice(1)
  1390. .map(line => (JSON.parse(line) as { data: { id: string } }).data.id)
  1391. expect(ids).toEqual([firstFreshId, secondFreshId, firstFreshId, secondFreshId])
  1392. })
  1393. it('keeps fresh ids when existing records remain unmatched', () => {
  1394. const freshId = '11111111-1111-4111-8111-111111111111'
  1395. const existingId = '22222222-2222-4222-8222-222222222222'
  1396. const fresh = [
  1397. '{"type":"session","id":"same","createdAt":200,"cwd":"/old"}',
  1398. JSON.stringify({ type: 'approval/asked', data: { id: freshId } }),
  1399. '',
  1400. ].join('\n')
  1401. const existing = [
  1402. '{"type":"session","id":"same","createdAt":100,"cwd":"/old"}',
  1403. JSON.stringify({ type: 'approval/asked', data: { id: existingId } }),
  1404. JSON.stringify({ type: 'approval/decided', data: { id: existingId } }),
  1405. '',
  1406. ].join('\n')
  1407. const output = stabilize(fresh, existing).trim().split('\n')
  1408. .map(line => JSON.parse(line) as Record<string, unknown>)
  1409. expect(output[1]).toEqual({ type: 'approval/asked', data: { id: freshId } })
  1410. })
  1411. it.each([
  1412. {
  1413. name: 'one fresh id would map to two existing ids',
  1414. fresh: ['a', 'b', 'b', 'a'],
  1415. existing: ['x', 'y', 'x', 'y'],
  1416. },
  1417. {
  1418. name: 'two fresh ids would map to one existing id',
  1419. fresh: ['a', 'b'],
  1420. existing: ['x', 'x'],
  1421. },
  1422. ])('keeps fresh ids when $name', ({ fresh: freshNames, existing: existingNames }) => {
  1423. const ids = {
  1424. a: '11111111-1111-4111-8111-111111111111',
  1425. b: '22222222-2222-4222-8222-222222222222',
  1426. x: '33333333-3333-4333-8333-333333333333',
  1427. y: '44444444-4444-4444-8444-444444444444',
  1428. } as const
  1429. const types = ['approval/asked', 'approval/asked', 'approval/decided', 'approval/decided']
  1430. const log = (names: string[]): string => [
  1431. '{"type":"session","id":"same","createdAt":100,"cwd":"/old"}',
  1432. ...names.map((name, index) => JSON.stringify({ type: types[index], data: { id: ids[name as keyof typeof ids] } })),
  1433. '',
  1434. ].join('\n')
  1435. const outputIds = stabilize(log(freshNames), log(existingNames)).trim().split('\n').slice(1)
  1436. .map(line => (JSON.parse(line) as { data: { id: string } }).data.id)
  1437. expect(outputIds).toEqual(freshNames.map(name => ids[name as keyof typeof ids]))
  1438. })
  1439. })