@cnrai/pave 0.3.32 → 0.3.34

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/MARKETPLACE.md +406 -0
  2. package/README.md +218 -21
  3. package/build-binary.js +591 -0
  4. package/build-npm.js +537 -0
  5. package/build.js +230 -0
  6. package/check-binary.js +26 -0
  7. package/deploy.sh +95 -0
  8. package/index.js +5775 -0
  9. package/lib/agent-registry.js +1037 -0
  10. package/lib/args-parser.js +837 -0
  11. package/lib/blessed-widget-patched.js +93 -0
  12. package/lib/cli-markdown.js +590 -0
  13. package/lib/compaction.js +153 -0
  14. package/lib/duration.js +94 -0
  15. package/lib/hash.js +22 -0
  16. package/lib/marketplace.js +866 -0
  17. package/lib/memory-config.js +166 -0
  18. package/lib/skill-manager.js +891 -0
  19. package/lib/soul.js +31 -0
  20. package/lib/tool-output-formatter.js +180 -0
  21. package/package.json +35 -33
  22. package/start-pave.sh +149 -0
  23. package/status.js +271 -0
  24. package/test/abort-stream.test.js +445 -0
  25. package/test/agent-auto-compaction.test.js +552 -0
  26. package/test/agent-comm-abort.test.js +95 -0
  27. package/test/agent-comm.test.js +598 -0
  28. package/test/agent-inbox.test.js +576 -0
  29. package/test/agent-init.test.js +264 -0
  30. package/test/agent-interrupt.test.js +314 -0
  31. package/test/agent-lifecycle.test.js +520 -0
  32. package/test/agent-log-files.test.js +349 -0
  33. package/test/agent-mode.manual-test.js +392 -0
  34. package/test/agent-parsing.test.js +228 -0
  35. package/test/agent-post-stream-idle.test.js +762 -0
  36. package/test/agent-registry.test.js +359 -0
  37. package/test/agent-rm.test.js +442 -0
  38. package/test/agent-spawn.test.js +933 -0
  39. package/test/agent-status-api.test.js +624 -0
  40. package/test/agent-update.test.js +435 -0
  41. package/test/args-parser.test.js +391 -0
  42. package/test/auto-compaction-chat.manual-test.js +227 -0
  43. package/test/auto-compaction.test.js +941 -0
  44. package/test/build-config.test.js +120 -0
  45. package/test/build-npm.test.js +388 -0
  46. package/test/chat-command.test.js +137 -0
  47. package/test/chat-leading-lines.test.js +159 -0
  48. package/test/config-flag.test.js +272 -0
  49. package/test/cursor-drift.test.js +135 -0
  50. package/test/debug-require.js +23 -0
  51. package/test/dir-migration.test.js +323 -0
  52. package/test/duration.test.js +229 -0
  53. package/test/ghostty-term.test.js +202 -0
  54. package/test/http500-backoff.test.js +854 -0
  55. package/test/integration.test.js +86 -0
  56. package/test/memory-guard-env.test.js +220 -0
  57. package/test/pr233-fixes.test.js +259 -0
  58. package/test/run-agent-init.js +297 -0
  59. package/test/run-all.js +64 -0
  60. package/test/run-config-flag.js +159 -0
  61. package/test/run-cursor-drift.js +82 -0
  62. package/test/run-session-path.js +154 -0
  63. package/test/run-tests.js +643 -0
  64. package/test/sandbox-redirect.test.js +202 -0
  65. package/test/session-path.test.js +132 -0
  66. package/test/shebang-strip.test.js +241 -0
  67. package/test/soul-reinject.test.js +1027 -0
  68. package/test/soul-reread.test.js +281 -0
  69. package/test/tool-output-formatter.test.js +486 -0
  70. package/test/tool-output-gating.test.js +143 -0
  71. package/test/tool-states.test.js +167 -0
  72. package/test/tools-flag.test.js +65 -0
  73. package/test/tui-attach.test.js +1255 -0
  74. package/test/tui-compaction.test.js +354 -0
  75. package/test/tui-wrap.test.js +568 -0
  76. package/test-binary.js +52 -0
  77. package/test-binary2.js +36 -0
  78. package/LICENSE +0 -21
  79. package/pave.js +0 -2
  80. package/sandbox/SandboxRunner.js +0 -1
  81. package/sandbox/pave-run.js +0 -2
  82. package/sandbox/permission.js +0 -1
  83. package/sandbox/utils/yaml.js +0 -1
