@cnrai/pave 0.3.35 → 0.3.51

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +21 -218
  3. package/package.json +32 -35
  4. package/pave.js +3 -0
  5. package/sandbox/SandboxRunner.js +1 -0
  6. package/sandbox/pave-run.js +2 -0
  7. package/sandbox/permission.js +1 -0
  8. package/sandbox/utils/yaml.js +1 -0
  9. package/MARKETPLACE.md +0 -406
  10. package/build-binary.js +0 -591
  11. package/build-npm.js +0 -537
  12. package/build.js +0 -230
  13. package/check-binary.js +0 -26
  14. package/deploy.sh +0 -95
  15. package/index.js +0 -5776
  16. package/lib/agent-registry.js +0 -1037
  17. package/lib/args-parser.js +0 -837
  18. package/lib/blessed-widget-patched.js +0 -93
  19. package/lib/cli-markdown.js +0 -590
  20. package/lib/compaction.js +0 -153
  21. package/lib/duration.js +0 -94
  22. package/lib/hash.js +0 -22
  23. package/lib/marketplace.js +0 -866
  24. package/lib/memory-config.js +0 -166
  25. package/lib/skill-manager.js +0 -891
  26. package/lib/soul.js +0 -31
  27. package/lib/tool-output-formatter.js +0 -180
  28. package/start-pave.sh +0 -149
  29. package/status.js +0 -271
  30. package/test/abort-stream.test.js +0 -445
  31. package/test/agent-auto-compaction.test.js +0 -552
  32. package/test/agent-comm-abort.test.js +0 -95
  33. package/test/agent-comm.test.js +0 -598
  34. package/test/agent-inbox.test.js +0 -576
  35. package/test/agent-init.test.js +0 -264
  36. package/test/agent-interrupt.test.js +0 -314
  37. package/test/agent-lifecycle.test.js +0 -520
  38. package/test/agent-log-files.test.js +0 -349
  39. package/test/agent-mode.manual-test.js +0 -392
  40. package/test/agent-parsing.test.js +0 -228
  41. package/test/agent-post-stream-idle.test.js +0 -762
  42. package/test/agent-registry.test.js +0 -359
  43. package/test/agent-rm.test.js +0 -442
  44. package/test/agent-spawn.test.js +0 -933
  45. package/test/agent-status-api.test.js +0 -624
  46. package/test/agent-update.test.js +0 -435
  47. package/test/args-parser.test.js +0 -391
  48. package/test/auto-compaction-chat.manual-test.js +0 -227
  49. package/test/auto-compaction.test.js +0 -941
  50. package/test/build-config.test.js +0 -120
  51. package/test/build-npm.test.js +0 -388
  52. package/test/chat-command.test.js +0 -137
  53. package/test/chat-leading-lines.test.js +0 -159
  54. package/test/config-flag.test.js +0 -272
  55. package/test/cursor-drift.test.js +0 -135
  56. package/test/debug-require.js +0 -23
  57. package/test/dir-migration.test.js +0 -323
  58. package/test/duration.test.js +0 -229
  59. package/test/ghostty-term.test.js +0 -202
  60. package/test/http500-backoff.test.js +0 -854
  61. package/test/integration.test.js +0 -86
  62. package/test/memory-guard-env.test.js +0 -220
  63. package/test/pr233-fixes.test.js +0 -259
  64. package/test/run-agent-init.js +0 -297
  65. package/test/run-all.js +0 -64
  66. package/test/run-config-flag.js +0 -159
  67. package/test/run-cursor-drift.js +0 -82
  68. package/test/run-session-path.js +0 -154
  69. package/test/run-tests.js +0 -643
  70. package/test/sandbox-redirect.test.js +0 -202
  71. package/test/session-path.test.js +0 -132
  72. package/test/shebang-strip.test.js +0 -241
  73. package/test/soul-reinject.test.js +0 -1027
  74. package/test/soul-reread.test.js +0 -281
  75. package/test/tool-output-formatter.test.js +0 -486
  76. package/test/tool-output-gating.test.js +0 -143
  77. package/test/tool-states.test.js +0 -167
  78. package/test/tools-flag.test.js +0 -65
  79. package/test/tui-attach.test.js +0 -1255
  80. package/test/tui-compaction.test.js +0 -354
  81. package/test/tui-wrap.test.js +0 -568
  82. package/test-binary.js +0 -52
  83. package/test-binary2.js +0 -36
