normalize.spec.ts 46 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179
  1. import { describe, expect, it } from 'vitest'
  2. import {
  3. type NormalizeContext,
  4. extractSnapshotSpillPaths,
  5. normalizeSessionLog,
  6. normalizeSessionFormatProvenance,
  7. normalizeSessionSnapshot,
  8. normalizeSessionSnapshots,
  9. normalizeStdout,
  10. scrubModelRequestBulk,
  11. scrubSessionSnapshot,
  12. scrubSystemPrompts,
  13. scrubToolSchemas,
  14. tokenizeSessionFixtureCwd,
  15. } from '../src/normalize.ts'
  16. /**
  17. * Unit tests for the pure snapshot normalizers. Live as a *.spec.ts (runs in
  18. * the default unit gate) and import the normalizers directly.
  19. */
  20. const ctx: NormalizeContext = {
  21. sessionIds: ['11111111-2222-3333-4444-555555555555'],
  22. cwd: '/tmp/acp-snap-cwd-abc123',
  23. }
  24. describe('normalizeStdout', () => {
  25. it('rewrites JSON-RPC ids to a stable first-seen sequence', () => {
  26. const raw = [
  27. JSON.stringify({ jsonrpc: '2.0', id: 42, method: 'initialize' }),
  28. JSON.stringify({ jsonrpc: '2.0', id: 42, result: {} }),
  29. JSON.stringify({ jsonrpc: '2.0', id: 99, method: 'session/new' }),
  30. ].join('\n')
  31. const out = normalizeStdout(raw, ctx)
  32. expect(out).toContain('"id":1')
  33. expect(out).toContain('"id":2')
  34. expect(out).not.toContain('42')
  35. expect(out).not.toContain('99')
  36. })
  37. it('scrubs the cwd and session id anywhere they appear', () => {
  38. const raw = JSON.stringify({
  39. jsonrpc: '2.0', method: 'session/update',
  40. params: { sessionId: ctx.sessionIds[0], cwd: ctx.cwd, note: `at ${ctx.cwd}/x` },
  41. })
  42. const out = normalizeStdout(raw, ctx)
  43. expect(out).toContain('{{sessionId}}')
  44. expect(out).toContain('{{cwd}}')
  45. expect(out).not.toContain(ctx.cwd)
  46. expect(out).not.toContain(ctx.sessionIds[0] as string)
  47. })
  48. it('keeps standard message identity distinct from session identity', () => {
  49. const raw = JSON.stringify({
  50. jsonrpc: '2.0',
  51. method: 'session/update',
  52. params: {
  53. sessionId: ctx.sessionIds[0],
  54. update: {
  55. sessionUpdate: 'agent_message_chunk',
  56. messageId: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
  57. content: { type: 'text', text: 'done' },
  58. },
  59. },
  60. })
  61. const out = normalizeStdout(raw, ctx)
  62. expect(out).toContain('"sessionId":"{{sessionId}}"')
  63. expect(out).toContain('"messageId":"{{messageId}}"')
  64. })
  65. it('stabilizes path-dependent context occupancy without hiding capacity', () => {
  66. const raw = JSON.stringify({
  67. jsonrpc: '2.0',
  68. method: 'session/update',
  69. params: {
  70. sessionId: ctx.sessionIds[0],
  71. update: { sessionUpdate: 'usage_update', used: 6_438, size: 1_000_000 },
  72. },
  73. })
  74. const frame = JSON.parse(normalizeStdout(raw, ctx)) as {
  75. params: { update: { used: string; size: number } }
  76. }
  77. expect(frame.params.update).toEqual({
  78. sessionUpdate: 'usage_update',
  79. used: '{{usedTokens}}',
  80. size: 1_000_000,
  81. })
  82. })
  83. it('scrubs cwd at file URI and chained-punctuation boundaries', () => {
  84. const raw = JSON.stringify({
  85. jsonrpc: '2.0',
  86. method: 'session/update',
  87. params: {
  88. uri: `file://${ctx.cwd}/proof.txt`,
  89. punctuated: `${ctx.cwd}.,`,
  90. dottedSegment: `${ctx.cwd}.backup`,
  91. dashedSegment: `${ctx.cwd}-backup`,
  92. },
  93. })
  94. const frame = JSON.parse(normalizeStdout(raw, ctx)) as {
  95. params: Record<string, string>
  96. }
  97. expect(frame.params).toEqual({
  98. uri: 'file://{{cwd}}/proof.txt',
  99. punctuated: '{{cwd}}.,',
  100. dottedSegment: `${ctx.cwd}.backup`,
  101. dashedSegment: `${ctx.cwd}-backup`,
  102. })
  103. })
  104. it('scrubs every filesystem spelling of the cwd longest-first', () => {
  105. const longCwd = String.raw`C:\Users\runneradmin\AppData\Local\Temp\acp-snapshot`
  106. const aliasedCtx: NormalizeContext = {
  107. sessionIds: [],
  108. cwd: String.raw`C:\Users\RUNNER~1\AppData\Local\Temp\acp-snapshot`,
  109. cwdAliases: [
  110. longCwd,
  111. String.raw`C:\Users\runneradmin\AppData\Local\Temp\acp`,
  112. ],
  113. }
  114. const raw = JSON.stringify({
  115. cwd: longCwd,
  116. path: `${longCwd}\\nested\\proof.txt`,
  117. })
  118. const frame = JSON.parse(normalizeStdout(raw, aliasedCtx)) as { cwd: string; path: string }
  119. expect(frame).toEqual({ cwd: '{{cwd}}', path: '{{cwd}}/nested/proof.txt' })
  120. })
  121. it('canonicalizes only cwd-rooted path separators', () => {
  122. const windowsCtx: NormalizeContext = {
  123. sessionIds: [],
  124. cwd: String.raw`C:\Users\runner\AppData\Local\Temp\acp-snapshot`,
  125. }
  126. const raw = JSON.stringify({
  127. jsonrpc: '2.0',
  128. method: 'session/update',
  129. params: {
  130. path: `${windowsCtx.cwd}\\nested\\proof.txt`,
  131. regex: String.raw`\d+\w+`,
  132. command: String.raw`printf "\\n"`,
  133. },
  134. })
  135. const frame = JSON.parse(normalizeStdout(raw, windowsCtx)) as {
  136. params: { path: string; regex: string; command: string }
  137. }
  138. expect(frame.params).toEqual({
  139. path: '{{cwd}}/nested/proof.txt',
  140. regex: String.raw`\d+\w+`,
  141. command: String.raw`printf "\\n"`,
  142. })
  143. })
  144. it('canonicalizes generated relative path fields and text markers without rewriting other text', () => {
  145. const raw = JSON.stringify({
  146. path: String.raw`nested\AGENTS.md`,
  147. content: String.raw`<path>.\nested\task.txt</path>
  148. Additional instructions from: nested\AGENTS.md`,
  149. regex: String.raw`\d+\w+`,
  150. })
  151. const frame = JSON.parse(normalizeStdout(raw, { sessionIds: [], cwd: '/unused' })) as {
  152. path: string
  153. content: string
  154. regex: string
  155. }
  156. expect(frame).toEqual({
  157. path: 'nested/AGENTS.md',
  158. content: '<path>./nested/task.txt</path>\nAdditional instructions from: nested/AGENTS.md',
  159. regex: String.raw`\d+\w+`,
  160. })
  161. })
  162. it('can preserve native cwd-rooted separators for a platform golden', () => {
  163. const windowsCtx: NormalizeContext = { sessionIds: [], cwd: String.raw`C:\work\snapshot` }
  164. const raw = JSON.stringify({ path: `${windowsCtx.cwd}\\nested\\proof.txt` })
  165. const frame = JSON.parse(normalizeStdout(raw, windowsCtx, { cwdPathMode: 'native' })) as { path: string }
  166. expect(frame.path).toBe(String.raw`{{cwd}}\nested\proof.txt`)
  167. })
  168. it('scrubs a stray UUID not in the known list', () => {
  169. const raw = JSON.stringify({ jsonrpc: '2.0', method: 'x', params: { id: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' } })
  170. expect(normalizeStdout(raw, ctx)).toContain('{{sessionId}}')
  171. })
  172. it('leaves notification frames without an id untouched in id-space', () => {
  173. const raw = JSON.stringify({ jsonrpc: '2.0', method: 'session/update', params: {} })
  174. const out = normalizeStdout(raw, ctx)
  175. expect(out).not.toContain('"id"')
  176. })
  177. it('stabilizes only the top-level event timestamp and spill byte count in event-read text', () => {
  178. const raw = JSON.stringify({
  179. jsonrpc: '2.0',
  180. method: 'session/update',
  181. params: {
  182. update: {
  183. sessionUpdate: 'tool_call_update',
  184. content: [{
  185. type: 'content',
  186. content: {
  187. type: 'text',
  188. 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.)',
  189. },
  190. }],
  191. },
  192. },
  193. })
  194. const out = normalizeStdout(raw, ctx)
  195. expect(out).toContain('\\"time\\": {{eventTime}}')
  196. expect(out).toContain('\\"time\\": 31337')
  197. expect(out).toContain('\\"time\\": 424242')
  198. expect(out).toContain('Omitted {{eventOmittedBytes}} bytes')
  199. expect(out).not.toContain('1784876275593')
  200. expect(out).not.toContain('39387')
  201. })
  202. it('preserves event-like timestamps in unrelated output text', () => {
  203. const raw = JSON.stringify({
  204. jsonrpc: '2.0',
  205. method: 'session/update',
  206. params: {
  207. update: {
  208. sessionUpdate: 'tool_call_update',
  209. content: [{
  210. type: 'content',
  211. content: {
  212. type: 'text',
  213. 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.)',
  214. },
  215. }],
  216. },
  217. },
  218. })
  219. const out = normalizeStdout(raw, ctx)
  220. expect(out).toContain('1784876275593')
  221. expect(out).toContain('39387')
  222. expect(out).not.toContain('{{eventTime}}')
  223. expect(out).not.toContain('{{eventOmittedBytes}}')
  224. })
  225. it('throws on a non-JSON stdout line (the purity check)', () => {
  226. const raw = `${JSON.stringify({ jsonrpc: '2.0', id: 1 })}\noops a log leaked\n`
  227. expect(() => normalizeStdout(raw, ctx)).toThrow()
  228. })
  229. it('ignores blank lines', () => {
  230. const raw = `\n${JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'm' })}\n\n`
  231. expect(() => normalizeStdout(raw, ctx)).not.toThrow()
  232. })
  233. })
  234. describe('normalizeSessionLog', () => {
  235. it('normalizes only message-feedback item clocks', () => {
  236. const item = { messageId: 'answer', version: 'version', createdAt: 123, updatedAt: 456, note: 'keep 123' }
  237. const input = ['feedback/message-put', 'tool/result'].map(type => JSON.stringify({ type, data: { item } })).join('\n')
  238. const output = normalizeSessionLog(input, ctx)
  239. expect(output).toContain('"createdAt":0,"updatedAt":0,"note":"keep 123"')
  240. expect(output).toContain('"createdAt":123,"updatedAt":456,"note":"keep 123"')
  241. })
  242. const header = (over: object) => JSON.stringify({ type: 'session', version: 0, id: 's', createdAt: 123, ...over })
  243. const event = (over: object) => JSON.stringify({ type: 'turn/start', seq: 1, time: 999, data: { turn: 1 }, ...over })
  244. it('keeps unexpected request-header fields observable in comparisons', () => {
  245. const request = (system: boolean) => event({
  246. type: 'request/header',
  247. data: { header: { config: { model: 'mock' }, ...(system ? { system: 'unexpected prompt' } : {}) } },
  248. })
  249. for (const normalize of [normalizeSessionLog, normalizeSessionSnapshot]) {
  250. const actual = normalize(`${header({})}\n${request(true)}\n`, ctx)
  251. expect(actual).toContain('"system":"unexpected prompt"')
  252. expect(actual).not.toEqual(normalize(`${header({})}\n${request(false)}\n`, ctx))
  253. }
  254. })
  255. it('zeroes the header createdAt', () => {
  256. const out = normalizeSessionLog(`${header({})}\n`, ctx)
  257. expect(out).toContain('"createdAt":0')
  258. expect(out).not.toContain('123')
  259. })
  260. it('preserves event sequence and zeroes event time', () => {
  261. const out = normalizeSessionLog(`${header({})}\n${event({ seq: 7, time: 999 })}\n`, ctx)
  262. expect(out).toContain('"time":0')
  263. expect(out).toContain('"seq":7')
  264. expect(out).not.toContain('999')
  265. })
  266. it('normalizes a projected event without adding a persistence envelope', () => {
  267. const projected = JSON.stringify({ type: 'turn/start', data: { turn: 1 } })
  268. const out = normalizeSessionLog(`${header({})}\n${projected}\n`, ctx)
  269. expect(JSON.parse(out.trimEnd().split('\n')[1] ?? '{}')).toStrictEqual({
  270. type: 'turn/start',
  271. data: { turn: 1 },
  272. })
  273. })
  274. it('scrubs cwd and session id deep inside event data', () => {
  275. const ev = JSON.stringify({
  276. type: 'tool/result', seq: 2, time: 5,
  277. data: { content: [{ type: 'text', text: `wrote ${ctx.cwd}/proof.txt` }] },
  278. })
  279. const out = normalizeSessionLog(`${header({ cwd: ctx.cwd })}\n${ev}\n`, ctx)
  280. expect(out).toContain('{{cwd}}')
  281. expect(out).not.toContain(ctx.cwd)
  282. })
  283. it('scrubs cwd at file URI and chained-punctuation boundaries in event data', () => {
  284. const ev = JSON.stringify({
  285. type: 'tool/result',
  286. seq: 2,
  287. time: 5,
  288. data: {
  289. uri: `file://${ctx.cwd}/proof.txt`,
  290. punctuated: `${ctx.cwd}.,`,
  291. },
  292. })
  293. const out = normalizeSessionLog(`${header({ cwd: ctx.cwd })}\n${ev}\n`, ctx)
  294. expect(out).toContain('file://{{cwd}}/proof.txt')
  295. expect(out).toContain('{{cwd}}.,')
  296. expect(out).not.toContain(`file://${ctx.cwd}`)
  297. })
  298. it('scrubs random local spill paths under the snapshot cwd', () => {
  299. const ev = JSON.stringify({
  300. type: 'tool/result', seq: 2, time: 5,
  301. data: {
  302. content: [{
  303. type: 'text',
  304. 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.`,
  305. }],
  306. },
  307. })
  308. const out = normalizeSessionLog(`${header({ cwd: ctx.cwd })}\n${ev}\n`, ctx)
  309. expect(out).toContain('{{spillLocator:bash.txt}}')
  310. expect(out).not.toContain('session-c22bc3f1d2af')
  311. expect(out).not.toContain('8a7b6c5d4e3f')
  312. })
  313. it('scrubs macOS /private aliases for local spill paths', () => {
  314. const ev = JSON.stringify({
  315. type: 'tool/result', seq: 2, time: 5,
  316. data: {
  317. content: [{
  318. type: 'text',
  319. 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.`,
  320. }],
  321. },
  322. })
  323. const out = normalizeSessionLog(`${header({ cwd: ctx.cwd })}\n${ev}\n`, ctx)
  324. expect(out).toContain('{{spillLocator:bash.txt}}')
  325. expect(out).not.toContain('/private{{spillLocator')
  326. })
  327. it('scrubs macOS /private prefix on cwd-rooted fs tool result paths', () => {
  328. const ev = JSON.stringify({
  329. type: 'tool/result', seq: 2, time: 5,
  330. data: {
  331. content: [{
  332. type: 'text',
  333. text: `The file /private${ctx.cwd}/config.txt has been updated successfully.`,
  334. }],
  335. },
  336. })
  337. const out = normalizeSessionLog(`${header({ cwd: ctx.cwd })}\n${ev}\n`, ctx)
  338. expect(out).toContain('{{cwd}}/config.txt')
  339. expect(out).not.toContain('/private{{cwd}}')
  340. })
  341. it('scrubs fixed snapshot spill paths', () => {
  342. const ev = JSON.stringify({
  343. type: 'tool/result', seq: 2, time: 5,
  344. data: {
  345. content: [{
  346. type: 'text',
  347. 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.',
  348. }],
  349. },
  350. })
  351. const out = normalizeSessionLog(`${header({ cwd: ctx.cwd })}\n${ev}\n`, ctx)
  352. expect(out).toContain('{{spillLocator:bash.txt}}')
  353. expect(out).not.toContain('/tmp/dsh-acp-snapshot-spill')
  354. })
  355. it('scrubs scenario-owned snapshot spill paths', () => {
  356. const ev = JSON.stringify({
  357. type: 'tool/result', seq: 2, time: 5,
  358. data: {
  359. content: [{
  360. type: 'text',
  361. 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.',
  362. }],
  363. },
  364. })
  365. const out = normalizeSessionLog(`${header({ cwd: ctx.cwd })}\n${ev}\n`, ctx)
  366. expect(out).toContain('{{spillLocator:bash.txt}}')
  367. expect(out).not.toContain('/tmp/dsh-acp-snap-012345678')
  368. })
  369. it('scrubs scenario-owned snapshot spill paths with Windows drive and separators', () => {
  370. const ev = JSON.stringify({
  371. type: 'tool/result', seq: 2, time: 5,
  372. data: {
  373. content: [{
  374. type: 'text',
  375. 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.`,
  376. }],
  377. },
  378. })
  379. const out = normalizeSessionLog(`${header({ cwd: ctx.cwd })}\n${ev}\n`, ctx)
  380. expect(out).toContain('{{spillLocator:bash.txt}}')
  381. expect(out).not.toContain('C:\\t\\dsh-acp-snap-012345678')
  382. })
  383. it('shares cwd-rooted path handling with stdout normalization', () => {
  384. const windowsCtx: NormalizeContext = { sessionIds: [], cwd: String.raw`C:\work\snapshot` }
  385. const ev = JSON.stringify({
  386. type: 'tool/result', seq: 2, time: 5,
  387. data: { path: `${windowsCtx.cwd}\\nested\\proof.txt` },
  388. })
  389. expect(normalizeSessionLog(`${header({ cwd: windowsCtx.cwd })}\n${ev}\n`, windowsCtx))
  390. .toContain('{{cwd}}/nested/proof.txt')
  391. expect(normalizeSessionLog(`${header({ cwd: windowsCtx.cwd })}\n${ev}\n`, windowsCtx, { cwdPathMode: 'native' }))
  392. .toContain(String.raw`{{cwd}}\\nested\\proof.txt`)
  393. })
  394. it('scrubs the session id in the header', () => {
  395. const out = normalizeSessionLog(`${header({ id: ctx.sessionIds[0] })}\n`, ctx)
  396. expect(out).toContain('{{sessionId}}')
  397. })
  398. it('zeroes a hook/result durationMs (run-to-run noise) but keeps its decision', () => {
  399. const ev = JSON.stringify({
  400. type: 'hook/result', seq: 2, time: 5,
  401. data: { turn: 1, point: 'UserPromptSubmit', handlerId: 'h', decision: 'block', exitCode: 2, durationMs: 37 },
  402. })
  403. const out = normalizeSessionLog(`${header({})}\n${ev}\n`, ctx)
  404. expect(out).toContain('"durationMs":0')
  405. expect(out).not.toContain('37')
  406. expect(out).toContain('"decision":"block"') // the decision is the behavior — kept
  407. })
  408. it('preserves a packed chunk row\'s sequence, zeroes time, and zeroes volatile dt gaps', () => {
  409. const row = JSON.stringify({
  410. type: 'text-chunks', seq0: 7, time0: 999,
  411. data: { turn: 1, step: 1, index: 0, dt: [212, 27, 0], texts: ['a', 'b', 'c', 'd'] },
  412. })
  413. const out = normalizeSessionLog(`${header({})}\n${row}\n`, ctx)
  414. expect(out).toContain('"time0":0')
  415. expect(out).toContain('"dt":[0,0,0]')
  416. expect(out).toContain('"seq0":7')
  417. expect(out).toContain('"texts":["a","b","c","d"]')
  418. expect(out).not.toContain('999')
  419. expect(out).not.toContain('212')
  420. })
  421. it('normalizes timing inside an embedded Assistant stream and ignores opaque members', () => {
  422. const event = JSON.stringify({
  423. type: 'assistant/attempt',
  424. seq: 2,
  425. time: 9,
  426. data: {
  427. turn: 1,
  428. step: 1,
  429. stream: [
  430. null,
  431. 'opaque',
  432. { type: 'chunk', time: 8, chunk: { type: 'finish', reason: { kind: 'stop' } } },
  433. { type: 'usage', time: 7, time0: 6, dt: [5, 4], usage: { inputTokens: 1, outputTokens: 2 } },
  434. { type: 'chunk', time: 8, chunk: { type: 'finish', reason: { kind: 'stop' } } },
  435. ],
  436. },
  437. })
  438. const [, normalized] = normalizeSessionLog(`${header({})}\n${event}\n`, ctx)
  439. .trimEnd()
  440. .split('\n')
  441. .map(line => JSON.parse(line) as Record<string, unknown>)
  442. expect(normalized).toMatchObject({
  443. time: 0,
  444. data: {
  445. stream: [
  446. null,
  447. 'opaque',
  448. { time: 0 },
  449. { time: 0, time0: 0, dt: [0, 0] },
  450. { type: 'chunk', time: 0, chunk: { type: 'finish', reason: { kind: 'stop' } } },
  451. ],
  452. },
  453. })
  454. })
  455. it('normalizes a headerless packed-like stream record without decoding it', () => {
  456. const row = JSON.stringify({ type: 'text-chunks', seq0: 1, time0: 999, data: 'not-an-object' })
  457. const out = normalizeSessionLog(`${row}\n`, ctx)
  458. expect(out).toContain('"seq0":1')
  459. expect(out).toContain('"time0":0')
  460. })
  461. it('leaves a non-hook event durationMs untouched (only hook/result is scrubbed)', () => {
  462. const ev = JSON.stringify({ type: 'tool/result', seq: 2, time: 5, data: { durationMs: 88 } })
  463. const out = normalizeSessionLog(`${header({})}\n${ev}\n`, ctx)
  464. expect(out).toContain('"durationMs":88')
  465. })
  466. it('normalizes goal lifecycle clocks without scrubbing unrelated payload timestamps', () => {
  467. const goal = JSON.stringify({
  468. type: 'goal/change',
  469. seq: 2,
  470. time: 5,
  471. data: { operation: 'create', createdAt: 123, updatedAt: 124 },
  472. })
  473. const tool = JSON.stringify({ type: 'tool/result', seq: 3, time: 6, data: { createdAt: 125 } })
  474. const goalWithoutClocks = JSON.stringify({ type: 'goal/change', seq: 4, time: 7, data: { operation: 'resume' } })
  475. const out = normalizeSessionLog(`${header({})}\n${goal}\n${tool}\n${goalWithoutClocks}\n`, ctx)
  476. expect(out).toContain('"operation":"create","createdAt":0,"updatedAt":0')
  477. expect(out).toContain('"createdAt":125')
  478. expect(out).toContain('"operation":"resume"')
  479. })
  480. it('normalizes subagent catalog child creation clocks', () => {
  481. const catalog = JSON.stringify({
  482. type: 'subagent/catalog',
  483. seq: 2,
  484. time: 5,
  485. data: {
  486. version: 0,
  487. childId: 'child',
  488. childCreatedAt: 123,
  489. mode: 'one-shot',
  490. },
  491. })
  492. const out = normalizeSessionLog(`${header({})}\n${catalog}\n`, ctx)
  493. expect(out).toContain('"childCreatedAt":0')
  494. })
  495. it('handles complete envelopes when optional normalized fields are absent', () => {
  496. const bareHeader = JSON.stringify({ type: 'session', id: 's' })
  497. const bareHook = JSON.stringify({ type: 'hook/result', seq: 2, time: 5, data: { decision: 'allow' } })
  498. const nullDataHook = JSON.stringify({ type: 'hook/result', seq: 3, time: 6, data: null })
  499. const bareCatalog = JSON.stringify({
  500. type: 'subagent/catalog',
  501. seq: 4,
  502. time: 7,
  503. data: { version: 0 },
  504. })
  505. const out = normalizeSessionLog(`${bareHeader}\n${bareHook}\n${nullDataHook}\n${bareCatalog}\n`, ctx)
  506. expect(out).toContain('"decision":"allow"')
  507. expect(out).toContain('"version":0')
  508. expect(out).not.toContain('durationMs')
  509. })
  510. })
  511. describe('normalizeSessionSnapshot', () => {
  512. it('normalizes, scrubs, and projects each parsed body record', () => {
  513. const raw = [
  514. JSON.stringify({ type: 'session', version: 0, createdAt: 123, cwd: ctx.cwd }),
  515. JSON.stringify({
  516. type: 'system/message',
  517. seq: 6,
  518. time: 998,
  519. data: { turn: 1, step: 1, message: { role: 'system', content: [{ type: 'text', text: `work in ${ctx.cwd}` }] } },
  520. }),
  521. JSON.stringify({
  522. type: 'request/header',
  523. seq: 7,
  524. time: 999,
  525. data: { header: { tools: [{ name: 'tool' }] } },
  526. }),
  527. ].join('\n') + '\n'
  528. expect(normalizeSessionSnapshot(raw, ctx)).toBe([
  529. JSON.stringify({ type: 'session', version: 0, createdAt: 0, cwd: '{{cwd}}' }),
  530. JSON.stringify({
  531. type: 'system/message',
  532. data: { turn: 1, step: 1, message: { role: 'system', content: [{ type: 'text', text: '{{system}}' }] } },
  533. }),
  534. JSON.stringify({ type: 'request/header', data: { header: { tools: '{{tools}}' } } }),
  535. ].join('\n') + '\n')
  536. })
  537. it('normalizes an already-projected packed row', () => {
  538. const raw = [
  539. JSON.stringify({ type: 'session', version: 0 }),
  540. JSON.stringify({
  541. type: 'text-chunks',
  542. data: { turn: 1, step: 1, index: 0, dt: [9, 8], texts: ['a', 'b', 'c'] },
  543. }),
  544. ].join('\n') + '\n'
  545. expect(normalizeSessionSnapshot(raw, ctx)).toContain('"dt":[0,0]')
  546. })
  547. it('retains historical packed-row boundaries while normalizing their timing', () => {
  548. const raw = [
  549. JSON.stringify({ type: 'session', version: 0 }),
  550. JSON.stringify({
  551. type: 'text-chunks',
  552. data: { turn: 1, step: 1, index: 0, dt: [4, 5], texts: ['a', 'b', 'c'] },
  553. }),
  554. JSON.stringify({
  555. type: 'text-chunks',
  556. data: { turn: 1, step: 1, index: 0, dt: [6, 7], texts: ['d', 'e', 'f'] },
  557. }),
  558. ].join('\n') + '\n'
  559. expect(normalizeSessionSnapshot(raw, ctx)).toBe([
  560. JSON.stringify({ type: 'session', version: 0 }),
  561. JSON.stringify({
  562. type: 'text-chunks',
  563. data: { turn: 1, step: 1, index: 0, dt: [0, 0], texts: ['a', 'b', 'c'] },
  564. }),
  565. JSON.stringify({
  566. type: 'text-chunks',
  567. data: { turn: 1, step: 1, index: 0, dt: [0, 0], texts: ['d', 'e', 'f'] },
  568. }),
  569. '',
  570. ].join('\n'))
  571. })
  572. it('preserves adjacent catalog facts in parent event order', () => {
  573. const raw = [
  574. JSON.stringify({ type: 'session', version: 0 }),
  575. JSON.stringify({ type: 'tool/call', data: { callId: 'parallel' } }),
  576. JSON.stringify({
  577. type: 'subagent/catalog',
  578. data: { version: 0, childId: '{{session:3}}', childCreatedAt: 123, mode: 'one-shot' },
  579. }),
  580. JSON.stringify({
  581. type: 'subagent/catalog',
  582. data: { version: 0, childId: '{{session:2}}', childCreatedAt: 124, mode: 'one-shot' },
  583. }),
  584. JSON.stringify({ type: 'tool/result', data: { callId: 'parallel' } }),
  585. ].join('\n') + '\n'
  586. const normalized = normalizeSessionSnapshot(raw, ctx)
  587. expect(normalized.indexOf('{{session:3}}')).toBeLessThan(normalized.indexOf('{{session:2}}'))
  588. expect(normalized).toContain('"childCreatedAt":0')
  589. })
  590. it('preserves malformed catalog payloads', () => {
  591. const raw = [
  592. JSON.stringify({ type: 'session', version: 0 }),
  593. JSON.stringify({
  594. type: 'subagent/catalog',
  595. data: { version: 0, childId: '{{session:2}}', childCreatedAt: 1, mode: 'one-shot' },
  596. }),
  597. JSON.stringify({
  598. type: 'subagent/catalog',
  599. data: { version: 0, childId: 3, childCreatedAt: 3, mode: 'one-shot' },
  600. }),
  601. ].join('\n') + '\n'
  602. const normalized = normalizeSessionSnapshot(raw, ctx)
  603. expect(normalized).toContain('{{session:2}}')
  604. expect(normalized).toContain('"childId":3')
  605. })
  606. it.each([
  607. { sources: [0, 1] },
  608. { sources: [0, 2] },
  609. ])('preserves source references and catalog order: $sources', ({ sources }) => {
  610. const records = [
  611. { type: 'session', version: 2 },
  612. { type: 'tool/call', data: { callId: 'parallel' } },
  613. { type: 'subagent/catalog', data: { childId: 'child-z', childCreatedAt: 1, version: 0, mode: 'one-shot' } },
  614. { type: 'subagent/catalog', data: { childId: 'child-a', childCreatedAt: 2, version: 0, mode: 'one-shot' } },
  615. { type: 'tool/result', data: { callId: 'parallel' }, sourceEventSeqs: sources, surfaceOp: 'append' },
  616. ]
  617. const normalized = normalizeSessionSnapshot(records.map(record => JSON.stringify(record)).join('\n'), ctx)
  618. expect(normalized).toBe([
  619. records[0],
  620. records[1],
  621. { ...records[2], data: { ...records[2]?.data, childCreatedAt: 0 } },
  622. { ...records[3], data: { ...records[3]?.data, childCreatedAt: 0 } },
  623. records[4],
  624. ].map(record => JSON.stringify(record)).join('\n') + '\n')
  625. expect(normalizeSessionSnapshot(normalized, ctx)).toBe(normalized)
  626. })
  627. it('migrates and re-packs multi-session fixtures after relationship-preserving id redaction', () => {
  628. const raw = [
  629. JSON.stringify({ type: 'session', version: 0, id: '{{session:1}}', createdAt: 0, delegationDepth: 0 }),
  630. JSON.stringify({ type: 'turn/start', data: { turn: 1 } }),
  631. JSON.stringify({ type: 'step/start', data: { turn: 1, step: 1 } }),
  632. JSON.stringify({
  633. type: 'reasoning-chunks',
  634. data: { turn: 1, step: 1, index: 0, dt: [1, 2], texts: ['a', 'b', 'c'] },
  635. }),
  636. JSON.stringify({
  637. type: 'reasoning-chunks',
  638. data: { turn: 1, step: 1, index: 0, dt: [3, 4], texts: ['d', 'e', 'f'] },
  639. }),
  640. ].join('\n') + '\n'
  641. expect(normalizeSessionSnapshots([raw], ctx)).toEqual([[
  642. JSON.stringify({
  643. type: 'session', id: '{{session:1}}', createdAt: 0, isSeeded: false, delegationDepth: 0,
  644. }),
  645. JSON.stringify({ type: 'turn/start', data: { turn: 1 } }),
  646. JSON.stringify({ type: 'step/start', data: { turn: 1, step: 1 } }),
  647. JSON.stringify({
  648. type: 'system/message',
  649. data: {
  650. turn: 1,
  651. step: 1,
  652. message: {
  653. id: 'v2-to-v3-system-590b72aa4994fd6d3c6e61bb4bf5bf2f80bae0bc7564d388378ba4f51b816fd6',
  654. role: 'system',
  655. source: { kind: 'plugin', plugin: '@deepseek-ai/dsh-system-prompt' },
  656. content: [],
  657. },
  658. },
  659. surfaceOp: 'append',
  660. }),
  661. JSON.stringify({
  662. type: 'assistant/attempt',
  663. data: {
  664. turn: 1,
  665. step: 1,
  666. stream: [{
  667. type: 'reasoning-chunks',
  668. time0: 0,
  669. index: 0,
  670. dt: [0, 0, 0, 0, 0],
  671. texts: ['a', 'b', 'c', 'd', 'e', 'f'],
  672. }],
  673. },
  674. }),
  675. '',
  676. ].join('\n')])
  677. })
  678. it('normalizes an already-projected snapshot without a released-format field', () => {
  679. const raw = `${JSON.stringify({
  680. type: 'session',
  681. id: '11111111-2222-3333-4444-555555555555',
  682. createdAt: 9,
  683. })}\n`
  684. expect(normalizeSessionSnapshots([raw], { sessionIds: [], cwd: '/unused' })).toEqual([
  685. `${JSON.stringify({ type: 'session', id: '{{session:1}}', createdAt: 0 })}\n`,
  686. ])
  687. })
  688. it('rejects an empty snapshot before classifying its released format', () => {
  689. expect(() => normalizeSessionSnapshots(['\n'], { sessionIds: [], cwd: '/unused' }))
  690. .toThrow('session snapshot must start with a session header')
  691. })
  692. it('rejects a nonempty snapshot whose first record is not a session header', () => {
  693. const raw = `${JSON.stringify({ type: 'turn/start', data: { turn: 1 } })}\n`
  694. expect(() => normalizeSessionSnapshots([raw], { sessionIds: [], cwd: '/unused' }))
  695. .toThrow('session snapshot must start with a session header')
  696. })
  697. it('preserves delivery and captured-source generations after artifact migration', () => {
  698. const event = (version: number): string => JSON.stringify({
  699. type: 'session-log-deepseek/delivery-accepted',
  700. data: { sessionId: 's', throughSeq: 4, sessionFormatVersion: version },
  701. })
  702. expect(normalizeSessionFormatProvenance(event(0))).toBe(event(0))
  703. expect(normalizeSessionFormatProvenance(event(3))).not.toBe(normalizeSessionFormatProvenance(event(0)))
  704. })
  705. it('preserves opaque generation qualifiers and their lookalikes', () => {
  706. const raw = [
  707. JSON.stringify({
  708. type: 'session',
  709. id: '11111111-2222-3333-4444-555555555555',
  710. createdAt: 0,
  711. }),
  712. JSON.stringify({
  713. type: 'session-log-deepseek/delivery-accepted',
  714. data: { sessionFormatVersion: 1, throughSeq: 21, otherVersion: 8 },
  715. }),
  716. JSON.stringify({
  717. type: 'user/message',
  718. data: {
  719. role: 'user',
  720. content: [],
  721. source: {
  722. kind: 'session-reference',
  723. form: 'recall',
  724. version: 1,
  725. references: [
  726. null,
  727. 'opaque',
  728. [{ capturedFormatVersion: 6 }],
  729. { capturedFormatVersion: 1, otherVersion: 9 },
  730. ],
  731. },
  732. },
  733. }),
  734. JSON.stringify({
  735. type: 'assistant/message',
  736. data: {
  737. message: {
  738. role: 'assistant',
  739. content: [],
  740. source: [{ capturedFormatVersion: 7 }],
  741. },
  742. },
  743. }),
  744. JSON.stringify({
  745. type: 'custom/event',
  746. data: { capturedFormatVersion: 5, sessionFormatVersion: 4 },
  747. ignorable: true,
  748. }),
  749. '',
  750. ].join('\n')
  751. const [normalized] = normalizeSessionSnapshots([raw], { sessionIds: [], cwd: '/unused' })
  752. const [, delivery, captured, sourceLookalike, opaqueEvent] = normalized
  753. ?.trimEnd()
  754. .split('\n')
  755. .map(line => JSON.parse(line) as Record<string, unknown>) ?? []
  756. expect(delivery?.data).toEqual({ sessionFormatVersion: 1, throughSeq: 21, otherVersion: 8 })
  757. expect(captured?.data).toMatchObject({
  758. source: {
  759. references: [
  760. null,
  761. 'opaque',
  762. [{ capturedFormatVersion: 6 }],
  763. { capturedFormatVersion: 1, otherVersion: 9 },
  764. ],
  765. },
  766. })
  767. expect(sourceLookalike?.data).toEqual({
  768. message: {
  769. role: 'assistant',
  770. content: [],
  771. source: [{ capturedFormatVersion: 7 }],
  772. },
  773. })
  774. expect(opaqueEvent?.data).toEqual({ capturedFormatVersion: 5, sessionFormatVersion: 4 })
  775. })
  776. it('preserves an unexpected session-reference payload instead of omitting its fields', () => {
  777. const raw = JSON.stringify({
  778. type: 'user/message',
  779. data: { source: { kind: 'session-reference', form: 'recall', version: 1, references: {} } },
  780. })
  781. expect(normalizeSessionFormatProvenance(raw)).toBe(raw)
  782. })
  783. it('keeps session-reference lookalikes outside Message source positions unchanged', () => {
  784. const lookalike = [
  785. JSON.stringify({ type: 'session', version: 1, id: 's', createdAt: 0, delegationDepth: 0 }),
  786. JSON.stringify({
  787. type: 'custom/event',
  788. data: {
  789. meta: {
  790. kind: 'session-reference',
  791. form: 'recall',
  792. version: 1,
  793. references: [{ capturedFormatVersion: 7 }],
  794. },
  795. },
  796. ignorable: true,
  797. }),
  798. '',
  799. ].join('\n')
  800. const normalized = normalizeSessionFormatProvenance(lookalike).split('\n')
  801. expect(JSON.parse(normalized[0] as string)).not.toHaveProperty('version')
  802. expect(normalized[1]).toBe(lookalike.split('\n')[1])
  803. })
  804. it('projects persisted provenance ranges back to logical seq arrays', () => {
  805. const raw = [
  806. JSON.stringify({ type: 'session', version: 0 }),
  807. JSON.stringify({
  808. type: 'assistant/message',
  809. sourceEventSeqs: [[1, 3], 5],
  810. surfaceOp: 'append',
  811. data: { turn: 1, step: 1 },
  812. }),
  813. ].join('\n') + '\n'
  814. expect(normalizeSessionSnapshot(raw, ctx)).toContain('"sourceEventSeqs":[1,2,3,5]')
  815. })
  816. it('rejects headerless input', () => {
  817. expect(() => normalizeSessionSnapshot('{"type":"turn/start"}\n', ctx))
  818. .toThrow('session snapshot must start with a session header')
  819. })
  820. })
  821. describe('tokenizeSessionFixtureCwd', () => {
  822. it.each([
  823. {
  824. name: 'macOS',
  825. context: {
  826. sessionIds: [],
  827. cwd: '/var/folders/2g/snapshot/T/acp-snap-cwd-abc123',
  828. cwdAliases: ['/private/var/folders/2g/snapshot/T/acp-snap-cwd-abc123'],
  829. },
  830. reportedCwd: '/private/var/folders/2g/snapshot/T/acp-snap-cwd-abc123',
  831. },
  832. {
  833. name: 'Linux',
  834. context: {
  835. sessionIds: [],
  836. cwd: '/tmp/acp-snap-cwd-abc123',
  837. },
  838. reportedCwd: '/tmp/acp-snap-cwd-abc123',
  839. },
  840. {
  841. name: 'Windows',
  842. context: {
  843. sessionIds: [],
  844. cwd: String.raw`C:\Users\runner\AppData\Local\Temp\acp-snap-cwd-abc123`,
  845. },
  846. reportedCwd: String.raw`C:\Users\runner\AppData\Local\Temp\acp-snap-cwd-abc123`,
  847. },
  848. ])('stores $name temporary workspaces with one portable root token', ({ context, reportedCwd }) => {
  849. const raw = [
  850. JSON.stringify({ type: 'session', id: 's', createdAt: 1, cwd: context.cwd }),
  851. JSON.stringify({
  852. type: 'tool/result',
  853. seq: 1,
  854. time: 2,
  855. data: {
  856. content: [{
  857. type: 'text',
  858. 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`,
  859. }],
  860. },
  861. }),
  862. '',
  863. ].join('\n')
  864. const out = tokenizeSessionFixtureCwd(raw)
  865. const result = JSON.parse(out.split('\n')[1] as string) as {
  866. data: { content: { text: string }[] }
  867. }
  868. const resultText = (result.data.content[0] as { text: string }).text
  869. expect(out).toContain('"cwd":"{{cwd}}"')
  870. expect(resultText).toContain('wrote {{cwd}}/proof.txt')
  871. expect(resultText).toContain('alias {{cwd}}/alias.txt')
  872. expect(resultText).toContain('cwd {{cwd}}. Next')
  873. expect(resultText).toContain(`${context.cwd}-backup`)
  874. expect(resultText).toContain(`${context.cwd}.backup`)
  875. expect(resultText).toContain('/tmp/authored.txt')
  876. expect(resultText).not.toContain(`${reportedCwd}/proof.txt`)
  877. expect(tokenizeSessionFixtureCwd(out)).toBe(out)
  878. })
  879. it('collapses a residual macOS realpath prefix around an existing cwd token', () => {
  880. const raw = [
  881. JSON.stringify({ type: 'session', id: 's', createdAt: 1, cwd: '{{cwd}}' }),
  882. JSON.stringify({
  883. type: 'tool/result',
  884. seq: 1,
  885. time: 2,
  886. data: { content: [{ type: 'text', text: 'wrote /private{{cwd}}/proof.txt' }] },
  887. }),
  888. '',
  889. ].join('\n')
  890. const out = tokenizeSessionFixtureCwd(raw)
  891. expect(out).toContain('wrote {{cwd}}/proof.txt')
  892. expect(out).not.toContain('/private{{cwd}}')
  893. expect(tokenizeSessionFixtureCwd(out)).toBe(out)
  894. })
  895. it('rejects a log without a session cwd', () => {
  896. expect(() => tokenizeSessionFixtureCwd('')).toThrow(
  897. 'acp-snapshot: cannot tokenize a cwd without a basename',
  898. )
  899. })
  900. })
  901. describe('extractSnapshotSpillPaths', () => {
  902. it.each([
  903. ['/tmp', '/'],
  904. ['/tmp', String.fromCharCode(92)],
  905. ['C:/t', String.fromCharCode(92)],
  906. ])('recognizes %s locators with %s separators in nested JSON omissions without scrubbing byte counts', (root, separator) => {
  907. const locator = `${root}/dsh-acp-snap-123456789/session-123456abcdef/abcdef123456-session-reference-1.txt`.replaceAll('/', separator)
  908. const notice = { sessionId: 'source', omittedBytes: 42, fullSnapshot: { status: 'saved', locator, bytes: 1234 } }
  909. const log = JSON.stringify({ type: 'user/message', data: { content: [{ type: 'text', text: JSON.stringify([notice]) }] } })
  910. const encodedLocator = JSON.stringify(JSON.stringify(locator).slice(1, -1)).slice(1, -1)
  911. expect(extractSnapshotSpillPaths(log)).toEqual(new Map([['session-reference-1.txt', encodedLocator]]))
  912. const normalized = normalizeSessionLog(log, ctx)
  913. const unrelated = '/tmp/unrelated/session-123456abcdef/abcdef123456-session-reference-1.txt'
  914. expect(normalizeSessionLog(log.replaceAll(encodedLocator, unrelated), ctx)).toContain(unrelated)
  915. const expectedNotice = { ...notice, fullSnapshot: { ...notice.fullSnapshot, locator: '{{spillLocator:session-reference-1.txt}}' } }
  916. expect(normalized).toBe(JSON.stringify({ type: 'user/message', data: { content: [{ type: 'text', text: JSON.stringify([expectedNotice]) }] } }) + '\n')
  917. expect(normalized).toContain('{{spillLocator:session-reference-1.txt}}')
  918. expect(normalized).toContain('omittedBytes\\":42')
  919. expect(normalized).toContain('bytes\\":1234')
  920. })
  921. it.each(['canonical', 'native'] as const)('normalizes nested Windows local spill locators with %s paths', (cwdPathMode) => {
  922. const locator = String.raw`{{cwd}}\.spill\session-123456abcdef\abcdef123456-session-reference-1.txt`
  923. const notice = { locator, unrelated: String.raw`C:\work\literal\file.txt`, regex: String.raw`\d+\w` }
  924. const log = JSON.stringify({ type: 'user/message', data: { text: JSON.stringify(notice) } })
  925. const expected = { ...notice, locator: '{{spillLocator:session-reference-1.txt}}' }
  926. expect(normalizeSessionLog(log, ctx, { cwdPathMode })).toBe(
  927. JSON.stringify({ type: 'user/message', data: { text: JSON.stringify(expected) } }) + '\n',
  928. )
  929. })
  930. it('maps each spill filename to its full matched path, last match wins per name', () => {
  931. const log = [
  932. '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.',
  933. 'stale copy at /tmp/dsh-acp-snap-012345678/session-aaaaaaaaaaaa/bbbbbbbbbbbb-grep.txt then',
  934. 'fresh copy at /tmp/dsh-acp-snap-012345678/session-cccccccccccc/dddddddddddd-grep.txt then',
  935. ].join('\n')
  936. expect(extractSnapshotSpillPaths(log)).toEqual(new Map([
  937. ['bash.txt', '/tmp/dsh-acp-snapshot-spill/session-c22bc3f1d2af/8a7b6c5d4e3f-bash.txt'],
  938. ['grep.txt', '/tmp/dsh-acp-snap-012345678/session-cccccccccccc/dddddddddddd-grep.txt'],
  939. ]))
  940. })
  941. it('returns an empty map when the log carries no snapshot spill paths', () => {
  942. expect(extractSnapshotSpillPaths('no spill paths here, only /tmp/other.txt\n')).toEqual(new Map())
  943. })
  944. })
  945. /** One `system/message` record whose single text block carries the rendered prompt. */
  946. function systemMessageEvent(text: string, seq = 2): string {
  947. return JSON.stringify({
  948. type: 'system/message',
  949. seq,
  950. time: 9,
  951. data: {
  952. turn: 1,
  953. step: 1,
  954. message: {
  955. id: '11111111-1111-4111-8111-111111111111',
  956. role: 'system',
  957. content: text.length === 0 ? [] : [{ type: 'text', text }],
  958. source: { kind: 'plugin', plugin: '@deepseek-ai/dsh-system-prompt' },
  959. },
  960. },
  961. })
  962. }
  963. describe('scrubModelRequestBulk', () => {
  964. const headerLine = JSON.stringify({ type: 'session', version: 0, id: 's', createdAt: 1, cwd: '/w' })
  965. const headerEvent = (header: object) =>
  966. JSON.stringify({ type: 'request/header', seq: 3, time: 9, data: { header, reason: 'initial' } })
  967. it('replaces system/message text and header tools with tokens, keeping config and reason', () => {
  968. const ev = headerEvent({
  969. config: { model: 'm' },
  970. tools: [{ name: 'read', description: 'Read a file.', parameters: { type: 'object' } }],
  971. })
  972. const out = scrubModelRequestBulk(`${headerLine}\n${systemMessageEvent('You are an agent.\nBe brief.')}\n${ev}\n`)
  973. expect(out).toContain('"content":[{"type":"text","text":"{{system}}"}]')
  974. expect(out).toContain('"tools":"{{tools}}"')
  975. expect(out).toContain('"config":{"model":"m"}')
  976. expect(out).toContain('"reason":"initial"')
  977. expect(out).not.toContain('You are an agent')
  978. expect(out).not.toContain('Read a file')
  979. })
  980. it('keeps an absent tools field absent and an empty system prompt empty (presence is behavior)', () => {
  981. const out = scrubModelRequestBulk(`${headerLine}\n${systemMessageEvent('')}\n${headerEvent({ config: { model: 'm' } })}\n`)
  982. expect(out).not.toContain('{{system}}')
  983. expect(out).toContain('"content":[]')
  984. expect(out).not.toContain('{{tools}}')
  985. })
  986. it('leaves malformed records with no scrubbable payload byte-identical', () => {
  987. const headerless = JSON.stringify({ type: 'request/header', seq: 10, time: 9, data: { reason: 'initial' } })
  988. const nullData = JSON.stringify({ type: 'request/header', seq: 11, time: 9, data: null })
  989. const messageless = JSON.stringify({ type: 'system/message', seq: 12, time: 9, data: { turn: 1, step: 1 } })
  990. const textless = JSON.stringify({
  991. type: 'system/message', seq: 13, time: 9, data: { message: { content: [{ type: 'image', data: 'x' }] } },
  992. })
  993. const raw = `${headerLine}\n${headerless}\n${nullData}\n${messageless}\n${textless}\n`
  994. expect(scrubModelRequestBulk(raw)).toBe(raw)
  995. })
  996. it('passes every other line through byte-for-byte and is idempotent', () => {
  997. const other = JSON.stringify({ type: 'assistant/chunk', seq: 4, time: 9, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'hi' } } })
  998. const raw = `${headerLine}\n${systemMessageEvent('s')}\n${headerEvent({ config: { model: 'm' }, tools: [] })}\n${other}\n`
  999. const once = scrubModelRequestBulk(raw)
  1000. expect(once.split('\n')[0]).toBe(headerLine)
  1001. expect(once.split('\n')[3]).toBe(other)
  1002. expect(scrubModelRequestBulk(once)).toBe(once)
  1003. })
  1004. })
  1005. describe('scrubSessionSnapshot', () => {
  1006. it('writes stable feedback clocks while retaining notes and version identity', () => {
  1007. const input = [
  1008. { type: 'session', id: 's' },
  1009. { type: 'feedback/message-put', data: { item: { version: 'opaque-version', createdAt: 12, updatedAt: 34, note: 'keep 12' } } },
  1010. { type: 'feedback/message-put', data: null },
  1011. { type: 'feedback/message-put', data: { item: null } },
  1012. { type: 'feedback/message-put', data: { item: {} } },
  1013. ].map(record => JSON.stringify(record)).join('\n')
  1014. const output = scrubSessionSnapshot(input)
  1015. expect(output).toContain('"version":"opaque-version","createdAt":0,"updatedAt":0,"note":"keep 12"')
  1016. expect(scrubSessionSnapshot(output)).toBe(output)
  1017. })
  1018. it('preserves the header while projecting and scrubbing each body record', () => {
  1019. const header = ' {"type":"session","version":0,"id":"s","createdAt":7} '
  1020. const system = systemMessageEvent('secret', 0)
  1021. const request = JSON.stringify({
  1022. type: 'request/header', seq: 1, time: 9,
  1023. data: { header: { tools: [{ name: 'read' }] }, reason: 'initial' },
  1024. })
  1025. const event = JSON.stringify({
  1026. type: 'turn/start', seq: 2, time: 10,
  1027. data: { turn: 1, seq: 41, time: 42 },
  1028. })
  1029. expect(scrubSessionSnapshot(`${header}\n${system}\n${request}\n${event}\n`)).toBe([
  1030. header,
  1031. '{"type":"system/message","data":{"turn":1,"step":1,"message":{"id":"11111111-1111-4111-8111-111111111111","role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"}}}}',
  1032. '{"type":"request/header","data":{"header":{"tools":"{{tools}}"},"reason":"initial"}}',
  1033. '{"type":"turn/start","data":{"turn":1,"seq":41,"time":42}}',
  1034. '',
  1035. ].join('\n'))
  1036. })
  1037. it('rejects headerless input', () => {
  1038. expect(() => scrubSessionSnapshot('{"type":"turn/start"}\n'))
  1039. .toThrow('session snapshot must start with a session header')
  1040. })
  1041. })
  1042. describe('scrubSystemPrompts', () => {
  1043. it('scrubs only system/message text while keeping header tools verbatim', () => {
  1044. const header = JSON.stringify({
  1045. type: 'request/header', seq: 1, time: 2,
  1046. data: { header: { tools: [{ name: 'read', description: 'full schema' }] }, reason: 'initial' },
  1047. })
  1048. const replaced = JSON.stringify({
  1049. type: 'system/message', seq: 3, time: 4,
  1050. surfaceOp: { op: 'replace', start: 0, end: 0 },
  1051. sourceEventSeqs: [0],
  1052. data: { turn: 1, step: 2, message: { role: 'system', content: [{ type: 'text', text: 'new prompt' }] } },
  1053. })
  1054. const out = scrubSystemPrompts(`${systemMessageEvent('full prompt', 0)}\n${header}\n${replaced}\n`)
  1055. expect(out.match(/"text":"{{system}}"/g)).toHaveLength(2)
  1056. expect(out).not.toContain('full prompt')
  1057. expect(out).not.toContain('new prompt')
  1058. expect(out).toContain('"surfaceOp":{"op":"replace","start":0,"end":0}')
  1059. expect(out.split('\n')[1]).toBe(header)
  1060. expect(scrubSystemPrompts(out)).toBe(out)
  1061. })
  1062. })
  1063. describe('scrubToolSchemas', () => {
  1064. it('scrubs only tool-schema payloads while keeping prompts verbatim', () => {
  1065. const header = JSON.stringify({
  1066. type: 'request/header', seq: 1, time: 2,
  1067. data: {
  1068. header: { tools: [{ name: 'read', description: 'full schema', parameters: { type: 'object' } }] },
  1069. reason: 'initial',
  1070. },
  1071. })
  1072. const changed = JSON.stringify({
  1073. type: 'request/header', seq: 2, time: 3,
  1074. data: { header: { tools: [{ name: 'grep', description: 'new schema' }] }, reason: 'change' },
  1075. })
  1076. const toolless = JSON.stringify({
  1077. type: 'request/header', seq: 3, time: 4,
  1078. data: { header: { config: { model: 'm' } }, reason: 'resume' },
  1079. })
  1080. const out = scrubToolSchemas(`${systemMessageEvent('full prompt', 0)}\n${header}\n${changed}\n${toolless}\n`)
  1081. expect(out.match(/"tools":"{{tools}}"/g)).toHaveLength(2)
  1082. expect(out).not.toContain('full schema')
  1083. expect(out).not.toContain('new schema')
  1084. expect(out).toContain('full prompt')
  1085. expect(out.split('\n')[3]).toBe(toolless)
  1086. expect(scrubToolSchemas(out)).toBe(out)
  1087. })
  1088. })