normalize.spec.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329
  1. import { describe, expect, it } from 'vitest'
  2. import {
  3. type NormalizeContext,
  4. normalizeSessionLog,
  5. normalizeStdout,
  6. scrubRequestHeaders,
  7. scrubSystemPrompts,
  8. scrubToolSchemas,
  9. } from '../src/normalize.ts'
  10. /**
  11. * Unit tests for the pure snapshot normalizers. Live as a *.spec.ts (runs in
  12. * the default unit gate) and import the normalizers directly.
  13. */
  14. const ctx: NormalizeContext = {
  15. sessionIds: ['11111111-2222-3333-4444-555555555555'],
  16. cwd: '/tmp/acp-snap-cwd-abc123',
  17. }
  18. describe('normalizeStdout', () => {
  19. it('rewrites JSON-RPC ids to a stable first-seen sequence', () => {
  20. const raw = [
  21. JSON.stringify({ jsonrpc: '2.0', id: 42, method: 'initialize' }),
  22. JSON.stringify({ jsonrpc: '2.0', id: 42, result: {} }),
  23. JSON.stringify({ jsonrpc: '2.0', id: 99, method: 'session/new' }),
  24. ].join('\n')
  25. const out = normalizeStdout(raw, ctx)
  26. expect(out).toContain('"id":1')
  27. expect(out).toContain('"id":2')
  28. expect(out).not.toContain('42')
  29. expect(out).not.toContain('99')
  30. })
  31. it('scrubs the cwd and session id anywhere they appear', () => {
  32. const raw = JSON.stringify({
  33. jsonrpc: '2.0', method: 'session/update',
  34. params: { sessionId: ctx.sessionIds[0], cwd: ctx.cwd, note: `at ${ctx.cwd}/x` },
  35. })
  36. const out = normalizeStdout(raw, ctx)
  37. expect(out).toContain('{{sessionId}}')
  38. expect(out).toContain('{{cwd}}')
  39. expect(out).not.toContain(ctx.cwd)
  40. expect(out).not.toContain(ctx.sessionIds[0] as string)
  41. })
  42. it('scrubs a stray UUID not in the known list', () => {
  43. const raw = JSON.stringify({ jsonrpc: '2.0', method: 'x', params: { id: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' } })
  44. expect(normalizeStdout(raw, ctx)).toContain('{{sessionId}}')
  45. })
  46. it('leaves notification frames without an id untouched in id-space', () => {
  47. const raw = JSON.stringify({ jsonrpc: '2.0', method: 'session/update', params: {} })
  48. const out = normalizeStdout(raw, ctx)
  49. expect(out).not.toContain('"id"')
  50. })
  51. it('throws on a non-JSON stdout line (the purity check)', () => {
  52. const raw = `${JSON.stringify({ jsonrpc: '2.0', id: 1 })}\noops a log leaked\n`
  53. expect(() => normalizeStdout(raw, ctx)).toThrow()
  54. })
  55. it('ignores blank lines', () => {
  56. const raw = `\n${JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'm' })}\n\n`
  57. expect(() => normalizeStdout(raw, ctx)).not.toThrow()
  58. })
  59. })
  60. describe('normalizeSessionLog', () => {
  61. const header = (over: object) => JSON.stringify({ type: 'session', version: 0, id: 's', createdAt: 123, ...over })
  62. const event = (over: object) => JSON.stringify({ type: 'turn/start', seq: 1, time: 999, data: { turn: 1 }, ...over })
  63. it('zeroes the header createdAt', () => {
  64. const out = normalizeSessionLog(`${header({})}\n`, ctx)
  65. expect(out).toContain('"createdAt":0')
  66. expect(out).not.toContain('123')
  67. })
  68. it('zeroes each event time but keeps seq', () => {
  69. const out = normalizeSessionLog(`${header({})}\n${event({ seq: 7, time: 999 })}\n`, ctx)
  70. expect(out).toContain('"time":0')
  71. expect(out).toContain('"seq":7') // seq is deterministic — NOT scrubbed
  72. expect(out).not.toContain('999')
  73. })
  74. it('scrubs cwd and session id deep inside event data', () => {
  75. const ev = JSON.stringify({
  76. type: 'tool/result', seq: 2, time: 5,
  77. data: { content: [{ type: 'text', text: `wrote ${ctx.cwd}/proof.txt` }] },
  78. })
  79. const out = normalizeSessionLog(`${header({ cwd: ctx.cwd })}\n${ev}\n`, ctx)
  80. expect(out).toContain('{{cwd}}')
  81. expect(out).not.toContain(ctx.cwd)
  82. })
  83. it('scrubs random local spill paths under the snapshot cwd', () => {
  84. const ev = JSON.stringify({
  85. type: 'tool/result', seq: 2, time: 5,
  86. data: {
  87. content: [{
  88. type: 'text',
  89. 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.`,
  90. }],
  91. },
  92. })
  93. const out = normalizeSessionLog(`${header({ cwd: ctx.cwd })}\n${ev}\n`, ctx)
  94. expect(out).toContain('{{spillLocator:bash.txt}}')
  95. expect(out).not.toContain('session-c22bc3f1d2af')
  96. expect(out).not.toContain('8a7b6c5d4e3f')
  97. })
  98. it('scrubs macOS /private aliases for local spill paths', () => {
  99. const ev = JSON.stringify({
  100. type: 'tool/result', seq: 2, time: 5,
  101. data: {
  102. content: [{
  103. type: 'text',
  104. 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.`,
  105. }],
  106. },
  107. })
  108. const out = normalizeSessionLog(`${header({ cwd: ctx.cwd })}\n${ev}\n`, ctx)
  109. expect(out).toContain('{{spillLocator:bash.txt}}')
  110. expect(out).not.toContain('/private{{spillLocator')
  111. })
  112. it('scrubs fixed snapshot spill paths', () => {
  113. const ev = JSON.stringify({
  114. type: 'tool/result', seq: 2, time: 5,
  115. data: {
  116. content: [{
  117. type: 'text',
  118. 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.',
  119. }],
  120. },
  121. })
  122. const out = normalizeSessionLog(`${header({ cwd: ctx.cwd })}\n${ev}\n`, ctx)
  123. expect(out).toContain('{{spillLocator:bash.txt}}')
  124. expect(out).not.toContain('/tmp/dsh-acp-snapshot-spill')
  125. })
  126. it('scrubs the session id in the header', () => {
  127. const out = normalizeSessionLog(`${header({ id: ctx.sessionIds[0] })}\n`, ctx)
  128. expect(out).toContain('{{sessionId}}')
  129. })
  130. it('zeroes a hook/result durationMs (run-to-run noise) but keeps its decision', () => {
  131. const ev = JSON.stringify({
  132. type: 'hook/result', seq: 2, time: 5,
  133. data: { turn: 1, point: 'UserPromptSubmit', handlerId: 'h', decision: 'block', exitCode: 2, durationMs: 37 },
  134. })
  135. const out = normalizeSessionLog(`${header({})}\n${ev}\n`, ctx)
  136. expect(out).toContain('"durationMs":0')
  137. expect(out).not.toContain('37')
  138. expect(out).toContain('"decision":"block"') // the decision is the behavior — kept
  139. })
  140. it('leaves a non-hook event durationMs untouched (only hook/result is scrubbed)', () => {
  141. const ev = JSON.stringify({ type: 'tool/result', seq: 2, time: 5, data: { durationMs: 88 } })
  142. const out = normalizeSessionLog(`${header({})}\n${ev}\n`, ctx)
  143. expect(out).toContain('"durationMs":88')
  144. })
  145. it('tolerates records missing the volatile fields it would zero', () => {
  146. const bareHeader = JSON.stringify({ type: 'session', id: 's' })
  147. const timeless = JSON.stringify({ type: 'note', seq: 1 })
  148. const bareHook = JSON.stringify({ type: 'hook/result', seq: 2, time: 5, data: { decision: 'allow' } })
  149. const nullDataHook = JSON.stringify({ type: 'hook/result', seq: 3, time: 6, data: null })
  150. const out = normalizeSessionLog(`${bareHeader}\n${timeless}\n${bareHook}\n${nullDataHook}\n`, ctx)
  151. expect(out).toContain('"type":"note","seq":1')
  152. expect(out).toContain('"decision":"allow"')
  153. expect(out).not.toContain('durationMs')
  154. })
  155. })
  156. describe('scrubRequestHeaders', () => {
  157. const headerLine = JSON.stringify({ type: 'session', version: 0, id: 's', createdAt: 1, cwd: '/w' })
  158. const headerEvent = (header: object) =>
  159. JSON.stringify({ type: 'request/header', seq: 3, time: 9, data: { header, reason: 'initial' } })
  160. it('replaces header system and tools with tokens, keeping config and reason', () => {
  161. const ev = headerEvent({
  162. config: { model: 'm' },
  163. system: 'You are an agent.\nBe brief.',
  164. tools: [{ name: 'read', description: 'Read a file.', parameters: { type: 'object' } }],
  165. })
  166. const out = scrubRequestHeaders(`${headerLine}\n${ev}\n`)
  167. expect(out).toContain('"system":"{{system}}"')
  168. expect(out).toContain('"tools":"{{tools}}"')
  169. expect(out).toContain('"config":{"model":"m"}')
  170. expect(out).toContain('"reason":"initial"')
  171. expect(out).not.toContain('You are an agent')
  172. expect(out).not.toContain('Read a file')
  173. })
  174. it('keeps an absent system/tools absent (presence is behavior)', () => {
  175. const out = scrubRequestHeaders(`${headerLine}\n${headerEvent({ config: { model: 'm' } })}\n`)
  176. expect(out).not.toContain('{{system}}')
  177. expect(out).not.toContain('{{tools}}')
  178. })
  179. it('scrubs a header carrying only one of system/tools, leaving the other absent', () => {
  180. const systemOnly = scrubRequestHeaders(`${headerLine}\n${headerEvent({ system: 'secret prompt' })}\n`)
  181. expect(systemOnly).toContain('"system":"{{system}}"')
  182. expect(systemOnly).not.toContain('{{tools}}')
  183. const toolsOnly = scrubRequestHeaders(`${headerLine}\n${headerEvent({ tools: [{ name: 't' }] })}\n`)
  184. expect(toolsOnly).toContain('"tools":"{{tools}}"')
  185. expect(toolsOnly).not.toContain('{{system}}')
  186. })
  187. it('scrubs the header session prefix to one token per message, keeping the count', () => {
  188. const ev = headerEvent({
  189. config: { model: 'm' },
  190. messagePrefix: [
  191. { role: 'user', content: [{ type: 'text', text: 'workspace AGENTS digest' }] },
  192. { role: 'user', content: [{ type: 'text', text: 'skills catalog' }] },
  193. ],
  194. })
  195. const out = scrubRequestHeaders(`${headerLine}\n${ev}\n`)
  196. expect(out).toContain('"messagePrefix":["{{messagePrefix}}","{{messagePrefix}}"]')
  197. expect(out).not.toContain('AGENTS digest')
  198. expect(out).not.toContain('skills catalog')
  199. // Absence stays absent — a prefix-less header gains no token…
  200. expect(scrubRequestHeaders(`${headerLine}\n${headerEvent({ system: 's' })}\n`)).not.toContain('{{messagePrefix}}')
  201. // …and a non-array shape passes through untouched.
  202. const odd = JSON.stringify({ type: 'request/header', seq: 4, time: 9, data: { header: { config: { model: 'm' }, messagePrefix: 'weird' }, reason: 'initial' } })
  203. expect(scrubRequestHeaders(`${headerLine}\n${odd}\n`)).toContain('"messagePrefix":"weird"')
  204. })
  205. it('leaves malformed headers with no scrubbable payload byte-identical', () => {
  206. const headerless = JSON.stringify({ type: 'request/header', seq: 10, time: 9, data: { reason: 'initial' } })
  207. const nullData = JSON.stringify({ type: 'request/header', seq: 11, time: 9, data: null })
  208. const raw = `${headerLine}\n${headerless}\n${nullData}\n`
  209. expect(scrubRequestHeaders(raw)).toBe(raw)
  210. })
  211. it('passes every other line through byte-for-byte and is idempotent', () => {
  212. const other = JSON.stringify({ type: 'assistant/chunk', seq: 4, time: 9, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'hi' } } })
  213. const raw = `${headerLine}\n${headerEvent({ config: { model: 'm' }, system: 's', tools: [] })}\n${other}\n`
  214. const once = scrubRequestHeaders(raw)
  215. expect(once.split('\n')[0]).toBe(headerLine)
  216. expect(once.split('\n')[2]).toBe(other)
  217. expect(scrubRequestHeaders(once)).toBe(once)
  218. })
  219. })
  220. describe('scrubSystemPrompts', () => {
  221. it('scrubs only system prompt payloads while keeping tools and prefixes verbatim', () => {
  222. const header = JSON.stringify({
  223. type: 'request/header', seq: 1, time: 2,
  224. data: {
  225. header: {
  226. system: 'full prompt',
  227. tools: [{ name: 'read', description: 'full schema' }],
  228. messagePrefix: [{ role: 'user', content: [{ type: 'text', text: 'full prefix' }] }],
  229. },
  230. reason: 'initial',
  231. },
  232. })
  233. const changed = JSON.stringify({
  234. type: 'request/header', seq: 2, time: 3,
  235. data: {
  236. header: {
  237. system: 'new prompt',
  238. tools: [{ name: 'read', description: 'changed schema' }],
  239. messagePrefix: [{ role: 'user', content: [{ type: 'text', text: 'changed prefix' }] }],
  240. },
  241. reason: 'change',
  242. },
  243. })
  244. const toolsOnly = JSON.stringify({
  245. type: 'request/header', seq: 3, time: 4,
  246. data: { header: { tools: [{ name: 'read', description: 'schema only' }] }, reason: 'resume' },
  247. })
  248. const out = scrubSystemPrompts(`${header}\n${changed}\n${toolsOnly}\n`)
  249. expect(out).toContain('"system":"{{system}}"')
  250. expect(out).not.toContain('full prompt')
  251. expect(out).not.toContain('new prompt')
  252. expect(out).toContain('full schema')
  253. expect(out).toContain('full prefix')
  254. expect(out).toContain('changed schema')
  255. expect(out).toContain('changed prefix')
  256. expect(out.split('\n')[2]).toBe(toolsOnly)
  257. expect(scrubSystemPrompts(out)).toBe(out)
  258. })
  259. })
  260. describe('scrubToolSchemas', () => {
  261. it('scrubs only tool-schema payloads while keeping prompts and prefixes verbatim', () => {
  262. const header = JSON.stringify({
  263. type: 'request/header', seq: 1, time: 2,
  264. data: {
  265. header: {
  266. system: 'full prompt',
  267. tools: [{ name: 'read', description: 'full schema', parameters: { type: 'object' } }],
  268. messagePrefix: [{ role: 'user', content: [{ type: 'text', text: 'full prefix' }] }],
  269. },
  270. reason: 'initial',
  271. },
  272. })
  273. const changed = JSON.stringify({
  274. type: 'request/header', seq: 2, time: 3,
  275. data: {
  276. header: {
  277. system: 'new prompt',
  278. tools: [{ name: 'grep', description: 'new schema' }],
  279. messagePrefix: [{ role: 'user', content: [{ type: 'text', text: 'changed prefix' }] }],
  280. },
  281. reason: 'change',
  282. },
  283. })
  284. const systemOnly = JSON.stringify({
  285. type: 'request/header', seq: 3, time: 4,
  286. data: { header: { system: 'prompt only' }, reason: 'resume' },
  287. })
  288. const out = scrubToolSchemas(`${header}\n${changed}\n${systemOnly}\n`)
  289. expect(out.match(/"tools":"{{tools}}"/g)).toHaveLength(2)
  290. expect(out).not.toContain('full schema')
  291. expect(out).not.toContain('new schema')
  292. expect(out).toContain('full prompt')
  293. expect(out).toContain('new prompt')
  294. expect(out).toContain('full prefix')
  295. expect(out).toContain('changed prefix')
  296. expect(out.split('\n')[2]).toBe(systemOnly)
  297. expect(scrubToolSchemas(out)).toBe(out)
  298. })
  299. })