@@ -1,941 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Unit tests for auto-compaction feature
4
- * Tests model parsing and compaction response handling logic
5
- * Run with: node test/auto-compaction.test.js
6
- */
7
-
8
- // ============================================================================
9
- // Import shared helper with sandbox fallback
10
- // ============================================================================
11
- // Try to import from lib/compaction.js first (normal Node execution).
12
- // Fall back to inline implementation for sandbox environments where the
13
- // relative require path doesn't work (test files are copied to temp directory).
14
- let parseModelForCompaction;
15
- try {
16
- const compaction = require('../lib/compaction');
17
- parseModelForCompaction = compaction.parseModelForCompaction;
18
- } catch (e) {
19
- // Fallback for sandbox: simplified inline version of lib/compaction.js.
20
- // This uses hardcoded defaults (no env var support) since sandbox tests
21
- // cannot modify process.env. See lib/compaction.js for the canonical
22
- // implementation with full PAVE_COMPACT_MODEL/PAVE_MODEL precedence.
23
- const KNOWN_COMPACTION_MODELS = [
24
- 'claude-opus-4.5',
25
- 'claude-opus-4.6',
26
- 'claude-sonnet-4', // Canonical model advertised in CLI help
27
- 'claude-sonnet-4.5',
28
- 'claude-sonnet-4.6',
29
- 'claude-3-5-sonnet-20241022',
30
- 'claude-3-5-haiku-20241022',
31
- 'claude-3-opus-20240229',
32
- 'gpt-4',
33
- 'gpt-4-turbo',
34
- 'gpt-3.5-turbo',
35
- ];
36
- const DEFAULT_PROVIDER_ID = 'github-copilot';
37
- const DEFAULT_MODEL_ID = 'claude-opus-4.5';
38
-
39
- parseModelForCompaction = function (model) {
40
- let providerID = DEFAULT_PROVIDER_ID;
41
- let modelID = DEFAULT_MODEL_ID;
42
-
43
- if (model) {
44
- if (model.includes('/')) {
45
- const parts = model.split('/');
46
- providerID = parts[0];
47
- modelID = parts.slice(1).join('/');
48
- } else {
49
- modelID = model;
50
- }
51
- }
52
-
53
- const compactionModelID = KNOWN_COMPACTION_MODELS.includes(modelID) ? modelID : DEFAULT_MODEL_ID;
54
- return { providerID, modelID, compactionModelID };
55
- };
56
- }
57
-
58
- // Simple URL parser for testing (avoids dependency on 'url' module in sandboxed environments)
59
- function parseURL(urlString) {
60
- // Extract pathname and search from URL string
61
- const match = urlString.match(/^https?:\/\/[^\/]+(\/[^?]*)?(\?.*)?$/);
62
- if (!match) return { pathname: '/', search: '' };
63
- return {
64
- pathname: match[1] || '/',
65
- search: match[2] || '',
66
- };
67
- }
68
-
69
- function runTest(name, testFn) {
70
- try {
71
- testFn();
72
- console.log(`✅ ${name}`);
73
- } catch (error) {
74
- console.log(`❌ ${name}: ${error.message}`);
75
- process.exitCode = 1;
76
- }
77
- }
78
-
79
- function assertEqual(actual, expected, message) {
80
- if (actual !== expected) {
81
- throw new Error(`${message}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
82
- }
83
- }
84
-
85
- function assertDeepEqual(actual, expected, message) {
86
- if (JSON.stringify(actual) !== JSON.stringify(expected)) {
87
- throw new Error(`${message}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
88
- }
89
- }
90
-
91
- /**
92
- * Alias for parseModelForCompaction - matches the test naming convention
93
- */
94
- const parseModelString = parseModelForCompaction;
95
-
96
- /**
97
- * Extract compaction fields from API response
98
- * This mirrors the field access pattern used in handleChatCommand
99
- */
100
- function extractCompactionFields(compactionCheck) {
101
- if (!compactionCheck) {
102
- return { needed: false, totalTokens: undefined, threshold: undefined };
103
- }
104
-
105
- return {
106
- needed: compactionCheck.needed || false,
107
- totalTokens: compactionCheck.totalTokens,
108
- threshold: compactionCheck.usage?.limits?.threshold,
109
- };
110
- }
111
-
112
- /**
113
- * Calculate token savings safely
114
- * Returns null if values are invalid or savings is not positive
115
- */
116
- function calculateTokenSavings(originalTokens, summaryTokens) {
117
- const summary = summaryTokens || 0;
118
- const saved = originalTokens - summary;
119
-
120
- if (!originalTokens || saved <= 0) {
121
- return null;
122
- }
123
-
124
- return { originalTokens, summaryTokens: summary, savedTokens: saved };
125
- }
126
-
127
- // ============== Model Parsing Tests ==============
128
-
129
- runTest('parseModelString: should return defaults when model is undefined', () => {
130
- const result = parseModelString(undefined);
131
- assertEqual(result.providerID, 'github-copilot', 'providerID');
132
- assertEqual(result.modelID, 'claude-opus-4.5', 'modelID');
133
- });
134
-
135
- runTest('parseModelString: should return defaults when model is empty string', () => {
136
- const result = parseModelString('');
137
- assertEqual(result.providerID, 'github-copilot', 'providerID');
138
- assertEqual(result.modelID, 'claude-opus-4.5', 'modelID');
139
- });
140
-
141
- runTest('parseModelString: should parse provider/model format correctly', () => {
142
- const result = parseModelString('github-copilot/claude-sonnet-4');
143
- assertEqual(result.providerID, 'github-copilot', 'providerID');
144
- assertEqual(result.modelID, 'claude-sonnet-4', 'modelID');
145
- });
146
-
147
- runTest('parseModelString: should handle model without provider (uses default provider)', () => {
148
- const result = parseModelString('gpt-4');
149
- assertEqual(result.providerID, 'github-copilot', 'providerID');
150
- assertEqual(result.modelID, 'gpt-4', 'modelID');
151
- });
152
-
153
- runTest('parseModelString: should handle models with multiple slashes', () => {
154
- const result = parseModelString('openai/gpt-4/turbo');
155
- assertEqual(result.providerID, 'openai', 'providerID');
156
- assertEqual(result.modelID, 'gpt-4/turbo', 'modelID');
157
- });
158
-
159
- runTest('parseModelString: should handle different providers', () => {
160
- const result = parseModelString('anthropic/claude-3-opus');
161
- assertEqual(result.providerID, 'anthropic', 'providerID');
162
- assertEqual(result.modelID, 'claude-3-opus', 'modelID');
163
- });
164
-
165
- // ============== Compaction Response Field Tests ==============
166
-
167
- runTest('extractCompactionFields: should handle null response', () => {
168
- const result = extractCompactionFields(null);
169
- assertEqual(result.needed, false, 'needed');
170
- assertEqual(result.totalTokens, undefined, 'totalTokens');
171
- assertEqual(result.threshold, undefined, 'threshold');
172
- });
173
-
174
- runTest('extractCompactionFields: should extract fields from valid response', () => {
175
- const response = {
176
- needed: true,
177
- totalTokens: 150000,
178
- usage: {
179
- limits: {
180
- threshold: 100000,
181
- },
182
- },
183
- };
184
- const result = extractCompactionFields(response);
185
- assertEqual(result.needed, true, 'needed');
186
- assertEqual(result.totalTokens, 150000, 'totalTokens');
187
- assertEqual(result.threshold, 100000, 'threshold');
188
- });
189
-
190
- runTest('extractCompactionFields: should handle response without usage.limits', () => {
191
- const response = {
192
- needed: false,
193
- totalTokens: 50000,
194
- };
195
- const result = extractCompactionFields(response);
196
- assertEqual(result.needed, false, 'needed');
197
- assertEqual(result.totalTokens, 50000, 'totalTokens');
198
- assertEqual(result.threshold, undefined, 'threshold');
199
- });
200
-
201
- runTest('extractCompactionFields: should use correct field name "needed" not "needsCompaction"', () => {
202
- // This test ensures we use the correct API field name
203
- const responseWithWrongField = {
204
- needsCompaction: true, // Wrong field name
205
- totalTokens: 150000,
206
- };
207
- const result = extractCompactionFields(responseWithWrongField);
208
- assertEqual(result.needed, false, 'needed should be false when using wrong field name');
209
- });
210
-
211
- // ============== Token Savings Calculation Tests ==============
212
-
213
- runTest('calculateTokenSavings: should return null when originalTokens is undefined', () => {
214
- const result = calculateTokenSavings(undefined, 1000);
215
- assertEqual(result, null, 'result');
216
- });
217
-
218
- runTest('calculateTokenSavings: should return null when originalTokens is 0', () => {
219
- const result = calculateTokenSavings(0, 1000);
220
- assertEqual(result, null, 'result');
221
- });
222
-
223
- runTest('calculateTokenSavings: should return null when savings is negative', () => {
224
- const result = calculateTokenSavings(1000, 2000);
225
- assertEqual(result, null, 'result');
226
- });
227
-
228
- runTest('calculateTokenSavings: should return null when savings is zero', () => {
229
- const result = calculateTokenSavings(1000, 1000);
230
- assertEqual(result, null, 'result');
231
- });
232
-
233
- runTest('calculateTokenSavings: should calculate positive savings correctly', () => {
234
- const result = calculateTokenSavings(150000, 5000);
235
- assertDeepEqual(result, {
236
- originalTokens: 150000,
237
- summaryTokens: 5000,
238
- savedTokens: 145000,
239
- }, 'result');
240
- });
241
-
242
- runTest('calculateTokenSavings: should handle undefined summaryTokens as 0', () => {
243
- const result = calculateTokenSavings(100000, undefined);
244
- assertDeepEqual(result, {
245
- originalTokens: 100000,
246
- summaryTokens: 0,
247
- savedTokens: 100000,
248
- }, 'result');
249
- });
250
-
251
- runTest('calculateTokenSavings: should handle null summaryTokens as 0', () => {
252
- const result = calculateTokenSavings(100000, null);
253
- assertDeepEqual(result, {
254
- originalTokens: 100000,
255
- summaryTokens: 0,
256
- savedTokens: 100000,
257
- }, 'result');
258
- });
259
-
260
- // ============== URL Query String Tests ==============
261
-
262
- runTest('URL should include query string in path', () => {
263
- // This test verifies the fix for httpRequest not including urlObj.search
264
- const url = 'http://localhost:4096/api/sessions/123/compaction/check?modelID=claude-opus-4.5';
265
- const urlObj = parseURL(url);
266
- const path = urlObj.pathname + urlObj.search;
267
-
268
- assertEqual(path, '/api/sessions/123/compaction/check?modelID=claude-opus-4.5', 'path should include query string');
269
- });
270
-
271
- runTest('URL path should work with encoded query parameters', () => {
272
- const modelID = 'claude-sonnet-4';
273
- const url = `http://localhost:4096/api/sessions/123/compaction/check?modelID=${encodeURIComponent(modelID)}`;
274
- const urlObj = parseURL(url);
275
- const path = urlObj.pathname + urlObj.search;
276
-
277
- assertEqual(path, '/api/sessions/123/compaction/check?modelID=claude-sonnet-4', 'path should include encoded query');
278
- });
279
-
280
- runTest('URL path should handle special characters in modelID', () => {
281
- const modelID = 'gpt-4/turbo';
282
- const url = `http://localhost:4096/api/sessions/123/compaction/check?modelID=${encodeURIComponent(modelID)}`;
283
- const urlObj = parseURL(url);
284
- const path = urlObj.pathname + urlObj.search;
285
-
286
- assertEqual(path, '/api/sessions/123/compaction/check?modelID=gpt-4%2Fturbo', 'path should properly encode slashes');
287
- });
288
-
289
- // ============== Integration Tests with Stubbed httpRequest ==============
290
-
291
- /**
292
- * Simulates the auto-compaction flow with stubbed httpRequest
293
- * This tests the integration between compaction check and compact operations
294
- */
295
- async function runAsyncTest(name, testFn) {
296
- try {
297
- await testFn();
298
- console.log(`✅ ${name}`);
299
- } catch (error) {
300
- console.log(`❌ ${name}: ${error.message}`);
301
- process.exitCode = 1;
302
- }
303
- }
304
-
305
- /**
306
- * Simulates the auto-compaction flow logic from handleChatCommand
307
- * Uses stubbed httpRequest to verify correct API calls are made
308
- */
309
- async function simulateAutoCompaction(options) {
310
- const {
311
- httpRequestStub,
312
- serverUrl = 'http://localhost:4096',
313
- sessionId = 'test-session-123',
314
- model = 'github-copilot/claude-sonnet-4',
315
- verbose = false,
316
- isNewSession = false, // Track if session was just created (skips compaction check)
317
- } = options;
318
-
319
- const logs = [];
320
- const mockConsoleError = (msg) => logs.push(msg);
321
-
322
- // Use shared helper for model parsing (same as production code)
323
- const { providerID, modelID, compactionModelID } = parseModelString(model);
324
-
325
- let currentSessionId = sessionId;
326
- let compactionCheck = null;
327
- let compactionResult = null;
328
- const apiCalls = [];
329
- let compactionSkipped = false; // Track if compaction was skipped due to new session
330
-
331
- // Step 1: Check if compaction is needed (uses compactionModelID, not raw modelID)
332
- // Skip compaction check on new session - no messages exist yet,
333
- // so the server would always return needed: false (wasted HTTP round-trip)
334
- if (!isNewSession) {
335
- try {
336
- const checkUrl = `${serverUrl}/api/sessions/${currentSessionId}/compaction/check?modelID=${encodeURIComponent(compactionModelID)}`;
337
- apiCalls.push({ method: 'GET', url: checkUrl, originalModelID: modelID, compactionModelID });
338
- compactionCheck = await httpRequestStub(checkUrl, 'GET');
339
- } catch (checkError) {
340
- if (verbose) {
341
- mockConsoleError(`Compaction check failed: ${checkError.message}`);
342
- }
343
- }
344
- } else {
345
- compactionSkipped = true;
346
- }
347
-
348
- // Step 2: Perform compaction if needed
349
- if (compactionCheck && compactionCheck.needed) {
350
- const threshold = compactionCheck.usage?.limits?.threshold;
351
- if (verbose) {
352
- mockConsoleError(`Auto-compaction triggered: ${compactionCheck.totalTokens} > ${threshold} tokens`);
353
- }
354
-
355
- try {
356
- const compactUrl = `${serverUrl}/api/sessions/${currentSessionId}/compaction/compact`;
357
- const compactBody = {
358
- title: `Auto-compacted Session (${new Date().toISOString().split('T')[0]})`,
359
- auto: true,
360
- force: false,
361
- model: { providerID, modelID },
362
- };
363
- apiCalls.push({ method: 'POST', url: compactUrl, body: compactBody });
364
- compactionResult = await httpRequestStub(compactUrl, 'POST', compactBody);
365
-
366
- if (compactionResult && compactionResult.success) {
367
- // Validate newSessionID before switching - keep original if invalid
368
- const newSessionID = compactionResult.newSessionID;
369
- if (!newSessionID || typeof newSessionID !== 'string' || newSessionID.trim() === '') {
370
- // Always show this warning (not gated by verbose) - matches production
371
- mockConsoleError(`Auto-compaction warning: server returned success but invalid newSessionID, keeping original session`);
372
- // Continue with original session
373
- } else {
374
- // Switch to the new compacted session
375
- const originalTokens = compactionCheck.totalTokens;
376
- currentSessionId = newSessionID;
377
-
378
- // Calculate token savings - check for actual summaryTokens availability
379
- // Don't use || 0 fallback so we can detect when summaryTokens is not provided
380
- const summaryTokens = compactionResult.summaryTokens;
381
- const hasSummaryTokens = typeof summaryTokens === 'number';
382
- const savedTokens = hasSummaryTokens ? originalTokens - summaryTokens : 0;
383
-
384
- if (verbose) {
385
- mockConsoleError(`Auto-compaction completed: switched to session ${currentSessionId}`);
386
- if (originalTokens && savedTokens > 0) {
387
- mockConsoleError(`Saved ${savedTokens} tokens`);
388
- }
389
- } else {
390
- if (originalTokens && hasSummaryTokens) {
391
- mockConsoleError(`Auto-compacted conversation (${originalTokens} → ${summaryTokens} tokens)`);
392
- } else {
393
- mockConsoleError(`Auto-compacted conversation to new session`);
394
- }
395
- }
396
- }
397
- } else {
398
- // Always show compaction failure warnings (not gated by verbose)
399
- mockConsoleError(`Auto-compaction failed: ${compactionResult?.error || 'unknown error'}`);
400
- }
401
- } catch (compactError) {
402
- // Always show compaction failure warnings (not gated by verbose)
403
- mockConsoleError(`Compaction execution failed: ${compactError.message}`);
404
- }
405
- } else if (verbose && compactionCheck) {
406
- const totalTokens = compactionCheck.totalTokens;
407
- const threshold = compactionCheck.usage?.limits?.threshold;
408
- if (typeof totalTokens === 'number' && typeof threshold === 'number') {
409
- mockConsoleError(`No compaction needed: ${totalTokens}/${threshold} tokens`);
410
- } else if (compactionCheck.reason || compactionCheck.error) {
411
- mockConsoleError(`Compaction check: ${compactionCheck.reason || compactionCheck.error}`);
412
- }
413
- }
414
-
415
- return {
416
- sessionId: currentSessionId,
417
- apiCalls,
418
- logs,
419
- compactionCheck,
420
- compactionResult,
421
- compactionSkipped, // True if compaction was skipped due to new session
422
- };
423
- }
424
-
425
- // Wrap all async tests in a main function to ensure proper awaiting
426
- async function runIntegrationTests() {
427
- // Integration test: Full compaction flow
428
- await runAsyncTest('Integration: should call correct APIs when compaction is needed', async () => {
429
- const httpRequestStub = async (url, method, body) => {
430
- if (url.includes('/compaction/check')) {
431
- return {
432
- needed: true,
433
- totalTokens: 150000,
434
- usage: { limits: { threshold: 100000 } },
435
- };
436
- }
437
- if (url.includes('/compaction/compact')) {
438
- return {
439
- success: true,
440
- newSessionID: 'new-compacted-session-456',
441
- summaryTokens: 5000,
442
- };
443
- }
444
- throw new Error(`Unexpected API call: ${url}`);
445
- };
446
-
447
- const result = await simulateAutoCompaction({
448
- httpRequestStub,
449
- model: 'github-copilot/gpt-4', // Use a known model from the registry
450
- });
451
-
452
- assertEqual(result.apiCalls.length, 2, 'should make 2 API calls');
453
- assertEqual(result.apiCalls[0].method, 'GET', 'first call should be GET');
454
- if (!result.apiCalls[0].url.includes('/compaction/check')) {
455
- throw new Error('first call should be to compaction/check');
456
- }
457
- if (!result.apiCalls[0].url.includes('modelID=gpt-4')) {
458
- throw new Error('check URL should include parsed modelID');
459
- }
460
- assertEqual(result.apiCalls[1].method, 'POST', 'second call should be POST');
461
- if (!result.apiCalls[1].url.includes('/compaction/compact')) {
462
- throw new Error('second call should be to compaction/compact');
463
- }
464
- assertEqual(result.apiCalls[1].body.model.providerID, 'github-copilot', 'compact body should have providerID');
465
- assertEqual(result.apiCalls[1].body.model.modelID, 'gpt-4', 'compact body should have modelID');
466
- assertEqual(result.sessionId, 'new-compacted-session-456', 'session should be updated');
467
- });
468
-
469
- // Integration test: New session optimization - skip compaction check
470
- await runAsyncTest('Integration: should skip compaction check for new session', async () => {
471
- const httpRequestStub = async (url, method, body) => {
472
- // This should NOT be called for new session
473
- throw new Error(`Unexpected API call: ${url} - should have been skipped for new session`);
474
- };
475
-
476
- const result = await simulateAutoCompaction({
477
- httpRequestStub,
478
- isNewSession: true, // New session flag
479
- });
480
-
481
- assertEqual(result.apiCalls.length, 0, 'should make no API calls for new session');
482
- assertEqual(result.compactionSkipped, true, 'compactionSkipped should be true');
483
- assertEqual(result.compactionCheck, null, 'compactionCheck should be null');
484
- assertEqual(result.sessionId, 'test-session-123', 'session should not change');
485
- });
486
-
487
- // Integration test: Existing session should still check compaction
488
- await runAsyncTest('Integration: should check compaction for existing session', async () => {
489
- const httpRequestStub = async (url, method, body) => {
490
- if (url.includes('/compaction/check')) {
491
- return { needed: false, totalTokens: 50000, usage: { limits: { threshold: 100000 } } };
492
- }
493
- throw new Error(`Unexpected API call: ${url}`);
494
- };
495
-
496
- const result = await simulateAutoCompaction({
497
- httpRequestStub,
498
- isNewSession: false, // Existing session (default)
499
- });
500
-
501
- assertEqual(result.apiCalls.length, 1, 'should make 1 API call for existing session');
502
- assertEqual(result.compactionSkipped, false, 'compactionSkipped should be false');
503
- assertEqual(result.compactionCheck.needed, false, 'compactionCheck should have response');
504
- });
505
-
506
- // Integration test: No compaction needed
507
- await runAsyncTest('Integration: should only call check API when compaction not needed', async () => {
508
- const httpRequestStub = async (url, method, body) => {
509
- if (url.includes('/compaction/check')) {
510
- return {
511
- needed: false,
512
- totalTokens: 50000,
513
- usage: { limits: { threshold: 100000 } },
514
- };
515
- }
516
- throw new Error(`Unexpected API call: ${url}`);
517
- };
518
-
519
- const result = await simulateAutoCompaction({
520
- httpRequestStub,
521
- verbose: true,
522
- });
523
-
524
- assertEqual(result.apiCalls.length, 1, 'should make only 1 API call');
525
- assertEqual(result.sessionId, 'test-session-123', 'session should not change');
526
- if (!result.logs.some((l) => l.includes('No compaction needed'))) {
527
- throw new Error('should log "No compaction needed"');
528
- }
529
- });
530
-
531
- // Integration test: Check fails gracefully
532
- await runAsyncTest('Integration: should continue when check fails', async () => {
533
- const httpRequestStub = async (url, method, body) => {
534
- if (url.includes('/compaction/check')) {
535
- throw new Error('Network error');
536
- }
537
- throw new Error(`Unexpected API call: ${url}`);
538
- };
539
-
540
- const result = await simulateAutoCompaction({
541
- httpRequestStub,
542
- verbose: true,
543
- });
544
-
545
- assertEqual(result.apiCalls.length, 1, 'should attempt 1 API call');
546
- assertEqual(result.sessionId, 'test-session-123', 'session should not change');
547
- if (!result.logs.some((l) => l.includes('Compaction check failed'))) {
548
- throw new Error('should log "Compaction check failed"');
549
- }
550
- });
551
-
552
- // Integration test: Compact fails gracefully
553
- await runAsyncTest('Integration: should continue when compact fails', async () => {
554
- const httpRequestStub = async (url, method, body) => {
555
- if (url.includes('/compaction/check')) {
556
- return {
557
- needed: true,
558
- totalTokens: 150000,
559
- usage: { limits: { threshold: 100000 } },
560
- };
561
- }
562
- if (url.includes('/compaction/compact')) {
563
- throw new Error('Server error 500');
564
- }
565
- throw new Error(`Unexpected API call: ${url}`);
566
- };
567
-
568
- const result = await simulateAutoCompaction({
569
- httpRequestStub,
570
- verbose: true,
571
- });
572
-
573
- assertEqual(result.apiCalls.length, 2, 'should attempt 2 API calls');
574
- assertEqual(result.sessionId, 'test-session-123', 'session should not change on failure');
575
- if (!result.logs.some((l) => l.includes('Compaction execution failed'))) {
576
- throw new Error('should log "Compaction execution failed" (not "check failed")');
577
- }
578
- });
579
-
580
- // Integration test: Verbose logging with undefined values
581
- await runAsyncTest('Integration: should not log undefined/undefined when check returns error', async () => {
582
- const httpRequestStub = async (url, method, body) => {
583
- if (url.includes('/compaction/check')) {
584
- return {
585
- needed: false,
586
- reason: 'Session not found',
587
- error: 'SESSION_NOT_FOUND',
588
- };
589
- }
590
- throw new Error(`Unexpected API call: ${url}`);
591
- };
592
-
593
- const result = await simulateAutoCompaction({
594
- httpRequestStub,
595
- verbose: true,
596
- });
597
-
598
- // Should not log "undefined/undefined tokens"
599
- if (result.logs.some((l) => l.includes('undefined'))) {
600
- throw new Error('should not log undefined values');
601
- }
602
- // Should log the reason/error instead
603
- if (!result.logs.some((l) => l.includes('SESSION_NOT_FOUND') || l.includes('Session not found'))) {
604
- throw new Error('should log error reason when available');
605
- }
606
- });
607
-
608
- // Integration test: Model parsing in API calls
609
- await runAsyncTest('Integration: should parse model-only format correctly', async () => {
610
- const httpRequestStub = async (url, method, body) => {
611
- if (url.includes('/compaction/check')) {
612
- return { needed: false, totalTokens: 1000, usage: { limits: { threshold: 100000 } } };
613
- }
614
- throw new Error(`Unexpected API call: ${url}`);
615
- };
616
-
617
- const result = await simulateAutoCompaction({
618
- httpRequestStub,
619
- model: 'gpt-4', // No provider prefix
620
- });
621
-
622
- // Should use default provider and the provided model
623
- if (!result.apiCalls[0].url.includes('modelID=gpt-4')) {
624
- throw new Error('should use model name from input');
625
- }
626
- });
627
-
628
- // Integration test: Unknown model should fallback to known model for compaction check
629
- await runAsyncTest('Integration: should fallback to known model for unknown modelID', async () => {
630
- const httpRequestStub = async (url, method, body) => {
631
- if (url.includes('/compaction/check')) {
632
- return { needed: false, totalTokens: 1000, usage: { limits: { threshold: 100000 } } };
633
- }
634
- throw new Error(`Unexpected API call: ${url}`);
635
- };
636
-
637
- // Simulate with unknown model - should fallback to known model
638
- const result = await simulateAutoCompaction({
639
- httpRequestStub,
640
- model: 'github-copilot/some-unknown-model',
641
- });
642
-
643
- assertEqual(result.apiCalls.length, 1, 'should make 1 API call');
644
-
645
- // Verify the fallback: URL should use claude-opus-4.5 (fallback), not some-unknown-model
646
- const checkUrl = result.apiCalls[0].url;
647
- if (checkUrl.includes('modelID=some-unknown-model')) {
648
- throw new Error('should NOT use unknown model in URL');
649
- }
650
- if (!checkUrl.includes('modelID=claude-opus-4.5')) {
651
- throw new Error('should use fallback model claude-opus-4.5 in URL');
652
- }
653
-
654
- // Also verify via apiCalls metadata
655
- assertEqual(result.apiCalls[0].originalModelID, 'some-unknown-model', 'should track original model');
656
- assertEqual(result.apiCalls[0].compactionModelID, 'claude-opus-4.5', 'should use fallback for compaction');
657
- });
658
-
659
- // Integration test: Known model should NOT fallback
660
- await runAsyncTest('Integration: should use known model directly without fallback', async () => {
661
- const httpRequestStub = async (url, method, body) => {
662
- if (url.includes('/compaction/check')) {
663
- return { needed: false, totalTokens: 1000, usage: { limits: { threshold: 100000 } } };
664
- }
665
- throw new Error(`Unexpected API call: ${url}`);
666
- };
667
-
668
- // Use a known model - should use it directly
669
- const result = await simulateAutoCompaction({
670
- httpRequestStub,
671
- model: 'github-copilot/gpt-4',
672
- });
673
-
674
- // Verify the URL uses gpt-4 directly (no fallback)
675
- const checkUrl = result.apiCalls[0].url;
676
- if (!checkUrl.includes('modelID=gpt-4')) {
677
- throw new Error('should use known model gpt-4 directly in URL');
678
- }
679
- assertEqual(result.apiCalls[0].originalModelID, 'gpt-4', 'original model');
680
- assertEqual(result.apiCalls[0].compactionModelID, 'gpt-4', 'should NOT fallback for known model');
681
- });
682
-
683
- // Integration test: Should not switch session if newSessionID is invalid
684
- await runAsyncTest('Integration: should keep original session if newSessionID is invalid', async () => {
685
- const httpRequestStub = async (url, method, body) => {
686
- if (url.includes('/compaction/check')) {
687
- return {
688
- needed: true,
689
- totalTokens: 150000,
690
- usage: { limits: { threshold: 100000 } },
691
- };
692
- }
693
- if (url.includes('/compaction/compact')) {
694
- // Server returns success but with empty/invalid newSessionID
695
- return {
696
- success: true,
697
- newSessionID: '', // Invalid!
698
- summaryTokens: 5000,
699
- };
700
- }
701
- throw new Error(`Unexpected API call: ${url}`);
702
- };
703
-
704
- const result = await simulateAutoCompaction({
705
- httpRequestStub,
706
- verbose: true,
707
- });
708
-
709
- // Session should NOT change when newSessionID is invalid
710
- assertEqual(result.sessionId, 'test-session-123', 'session should not change with invalid newSessionID');
711
- // Should log a warning
712
- if (!result.logs.some((l) => l.includes('invalid newSessionID') || l.includes('keeping original'))) {
713
- throw new Error('should log warning about invalid newSessionID');
714
- }
715
- });
716
-
717
- // ============================================================================
718
- // Non-verbose failure path tests
719
- // These tests verify that compaction failure messages are always shown,
720
- // even when verbose is false. This ensures future changes don't accidentally
721
- // re-introduce verbose-gating on the failure paths.
722
- // ============================================================================
723
-
724
- // Test: Non-verbose mode - compaction execution failure should always be shown
725
- await runAsyncTest('Integration: should always show execution failure even when verbose is false', async () => {
726
- const httpRequestStub = async (url, method, body) => {
727
- if (url.includes('/compaction/check')) {
728
- return { needed: true, totalTokens: 150000, usage: { limits: { threshold: 100000 } } };
729
- }
730
- if (url.includes('/compaction/compact')) {
731
- throw new Error('Network timeout');
732
- }
733
- throw new Error(`Unexpected API call: ${url}`);
734
- };
735
-
736
- const result = await simulateAutoCompaction({
737
- httpRequestStub,
738
- verbose: false, // Non-verbose mode
739
- });
740
-
741
- // Should always show execution failure message even without verbose
742
- if (!result.logs.some((l) => l.includes('Compaction execution failed'))) {
743
- throw new Error(`should show execution failure message even in non-verbose mode, got: ${result.logs.join(', ')}`);
744
- }
745
- if (!result.logs.some((l) => l.includes('Network timeout'))) {
746
- throw new Error(`should include error message, got: ${result.logs.join(', ')}`);
747
- }
748
- });
749
-
750
- // Test: Non-verbose mode - compaction API failure should always be shown
751
- await runAsyncTest('Integration: should always show API failure even when verbose is false', async () => {
752
- const httpRequestStub = async (url, method, body) => {
753
- if (url.includes('/compaction/check')) {
754
- return { needed: true, totalTokens: 150000, usage: { limits: { threshold: 100000 } } };
755
- }
756
- if (url.includes('/compaction/compact')) {
757
- // Server returns success: false with an error
758
- return { success: false, error: 'Compaction service unavailable' };
759
- }
760
- throw new Error(`Unexpected API call: ${url}`);
761
- };
762
-
763
- const result = await simulateAutoCompaction({
764
- httpRequestStub,
765
- verbose: false, // Non-verbose mode
766
- });
767
-
768
- // Should always show compaction failure message even without verbose
769
- if (!result.logs.some((l) => l.includes('Auto-compaction failed'))) {
770
- throw new Error(`should show compaction failure message even in non-verbose mode, got: ${result.logs.join(', ')}`);
771
- }
772
- if (!result.logs.some((l) => l.includes('Compaction service unavailable'))) {
773
- throw new Error(`should include error message, got: ${result.logs.join(', ')}`);
774
- }
775
- });
776
-
777
- // Test: Non-verbose mode - invalid newSessionID warning should always be shown
778
- await runAsyncTest('Integration: should always show invalid newSessionID warning even when verbose is false', async () => {
779
- const httpRequestStub = async (url, method, body) => {
780
- if (url.includes('/compaction/check')) {
781
- return { needed: true, totalTokens: 150000, usage: { limits: { threshold: 100000 } } };
782
- }
783
- if (url.includes('/compaction/compact')) {
784
- // Server returns success but with null/invalid newSessionID
785
- return { success: true, newSessionID: null };
786
- }
787
- throw new Error(`Unexpected API call: ${url}`);
788
- };
789
-
790
- const result = await simulateAutoCompaction({
791
- httpRequestStub,
792
- verbose: false, // Non-verbose mode
793
- });
794
-
795
- // Should always show invalid session warning even without verbose
796
- if (!result.logs.some((l) => l.includes('invalid newSessionID'))) {
797
- throw new Error(`should show invalid newSessionID warning even in non-verbose mode, got: ${result.logs.join(', ')}`);
798
- }
799
- // Should keep original session
800
- assertEqual(result.sessionId, 'test-session-123', 'should not change session when newSessionID is invalid');
801
- });
802
-
803
- // Test: Non-verbose mode - empty string newSessionID should also trigger warning
804
- await runAsyncTest('Integration: should always show warning for empty string newSessionID when verbose is false', async () => {
805
- const httpRequestStub = async (url, method, body) => {
806
- if (url.includes('/compaction/check')) {
807
- return { needed: true, totalTokens: 150000, usage: { limits: { threshold: 100000 } } };
808
- }
809
- if (url.includes('/compaction/compact')) {
810
- // Server returns success but with empty string newSessionID
811
- return { success: true, newSessionID: ' ' };
812
- }
813
- throw new Error(`Unexpected API call: ${url}`);
814
- };
815
-
816
- const result = await simulateAutoCompaction({
817
- httpRequestStub,
818
- verbose: false, // Non-verbose mode
819
- });
820
-
821
- // Should always show invalid session warning even without verbose
822
- if (!result.logs.some((l) => l.includes('invalid newSessionID'))) {
823
- throw new Error(`should show invalid newSessionID warning for empty string even in non-verbose mode, got: ${result.logs.join(', ')}`);
824
- }
825
- // Should keep original session
826
- assertEqual(result.sessionId, 'test-session-123', 'should not change session when newSessionID is empty string');
827
- });
828
- }
829
-
830
- // Run sync tests first, then async tests with proper awaiting
831
- console.log('\n✓ Auto-compaction unit tests completed');
832
-
833
- // ============================================================================
834
- // Env var precedence tests (only run when lib/compaction.js is available)
835
- // ============================================================================
836
- try {
837
- const compaction = require('../lib/compaction');
838
- const { getDefaultModelIDForCompaction, getDefaultProviderIDForCompaction } = compaction;
839
-
840
- console.log('\n=== Env Var Precedence Tests ===');
841
-
842
- // Save original env vars
843
- const origPaveModel = process.env.PAVE_MODEL;
844
- const origPaveCompactModel = process.env.PAVE_COMPACT_MODEL;
845
-
846
- function cleanTestEnv() {
847
- delete process.env.PAVE_MODEL;
848
- delete process.env.PAVE_COMPACT_MODEL;
849
- }
850
-
851
- function restoreTestEnv() {
852
- if (origPaveModel !== undefined) process.env.PAVE_MODEL = origPaveModel;
853
- else delete process.env.PAVE_MODEL;
854
- if (origPaveCompactModel !== undefined) process.env.PAVE_COMPACT_MODEL = origPaveCompactModel;
855
- else delete process.env.PAVE_COMPACT_MODEL;
856
- }
857
-
858
- // Check if process.env mutation works in this environment
859
- let canMutateEnv = false;
860
- try {
861
- cleanTestEnv();
862
- process.env.__PAVE_TEST_PROBE = 'yes';
863
- if (process.env.__PAVE_TEST_PROBE === 'yes') {
864
- canMutateEnv = true;
865
- }
866
- delete process.env.__PAVE_TEST_PROBE;
867
- } catch (e) {
868
- canMutateEnv = false;
869
- }
870
-
871
- // Test: no env vars -> hardcoded defaults (always works)
872
- cleanTestEnv();
873
- runTest('Env: no env vars -> hardcoded default model', () => {
874
- assertEqual(getDefaultModelIDForCompaction(), 'claude-opus-4.5', 'default model');
875
- });
876
-
877
- cleanTestEnv();
878
- runTest('Env: no env vars -> hardcoded default provider', () => {
879
- assertEqual(getDefaultProviderIDForCompaction(), 'github-copilot', 'default provider');
880
- });
881
-
882
- try {
883
- if (canMutateEnv) {
884
- // Test: PAVE_MODEL sets default
885
- cleanTestEnv();
886
- process.env.PAVE_MODEL = 'github-copilot/claude-sonnet-4.6';
887
- runTest('Env: PAVE_MODEL sets model', () => {
888
- assertEqual(getDefaultModelIDForCompaction(), 'claude-sonnet-4.6', 'model from PAVE_MODEL');
889
- });
890
-
891
- cleanTestEnv();
892
- process.env.PAVE_MODEL = 'github-copilot/claude-sonnet-4.6';
893
- runTest('Env: PAVE_MODEL sets provider', () => {
894
- assertEqual(getDefaultProviderIDForCompaction(), 'github-copilot', 'provider from PAVE_MODEL');
895
- });
896
-
897
- // Test: PAVE_COMPACT_MODEL takes precedence
898
- cleanTestEnv();
899
- process.env.PAVE_MODEL = 'github-copilot/claude-opus-4.6';
900
- process.env.PAVE_COMPACT_MODEL = 'github-copilot/claude-sonnet-4';
901
- runTest('Env: PAVE_COMPACT_MODEL takes precedence over PAVE_MODEL', () => {
902
- assertEqual(getDefaultModelIDForCompaction(), 'claude-sonnet-4', 'compact model wins');
903
- });
904
-
905
- // Test: bare model ID defaults to github-copilot
906
- cleanTestEnv();
907
- process.env.PAVE_COMPACT_MODEL = 'claude-sonnet-4.5';
908
- runTest('Env: bare PAVE_COMPACT_MODEL uses default provider', () => {
909
- assertEqual(getDefaultProviderIDForCompaction(), 'github-copilot', 'bare model -> default provider');
910
- });
911
-
912
- // Test: malformed spec falls back
913
- cleanTestEnv();
914
- process.env.PAVE_COMPACT_MODEL = 'provider/';
915
- runTest('Env: malformed PAVE_COMPACT_MODEL falls back to hardcoded', () => {
916
- assertEqual(getDefaultModelIDForCompaction(), 'claude-opus-4.5', 'malformed -> hardcoded');
917
- });
918
-
919
- cleanTestEnv();
920
- process.env.PAVE_COMPACT_MODEL = '/claude-opus-4.5';
921
- runTest('Env: /model format falls back to default provider', () => {
922
- assertEqual(getDefaultProviderIDForCompaction(), 'github-copilot', '/model -> default provider');
923
- });
924
- } else {
925
- console.log(' (skipping env mutation tests - process.env is read-only in this environment)');
926
- }
927
- } finally {
928
- restoreTestEnv();
929
- }
930
- console.log('✓ Env var precedence tests completed');
931
- } catch (e) {
932
- console.log('\n⚠️ Skipping env var precedence tests (lib/compaction.js not available in sandbox)');
933
- }
934
-
935
- // Run integration tests with proper await
936
- runIntegrationTests().then(() => {
937
- console.log('✓ Integration tests with stubbed httpRequest completed');
938
- }).catch((err) => {
939
- console.error('Integration tests failed:', err.message);
940
- process.exitCode = 1;
941
- });