| 1 | import { test } from 'node:test'; |
| 2 | import assert from 'node:assert/strict'; |
| 3 | import { readFileSync } from 'node:fs'; |
| 4 | import { fileURLToPath } from 'node:url'; |
| 5 | import { dirname, join } from 'node:path'; |
| 6 | |
| 7 | import { adaptFrom, autoAdapt, TOOLS } from '../src/adapters/index.js'; |
| 8 | import { classifyPrompts } from '../src/extract.js'; |
| 9 | import { buildTree } from '../src/tree.js'; |
| 10 | import { analyzeTree } from '../src/analyze.js'; |
| 11 | import { detectChatGPT } from '../src/adapters/chatgpt.js'; |
| 12 | import { detectCopilot } from '../src/adapters/copilot.js'; |
| 13 | import { detectGeminiJson } from '../src/adapters/gemini.js'; |
| 14 | import { detectCursor } from '../src/adapters/cursor.js'; |
| 15 | import { detectGrok } from '../src/adapters/grok.js'; |
| 16 | |
| 17 | const DIR = join(dirname(fileURLToPath(import.meta.url)), 'fixtures', 'adapters'); |
| 18 | const fx = (name) => join(DIR, name); |
| 19 | const read = (name) => readFileSync(fx(name), 'utf8'); |
| 20 | |
| 21 | function pipeline(sessions) { |
| 22 | const nodes = classifyPrompts(sessions); |
| 23 | return { nodes, tree: buildTree(sessions, nodes) }; |
| 24 | } |
| 25 | |
| 26 | test('codex: parses real rollout JSONL into human prompts', () => { |
| 27 | const sessions = adaptFrom('codex', read('codex-session.jsonl'), fx('codex-session.jsonl')); |
| 28 | assert.equal(sessions.length, 1); |
| 29 | const s = sessions[0]; |
| 30 | assert.equal(s.prompts.length, 3); |
| 31 | assert.ok(s.prompts.every((p) => !p.text.startsWith('<environment_context>'))); |
| 32 | assert.ok(s.prompts[0].text.includes('/version subcommand')); |
| 33 | assert.equal(s.stats.assistantLines, 3); |
| 34 | assert.ok(s.stats.toolUses >= 1); |
| 35 | assert.equal(s.stats.inputTokens, 5200); |
| 36 | const { tree } = pipeline(sessions); |
| 37 | assert.equal(tree.roots.length, 1); |
| 38 | assert.equal(tree.stats.promptCount, 3); |
| 39 | }); |
| 40 | |
| 41 | test('codex: auto-detected from JSONL shape', () => { |
| 42 | const detected = autoAdapt(read('codex-session.jsonl'), fx('codex-session.jsonl')); |
| 43 | assert.ok(detected); |
| 44 | assert.equal(detected.tool, 'codex'); |
| 45 | assert.equal(detected.sessions[0].prompts.length, 3); |
| 46 | }); |
| 47 | |
| 48 | test('chatgpt: parses export mapping, walks user turns, detects model', () => { |
| 49 | const sessions = adaptFrom('chatgpt', read('chatgpt-conversations.json'), fx('chatgpt-conversations.json')); |
| 50 | assert.equal(sessions.length, 1); |
| 51 | const s = sessions[0]; |
| 52 | assert.equal(s.prompts.length, 1); |
| 53 | assert.equal(s.title, 'Debounce a search input in React'); |
| 54 | assert.ok(s.stats.models.includes('gpt-4o')); |
| 55 | assert.ok(s.stats.assistantLines >= 1); |
| 56 | assert.ok(s.stats.toolUses >= 1); |
| 57 | }); |
| 58 | |
| 59 | test('chatgpt: auto-detected from mapping shape', () => { |
| 60 | const detected = autoAdapt(read('chatgpt-conversations.json'), fx('chatgpt-conversations.json')); |
| 61 | assert.ok(detected); |
| 62 | assert.equal(detected.tool, 'chatgpt'); |
| 63 | }); |
| 64 | |
| 65 | test('gemini: parses ChatRecordingService session, tools and tokens', () => { |
| 66 | const sessions = adaptFrom('gemini', read('gemini-session.json'), fx('gemini-session.json')); |
| 67 | const s = sessions[0]; |
| 68 | assert.equal(s.prompts.length, 3); |
| 69 | assert.ok(s.prompts[0].text.includes('health-check')); |
| 70 | assert.equal(s.stats.assistantLines, 3); |
| 71 | assert.ok(s.stats.toolUses >= 1); |
| 72 | assert.ok(s.stats.filesTouched.includes('src/server/health.ts')); |
| 73 | assert.ok(s.stats.models.includes('gemini-3-flash-preview')); |
| 74 | }); |
| 75 | |
| 76 | test('gemini: auto-detected from session JSON', () => { |
| 77 | const detected = autoAdapt(read('gemini-session.json'), fx('gemini-session.json')); |
| 78 | assert.ok(detected); |
| 79 | assert.equal(detected.tool, 'gemini'); |
| 80 | assert.equal(detected.sessions[0].prompts.length, 3); |
| 81 | }); |
| 82 | |
| 83 | test('copilot: parses requests[] into prompts, counts tool invocations', () => { |
| 84 | const sessions = adaptFrom('copilot', read('copilot-chatsession.json'), fx('copilot-chatsession.json')); |
| 85 | const s = sessions[0]; |
| 86 | assert.equal(s.prompts.length, 5); |
| 87 | assert.ok(s.prompts[0].text.toLowerCase().includes('html')); |
| 88 | assert.equal(s.stats.assistantLines, 5); |
| 89 | }); |
| 90 | |
| 91 | test('copilot: auto-detected from requesterUsername/requests', () => { |
| 92 | const detected = autoAdapt(read('copilot-chatsession.json'), fx('copilot-chatsession.json')); |
| 93 | assert.ok(detected); |
| 94 | assert.equal(detected.tool, 'copilot'); |
| 95 | }); |
| 96 | |
| 97 | test('cursor: parses exported session messages, model and files', () => { |
| 98 | const sessions = adaptFrom('cursor', read('cursor-export.json'), fx('cursor-export.json')); |
| 99 | const s = sessions[0]; |
| 100 | assert.equal(s.prompts.length, 3); |
| 101 | assert.equal(s.title, 'Add pagination to the users table'); |
| 102 | assert.ok(s.stats.models.includes('claude-3.5-sonnet')); |
| 103 | assert.ok(s.stats.toolUses >= 1); |
| 104 | assert.ok(s.stats.filesTouched.some((f) => f.endsWith('Users.tsx'))); |
| 105 | }); |
| 106 | |
| 107 | test('cursor: auto-detected from exported session shape', () => { |
| 108 | const detected = autoAdapt(read('cursor-export.json'), fx('cursor-export.json')); |
| 109 | assert.ok(detected); |
| 110 | assert.equal(detected.tool, 'cursor'); |
| 111 | }); |
| 112 | |
| 113 | test('grok: parses conversation[] role/content into prompts', () => { |
| 114 | const sessions = adaptFrom('grok', read('grok-session.json'), fx('grok-session.json')); |
| 115 | const s = sessions[0]; |
| 116 | assert.equal(s.prompts.length, 3); |
| 117 | assert.ok(s.prompts[0].text.includes('Fibonacci')); |
| 118 | assert.ok(s.stats.models.includes('grok-4')); |
| 119 | assert.equal(s.stats.assistantLines, 2); |
| 120 | }); |
| 121 | |
| 122 | test('grok: auto-detected from conversation messages', () => { |
| 123 | const detected = autoAdapt(read('grok-session.json'), fx('grok-session.json')); |
| 124 | assert.ok(detected); |
| 125 | assert.equal(detected.tool, 'grok'); |
| 126 | }); |
| 127 | |
| 128 | test('adapter output flows through the full classify/tree pipeline', () => { |
| 129 | for (const name of ['codex-session.jsonl', 'gemini-session.json', 'cursor-export.json', 'grok-session.json']) { |
| 130 | const text = read(name); |
| 131 | const detected = autoAdapt(text, fx(name)); |
| 132 | assert.ok(detected, `no detection for ${name}`); |
| 133 | const { tree } = pipeline(detected.sessions); |
| 134 | assert.ok(tree.nodes.length >= 1, `no nodes for ${name}`); |
| 135 | assert.equal(tree.nodes[0].kind, 'root', `first node not root for ${name}`); |
| 136 | assert.ok(tree.roots.length >= 1); |
| 137 | } |
| 138 | }); |
| 139 | |
| 140 | test('detectors: exactly one JSON detector fires per fixture (no cursor/grok overlap)', () => { |
| 141 | const jsonDetectors = { |
| 142 | chatgpt: detectChatGPT, |
| 143 | copilot: detectCopilot, |
| 144 | gemini: detectGeminiJson, |
| 145 | cursor: detectCursor, |
| 146 | grok: detectGrok, |
| 147 | }; |
| 148 | const expected = { |
| 149 | 'chatgpt-conversations.json': 'chatgpt', |
| 150 | 'copilot-chatsession.json': 'copilot', |
| 151 | 'gemini-session.json': 'gemini', |
| 152 | 'cursor-export.json': 'cursor', |
| 153 | 'grok-session.json': 'grok', |
| 154 | }; |
| 155 | for (const [name, want] of Object.entries(expected)) { |
| 156 | const parsed = JSON.parse(read(name)); |
| 157 | const fired = Object.entries(jsonDetectors).filter(([, fn]) => fn(parsed)).map(([k]) => k); |
| 158 | assert.deepEqual(fired, [want], `${name} should fire exactly [${want}], got [${fired.join(', ')}]`); |
| 159 | } |
| 160 | }); |
| 161 | |
| 162 | test('detectGrok requires a grok-specific signal, not just role/content messages', () => { |
| 163 | const generic = { messages: [{ role: 'user', content: 'hi' }, { role: 'assistant', content: 'hello' }] }; |
| 164 | assert.equal(detectGrok(generic), false, 'generic role/content dump must not be claimed by grok'); |
| 165 | assert.equal(detectGrok({ model: 'grok-4', messages: generic.messages }), true, 'a grok model marker should be detected'); |
| 166 | }); |
| 167 | |
| 168 | test('copilot and cursor adapters handle null/primitive JSON without throwing', () => { |
| 169 | for (const bad of ['null', '42', 'true', '"hi"']) { |
| 170 | assert.doesNotThrow(() => { |
| 171 | const c = adaptFrom('copilot', bad, fx('bad.json')); |
| 172 | assert.equal(c[0].prompts.length, 0); |
| 173 | }, `copilot threw on ${bad}`); |
| 174 | assert.doesNotThrow(() => { |
| 175 | const c = adaptFrom('cursor', bad, fx('bad.json')); |
| 176 | assert.equal(c[0].prompts.length, 0); |
| 177 | }, `cursor threw on ${bad}`); |
| 178 | } |
| 179 | }); |
| 180 | |
| 181 | test('adaptFrom rejects an unknown tool name', () => { |
| 182 | assert.throws(() => adaptFrom('notatool', '{}', 'x.json'), /unknown/); |
| 183 | assert.ok(TOOLS.includes('codex') && TOOLS.includes('cursor')); |
| 184 | }); |
| 185 | |
| 186 | test('codex import emits actions that drive a verified security signal and model attribution', () => { |
| 187 | const jsonl = [ |
| 188 | { type: 'session_meta', timestamp: '2026-06-12T10:00:00Z', payload: { id: 'cdx1', originator: 'codex_cli_rs', cwd: '/repo', cli_version: '0.139.0' } }, |
| 189 | { type: 'turn_context', timestamp: '2026-06-12T10:00:01Z', payload: { model: 'gpt-5.5', cwd: '/repo' } }, |
| 190 | { type: 'response_item', timestamp: '2026-06-12T10:00:02Z', payload: { type: 'message', role: 'user', content: [{ type: 'text', text: 'Add rate limiting to the checkout endpoint' }] } }, |
| 191 | { type: 'response_item', timestamp: '2026-06-12T10:00:03Z', payload: { type: 'reasoning', summary: [] } }, |
| 192 | { type: 'response_item', timestamp: '2026-06-12T10:00:05Z', payload: { type: 'function_call', name: 'apply_patch', arguments: JSON.stringify({ path: 'src/auth/session.ts' }), call_id: 'c1' } }, |
| 193 | { type: 'response_item', timestamp: '2026-06-12T10:00:06Z', payload: { type: 'message', role: 'assistant', content: [{ type: 'text', text: 'Edited session.ts' }] } }, |
| 194 | ].map((r) => JSON.stringify(r)).join('\n'); |
| 195 | |
| 196 | const sessions = adaptFrom('codex', jsonl, fx('codex-synth.jsonl')); |
| 197 | const s = sessions[0]; |
| 198 | assert.equal(s.prompts[0].actions.length, 1); |
| 199 | assert.equal(s.prompts[0].actions[0].file, 'src/auth/session.ts'); |
| 200 | assert.equal(s.prompts[0].actions[0].model, 'gpt-5.5'); |
| 201 | assert.equal(s.prompts[0].thinking, 1); |
| 202 | |
| 203 | const { tree } = pipeline(sessions); |
| 204 | const analysis = analyzeTree(tree); |
| 205 | const sec = analysis.failures.find((f) => f.type === 'security_or_privacy_risk' && f.tier === 'verified'); |
| 206 | assert.ok(sec, 'a codex import should now produce a verified security signal'); |
| 207 | assert.equal(sec.model, 'gpt-5.5'); |
| 208 | assert.deepEqual(analysis.summary.models, ['gpt-5.5']); |
| 209 | assert.ok(analysis.summary.thinkingBlocks >= 1); |
| 210 | }); |
| 211 | |
| 212 | test('gemini import emits actions for a verified security signal', () => { |
| 213 | const obj = { |
| 214 | sessionId: 'g1', |
| 215 | messages: [ |
| 216 | { type: 'user', content: [{ text: 'Add rate limiting to checkout' }], timestamp: '2026-06-12T10:00:00Z' }, |
| 217 | { type: 'gemini', model: 'gemini-3-flash', timestamp: '2026-06-12T10:00:05Z', toolCalls: [{ name: 'edit_file', args: { file_path: 'src/auth/middleware.ts' } }], thoughts: [{ subject: 'a', description: 'b' }] }, |
| 218 | ], |
| 219 | }; |
| 220 | const sessions = adaptFrom('gemini', JSON.stringify(obj), fx('gemini-synth.json')); |
| 221 | const analysis = analyzeTree(pipeline(sessions).tree); |
| 222 | const sec = analysis.failures.find((f) => f.type === 'security_or_privacy_risk' && f.tier === 'verified'); |
| 223 | assert.ok(sec, 'gemini import should produce a verified security signal'); |
| 224 | assert.equal(sec.model, 'gemini-3-flash'); |
| 225 | assert.ok(analysis.summary.thinkingBlocks >= 1); |
| 226 | }); |
| 227 | |
| 228 | test('copilot import emits actions from toolSpecificData for a verified security signal', () => { |
| 229 | const obj = { |
| 230 | version: 3, |
| 231 | requests: [ |
| 232 | { |
| 233 | requestId: 'r1', |
| 234 | message: { text: 'Add rate limiting to checkout' }, |
| 235 | result: { metadata: { modelId: 'gpt-4o-copilot' } }, |
| 236 | response: [{ kind: 'toolInvocationSerialized', toolId: 'copilot_editFile', toolSpecificData: { uri: { path: 'src/auth/session.ts' } } }], |
| 237 | }, |
| 238 | ], |
| 239 | }; |
| 240 | const sessions = adaptFrom('copilot', JSON.stringify(obj), fx('copilot-synth.json')); |
| 241 | const analysis = analyzeTree(pipeline(sessions).tree); |
| 242 | const sec = analysis.failures.find((f) => f.type === 'security_or_privacy_risk' && f.tier === 'verified'); |
| 243 | assert.ok(sec, 'copilot import should produce a verified security signal'); |
| 244 | assert.equal(sec.model, 'gpt-4o-copilot'); |
| 245 | }); |
| 246 | |
| 247 | test('cursor import emits actions from exported tool calls for a verified security signal', () => { |
| 248 | const obj = { |
| 249 | id: 'cur1', |
| 250 | title: 'session', |
| 251 | workspaceId: 'w1', |
| 252 | messages: [ |
| 253 | { role: 'user', content: 'Add rate limiting to checkout', timestamp: '2026-06-12T10:00:00Z' }, |
| 254 | { role: 'assistant', model: 'claude-sonnet-4-6', toolCalls: [{ name: 'edit_file', filePath: 'src/auth/session.ts' }] }, |
| 255 | ], |
| 256 | }; |
| 257 | const sessions = adaptFrom('cursor', JSON.stringify(obj), fx('cursor-synth.json')); |
| 258 | const analysis = analyzeTree(pipeline(sessions).tree); |
| 259 | const sec = analysis.failures.find((f) => f.type === 'security_or_privacy_risk' && f.tier === 'verified'); |
| 260 | assert.ok(sec, 'cursor import should produce a verified security signal'); |
| 261 | assert.equal(sec.model, 'claude-sonnet-4-6'); |
| 262 | }); |
| 263 | |
| 264 | test('adapters capture an assistant refusal as model_refusal', () => { |
| 265 | const gem = JSON.stringify({ sessionId: 'g1', messages: [ |
| 266 | { type: 'user', content: '[disallowed ask]' }, |
| 267 | { type: 'gemini', content: [{ text: "I'm sorry, I cannot help with that." }], model: 'gemini-3' }, |
| 268 | ] }); |
| 269 | assert.equal(adaptFrom('gemini', gem, 'g.json')[0].stats.rejectionsByKind.model_refusal, 1, 'gemini'); |
| 270 | |
| 271 | const cdx = [ |
| 272 | JSON.stringify({ type: 'response_item', payload: { type: 'message', role: 'user', content: '[disallowed ask]' } }), |
| 273 | JSON.stringify({ type: 'response_item', payload: { type: 'message', role: 'assistant', content: [{ type: 'text', text: 'I cannot help with that request.' }] } }), |
| 274 | ].join('\n'); |
| 275 | assert.equal(adaptFrom('codex', cdx, 'c.jsonl')[0].stats.rejectionsByKind.model_refusal, 1, 'codex'); |
| 276 | |
| 277 | const cg = JSON.stringify([{ title: 't', mapping: { |
| 278 | a: { id: 'a', message: { author: { role: 'user' }, content: { content_type: 'text', parts: ['[disallowed ask]'] }, create_time: 1 } }, |
| 279 | b: { id: 'b', message: { author: { role: 'assistant' }, content: { content_type: 'text', parts: ["I'm sorry, I can't help with that."] }, create_time: 2 } }, |
| 280 | } }]); |
| 281 | assert.equal(adaptFrom('chatgpt', cg, 'x.json')[0].stats.rejectionsByKind.model_refusal, 1, 'chatgpt'); |
| 282 | |
| 283 | const cur = JSON.stringify({ messages: [ |
| 284 | { role: 'user', content: '[disallowed ask]' }, |
| 285 | { role: 'assistant', content: 'I cannot help with that.', model: 'claude-3.5' }, |
| 286 | ], workspaceId: 'w' }); |
| 287 | assert.equal(adaptFrom('cursor', cur, 'cur.json')[0].stats.rejectionsByKind.model_refusal, 1, 'cursor'); |
| 288 | }); |
| 289 | |
| 290 | test('a benign assistant turn produces no false refusal', () => { |
| 291 | const gem = JSON.stringify({ sessionId: 'g2', messages: [ |
| 292 | { type: 'user', content: 'help me write a function' }, |
| 293 | { type: 'gemini', content: [{ text: 'Sure, here is a function that does that.' }], model: 'gemini-3' }, |
| 294 | ] }); |
| 295 | const s = adaptFrom('gemini', gem, 'g2.json')[0]; |
| 296 | assert.equal(s.stats.rejections, 0, 'no false positive on a helpful answer'); |
| 297 | }); |