normalize.spec.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517
  1. import { describe, expect, it } from 'vitest'
  2. import {
  3. type NormalizeContext,
  4. extractSnapshotSpillPaths,
  5. normalizeSessionLog,
  6. normalizeStdout,
  7. scrubRequestHeaders,
  8. scrubSystemPrompts,
  9. scrubToolSchemas,
  10. } from '../src/normalize.ts'
  11. /**
  12. * Unit tests for the pure snapshot normalizers. Live as a *.spec.ts (runs in
  13. * the default unit gate) and import the normalizers directly.
  14. */
  15. const ctx: NormalizeContext = {
  16. sessionIds: ['11111111-2222-3333-4444-555555555555'],
  17. cwd: '/tmp/acp-snap-cwd-abc123',
  18. }
  19. describe('normalizeStdout', () => {
  20. it('rewrites JSON-RPC ids to a stable first-seen sequence', () => {
  21. const raw = [
  22. JSON.stringify({ jsonrpc: '2.0', id: 42, method: 'initialize' }),
  23. JSON.stringify({ jsonrpc: '2.0', id: 42, result: {} }),
  24. JSON.stringify({ jsonrpc: '2.0', id: 99, method: 'session/new' }),
  25. ].join('\n')
  26. const out = normalizeStdout(raw, ctx)
  27. expect(out).toContain('"id":1')
  28. expect(out).toContain('"id":2')
  29. expect(out).not.toContain('42')
  30. expect(out).not.toContain('99')
  31. })
  32. it('scrubs the cwd and session id anywhere they appear', () => {
  33. const raw = JSON.stringify({
  34. jsonrpc: '2.0', method: 'session/update',
  35. params: { sessionId: ctx.sessionIds[0], cwd: ctx.cwd, note: `at ${ctx.cwd}/x` },
  36. })
  37. const out = normalizeStdout(raw, ctx)
  38. expect(out).toContain('{{sessionId}}')
  39. expect(out).toContain('{{cwd}}')
  40. expect(out).not.toContain(ctx.cwd)
  41. expect(out).not.toContain(ctx.sessionIds[0] as string)
  42. })
  43. it('scrubs every filesystem spelling of the cwd longest-first', () => {
  44. const longCwd = String.raw`C:\Users\runneradmin\AppData\Local\Temp\acp-snapshot`
  45. const aliasedCtx: NormalizeContext = {
  46. sessionIds: [],
  47. cwd: String.raw`C:\Users\RUNNER~1\AppData\Local\Temp\acp-snapshot`,
  48. cwdAliases: [
  49. longCwd,
  50. String.raw`C:\Users\runneradmin\AppData\Local\Temp\acp`,
  51. ],
  52. }
  53. const raw = JSON.stringify({
  54. cwd: longCwd,
  55. path: `${longCwd}\\nested\\proof.txt`,
  56. })
  57. const frame = JSON.parse(normalizeStdout(raw, aliasedCtx)) as { cwd: string; path: string }
  58. expect(frame).toEqual({ cwd: '{{cwd}}', path: '{{cwd}}/nested/proof.txt' })
  59. })
  60. it('canonicalizes only cwd-rooted path separators', () => {
  61. const windowsCtx: NormalizeContext = {
  62. sessionIds: [],
  63. cwd: String.raw`C:\Users\runner\AppData\Local\Temp\acp-snapshot`,
  64. }
  65. const raw = JSON.stringify({
  66. jsonrpc: '2.0',
  67. method: 'session/update',
  68. params: {
  69. path: `${windowsCtx.cwd}\\nested\\proof.txt`,
  70. regex: String.raw`\d+\w+`,
  71. command: String.raw`printf "\\n"`,
  72. },
  73. })
  74. const frame = JSON.parse(normalizeStdout(raw, windowsCtx)) as {
  75. params: { path: string; regex: string; command: string }
  76. }
  77. expect(frame.params).toEqual({
  78. path: '{{cwd}}/nested/proof.txt',
  79. regex: String.raw`\d+\w+`,
  80. command: String.raw`printf "\\n"`,
  81. })
  82. })
  83. it('canonicalizes generated relative path fields and text markers without rewriting other text', () => {
  84. const raw = JSON.stringify({
  85. path: String.raw`nested\AGENTS.md`,
  86. content: String.raw`<path>.\nested\task.txt</path>
  87. Additional instructions from: nested\AGENTS.md`,
  88. regex: String.raw`\d+\w+`,
  89. })
  90. const frame = JSON.parse(normalizeStdout(raw, { sessionIds: [], cwd: '/unused' })) as {
  91. path: string
  92. content: string
  93. regex: string
  94. }
  95. expect(frame).toEqual({
  96. path: 'nested/AGENTS.md',
  97. content: '<path>./nested/task.txt</path>\nAdditional instructions from: nested/AGENTS.md',
  98. regex: String.raw`\d+\w+`,
  99. })
  100. })
  101. it('can preserve native cwd-rooted separators for a platform golden', () => {
  102. const windowsCtx: NormalizeContext = { sessionIds: [], cwd: String.raw`C:\work\snapshot` }
  103. const raw = JSON.stringify({ path: `${windowsCtx.cwd}\\nested\\proof.txt` })
  104. const frame = JSON.parse(normalizeStdout(raw, windowsCtx, { cwdPathMode: 'native' })) as { path: string }
  105. expect(frame.path).toBe(String.raw`{{cwd}}\nested\proof.txt`)
  106. })
  107. it('scrubs a stray UUID not in the known list', () => {
  108. const raw = JSON.stringify({ jsonrpc: '2.0', method: 'x', params: { id: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' } })
  109. expect(normalizeStdout(raw, ctx)).toContain('{{sessionId}}')
  110. })
  111. it('leaves notification frames without an id untouched in id-space', () => {
  112. const raw = JSON.stringify({ jsonrpc: '2.0', method: 'session/update', params: {} })
  113. const out = normalizeStdout(raw, ctx)
  114. expect(out).not.toContain('"id"')
  115. })
  116. it('stabilizes only the top-level event timestamp and spill byte count in event-read text', () => {
  117. const raw = JSON.stringify({
  118. jsonrpc: '2.0',
  119. method: 'session/update',
  120. params: {
  121. update: {
  122. sessionUpdate: 'tool_call_update',
  123. content: [{
  124. type: 'content',
  125. content: {
  126. type: 'text',
  127. 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.)',
  128. },
  129. }],
  130. },
  131. },
  132. })
  133. const out = normalizeStdout(raw, ctx)
  134. expect(out).toContain('\\"time\\": {{eventTime}}')
  135. expect(out).toContain('\\"time\\": 31337')
  136. expect(out).toContain('\\"time\\": 424242')
  137. expect(out).toContain('Omitted {{eventOmittedBytes}} bytes')
  138. expect(out).not.toContain('1784876275593')
  139. expect(out).not.toContain('39387')
  140. })
  141. it('preserves event-like timestamps in unrelated output text', () => {
  142. const raw = JSON.stringify({
  143. jsonrpc: '2.0',
  144. method: 'session/update',
  145. params: {
  146. update: {
  147. sessionUpdate: 'tool_call_update',
  148. content: [{
  149. type: 'content',
  150. content: {
  151. type: 'text',
  152. 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.)',
  153. },
  154. }],
  155. },
  156. },
  157. })
  158. const out = normalizeStdout(raw, ctx)
  159. expect(out).toContain('1784876275593')
  160. expect(out).toContain('39387')
  161. expect(out).not.toContain('{{eventTime}}')
  162. expect(out).not.toContain('{{eventOmittedBytes}}')
  163. })
  164. it('throws on a non-JSON stdout line (the purity check)', () => {
  165. const raw = `${JSON.stringify({ jsonrpc: '2.0', id: 1 })}\noops a log leaked\n`
  166. expect(() => normalizeStdout(raw, ctx)).toThrow()
  167. })
  168. it('ignores blank lines', () => {
  169. const raw = `\n${JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'm' })}\n\n`
  170. expect(() => normalizeStdout(raw, ctx)).not.toThrow()
  171. })
  172. })
  173. describe('normalizeSessionLog', () => {
  174. const header = (over: object) => JSON.stringify({ type: 'session', version: 0, id: 's', createdAt: 123, ...over })
  175. const event = (over: object) => JSON.stringify({ type: 'turn/start', seq: 1, time: 999, data: { turn: 1 }, ...over })
  176. it('zeroes the header createdAt', () => {
  177. const out = normalizeSessionLog(`${header({})}\n`, ctx)
  178. expect(out).toContain('"createdAt":0')
  179. expect(out).not.toContain('123')
  180. })
  181. it('zeroes each event time but keeps seq', () => {
  182. const out = normalizeSessionLog(`${header({})}\n${event({ seq: 7, time: 999 })}\n`, ctx)
  183. expect(out).toContain('"time":0')
  184. expect(out).toContain('"seq":7') // seq is deterministic — NOT scrubbed
  185. expect(out).not.toContain('999')
  186. })
  187. it('scrubs cwd and session id deep inside event data', () => {
  188. const ev = JSON.stringify({
  189. type: 'tool/result', seq: 2, time: 5,
  190. data: { content: [{ type: 'text', text: `wrote ${ctx.cwd}/proof.txt` }] },
  191. })
  192. const out = normalizeSessionLog(`${header({ cwd: ctx.cwd })}\n${ev}\n`, ctx)
  193. expect(out).toContain('{{cwd}}')
  194. expect(out).not.toContain(ctx.cwd)
  195. })
  196. it('scrubs random local spill paths under the snapshot cwd', () => {
  197. const ev = JSON.stringify({
  198. type: 'tool/result', seq: 2, time: 5,
  199. data: {
  200. content: [{
  201. type: 'text',
  202. 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.`,
  203. }],
  204. },
  205. })
  206. const out = normalizeSessionLog(`${header({ cwd: ctx.cwd })}\n${ev}\n`, ctx)
  207. expect(out).toContain('{{spillLocator:bash.txt}}')
  208. expect(out).not.toContain('session-c22bc3f1d2af')
  209. expect(out).not.toContain('8a7b6c5d4e3f')
  210. })
  211. it('scrubs macOS /private aliases for local spill paths', () => {
  212. const ev = JSON.stringify({
  213. type: 'tool/result', seq: 2, time: 5,
  214. data: {
  215. content: [{
  216. type: 'text',
  217. 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.`,
  218. }],
  219. },
  220. })
  221. const out = normalizeSessionLog(`${header({ cwd: ctx.cwd })}\n${ev}\n`, ctx)
  222. expect(out).toContain('{{spillLocator:bash.txt}}')
  223. expect(out).not.toContain('/private{{spillLocator')
  224. })
  225. it('scrubs macOS /private prefix on cwd-rooted fs tool result paths', () => {
  226. const ev = JSON.stringify({
  227. type: 'tool/result', seq: 2, time: 5,
  228. data: {
  229. content: [{
  230. type: 'text',
  231. text: `The file /private${ctx.cwd}/config.txt has been updated successfully.`,
  232. }],
  233. },
  234. })
  235. const out = normalizeSessionLog(`${header({ cwd: ctx.cwd })}\n${ev}\n`, ctx)
  236. expect(out).toContain('{{cwd}}/config.txt')
  237. expect(out).not.toContain('/private{{cwd}}')
  238. })
  239. it('scrubs fixed snapshot spill paths', () => {
  240. const ev = JSON.stringify({
  241. type: 'tool/result', seq: 2, time: 5,
  242. data: {
  243. content: [{
  244. type: 'text',
  245. 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.',
  246. }],
  247. },
  248. })
  249. const out = normalizeSessionLog(`${header({ cwd: ctx.cwd })}\n${ev}\n`, ctx)
  250. expect(out).toContain('{{spillLocator:bash.txt}}')
  251. expect(out).not.toContain('/tmp/dsh-acp-snapshot-spill')
  252. })
  253. it('scrubs scenario-owned snapshot spill paths', () => {
  254. const ev = JSON.stringify({
  255. type: 'tool/result', seq: 2, time: 5,
  256. data: {
  257. content: [{
  258. type: 'text',
  259. 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.',
  260. }],
  261. },
  262. })
  263. const out = normalizeSessionLog(`${header({ cwd: ctx.cwd })}\n${ev}\n`, ctx)
  264. expect(out).toContain('{{spillLocator:bash.txt}}')
  265. expect(out).not.toContain('/tmp/dsh-acp-snap-012345678')
  266. })
  267. it('scrubs scenario-owned snapshot spill paths with Windows drive and separators', () => {
  268. const ev = JSON.stringify({
  269. type: 'tool/result', seq: 2, time: 5,
  270. data: {
  271. content: [{
  272. type: 'text',
  273. 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.`,
  274. }],
  275. },
  276. })
  277. const out = normalizeSessionLog(`${header({ cwd: ctx.cwd })}\n${ev}\n`, ctx)
  278. expect(out).toContain('{{spillLocator:bash.txt}}')
  279. expect(out).not.toContain('C:\\t\\dsh-acp-snap-012345678')
  280. })
  281. it('shares cwd-rooted path handling with stdout normalization', () => {
  282. const windowsCtx: NormalizeContext = { sessionIds: [], cwd: String.raw`C:\work\snapshot` }
  283. const ev = JSON.stringify({
  284. type: 'tool/result', seq: 2, time: 5,
  285. data: { path: `${windowsCtx.cwd}\\nested\\proof.txt` },
  286. })
  287. expect(normalizeSessionLog(`${header({ cwd: windowsCtx.cwd })}\n${ev}\n`, windowsCtx))
  288. .toContain('{{cwd}}/nested/proof.txt')
  289. expect(normalizeSessionLog(`${header({ cwd: windowsCtx.cwd })}\n${ev}\n`, windowsCtx, { cwdPathMode: 'native' }))
  290. .toContain(String.raw`{{cwd}}\\nested\\proof.txt`)
  291. })
  292. it('scrubs the session id in the header', () => {
  293. const out = normalizeSessionLog(`${header({ id: ctx.sessionIds[0] })}\n`, ctx)
  294. expect(out).toContain('{{sessionId}}')
  295. })
  296. it('zeroes a hook/result durationMs (run-to-run noise) but keeps its decision', () => {
  297. const ev = JSON.stringify({
  298. type: 'hook/result', seq: 2, time: 5,
  299. data: { turn: 1, point: 'UserPromptSubmit', handlerId: 'h', decision: 'block', exitCode: 2, durationMs: 37 },
  300. })
  301. const out = normalizeSessionLog(`${header({})}\n${ev}\n`, ctx)
  302. expect(out).toContain('"durationMs":0')
  303. expect(out).not.toContain('37')
  304. expect(out).toContain('"decision":"block"') // the decision is the behavior — kept
  305. })
  306. it('zeroes a packed chunk row\'s time0 and dt gaps but keeps seq0 and payload', () => {
  307. const row = JSON.stringify({
  308. type: 'text-chunks', seq0: 7, time0: 999,
  309. data: { turn: 1, step: 1, index: 0, dt: [212, 27, 0], texts: ['a', 'b', 'c', 'd'] },
  310. })
  311. const out = normalizeSessionLog(`${header({})}\n${row}\n`, ctx)
  312. expect(out).toContain('"time0":0')
  313. expect(out).toContain('"dt":[0,0,0]')
  314. expect(out).toContain('"seq0":7') // seq0 is deterministic, like seq — NOT scrubbed
  315. expect(out).toContain('"texts":["a","b","c","d"]')
  316. expect(out).not.toContain('999')
  317. expect(out).not.toContain('212')
  318. })
  319. it('zeroes time0 even when a malformed row carries no dt array', () => {
  320. const row = JSON.stringify({ type: 'text-chunks', seq0: 1, time0: 999, data: 'not-an-object' })
  321. const out = normalizeSessionLog(`${header({})}\n${row}\n`, ctx)
  322. expect(out).toContain('"time0":0')
  323. expect(out).not.toContain('999')
  324. })
  325. it('leaves a non-hook event durationMs untouched (only hook/result is scrubbed)', () => {
  326. const ev = JSON.stringify({ type: 'tool/result', seq: 2, time: 5, data: { durationMs: 88 } })
  327. const out = normalizeSessionLog(`${header({})}\n${ev}\n`, ctx)
  328. expect(out).toContain('"durationMs":88')
  329. })
  330. it('tolerates records missing the volatile fields it would zero', () => {
  331. const bareHeader = JSON.stringify({ type: 'session', id: 's' })
  332. const timeless = JSON.stringify({ type: 'note', seq: 1 })
  333. const bareHook = JSON.stringify({ type: 'hook/result', seq: 2, time: 5, data: { decision: 'allow' } })
  334. const nullDataHook = JSON.stringify({ type: 'hook/result', seq: 3, time: 6, data: null })
  335. const out = normalizeSessionLog(`${bareHeader}\n${timeless}\n${bareHook}\n${nullDataHook}\n`, ctx)
  336. expect(out).toContain('"type":"note","seq":1')
  337. expect(out).toContain('"decision":"allow"')
  338. expect(out).not.toContain('durationMs')
  339. })
  340. })
  341. describe('extractSnapshotSpillPaths', () => {
  342. it('maps each spill filename to its full matched path, last match wins per name', () => {
  343. const log = [
  344. '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.',
  345. 'stale copy at /tmp/dsh-acp-snap-012345678/session-aaaaaaaaaaaa/bbbbbbbbbbbb-grep.txt then',
  346. 'fresh copy at /tmp/dsh-acp-snap-012345678/session-cccccccccccc/dddddddddddd-grep.txt then',
  347. ].join('\n')
  348. expect(extractSnapshotSpillPaths(log)).toEqual(new Map([
  349. ['bash.txt', '/tmp/dsh-acp-snapshot-spill/session-c22bc3f1d2af/8a7b6c5d4e3f-bash.txt'],
  350. ['grep.txt', '/tmp/dsh-acp-snap-012345678/session-cccccccccccc/dddddddddddd-grep.txt'],
  351. ]))
  352. })
  353. it('returns an empty map when the log carries no snapshot spill paths', () => {
  354. expect(extractSnapshotSpillPaths('no spill paths here, only /tmp/other.txt\n')).toEqual(new Map())
  355. })
  356. })
  357. describe('scrubRequestHeaders', () => {
  358. const headerLine = JSON.stringify({ type: 'session', version: 0, id: 's', createdAt: 1, cwd: '/w' })
  359. const headerEvent = (header: object) =>
  360. JSON.stringify({ type: 'request/header', seq: 3, time: 9, data: { header, reason: 'initial' } })
  361. it('replaces header system and tools with tokens, keeping config and reason', () => {
  362. const ev = headerEvent({
  363. config: { model: 'm' },
  364. system: 'You are an agent.\nBe brief.',
  365. tools: [{ name: 'read', description: 'Read a file.', parameters: { type: 'object' } }],
  366. })
  367. const out = scrubRequestHeaders(`${headerLine}\n${ev}\n`)
  368. expect(out).toContain('"system":"{{system}}"')
  369. expect(out).toContain('"tools":"{{tools}}"')
  370. expect(out).toContain('"config":{"model":"m"}')
  371. expect(out).toContain('"reason":"initial"')
  372. expect(out).not.toContain('You are an agent')
  373. expect(out).not.toContain('Read a file')
  374. })
  375. it('keeps an absent system/tools absent (presence is behavior)', () => {
  376. const out = scrubRequestHeaders(`${headerLine}\n${headerEvent({ config: { model: 'm' } })}\n`)
  377. expect(out).not.toContain('{{system}}')
  378. expect(out).not.toContain('{{tools}}')
  379. })
  380. it('scrubs a header carrying only one of system/tools, leaving the other absent', () => {
  381. const systemOnly = scrubRequestHeaders(`${headerLine}\n${headerEvent({ system: 'secret prompt' })}\n`)
  382. expect(systemOnly).toContain('"system":"{{system}}"')
  383. expect(systemOnly).not.toContain('{{tools}}')
  384. const toolsOnly = scrubRequestHeaders(`${headerLine}\n${headerEvent({ tools: [{ name: 't' }] })}\n`)
  385. expect(toolsOnly).toContain('"tools":"{{tools}}"')
  386. expect(toolsOnly).not.toContain('{{system}}')
  387. })
  388. it('leaves malformed headers with no scrubbable payload byte-identical', () => {
  389. const headerless = JSON.stringify({ type: 'request/header', seq: 10, time: 9, data: { reason: 'initial' } })
  390. const nullData = JSON.stringify({ type: 'request/header', seq: 11, time: 9, data: null })
  391. const raw = `${headerLine}\n${headerless}\n${nullData}\n`
  392. expect(scrubRequestHeaders(raw)).toBe(raw)
  393. })
  394. it('passes every other line through byte-for-byte and is idempotent', () => {
  395. const other = JSON.stringify({ type: 'assistant/chunk', seq: 4, time: 9, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'hi' } } })
  396. const raw = `${headerLine}\n${headerEvent({ config: { model: 'm' }, system: 's', tools: [] })}\n${other}\n`
  397. const once = scrubRequestHeaders(raw)
  398. expect(once.split('\n')[0]).toBe(headerLine)
  399. expect(once.split('\n')[2]).toBe(other)
  400. expect(scrubRequestHeaders(once)).toBe(once)
  401. })
  402. })
  403. describe('scrubSystemPrompts', () => {
  404. it('scrubs only system prompt payloads while keeping tools verbatim', () => {
  405. const header = JSON.stringify({
  406. type: 'request/header', seq: 1, time: 2,
  407. data: {
  408. header: {
  409. system: 'full prompt',
  410. tools: [{ name: 'read', description: 'full schema' }],
  411. },
  412. reason: 'initial',
  413. },
  414. })
  415. const changed = JSON.stringify({
  416. type: 'request/header', seq: 2, time: 3,
  417. data: {
  418. header: {
  419. system: 'new prompt',
  420. tools: [{ name: 'read', description: 'changed schema' }],
  421. },
  422. reason: 'change',
  423. },
  424. })
  425. const toolsOnly = JSON.stringify({
  426. type: 'request/header', seq: 3, time: 4,
  427. data: { header: { tools: [{ name: 'read', description: 'schema only' }] }, reason: 'resume' },
  428. })
  429. const out = scrubSystemPrompts(`${header}\n${changed}\n${toolsOnly}\n`)
  430. expect(out).toContain('"system":"{{system}}"')
  431. expect(out).not.toContain('full prompt')
  432. expect(out).not.toContain('new prompt')
  433. expect(out).toContain('full schema')
  434. expect(out).toContain('changed schema')
  435. expect(out.split('\n')[2]).toBe(toolsOnly)
  436. expect(scrubSystemPrompts(out)).toBe(out)
  437. })
  438. })
  439. describe('scrubToolSchemas', () => {
  440. it('scrubs only tool-schema payloads while keeping prompts verbatim', () => {
  441. const header = JSON.stringify({
  442. type: 'request/header', seq: 1, time: 2,
  443. data: {
  444. header: {
  445. system: 'full prompt',
  446. tools: [{ name: 'read', description: 'full schema', parameters: { type: 'object' } }],
  447. },
  448. reason: 'initial',
  449. },
  450. })
  451. const changed = JSON.stringify({
  452. type: 'request/header', seq: 2, time: 3,
  453. data: {
  454. header: {
  455. system: 'new prompt',
  456. tools: [{ name: 'grep', description: 'new schema' }],
  457. },
  458. reason: 'change',
  459. },
  460. })
  461. const systemOnly = JSON.stringify({
  462. type: 'request/header', seq: 3, time: 4,
  463. data: { header: { system: 'prompt only' }, reason: 'resume' },
  464. })
  465. const out = scrubToolSchemas(`${header}\n${changed}\n${systemOnly}\n`)
  466. expect(out.match(/"tools":"{{tools}}"/g)).toHaveLength(2)
  467. expect(out).not.toContain('full schema')
  468. expect(out).not.toContain('new schema')
  469. expect(out).toContain('full prompt')
  470. expect(out).toContain('new prompt')
  471. expect(out.split('\n')[2]).toBe(systemOnly)
  472. expect(scrubToolSchemas(out)).toBe(out)
  473. })
  474. })