normalize.spec.ts 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767
  1. import { describe, expect, it } from 'vitest'
  2. import {
  3. type NormalizeContext,
  4. extractSnapshotSpillPaths,
  5. normalizeSessionLog,
  6. normalizeSessionSnapshot,
  7. normalizeStdout,
  8. scrubRequestHeaders,
  9. scrubSessionSnapshot,
  10. scrubSystemPrompts,
  11. scrubToolSchemas,
  12. tokenizeSessionFixtureCwd,
  13. } from '../src/normalize.ts'
  14. /**
  15. * Unit tests for the pure snapshot normalizers. Live as a *.spec.ts (runs in
  16. * the default unit gate) and import the normalizers directly.
  17. */
  18. const ctx: NormalizeContext = {
  19. sessionIds: ['11111111-2222-3333-4444-555555555555'],
  20. cwd: '/tmp/acp-snap-cwd-abc123',
  21. }
  22. describe('normalizeStdout', () => {
  23. it('rewrites JSON-RPC ids to a stable first-seen sequence', () => {
  24. const raw = [
  25. JSON.stringify({ jsonrpc: '2.0', id: 42, method: 'initialize' }),
  26. JSON.stringify({ jsonrpc: '2.0', id: 42, result: {} }),
  27. JSON.stringify({ jsonrpc: '2.0', id: 99, method: 'session/new' }),
  28. ].join('\n')
  29. const out = normalizeStdout(raw, ctx)
  30. expect(out).toContain('"id":1')
  31. expect(out).toContain('"id":2')
  32. expect(out).not.toContain('42')
  33. expect(out).not.toContain('99')
  34. })
  35. it('scrubs the cwd and session id anywhere they appear', () => {
  36. const raw = JSON.stringify({
  37. jsonrpc: '2.0', method: 'session/update',
  38. params: { sessionId: ctx.sessionIds[0], cwd: ctx.cwd, note: `at ${ctx.cwd}/x` },
  39. })
  40. const out = normalizeStdout(raw, ctx)
  41. expect(out).toContain('{{sessionId}}')
  42. expect(out).toContain('{{cwd}}')
  43. expect(out).not.toContain(ctx.cwd)
  44. expect(out).not.toContain(ctx.sessionIds[0] as string)
  45. })
  46. it('keeps standard message identity distinct from session identity', () => {
  47. const raw = JSON.stringify({
  48. jsonrpc: '2.0',
  49. method: 'session/update',
  50. params: {
  51. sessionId: ctx.sessionIds[0],
  52. update: {
  53. sessionUpdate: 'agent_message_chunk',
  54. messageId: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
  55. content: { type: 'text', text: 'done' },
  56. },
  57. },
  58. })
  59. const out = normalizeStdout(raw, ctx)
  60. expect(out).toContain('"sessionId":"{{sessionId}}"')
  61. expect(out).toContain('"messageId":"{{messageId}}"')
  62. })
  63. it('stabilizes path-dependent context occupancy without hiding capacity', () => {
  64. const raw = JSON.stringify({
  65. jsonrpc: '2.0',
  66. method: 'session/update',
  67. params: {
  68. sessionId: ctx.sessionIds[0],
  69. update: { sessionUpdate: 'usage_update', used: 6_438, size: 1_000_000 },
  70. },
  71. })
  72. const frame = JSON.parse(normalizeStdout(raw, ctx)) as {
  73. params: { update: { used: string; size: number } }
  74. }
  75. expect(frame.params.update).toEqual({
  76. sessionUpdate: 'usage_update',
  77. used: '{{usedTokens}}',
  78. size: 1_000_000,
  79. })
  80. })
  81. it('scrubs cwd at file URI and chained-punctuation boundaries', () => {
  82. const raw = JSON.stringify({
  83. jsonrpc: '2.0',
  84. method: 'session/update',
  85. params: {
  86. uri: `file://${ctx.cwd}/proof.txt`,
  87. punctuated: `${ctx.cwd}.,`,
  88. dottedSegment: `${ctx.cwd}.backup`,
  89. dashedSegment: `${ctx.cwd}-backup`,
  90. },
  91. })
  92. const frame = JSON.parse(normalizeStdout(raw, ctx)) as {
  93. params: Record<string, string>
  94. }
  95. expect(frame.params).toEqual({
  96. uri: 'file://{{cwd}}/proof.txt',
  97. punctuated: '{{cwd}}.,',
  98. dottedSegment: `${ctx.cwd}.backup`,
  99. dashedSegment: `${ctx.cwd}-backup`,
  100. })
  101. })
  102. it('scrubs every filesystem spelling of the cwd longest-first', () => {
  103. const longCwd = String.raw`C:\Users\runneradmin\AppData\Local\Temp\acp-snapshot`
  104. const aliasedCtx: NormalizeContext = {
  105. sessionIds: [],
  106. cwd: String.raw`C:\Users\RUNNER~1\AppData\Local\Temp\acp-snapshot`,
  107. cwdAliases: [
  108. longCwd,
  109. String.raw`C:\Users\runneradmin\AppData\Local\Temp\acp`,
  110. ],
  111. }
  112. const raw = JSON.stringify({
  113. cwd: longCwd,
  114. path: `${longCwd}\\nested\\proof.txt`,
  115. })
  116. const frame = JSON.parse(normalizeStdout(raw, aliasedCtx)) as { cwd: string; path: string }
  117. expect(frame).toEqual({ cwd: '{{cwd}}', path: '{{cwd}}/nested/proof.txt' })
  118. })
  119. it('canonicalizes only cwd-rooted path separators', () => {
  120. const windowsCtx: NormalizeContext = {
  121. sessionIds: [],
  122. cwd: String.raw`C:\Users\runner\AppData\Local\Temp\acp-snapshot`,
  123. }
  124. const raw = JSON.stringify({
  125. jsonrpc: '2.0',
  126. method: 'session/update',
  127. params: {
  128. path: `${windowsCtx.cwd}\\nested\\proof.txt`,
  129. regex: String.raw`\d+\w+`,
  130. command: String.raw`printf "\\n"`,
  131. },
  132. })
  133. const frame = JSON.parse(normalizeStdout(raw, windowsCtx)) as {
  134. params: { path: string; regex: string; command: string }
  135. }
  136. expect(frame.params).toEqual({
  137. path: '{{cwd}}/nested/proof.txt',
  138. regex: String.raw`\d+\w+`,
  139. command: String.raw`printf "\\n"`,
  140. })
  141. })
  142. it('canonicalizes generated relative path fields and text markers without rewriting other text', () => {
  143. const raw = JSON.stringify({
  144. path: String.raw`nested\AGENTS.md`,
  145. content: String.raw`<path>.\nested\task.txt</path>
  146. Additional instructions from: nested\AGENTS.md`,
  147. regex: String.raw`\d+\w+`,
  148. })
  149. const frame = JSON.parse(normalizeStdout(raw, { sessionIds: [], cwd: '/unused' })) as {
  150. path: string
  151. content: string
  152. regex: string
  153. }
  154. expect(frame).toEqual({
  155. path: 'nested/AGENTS.md',
  156. content: '<path>./nested/task.txt</path>\nAdditional instructions from: nested/AGENTS.md',
  157. regex: String.raw`\d+\w+`,
  158. })
  159. })
  160. it('can preserve native cwd-rooted separators for a platform golden', () => {
  161. const windowsCtx: NormalizeContext = { sessionIds: [], cwd: String.raw`C:\work\snapshot` }
  162. const raw = JSON.stringify({ path: `${windowsCtx.cwd}\\nested\\proof.txt` })
  163. const frame = JSON.parse(normalizeStdout(raw, windowsCtx, { cwdPathMode: 'native' })) as { path: string }
  164. expect(frame.path).toBe(String.raw`{{cwd}}\nested\proof.txt`)
  165. })
  166. it('scrubs a stray UUID not in the known list', () => {
  167. const raw = JSON.stringify({ jsonrpc: '2.0', method: 'x', params: { id: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' } })
  168. expect(normalizeStdout(raw, ctx)).toContain('{{sessionId}}')
  169. })
  170. it('leaves notification frames without an id untouched in id-space', () => {
  171. const raw = JSON.stringify({ jsonrpc: '2.0', method: 'session/update', params: {} })
  172. const out = normalizeStdout(raw, ctx)
  173. expect(out).not.toContain('"id"')
  174. })
  175. it('stabilizes only the top-level event timestamp and spill byte count in event-read text', () => {
  176. const raw = JSON.stringify({
  177. jsonrpc: '2.0',
  178. method: 'session/update',
  179. params: {
  180. update: {
  181. sessionUpdate: 'tool_call_update',
  182. content: [{
  183. type: 'content',
  184. content: {
  185. type: 'text',
  186. text: 'Session prior — title\nTarget event seq 4:\n```json\n{\n "seq": 4,\n "time": 1784876275593,\n "data": {\n "time": 31337,\n "note": "model-visible"\n }\n}\n```\n\nAfter:\n "time": 424242,\n neighbor semantic text\n\n(Omitted 39387 bytes. Full formatted result stored at: /tmp/result.txt.)',
  187. },
  188. }],
  189. },
  190. },
  191. })
  192. const out = normalizeStdout(raw, ctx)
  193. expect(out).toContain('\\"time\\": {{eventTime}}')
  194. expect(out).toContain('\\"time\\": 31337')
  195. expect(out).toContain('\\"time\\": 424242')
  196. expect(out).toContain('Omitted {{eventOmittedBytes}} bytes')
  197. expect(out).not.toContain('1784876275593')
  198. expect(out).not.toContain('39387')
  199. })
  200. it('preserves event-like timestamps in unrelated output text', () => {
  201. const raw = JSON.stringify({
  202. jsonrpc: '2.0',
  203. method: 'session/update',
  204. params: {
  205. update: {
  206. sessionUpdate: 'tool_call_update',
  207. content: [{
  208. type: 'content',
  209. content: {
  210. type: 'text',
  211. text: 'bash output:\n```json\n{\n "time": 1784876275593,\n "data": {}\n}\n```\n\n(Omitted 39387 bytes. Full formatted result stored at: /tmp/result.txt.)',
  212. },
  213. }],
  214. },
  215. },
  216. })
  217. const out = normalizeStdout(raw, ctx)
  218. expect(out).toContain('1784876275593')
  219. expect(out).toContain('39387')
  220. expect(out).not.toContain('{{eventTime}}')
  221. expect(out).not.toContain('{{eventOmittedBytes}}')
  222. })
  223. it('throws on a non-JSON stdout line (the purity check)', () => {
  224. const raw = `${JSON.stringify({ jsonrpc: '2.0', id: 1 })}\noops a log leaked\n`
  225. expect(() => normalizeStdout(raw, ctx)).toThrow()
  226. })
  227. it('ignores blank lines', () => {
  228. const raw = `\n${JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'm' })}\n\n`
  229. expect(() => normalizeStdout(raw, ctx)).not.toThrow()
  230. })
  231. })
  232. describe('normalizeSessionLog', () => {
  233. const header = (over: object) => JSON.stringify({ type: 'session', version: 0, id: 's', createdAt: 123, ...over })
  234. const event = (over: object) => JSON.stringify({ type: 'turn/start', seq: 1, time: 999, data: { turn: 1 }, ...over })
  235. it('zeroes the header createdAt', () => {
  236. const out = normalizeSessionLog(`${header({})}\n`, ctx)
  237. expect(out).toContain('"createdAt":0')
  238. expect(out).not.toContain('123')
  239. })
  240. it('preserves event sequence and zeroes event time', () => {
  241. const out = normalizeSessionLog(`${header({})}\n${event({ seq: 7, time: 999 })}\n`, ctx)
  242. expect(out).toContain('"time":0')
  243. expect(out).toContain('"seq":7')
  244. expect(out).not.toContain('999')
  245. })
  246. it('normalizes a projected event without adding a persistence envelope', () => {
  247. const projected = JSON.stringify({ type: 'turn/start', data: { turn: 1 } })
  248. const out = normalizeSessionLog(`${header({})}\n${projected}\n`, ctx)
  249. expect(JSON.parse(out.trimEnd().split('\n')[1] ?? '{}')).toStrictEqual({
  250. type: 'turn/start',
  251. data: { turn: 1 },
  252. })
  253. })
  254. it('scrubs cwd and session id deep inside event data', () => {
  255. const ev = JSON.stringify({
  256. type: 'tool/result', seq: 2, time: 5,
  257. data: { content: [{ type: 'text', text: `wrote ${ctx.cwd}/proof.txt` }] },
  258. })
  259. const out = normalizeSessionLog(`${header({ cwd: ctx.cwd })}\n${ev}\n`, ctx)
  260. expect(out).toContain('{{cwd}}')
  261. expect(out).not.toContain(ctx.cwd)
  262. })
  263. it('scrubs cwd at file URI and chained-punctuation boundaries in event data', () => {
  264. const ev = JSON.stringify({
  265. type: 'tool/result',
  266. seq: 2,
  267. time: 5,
  268. data: {
  269. uri: `file://${ctx.cwd}/proof.txt`,
  270. punctuated: `${ctx.cwd}.,`,
  271. },
  272. })
  273. const out = normalizeSessionLog(`${header({ cwd: ctx.cwd })}\n${ev}\n`, ctx)
  274. expect(out).toContain('file://{{cwd}}/proof.txt')
  275. expect(out).toContain('{{cwd}}.,')
  276. expect(out).not.toContain(`file://${ctx.cwd}`)
  277. })
  278. it('scrubs random local spill paths under the snapshot cwd', () => {
  279. const ev = JSON.stringify({
  280. type: 'tool/result', seq: 2, time: 5,
  281. data: {
  282. content: [{
  283. type: 'text',
  284. text: `Full formatted result stored at: ${ctx.cwd}/.spill/session-c22bc3f1d2af/8a7b6c5d4e3f-bash.txt. Use read with offset/limit, or grep this path to search within it.`,
  285. }],
  286. },
  287. })
  288. const out = normalizeSessionLog(`${header({ cwd: ctx.cwd })}\n${ev}\n`, ctx)
  289. expect(out).toContain('{{spillLocator:bash.txt}}')
  290. expect(out).not.toContain('session-c22bc3f1d2af')
  291. expect(out).not.toContain('8a7b6c5d4e3f')
  292. })
  293. it('scrubs macOS /private aliases for local spill paths', () => {
  294. const ev = JSON.stringify({
  295. type: 'tool/result', seq: 2, time: 5,
  296. data: {
  297. content: [{
  298. type: 'text',
  299. text: `Full formatted result stored at: /private${ctx.cwd}/.spill/session-c22bc3f1d2af/8a7b6c5d4e3f-bash.txt. Use read with offset/limit, or grep this path to search within it.`,
  300. }],
  301. },
  302. })
  303. const out = normalizeSessionLog(`${header({ cwd: ctx.cwd })}\n${ev}\n`, ctx)
  304. expect(out).toContain('{{spillLocator:bash.txt}}')
  305. expect(out).not.toContain('/private{{spillLocator')
  306. })
  307. it('scrubs macOS /private prefix on cwd-rooted fs tool result paths', () => {
  308. const ev = JSON.stringify({
  309. type: 'tool/result', seq: 2, time: 5,
  310. data: {
  311. content: [{
  312. type: 'text',
  313. text: `The file /private${ctx.cwd}/config.txt has been updated successfully.`,
  314. }],
  315. },
  316. })
  317. const out = normalizeSessionLog(`${header({ cwd: ctx.cwd })}\n${ev}\n`, ctx)
  318. expect(out).toContain('{{cwd}}/config.txt')
  319. expect(out).not.toContain('/private{{cwd}}')
  320. })
  321. it('scrubs fixed snapshot spill paths', () => {
  322. const ev = JSON.stringify({
  323. type: 'tool/result', seq: 2, time: 5,
  324. data: {
  325. content: [{
  326. type: 'text',
  327. text: 'Full formatted result stored at: /tmp/dsh-acp-snapshot-spill/session-c22bc3f1d2af/8a7b6c5d4e3f-bash.txt. Use read with offset/limit, or grep this path to search within it.',
  328. }],
  329. },
  330. })
  331. const out = normalizeSessionLog(`${header({ cwd: ctx.cwd })}\n${ev}\n`, ctx)
  332. expect(out).toContain('{{spillLocator:bash.txt}}')
  333. expect(out).not.toContain('/tmp/dsh-acp-snapshot-spill')
  334. })
  335. it('scrubs scenario-owned snapshot spill paths', () => {
  336. const ev = JSON.stringify({
  337. type: 'tool/result', seq: 2, time: 5,
  338. data: {
  339. content: [{
  340. type: 'text',
  341. text: 'Full formatted result stored at: /tmp/dsh-acp-snap-012345678/session-c22bc3f1d2af/8a7b6c5d4e3f-bash.txt. Use read with offset/limit, or grep this path to search within it.',
  342. }],
  343. },
  344. })
  345. const out = normalizeSessionLog(`${header({ cwd: ctx.cwd })}\n${ev}\n`, ctx)
  346. expect(out).toContain('{{spillLocator:bash.txt}}')
  347. expect(out).not.toContain('/tmp/dsh-acp-snap-012345678')
  348. })
  349. it('scrubs scenario-owned snapshot spill paths with Windows drive and separators', () => {
  350. const ev = JSON.stringify({
  351. type: 'tool/result', seq: 2, time: 5,
  352. data: {
  353. content: [{
  354. type: 'text',
  355. text: String.raw`Full formatted result stored at: C:\t\dsh-acp-snap-012345678\session-c22bc3f1d2af\8a7b6c5d4e3f-bash.txt. Use read with offset/limit, or grep this path to search within it.`,
  356. }],
  357. },
  358. })
  359. const out = normalizeSessionLog(`${header({ cwd: ctx.cwd })}\n${ev}\n`, ctx)
  360. expect(out).toContain('{{spillLocator:bash.txt}}')
  361. expect(out).not.toContain('C:\\t\\dsh-acp-snap-012345678')
  362. })
  363. it('shares cwd-rooted path handling with stdout normalization', () => {
  364. const windowsCtx: NormalizeContext = { sessionIds: [], cwd: String.raw`C:\work\snapshot` }
  365. const ev = JSON.stringify({
  366. type: 'tool/result', seq: 2, time: 5,
  367. data: { path: `${windowsCtx.cwd}\\nested\\proof.txt` },
  368. })
  369. expect(normalizeSessionLog(`${header({ cwd: windowsCtx.cwd })}\n${ev}\n`, windowsCtx))
  370. .toContain('{{cwd}}/nested/proof.txt')
  371. expect(normalizeSessionLog(`${header({ cwd: windowsCtx.cwd })}\n${ev}\n`, windowsCtx, { cwdPathMode: 'native' }))
  372. .toContain(String.raw`{{cwd}}\\nested\\proof.txt`)
  373. })
  374. it('scrubs the session id in the header', () => {
  375. const out = normalizeSessionLog(`${header({ id: ctx.sessionIds[0] })}\n`, ctx)
  376. expect(out).toContain('{{sessionId}}')
  377. })
  378. it('zeroes a hook/result durationMs (run-to-run noise) but keeps its decision', () => {
  379. const ev = JSON.stringify({
  380. type: 'hook/result', seq: 2, time: 5,
  381. data: { turn: 1, point: 'UserPromptSubmit', handlerId: 'h', decision: 'block', exitCode: 2, durationMs: 37 },
  382. })
  383. const out = normalizeSessionLog(`${header({})}\n${ev}\n`, ctx)
  384. expect(out).toContain('"durationMs":0')
  385. expect(out).not.toContain('37')
  386. expect(out).toContain('"decision":"block"') // the decision is the behavior — kept
  387. })
  388. it('preserves a packed chunk row\'s sequence, zeroes time, and zeroes volatile dt gaps', () => {
  389. const row = JSON.stringify({
  390. type: 'text-chunks', seq0: 7, time0: 999,
  391. data: { turn: 1, step: 1, index: 0, dt: [212, 27, 0], texts: ['a', 'b', 'c', 'd'] },
  392. })
  393. const out = normalizeSessionLog(`${header({})}\n${row}\n`, ctx)
  394. expect(out).toContain('"time0":0')
  395. expect(out).toContain('"dt":[0,0,0]')
  396. expect(out).toContain('"seq0":7')
  397. expect(out).toContain('"texts":["a","b","c","d"]')
  398. expect(out).not.toContain('999')
  399. expect(out).not.toContain('212')
  400. })
  401. it('normalizes a headerless packed-like stream record without decoding it', () => {
  402. const row = JSON.stringify({ type: 'text-chunks', seq0: 1, time0: 999, data: 'not-an-object' })
  403. const out = normalizeSessionLog(`${row}\n`, ctx)
  404. expect(out).toContain('"seq0":1')
  405. expect(out).toContain('"time0":0')
  406. })
  407. it('leaves a non-hook event durationMs untouched (only hook/result is scrubbed)', () => {
  408. const ev = JSON.stringify({ type: 'tool/result', seq: 2, time: 5, data: { durationMs: 88 } })
  409. const out = normalizeSessionLog(`${header({})}\n${ev}\n`, ctx)
  410. expect(out).toContain('"durationMs":88')
  411. })
  412. it('normalizes goal lifecycle clocks without scrubbing unrelated payload timestamps', () => {
  413. const goal = JSON.stringify({
  414. type: 'goal/change',
  415. seq: 2,
  416. time: 5,
  417. data: { operation: 'create', createdAt: 123, updatedAt: 124 },
  418. })
  419. const tool = JSON.stringify({ type: 'tool/result', seq: 3, time: 6, data: { createdAt: 125 } })
  420. const goalWithoutClocks = JSON.stringify({ type: 'goal/change', seq: 4, time: 7, data: { operation: 'resume' } })
  421. const out = normalizeSessionLog(`${header({})}\n${goal}\n${tool}\n${goalWithoutClocks}\n`, ctx)
  422. expect(out).toContain('"operation":"create","createdAt":0,"updatedAt":0')
  423. expect(out).toContain('"createdAt":125')
  424. expect(out).toContain('"operation":"resume"')
  425. })
  426. it('handles complete envelopes when optional normalized fields are absent', () => {
  427. const bareHeader = JSON.stringify({ type: 'session', id: 's' })
  428. const bareHook = JSON.stringify({ type: 'hook/result', seq: 2, time: 5, data: { decision: 'allow' } })
  429. const nullDataHook = JSON.stringify({ type: 'hook/result', seq: 3, time: 6, data: null })
  430. const out = normalizeSessionLog(`${bareHeader}\n${bareHook}\n${nullDataHook}\n`, ctx)
  431. expect(out).toContain('"decision":"allow"')
  432. expect(out).not.toContain('durationMs')
  433. })
  434. })
  435. describe('normalizeSessionSnapshot', () => {
  436. it('normalizes, scrubs, and projects each parsed body record', () => {
  437. const raw = [
  438. JSON.stringify({ type: 'session', version: 0, createdAt: 123, cwd: ctx.cwd }),
  439. JSON.stringify({
  440. type: 'request/header',
  441. seq: 7,
  442. time: 999,
  443. data: { header: { system: 'volatile', tools: [{ name: 'tool' }] } },
  444. }),
  445. ].join('\n') + '\n'
  446. expect(normalizeSessionSnapshot(raw, ctx)).toBe([
  447. JSON.stringify({ type: 'session', version: 0, createdAt: 0, cwd: '{{cwd}}' }),
  448. JSON.stringify({ type: 'request/header', data: { header: { system: '{{system}}', tools: '{{tools}}' } } }),
  449. ].join('\n') + '\n')
  450. })
  451. it('normalizes an already-projected packed row', () => {
  452. const raw = [
  453. JSON.stringify({ type: 'session', version: 0 }),
  454. JSON.stringify({
  455. type: 'text-chunks',
  456. data: { turn: 1, step: 1, index: 0, dt: [9], texts: ['a', 'b'] },
  457. }),
  458. ].join('\n') + '\n'
  459. expect(normalizeSessionSnapshot(raw, ctx)).toContain('"dt":[0]')
  460. })
  461. it('rejects headerless input', () => {
  462. expect(() => normalizeSessionSnapshot('{"type":"turn/start"}\n', ctx))
  463. .toThrow('session snapshot must start with a session header')
  464. })
  465. })
  466. describe('tokenizeSessionFixtureCwd', () => {
  467. it.each([
  468. {
  469. name: 'macOS',
  470. context: {
  471. sessionIds: [],
  472. cwd: '/var/folders/2g/snapshot/T/acp-snap-cwd-abc123',
  473. cwdAliases: ['/private/var/folders/2g/snapshot/T/acp-snap-cwd-abc123'],
  474. },
  475. reportedCwd: '/private/var/folders/2g/snapshot/T/acp-snap-cwd-abc123',
  476. },
  477. {
  478. name: 'Linux',
  479. context: {
  480. sessionIds: [],
  481. cwd: '/tmp/acp-snap-cwd-abc123',
  482. },
  483. reportedCwd: '/tmp/acp-snap-cwd-abc123',
  484. },
  485. {
  486. name: 'Windows',
  487. context: {
  488. sessionIds: [],
  489. cwd: String.raw`C:\Users\runner\AppData\Local\Temp\acp-snap-cwd-abc123`,
  490. },
  491. reportedCwd: String.raw`C:\Users\runner\AppData\Local\Temp\acp-snap-cwd-abc123`,
  492. },
  493. ])('stores $name temporary workspaces with one portable root token', ({ context, reportedCwd }) => {
  494. const raw = [
  495. JSON.stringify({ type: 'session', id: 's', createdAt: 1, cwd: context.cwd }),
  496. JSON.stringify({
  497. type: 'tool/result',
  498. seq: 1,
  499. time: 2,
  500. data: {
  501. content: [{
  502. type: 'text',
  503. text: `wrote ${reportedCwd}/proof.txt. alias /different/root/acp-snap-cwd-abc123/alias.txt. cwd ${context.cwd}. Next; kept ${context.cwd}-backup, ${context.cwd}.backup, and /tmp/authored.txt`,
  504. }],
  505. },
  506. }),
  507. '',
  508. ].join('\n')
  509. const out = tokenizeSessionFixtureCwd(raw)
  510. const result = JSON.parse(out.split('\n')[1] as string) as {
  511. data: { content: { text: string }[] }
  512. }
  513. const resultText = (result.data.content[0] as { text: string }).text
  514. expect(out).toContain('"cwd":"{{cwd}}"')
  515. expect(resultText).toContain('wrote {{cwd}}/proof.txt')
  516. expect(resultText).toContain('alias {{cwd}}/alias.txt')
  517. expect(resultText).toContain('cwd {{cwd}}. Next')
  518. expect(resultText).toContain(`${context.cwd}-backup`)
  519. expect(resultText).toContain(`${context.cwd}.backup`)
  520. expect(resultText).toContain('/tmp/authored.txt')
  521. expect(resultText).not.toContain(`${reportedCwd}/proof.txt`)
  522. expect(tokenizeSessionFixtureCwd(out)).toBe(out)
  523. })
  524. it('collapses a residual macOS realpath prefix around an existing cwd token', () => {
  525. const raw = [
  526. JSON.stringify({ type: 'session', id: 's', createdAt: 1, cwd: '{{cwd}}' }),
  527. JSON.stringify({
  528. type: 'tool/result',
  529. seq: 1,
  530. time: 2,
  531. data: { content: [{ type: 'text', text: 'wrote /private{{cwd}}/proof.txt' }] },
  532. }),
  533. '',
  534. ].join('\n')
  535. const out = tokenizeSessionFixtureCwd(raw)
  536. expect(out).toContain('wrote {{cwd}}/proof.txt')
  537. expect(out).not.toContain('/private{{cwd}}')
  538. expect(tokenizeSessionFixtureCwd(out)).toBe(out)
  539. })
  540. it('rejects a log without a session cwd', () => {
  541. expect(() => tokenizeSessionFixtureCwd('')).toThrow(
  542. 'acp-snapshot: cannot tokenize a cwd without a basename',
  543. )
  544. })
  545. })
  546. describe('extractSnapshotSpillPaths', () => {
  547. it('maps each spill filename to its full matched path, last match wins per name', () => {
  548. const log = [
  549. 'Full formatted result stored at: /tmp/dsh-acp-snapshot-spill/session-c22bc3f1d2af/8a7b6c5d4e3f-bash.txt. Use read with offset/limit, or grep this path to search within it.',
  550. 'stale copy at /tmp/dsh-acp-snap-012345678/session-aaaaaaaaaaaa/bbbbbbbbbbbb-grep.txt then',
  551. 'fresh copy at /tmp/dsh-acp-snap-012345678/session-cccccccccccc/dddddddddddd-grep.txt then',
  552. ].join('\n')
  553. expect(extractSnapshotSpillPaths(log)).toEqual(new Map([
  554. ['bash.txt', '/tmp/dsh-acp-snapshot-spill/session-c22bc3f1d2af/8a7b6c5d4e3f-bash.txt'],
  555. ['grep.txt', '/tmp/dsh-acp-snap-012345678/session-cccccccccccc/dddddddddddd-grep.txt'],
  556. ]))
  557. })
  558. it('returns an empty map when the log carries no snapshot spill paths', () => {
  559. expect(extractSnapshotSpillPaths('no spill paths here, only /tmp/other.txt\n')).toEqual(new Map())
  560. })
  561. })
  562. describe('scrubRequestHeaders', () => {
  563. const headerLine = JSON.stringify({ type: 'session', version: 0, id: 's', createdAt: 1, cwd: '/w' })
  564. const headerEvent = (header: object) =>
  565. JSON.stringify({ type: 'request/header', seq: 3, time: 9, data: { header, reason: 'initial' } })
  566. it('replaces header system and tools with tokens, keeping config and reason', () => {
  567. const ev = headerEvent({
  568. config: { model: 'm' },
  569. system: 'You are an agent.\nBe brief.',
  570. tools: [{ name: 'read', description: 'Read a file.', parameters: { type: 'object' } }],
  571. })
  572. const out = scrubRequestHeaders(`${headerLine}\n${ev}\n`)
  573. expect(out).toContain('"system":"{{system}}"')
  574. expect(out).toContain('"tools":"{{tools}}"')
  575. expect(out).toContain('"config":{"model":"m"}')
  576. expect(out).toContain('"reason":"initial"')
  577. expect(out).not.toContain('You are an agent')
  578. expect(out).not.toContain('Read a file')
  579. })
  580. it('keeps an absent system/tools absent (presence is behavior)', () => {
  581. const out = scrubRequestHeaders(`${headerLine}\n${headerEvent({ config: { model: 'm' } })}\n`)
  582. expect(out).not.toContain('{{system}}')
  583. expect(out).not.toContain('{{tools}}')
  584. })
  585. it('scrubs a header carrying only one of system/tools, leaving the other absent', () => {
  586. const systemOnly = scrubRequestHeaders(`${headerLine}\n${headerEvent({ system: 'secret prompt' })}\n`)
  587. expect(systemOnly).toContain('"system":"{{system}}"')
  588. expect(systemOnly).not.toContain('{{tools}}')
  589. const toolsOnly = scrubRequestHeaders(`${headerLine}\n${headerEvent({ tools: [{ name: 't' }] })}\n`)
  590. expect(toolsOnly).toContain('"tools":"{{tools}}"')
  591. expect(toolsOnly).not.toContain('{{system}}')
  592. })
  593. it('leaves malformed headers with no scrubbable payload byte-identical', () => {
  594. const headerless = JSON.stringify({ type: 'request/header', seq: 10, time: 9, data: { reason: 'initial' } })
  595. const nullData = JSON.stringify({ type: 'request/header', seq: 11, time: 9, data: null })
  596. const raw = `${headerLine}\n${headerless}\n${nullData}\n`
  597. expect(scrubRequestHeaders(raw)).toBe(raw)
  598. })
  599. it('passes every other line through byte-for-byte and is idempotent', () => {
  600. const other = JSON.stringify({ type: 'assistant/chunk', seq: 4, time: 9, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'hi' } } })
  601. const raw = `${headerLine}\n${headerEvent({ config: { model: 'm' }, system: 's', tools: [] })}\n${other}\n`
  602. const once = scrubRequestHeaders(raw)
  603. expect(once.split('\n')[0]).toBe(headerLine)
  604. expect(once.split('\n')[2]).toBe(other)
  605. expect(scrubRequestHeaders(once)).toBe(once)
  606. })
  607. })
  608. describe('scrubSessionSnapshot', () => {
  609. it('preserves the header while projecting and scrubbing each body record', () => {
  610. const header = ' {"type":"session","version":0,"id":"s","createdAt":7} '
  611. const request = JSON.stringify({
  612. type: 'request/header', seq: 0, time: 9,
  613. data: { header: { system: 'secret', tools: [{ name: 'read' }] }, reason: 'initial' },
  614. })
  615. const event = JSON.stringify({
  616. type: 'turn/start', seq: 1, time: 10,
  617. data: { turn: 1, seq: 41, time: 42 },
  618. })
  619. expect(scrubSessionSnapshot(`${header}\n${request}\n${event}\n`)).toBe([
  620. header,
  621. '{"type":"request/header","data":{"header":{"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}',
  622. '{"type":"turn/start","data":{"turn":1,"seq":41,"time":42}}',
  623. '',
  624. ].join('\n'))
  625. })
  626. it('rejects headerless input', () => {
  627. expect(() => scrubSessionSnapshot('{"type":"turn/start"}\n'))
  628. .toThrow('session snapshot must start with a session header')
  629. })
  630. })
  631. describe('scrubSystemPrompts', () => {
  632. it('scrubs only system prompt payloads while keeping tools verbatim', () => {
  633. const header = JSON.stringify({
  634. type: 'request/header', seq: 1, time: 2,
  635. data: {
  636. header: {
  637. system: 'full prompt',
  638. tools: [{ name: 'read', description: 'full schema' }],
  639. },
  640. reason: 'initial',
  641. },
  642. })
  643. const changed = JSON.stringify({
  644. type: 'request/header', seq: 2, time: 3,
  645. data: {
  646. header: {
  647. system: 'new prompt',
  648. tools: [{ name: 'read', description: 'changed schema' }],
  649. },
  650. reason: 'change',
  651. },
  652. })
  653. const toolsOnly = JSON.stringify({
  654. type: 'request/header', seq: 3, time: 4,
  655. data: { header: { tools: [{ name: 'read', description: 'schema only' }] }, reason: 'resume' },
  656. })
  657. const out = scrubSystemPrompts(`${header}\n${changed}\n${toolsOnly}\n`)
  658. expect(out).toContain('"system":"{{system}}"')
  659. expect(out).not.toContain('full prompt')
  660. expect(out).not.toContain('new prompt')
  661. expect(out).toContain('full schema')
  662. expect(out).toContain('changed schema')
  663. expect(out.split('\n')[2]).toBe(toolsOnly)
  664. expect(scrubSystemPrompts(out)).toBe(out)
  665. })
  666. })
  667. describe('scrubToolSchemas', () => {
  668. it('scrubs only tool-schema payloads while keeping prompts verbatim', () => {
  669. const header = JSON.stringify({
  670. type: 'request/header', seq: 1, time: 2,
  671. data: {
  672. header: {
  673. system: 'full prompt',
  674. tools: [{ name: 'read', description: 'full schema', parameters: { type: 'object' } }],
  675. },
  676. reason: 'initial',
  677. },
  678. })
  679. const changed = JSON.stringify({
  680. type: 'request/header', seq: 2, time: 3,
  681. data: {
  682. header: {
  683. system: 'new prompt',
  684. tools: [{ name: 'grep', description: 'new schema' }],
  685. },
  686. reason: 'change',
  687. },
  688. })
  689. const systemOnly = JSON.stringify({
  690. type: 'request/header', seq: 3, time: 4,
  691. data: { header: { system: 'prompt only' }, reason: 'resume' },
  692. })
  693. const out = scrubToolSchemas(`${header}\n${changed}\n${systemOnly}\n`)
  694. expect(out.match(/"tools":"{{tools}}"/g)).toHaveLength(2)
  695. expect(out).not.toContain('full schema')
  696. expect(out).not.toContain('new schema')
  697. expect(out).toContain('full prompt')
  698. expect(out).toContain('new prompt')
  699. expect(out.split('\n')[2]).toBe(systemOnly)
  700. expect(scrubToolSchemas(out)).toBe(out)
  701. })
  702. })