@@ -0,0 +1,552 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Agent auto-compaction tests
4
+ * Uses functional testing with simulated agent compaction flow
5
+ * Run with: node test/agent-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
+ // ============================================================================
59
+ // Test Utilities
60
+ // ============================================================================
61
+
62
+ function runTest(name, testFn) {
63
+ try {
64
+ testFn();
65
+ console.log(`✅ ${name}`);
66
+ } catch (error) {
67
+ console.log(`❌ ${name}: ${error.message}`);
68
+ process.exitCode = 1;
69
+ }
70
+ }
71
+
72
+ function assertEqual(actual, expected, message) {
73
+ if (actual !== expected) {
74
+ throw new Error(`${message}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
75
+ }
76
+ }
77
+
78
+ function assertDeepEqual(actual, expected, message) {
79
+ if (JSON.stringify(actual) !== JSON.stringify(expected)) {
80
+ throw new Error(`${message}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
81
+ }
82
+ }
83
+
84
+ // ============================================================================
85
+ // parseModelForCompaction Tests
86
+ // ============================================================================
87
+
88
+ console.log('Running agent-auto-compaction.test.js...');
89
+ console.log('\n=== parseModelForCompaction Tests ===');
90
+
91
+ runTest('parseModelForCompaction: should return defaults when model is undefined', () => {
92
+ const result = parseModelForCompaction(undefined);
93
+ assertEqual(result.providerID, 'github-copilot', 'providerID');
94
+ assertEqual(result.modelID, 'claude-opus-4.5', 'modelID');
95
+ assertEqual(result.compactionModelID, 'claude-opus-4.5', 'compactionModelID');
96
+ });
97
+
98
+ runTest('parseModelForCompaction: should return defaults when model is empty string', () => {
99
+ const result = parseModelForCompaction('');
100
+ assertEqual(result.providerID, 'github-copilot', 'providerID');
101
+ assertEqual(result.modelID, 'claude-opus-4.5', 'modelID');
102
+ assertEqual(result.compactionModelID, 'claude-opus-4.5', 'compactionModelID');
103
+ });
104
+
105
+ runTest('parseModelForCompaction: should parse provider/model format correctly', () => {
106
+ const result = parseModelForCompaction('github-copilot/claude-sonnet-4');
107
+ assertEqual(result.providerID, 'github-copilot', 'providerID');
108
+ assertEqual(result.modelID, 'claude-sonnet-4', 'modelID');
109
+ assertEqual(result.compactionModelID, 'claude-sonnet-4', 'compactionModelID should use known model directly');
110
+ });
111
+
112
+ runTest('parseModelForCompaction: should handle model without provider (uses default provider)', () => {
113
+ const result = parseModelForCompaction('gpt-4');
114
+ assertEqual(result.providerID, 'github-copilot', 'providerID');
115
+ assertEqual(result.modelID, 'gpt-4', 'modelID');
116
+ assertEqual(result.compactionModelID, 'gpt-4', 'compactionModelID should use known model directly');
117
+ });
118
+
119
+ runTest('parseModelForCompaction: should handle models with multiple slashes', () => {
120
+ const result = parseModelForCompaction('openai/gpt-4/turbo');
121
+ assertEqual(result.providerID, 'openai', 'providerID');
122
+ assertEqual(result.modelID, 'gpt-4/turbo', 'modelID');
123
+ });
124
+
125
+ runTest('parseModelForCompaction: should fallback to default for unknown models', () => {
126
+ const result = parseModelForCompaction('github-copilot/some-unknown-model');
127
+ assertEqual(result.providerID, 'github-copilot', 'providerID');
128
+ assertEqual(result.modelID, 'some-unknown-model', 'modelID should preserve original');
129
+ assertEqual(result.compactionModelID, 'claude-opus-4.5', 'compactionModelID should fallback to default');
130
+ });
131
+
132
+ runTest('parseModelForCompaction: should use known model directly without fallback', () => {
133
+ const result = parseModelForCompaction('anthropic/gpt-4');
134
+ assertEqual(result.providerID, 'anthropic', 'providerID');
135
+ assertEqual(result.modelID, 'gpt-4', 'modelID');
136
+ assertEqual(result.compactionModelID, 'gpt-4', 'compactionModelID should use known model directly');
137
+ });
138
+
139
+ // ============================================================================
140
+ // Agent-specific Compaction Flow Simulation
141
+ // ============================================================================
142
+
143
+ /**
144
+ * Simulates the agent auto-compaction flow with stubbed httpRequest.
145
+ * This mirrors the actual logic in handleAgentCommand.
146
+ */
147
+ async function simulateAgentCompaction(options) {
148
+ const {
149
+ httpRequestStub,
150
+ serverUrl = 'http://localhost:4096',
151
+ sessionId = 'test-session-123',
152
+ model = 'github-copilot/claude-sonnet-4',
153
+ verbose = false,
154
+ isNewSession = false,
155
+ iteration = 2,
156
+ agentName = 'test-agent',
157
+ } = options;
158
+
159
+ const logs = [];
160
+ const mockLog = (msg) => logs.push(msg);
161
+
162
+ // Use shared helper for model parsing (same as production code)
163
+ const { providerID, modelID, compactionModelID } = parseModelForCompaction(model);
164
+
165
+ let currentSessionId = sessionId;
166
+ let compactionCheck = null;
167
+ let compactionResult = null;
168
+ const apiCalls = [];
169
+ let sessionFileUpdated = false;
170
+ let newSessionFileContent = null;
171
+
172
+ // Skip compaction check on first iteration of new session (same as production)
173
+ if (!(isNewSession && iteration === 1)) {
174
+ // Step 1: Check if compaction is needed
175
+ try {
176
+ const checkUrl = `${serverUrl}/api/sessions/${currentSessionId}/compaction/check?modelID=${encodeURIComponent(compactionModelID)}`;
177
+ apiCalls.push({ method: 'GET', url: checkUrl, originalModelID: modelID, compactionModelID });
178
+ compactionCheck = await httpRequestStub(checkUrl, 'GET');
179
+ } catch (checkError) {
180
+ if (verbose) {
181
+ mockLog(`Compaction check failed: ${checkError.message}`);
182
+ }
183
+ }
184
+ } else {
185
+ logs.push('[SKIPPED] First iteration of new session - no compaction check');
186
+ }
187
+
188
+ // Step 2: Perform compaction if needed
189
+ if (compactionCheck && compactionCheck.needed) {
190
+ const threshold = compactionCheck.usage?.limits?.threshold;
191
+ if (verbose) {
192
+ mockLog(`Auto-compaction triggered: ${compactionCheck.totalTokens} > ${threshold} tokens`);
193
+ }
194
+
195
+ try {
196
+ const compactUrl = `${serverUrl}/api/sessions/${currentSessionId}/compaction/compact`;
197
+ const compactBody = {
198
+ title: `Agent Auto-compacted (${agentName}) - ${new Date().toISOString().split('T')[0]}`,
199
+ auto: true,
200
+ force: false,
201
+ model: { providerID, modelID },
202
+ };
203
+ apiCalls.push({ method: 'POST', url: compactUrl, body: compactBody });
204
+ compactionResult = await httpRequestStub(compactUrl, 'POST', compactBody);
205
+
206
+ if (compactionResult && compactionResult.success) {
207
+ const newSessionID = compactionResult.newSessionID;
208
+ if (!newSessionID || typeof newSessionID !== 'string' || newSessionID.trim() === '') {
209
+ // Always show this warning (not gated by verbose) - matches production
210
+ mockLog(`Auto-compaction warning: invalid newSessionID, keeping original session`);
211
+ } else {
212
+ const originalTokens = compactionCheck.totalTokens;
213
+ currentSessionId = newSessionID;
214
+
215
+ sessionFileUpdated = true;
216
+ newSessionFileContent = { sessionId: currentSessionId, agentName };
217
+
218
+ // Calculate token savings (same logic as production - no || 0 fallback)
219
+ const summaryTokens = compactionResult.summaryTokens;
220
+ const hasSummaryTokens = typeof summaryTokens === 'number';
221
+ const savedTokens = hasSummaryTokens ? originalTokens - summaryTokens : 0;
222
+
223
+ if (verbose) {
224
+ mockLog(`Auto-compaction completed: switched to session ${currentSessionId}`);
225
+ if (originalTokens && savedTokens > 0) {
226
+ mockLog(`Saved ${savedTokens} tokens`);
227
+ }
228
+ } else {
229
+ if (originalTokens && hasSummaryTokens) {
230
+ mockLog(`Auto-compacted conversation (${originalTokens} → ${summaryTokens} tokens)`);
231
+ } else {
232
+ mockLog(`Auto-compacted conversation to new session`);
233
+ }
234
+ }
235
+ }
236
+ } else {
237
+ // Always show compaction failure warnings (not gated by verbose)
238
+ // This matches production behavior in handleAgentCommand
239
+ mockLog(`Auto-compaction failed: ${compactionResult?.error || 'unknown error'}`);
240
+ }
241
+ } catch (compactError) {
242
+ // Always show compaction failure warnings (not gated by verbose)
243
+ // This matches production behavior in handleAgentCommand
244
+ mockLog(`Compaction execution failed: ${compactError.message}`);
245
+ }
246
+ }
247
+
248
+ return {
249
+ sessionId: currentSessionId,
250
+ apiCalls,
251
+ logs,
252
+ compactionCheck,
253
+ compactionResult,
254
+ sessionFileUpdated,
255
+ newSessionFileContent,
256
+ };
257
+ }
258
+
259
+ async function runAsyncTest(name, testFn) {
260
+ try {
261
+ await testFn();
262
+ console.log(`✅ ${name}`);
263
+ } catch (error) {
264
+ console.log(`❌ ${name}: ${error.message}`);
265
+ process.exitCode = 1;
266
+ }
267
+ }
268
+
269
+ // ============================================================================
270
+ // Agent Integration Tests
271
+ // ============================================================================
272
+
273
+ async function runAgentIntegrationTests() {
274
+ console.log('\n=== Agent Compaction Flow Tests ===');
275
+
276
+ // Test: Skip compaction check on first iteration of new session
277
+ await runAsyncTest('Agent: should skip compaction check on first iteration of new session', async () => {
278
+ let checkCalled = false;
279
+ const httpRequestStub = async (url, method, body) => {
280
+ if (url.includes('/compaction/check')) {
281
+ checkCalled = true;
282
+ return { needed: false };
283
+ }
284
+ throw new Error(`Unexpected API call: ${url}`);
285
+ };
286
+
287
+ const result = await simulateAgentCompaction({
288
+ httpRequestStub,
289
+ isNewSession: true,
290
+ iteration: 1,
291
+ });
292
+
293
+ if (checkCalled) {
294
+ throw new Error('compaction check should NOT be called on first iteration of new session');
295
+ }
296
+ assertEqual(result.apiCalls.length, 0, 'should make 0 API calls');
297
+ if (!result.logs.some((l) => l.includes('SKIPPED'))) {
298
+ throw new Error('should log that check was skipped');
299
+ }
300
+ });
301
+
302
+ // Test: Should check compaction on second iteration of new session
303
+ await runAsyncTest('Agent: should check compaction on second iteration of new session', async () => {
304
+ const httpRequestStub = async (url, method, body) => {
305
+ if (url.includes('/compaction/check')) {
306
+ return { needed: false, totalTokens: 5000, usage: { limits: { threshold: 100000 } } };
307
+ }
308
+ throw new Error(`Unexpected API call: ${url}`);
309
+ };
310
+
311
+ const result = await simulateAgentCompaction({
312
+ httpRequestStub,
313
+ isNewSession: true,
314
+ iteration: 2,
315
+ });
316
+
317
+ assertEqual(result.apiCalls.length, 1, 'should make 1 API call');
318
+ if (!result.apiCalls[0].url.includes('/compaction/check')) {
319
+ throw new Error('should call compaction check');
320
+ }
321
+ });
322
+
323
+ // Test: Should check compaction on resumed session (not new)
324
+ await runAsyncTest('Agent: should check compaction on resumed session', async () => {
325
+ const httpRequestStub = async (url, method, body) => {
326
+ if (url.includes('/compaction/check')) {
327
+ return { needed: false, totalTokens: 50000, usage: { limits: { threshold: 100000 } } };
328
+ }
329
+ throw new Error(`Unexpected API call: ${url}`);
330
+ };
331
+
332
+ const result = await simulateAgentCompaction({
333
+ httpRequestStub,
334
+ isNewSession: false,
335
+ iteration: 1,
336
+ });
337
+
338
+ assertEqual(result.apiCalls.length, 1, 'should make 1 API call');
339
+ });
340
+
341
+ // Test: Agent session file should include agentName after compaction
342
+ await runAsyncTest('Agent: session file should include agentName after compaction', async () => {
343
+ const httpRequestStub = async (url, method, body) => {
344
+ if (url.includes('/compaction/check')) {
345
+ return { needed: true, totalTokens: 150000, usage: { limits: { threshold: 100000 } } };
346
+ }
347
+ if (url.includes('/compaction/compact')) {
348
+ return { success: true, newSessionID: 'new-session-456', summaryTokens: 5000 };
349
+ }
350
+ throw new Error(`Unexpected API call: ${url}`);
351
+ };
352
+
353
+ const result = await simulateAgentCompaction({
354
+ httpRequestStub,
355
+ agentName: 'my-agent',
356
+ });
357
+
358
+ assertEqual(result.sessionFileUpdated, true, 'session file should be updated');
359
+ assertDeepEqual(result.newSessionFileContent, {
360
+ sessionId: 'new-session-456',
361
+ agentName: 'my-agent',
362
+ }, 'session file should include agentName');
363
+ });
364
+
365
+ // Test: Agent compaction title should include agent name
366
+ await runAsyncTest('Agent: compaction title should include agent name', async () => {
367
+ const httpRequestStub = async (url, method, body) => {
368
+ if (url.includes('/compaction/check')) {
369
+ return { needed: true, totalTokens: 150000, usage: { limits: { threshold: 100000 } } };
370
+ }
371
+ if (url.includes('/compaction/compact')) {
372
+ return { success: true, newSessionID: 'new-session-456', summaryTokens: 5000 };
373
+ }
374
+ throw new Error(`Unexpected API call: ${url}`);
375
+ };
376
+
377
+ const result = await simulateAgentCompaction({
378
+ httpRequestStub,
379
+ agentName: 'my-awesome-agent',
380
+ });
381
+
382
+ assertEqual(result.apiCalls.length, 2, 'should make 2 API calls');
383
+ const compactBody = result.apiCalls[1].body;
384
+ if (!compactBody.title.includes('my-awesome-agent')) {
385
+ throw new Error(`compaction title should include agent name, got: ${compactBody.title}`);
386
+ }
387
+ if (!compactBody.title.includes('Agent Auto-compacted')) {
388
+ throw new Error(`compaction title should include 'Agent Auto-compacted', got: ${compactBody.title}`);
389
+ }
390
+ });
391
+
392
+ // Test: Uses shared parseModelForCompaction helper with fallback
393
+ await runAsyncTest('Agent: should use fallback model for unknown models', async () => {
394
+ const httpRequestStub = async (url, method, body) => {
395
+ if (url.includes('/compaction/check')) {
396
+ return { needed: false };
397
+ }
398
+ throw new Error(`Unexpected API call: ${url}`);
399
+ };
400
+
401
+ const result = await simulateAgentCompaction({
402
+ httpRequestStub,
403
+ model: 'custom-provider/unknown-model-xyz',
404
+ isNewSession: false,
405
+ });
406
+
407
+ const checkUrl = result.apiCalls[0].url;
408
+ if (checkUrl.includes('unknown-model-xyz')) {
409
+ throw new Error('should NOT use unknown model in URL');
410
+ }
411
+ if (!checkUrl.includes('modelID=claude-opus-4.5')) {
412
+ throw new Error('should use fallback model claude-opus-4.5 in URL');
413
+ }
414
+ assertEqual(result.apiCalls[0].originalModelID, 'unknown-model-xyz', 'should track original model');
415
+ assertEqual(result.apiCalls[0].compactionModelID, 'claude-opus-4.5', 'should use fallback for compaction');
416
+ });
417
+
418
+ // Test: Dead code fix - hasSummaryTokens check works correctly
419
+ await runAsyncTest('Agent: should show correct message when summaryTokens is undefined', async () => {
420
+ const httpRequestStub = async (url, method, body) => {
421
+ if (url.includes('/compaction/check')) {
422
+ return { needed: true, totalTokens: 150000, usage: { limits: { threshold: 100000 } } };
423
+ }
424
+ if (url.includes('/compaction/compact')) {
425
+ return { success: true, newSessionID: 'new-session-456' }; // No summaryTokens
426
+ }
427
+ throw new Error(`Unexpected API call: ${url}`);
428
+ };
429
+
430
+ const result = await simulateAgentCompaction({
431
+ httpRequestStub,
432
+ verbose: false,
433
+ });
434
+
435
+ if (!result.logs.some((l) => l.includes('Auto-compacted conversation to new session'))) {
436
+ throw new Error(`should show simple message when summaryTokens not available, got: ${result.logs.join(', ')}`);
437
+ }
438
+ if (result.logs.some((l) => l.includes('→'))) {
439
+ throw new Error('should NOT show token counts when summaryTokens not available');
440
+ }
441
+ });
442
+
443
+ // Test: Should show token counts when summaryTokens is available
444
+ await runAsyncTest('Agent: should show token counts when summaryTokens is available', async () => {
445
+ const httpRequestStub = async (url, method, body) => {
446
+ if (url.includes('/compaction/check')) {
447
+ return { needed: true, totalTokens: 150000, usage: { limits: { threshold: 100000 } } };
448
+ }
449
+ if (url.includes('/compaction/compact')) {
450
+ return { success: true, newSessionID: 'new-session-456', summaryTokens: 5000 };
451
+ }
452
+ throw new Error(`Unexpected API call: ${url}`);
453
+ };
454
+
455
+ const result = await simulateAgentCompaction({
456
+ httpRequestStub,
457
+ verbose: false,
458
+ });
459
+
460
+ if (!result.logs.some((l) => l.includes('150000') && l.includes('5000'))) {
461
+ throw new Error(`should show token counts, got logs: ${result.logs.join(', ')}`);
462
+ }
463
+ });
464
+
465
+ // Test: Non-verbose failure path - compaction fails with error
466
+ await runAsyncTest('Agent: should always show compaction failure even when verbose is false', async () => {
467
+ const httpRequestStub = async (url, method, body) => {
468
+ if (url.includes('/compaction/check')) {
469
+ return { needed: true, totalTokens: 150000, usage: { limits: { threshold: 100000 } } };
470
+ }
471
+ if (url.includes('/compaction/compact')) {
472
+ return { success: false, error: 'Server error during compaction' };
473
+ }
474
+ throw new Error(`Unexpected API call: ${url}`);
475
+ };
476
+
477
+ const result = await simulateAgentCompaction({
478
+ httpRequestStub,
479
+ verbose: false, // Non-verbose mode
480
+ });
481
+
482
+ // Should always show failure message even without verbose
483
+ if (!result.logs.some((l) => l.includes('Auto-compaction failed'))) {
484
+ throw new Error(`should show failure message even in non-verbose mode, got: ${result.logs.join(', ')}`);
485
+ }
486
+ if (!result.logs.some((l) => l.includes('Server error during compaction'))) {
487
+ throw new Error(`should include error details, got: ${result.logs.join(', ')}`);
488
+ }
489
+ });
490
+
491
+ // Test: Non-verbose failure path - compaction execution throws
492
+ await runAsyncTest('Agent: should always show execution failure even when verbose is false', async () => {
493
+ const httpRequestStub = async (url, method, body) => {
494
+ if (url.includes('/compaction/check')) {
495
+ return { needed: true, totalTokens: 150000, usage: { limits: { threshold: 100000 } } };
496
+ }
497
+ if (url.includes('/compaction/compact')) {
498
+ throw new Error('Network timeout');
499
+ }
500
+ throw new Error(`Unexpected API call: ${url}`);
501
+ };
502
+
503
+ const result = await simulateAgentCompaction({
504
+ httpRequestStub,
505
+ verbose: false, // Non-verbose mode
506
+ });
507
+
508
+ // Should always show execution failure message even without verbose
509
+ if (!result.logs.some((l) => l.includes('Compaction execution failed'))) {
510
+ throw new Error(`should show execution failure message even in non-verbose mode, got: ${result.logs.join(', ')}`);
511
+ }
512
+ if (!result.logs.some((l) => l.includes('Network timeout'))) {
513
+ throw new Error(`should include error message, got: ${result.logs.join(', ')}`);
514
+ }
515
+ });
516
+
517
+ // Test: Non-verbose mode - invalid newSessionID warning should always be shown
518
+ await runAsyncTest('Agent: should always show invalid newSessionID warning even when verbose is false', async () => {
519
+ const httpRequestStub = async (url, method, body) => {
520
+ if (url.includes('/compaction/check')) {
521
+ return { needed: true, totalTokens: 150000, usage: { limits: { threshold: 100000 } } };
522
+ }
523
+ if (url.includes('/compaction/compact')) {
524
+ // Server returns success but with null/invalid newSessionID
525
+ return { success: true, newSessionID: null };
526
+ }
527
+ throw new Error(`Unexpected API call: ${url}`);
528
+ };
529
+
530
+ const result = await simulateAgentCompaction({
531
+ httpRequestStub,
532
+ verbose: false, // Non-verbose mode
533
+ });
534
+
535
+ // Should always show invalid session warning even without verbose
536
+ if (!result.logs.some((l) => l.includes('invalid newSessionID'))) {
537
+ throw new Error(`should show invalid newSessionID warning even in non-verbose mode, got: ${result.logs.join(', ')}`);
538
+ }
539
+ // Should keep original session
540
+ if (result.sessionFileUpdated) {
541
+ throw new Error('should not update session file when newSessionID is invalid');
542
+ }
543
+ });
544
+ }
545
+
546
+ // Run tests
547
+ runAgentIntegrationTests().then(() => {
548
+ console.log('\n✓ Agent auto-compaction tests completed');
549
+ }).catch((err) => {
550
+ console.error('Agent tests failed:', err.message);
551
+ process.exitCode = 1;
552
+ });
@@ -0,0 +1,95 @@
1
+ // Tests for agent-comm urgent abort behavior and sleep-wake logic
2
+ // Split from abort-stream.test.js per Copilot review to keep test scopes narrow
3
+
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const assert = require('assert');
7
+
8
+ let passed = 0;
9
+ let failed = 0;
10
+
11
+ function runTest(name, fn) {
12
+ try {
13
+ fn();
14
+ console.log('\u2705 ' + name);
15
+ passed++;
16
+ } catch (e) {
17
+ console.log('\u274C ' + name + ': ' + e.message);
18
+ failed++;
19
+ }
20
+ }
21
+
22
+ // ============================================================
23
+ // agent-comm.js: urgent abort HTTP wiring
24
+ // ============================================================
25
+
26
+ runTest('agent-comm: urgent abort uses serverUrl hostname and port', () => {
27
+ const agentCommSource = fs.readFileSync(
28
+ path.join(__dirname, '..', '..', 'opencode-lite', 'src', 'tools', 'agent-comm.js'), 'utf8');
29
+ // Should parse hostname from status.serverUrl
30
+ assert(agentCommSource.indexOf('status.serverUrl') !== -1,
31
+ 'should reference status.serverUrl for hostname');
32
+ assert(agentCommSource.indexOf('new URL(status.serverUrl)') !== -1,
33
+ 'should parse serverUrl with URL constructor');
34
+ // Port should be extracted from serverUrl (not PAVE_PORT env)
35
+ assert(agentCommSource.indexOf('parsed.port') !== -1,
36
+ 'should extract port from parsed serverUrl');
37
+ });
38
+
39
+ runTest('agent-comm: urgent success message uses cautious wording', () => {
40
+ const agentCommSource = fs.readFileSync(
41
+ path.join(__dirname, '..', '..', 'opencode-lite', 'src', 'tools', 'agent-comm.js'), 'utf8');
42
+ // Should NOT promise guaranteed abort — the abort POST is best-effort
43
+ assert(agentCommSource.indexOf('will abort its current task') === -1,
44
+ 'should not promise guaranteed abort (best-effort fire-and-forget)');
45
+ });
46
+
47
+ runTest('agent-comm: urgent param description uses best-effort wording', () => {
48
+ const agentCommSource = fs.readFileSync(
49
+ path.join(__dirname, '..', '..', 'opencode-lite', 'src', 'tools', 'agent-comm.js'), 'utf8');
50
+ // The urgent parameter description should NOT say "forces immediate processing"
51
+ assert(agentCommSource.indexOf('forces immediate processing') === -1,
52
+ 'urgent param should not promise forced immediate processing');
53
+ // Should use best-effort wording
54
+ assert(agentCommSource.indexOf('best-effort') !== -1,
55
+ 'urgent param should mention best-effort delivery');
56
+ assert(agentCommSource.indexOf('attempts to abort') !== -1,
57
+ 'urgent param should say it attempts to abort');
58
+ });
59
+
60
+ // ============================================================
61
+ // pave/index.js: sleep-wake behavior
62
+ // ============================================================
63
+
64
+ runTest('pave/index.js: SOUL read error sleep does not check inbox', () => {
65
+ const paveSource = fs.readFileSync(
66
+ path.join(__dirname, '..', '..', 'pave', 'index.js'), 'utf8');
67
+ // Find the SOUL read error section
68
+ const soulErrIdx = paveSource.indexOf('Error reading SOUL file');
69
+ assert(soulErrIdx !== -1, 'should find SOUL read error handler');
70
+ const errSection = paveSource.substring(soulErrIdx, soulErrIdx + 400);
71
+ // The signalAwareSleep in the error path should NOT check inboxHasMessages
72
+ // to prevent tight loops when SOUL is unreadable + inbox has messages
73
+ assert(errSection.indexOf('inboxHasMessages') === -1,
74
+ 'SOUL read error sleep should NOT check inbox to prevent tight loop');
75
+ });
76
+
77
+ runTest('pave/index.js: normal agent sleep wakes on inbox messages', () => {
78
+ const paveSource = fs.readFileSync(
79
+ path.join(__dirname, '..', '..', 'pave', 'index.js'), 'utf8');
80
+ // Find the agent sleep at end of iteration loop — look for the comment we added
81
+ const sleepCommentIdx = paveSource.indexOf('Wake early from sleep if');
82
+ assert(sleepCommentIdx !== -1, 'should find sleep comment');
83
+ const sleepSection = paveSource.substring(sleepCommentIdx, sleepCommentIdx + 600);
84
+ assert(sleepSection.indexOf('inboxHasMessages') !== -1,
85
+ 'normal agent sleep should check inboxHasMessages to wake promptly');
86
+ assert(sleepSection.indexOf('hasInterrupt') !== -1,
87
+ 'normal agent sleep should also check interrupt file');
88
+ });
89
+
90
+ // ============================================================
91
+ // Summary
92
+ // ============================================================
93
+
94
+ console.log('\nTotal: ' + (passed + failed) + ', Passed: ' + passed + ', Failed: ' + failed);
95
+ if (failed > 0) process.exitCode = 1;