@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,762 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Tests for post-stream idle verification fix (Issue #97)
4
- *
5
- * Verifies that after sendMessageAndStream returns, the agent loop
6
- * calls waitForSessionIdle before sleeping, preventing "Session still busy"
7
- * / HTTP 409 errors in the next iteration.
8
- *
9
- * Run with: node test/agent-post-stream-idle.test.js
10
- */
11
-
12
- const fs = require('fs');
13
- const path = require('path');
14
-
15
- // ============================================================================
16
- // Test Utilities
17
- // ============================================================================
18
-
19
- function runTest(name, testFn) {
20
- try {
21
- testFn();
22
- console.log(`\u2705 ${name}`);
23
- } catch (error) {
24
- console.log(`\u274C ${name}: ${error.message}`);
25
- process.exitCode = 1;
26
- }
27
- }
28
-
29
- function assertEqual(actual, expected, message) {
30
- if (actual !== expected) {
31
- throw new Error(`${message}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
32
- }
33
- }
34
-
35
- function assertTrue(value, message) {
36
- if (!value) {
37
- throw new Error(`${message}: expected truthy value, got ${JSON.stringify(value)}`);
38
- }
39
- }
40
-
41
- // ============================================================================
42
- // Structural Tests - Verify the fix exists in index.js
43
- // ============================================================================
44
-
45
- console.log('Running agent-post-stream-idle.test.js...');
46
- console.log('\n=== Structural Tests: Post-Stream Idle Check ===');
47
-
48
- const indexPath = path.join(__dirname, '..', 'index.js');
49
- const indexContent = fs.readFileSync(indexPath, 'utf8');
50
-
51
- runTest('index.js should contain waitForSessionIdle function', () => {
52
- assertTrue(
53
- indexContent.includes('async function waitForSessionIdle('),
54
- 'waitForSessionIdle function should be defined',
55
- );
56
- });
57
-
58
- runTest('waitForSessionIdle should return idle status and wait info', () => {
59
- // Verify the function returns the expected shape using regex to tolerate
60
- // formatting/whitespace changes (per Copilot review feedback)
61
- const hasIdleTrueReturn = /return\s*{\s*idle\s*:\s*true\s*,\s*waited\s*,\s*waitTimeMs\s*}/.test(indexContent);
62
- const hasIdleFalseReturn = /return\s*{\s*idle\s*:\s*false\s*,\s*waited\s*,\s*waitTimeMs\s*:/.test(indexContent);
63
-
64
- assertTrue(
65
- hasIdleTrueReturn,
66
- 'should return idle:true with waited and waitTimeMs',
67
- );
68
- assertTrue(
69
- hasIdleFalseReturn,
70
- 'should return idle:false on timeout',
71
- );
72
- });
73
-
74
- runTest('Agent loop should have post-stream idle check after sendMessageAndStream', () => {
75
- // Assert on stable code structure rather than comment text:
76
- // Verify waitForSessionIdle is called between sendMessageAndStream and the sleep log.
77
- // Use handleAgentCommand as anchor to find the agent loop's send call (not the
78
- // sendMessageAndStream function definition earlier in the file).
79
- const agentHandlerIdx = indexContent.indexOf('async function handleAgentCommand');
80
- assertTrue(agentHandlerIdx >= 0, 'handleAgentCommand function should exist');
81
-
82
- const sendIdx = indexContent.indexOf('await sendMessageAndStream(', agentHandlerIdx);
83
- assertTrue(sendIdx > agentHandlerIdx, 'agent loop should await sendMessageAndStream');
84
-
85
- const sleepIdx = indexContent.indexOf('Sleeping for', sendIdx);
86
- assertTrue(sleepIdx > sendIdx, 'agent loop should log Sleeping for after sendMessageAndStream');
87
-
88
- const idleIdx = indexContent.indexOf('waitForSessionIdle', sendIdx);
89
- assertTrue(
90
- idleIdx > sendIdx && idleIdx < sleepIdx,
91
- 'agent loop should call waitForSessionIdle between sendMessageAndStream and Sleeping for',
92
- );
93
- });
94
-
95
- runTest('Post-stream idle check should use 30 second timeout (#271)', () => {
96
- // Verify the waitForSessionIdle call between sendMessageAndStream and
97
- // Sleeping for uses a 30 second (30 * 1000 ms) timeout — reduced from
98
- // 10 minutes per issue #271 (HTTP 500 recovery).
99
- const agentHandlerIdx = indexContent.indexOf('async function handleAgentCommand');
100
- const sendIdx = indexContent.indexOf('await sendMessageAndStream(', agentHandlerIdx);
101
- assertTrue(sendIdx > agentHandlerIdx, 'agent loop should await sendMessageAndStream');
102
-
103
- const sleepIdx = indexContent.indexOf('Sleeping for', sendIdx);
104
- assertTrue(sleepIdx > sendIdx, 'Sleeping for marker should exist after sendMessageAndStream');
105
-
106
- const idleIdx = indexContent.indexOf('waitForSessionIdle', sendIdx);
107
- assertTrue(idleIdx > sendIdx && idleIdx < sleepIdx, 'waitForSessionIdle should exist between send and sleep');
108
-
109
- const postStreamSection = indexContent.substring(idleIdx, sleepIdx);
110
- assertTrue(
111
- postStreamSection.includes('30 * 1000'),
112
- 'post-stream idle check should use 30 second timeout (reduced from 10 minutes per #271)',
113
- );
114
- });
115
-
116
- runTest('Post-stream idle check should use 1000ms poll interval (#271)', () => {
117
- const agentHandlerIdx = indexContent.indexOf('async function handleAgentCommand');
118
- const sendIdx = indexContent.indexOf('await sendMessageAndStream(', agentHandlerIdx);
119
- assertTrue(sendIdx > agentHandlerIdx, 'agent loop should await sendMessageAndStream');
120
-
121
- const sleepIdx = indexContent.indexOf('Sleeping for', sendIdx);
122
- assertTrue(sleepIdx > sendIdx, 'Sleeping for marker should exist after sendMessageAndStream');
123
-
124
- const idleIdx = indexContent.indexOf('waitForSessionIdle', sendIdx);
125
- assertTrue(idleIdx > sendIdx && idleIdx < sleepIdx, 'waitForSessionIdle should exist between send and sleep');
126
-
127
- const postStreamSection = indexContent.substring(idleIdx, sleepIdx);
128
- assertTrue(
129
- postStreamSection.includes('pollIntervalMs: 1000'),
130
- 'post-stream idle check should poll every 1000ms (increased frequency per #271)',
131
- );
132
- });
133
-
134
- runTest('Post-stream idle check should log wait time when session was still processing', () => {
135
- assertTrue(
136
- indexContent.includes('Session was still processing after stream ended'),
137
- 'should log informative message when session was still processing after successful wait',
138
- );
139
- assertTrue(
140
- indexContent.includes('Session did not become idle after stream ended'),
141
- 'should log warning when session timed out and did not become idle',
142
- );
143
- assertTrue(
144
- indexContent.includes('Could not verify session idle state after stream ended'),
145
- 'should log distinct warning when status check failed with an error',
146
- );
147
- });
148
-
149
- runTest('Post-stream idle check occurs BEFORE sleep, AFTER sendMessageAndStream in agent loop', () => {
150
- // The postSendCheck variable is unique to the agent loop, so find it and
151
- // verify it appears between sendMessageAndStream and signalAwareSleep
152
- // Use the postSendCheck as anchor since it only exists in the agent handler
153
- const postCheckIdx = indexContent.indexOf('postSendCheck');
154
- assertTrue(postCheckIdx > 0, 'postSendCheck should exist');
155
-
156
- // Find the sendMessageAndStream call that precedes postSendCheck
157
- // (search backwards from postSendCheck)
158
- const beforePostCheck = indexContent.substring(0, postCheckIdx);
159
- const sendMsgIdx = beforePostCheck.lastIndexOf('await sendMessageAndStream(');
160
- assertTrue(sendMsgIdx > 0, 'sendMessageAndStream should precede postSendCheck');
161
-
162
- // Find the signalAwareSleep that follows postSendCheck
163
- const sleepIdx = indexContent.indexOf('signalAwareSleep(', postCheckIdx);
164
- assertTrue(sleepIdx > 0, 'signalAwareSleep should follow postSendCheck');
165
-
166
- assertTrue(
167
- sendMsgIdx < postCheckIdx && postCheckIdx < sleepIdx,
168
- 'order should be: sendMessageAndStream -> postSendCheck -> signalAwareSleep',
169
- );
170
- });
171
-
172
- runTest('Post-stream idle check is gated to skip only HTTP 409 rejections', () => {
173
- // The post-stream idle check should run for all cases EXCEPT when
174
- // sendMessageAndStream threw with HTTP 409 (session busy), since the
175
- // server definitively rejected the request. For non-409 errors (e.g.,
176
- // SSE connection drops), the server may still be processing.
177
- assertTrue(
178
- indexContent.includes('let sendRejectedAs409 = false'),
179
- 'sendRejectedAs409 flag should be declared as false before try block',
180
- );
181
- assertTrue(
182
- indexContent.includes('sendRejectedAs409 = true'),
183
- 'sendRejectedAs409 should be set to true in the 409 catch branch',
184
- );
185
- assertTrue(
186
- indexContent.includes('if (!sendRejectedAs409)'),
187
- 'post-stream idle check should be gated by !sendRejectedAs409 flag',
188
- );
189
-
190
- // Verify sendRejectedAs409 = true is inside the catch block (409 handling)
191
- const agentHandlerIdx = indexContent.indexOf('async function handleAgentCommand');
192
- const sendMsgIdx = indexContent.indexOf('await sendMessageAndStream(', agentHandlerIdx);
193
- const catchIdx = indexContent.indexOf('} catch (err)', sendMsgIdx);
194
- const rejectedIdx = indexContent.indexOf('sendRejectedAs409 = true', catchIdx);
195
- assertTrue(rejectedIdx > catchIdx, 'sendRejectedAs409=true should be inside the catch block');
196
-
197
- // Verify if (!sendRejectedAs409) comes AFTER the catch block
198
- const ifNotRejectedIdx = indexContent.indexOf('if (!sendRejectedAs409)', catchIdx);
199
- assertTrue(ifNotRejectedIdx > catchIdx, 'if (!sendRejectedAs409) gate should come after the catch block');
200
- });
201
-
202
- runTest('Pre-send idle check should still exist (belt-and-suspenders)', () => {
203
- // Locate the pre-send waitForSessionIdle call in the agent handler.
204
- // It should appear before the main sendMessageAndStream call in the loop.
205
- const agentHandlerIdx = indexContent.indexOf('async function handleAgentCommand');
206
- assertTrue(agentHandlerIdx >= 0, 'handleAgentCommand function should exist');
207
-
208
- const sendMsgIdx = indexContent.indexOf('await sendMessageAndStream(', agentHandlerIdx);
209
- assertTrue(sendMsgIdx > agentHandlerIdx, 'sendMessageAndStream should exist in agent handler');
210
-
211
- // The pre-send waitForSessionIdle call should appear between handleAgentCommand and sendMessageAndStream
212
- const preSendSection = indexContent.substring(agentHandlerIdx, sendMsgIdx);
213
- assertTrue(
214
- preSendSection.includes('waitForSessionIdle'),
215
- 'pre-send waitForSessionIdle call should exist before sendMessageAndStream',
216
- );
217
- });
218
-
219
- runTest('Pre-send uses shorter timeout (5 min) vs post-stream (10 min)', () => {
220
- // Find the pre-send waitForSessionIdle call (the one before sendMessageAndStream)
221
- const agentHandlerIdx = indexContent.indexOf('async function handleAgentCommand');
222
- const sendMsgIdx = indexContent.indexOf('await sendMessageAndStream(', agentHandlerIdx);
223
- const preSendSection = indexContent.substring(agentHandlerIdx, sendMsgIdx);
224
-
225
- assertTrue(
226
- preSendSection.includes('5 * 60 * 1000'),
227
- 'pre-send check should use 5 minute timeout',
228
- );
229
- });
230
-
231
- // ============================================================================
232
- // waitForSessionIdle Unit Tests (functional simulation)
233
- // ============================================================================
234
-
235
- console.log('\n=== Functional Tests: waitForSessionIdle behavior ===');
236
-
237
- /**
238
- * Simulates waitForSessionIdle with a stubbed httpRequest.
239
- * Mirrors the actual function logic from index.js.
240
- * Fully synchronous — uses simulated elapsed time instead of real timers.
241
- */
242
- function simulateWaitForSessionIdle(statusSequence, options = {}) {
243
- const { maxWaitMs = 5000, pollIntervalMs = 50, verbose = false } = options;
244
- let simulatedElapsedMs = 0;
245
- let waited = false;
246
- let pollCount = 0;
247
- const logs = [];
248
-
249
- for (const status of statusSequence) {
250
- pollCount++;
251
-
252
- // Simulate an error during status check (mirrors the catch branch in production)
253
- if (status && typeof status === 'object' && status.type === 'error') {
254
- return { idle: true, waited, waitTimeMs: simulatedElapsedMs, error: status.message || 'Unknown error', pollCount, logs };
255
- }
256
-
257
- // Simulate the idle check
258
- if (!status || (typeof status === 'object' && status.type === 'idle')) {
259
- return { idle: true, waited, waitTimeMs: simulatedElapsedMs, pollCount, logs };
260
- }
261
-
262
- // Session is busy
263
- if (!waited) {
264
- waited = true;
265
- if (verbose) {
266
- logs.push('Session is busy, waiting for idle...');
267
- }
268
- }
269
-
270
- // Simulate poll interval elapsed
271
- simulatedElapsedMs += pollIntervalMs;
272
-
273
- // Check timeout
274
- if (simulatedElapsedMs >= maxWaitMs) {
275
- return { idle: false, waited, waitTimeMs: simulatedElapsedMs, timedOut: true, pollCount, logs };
276
- }
277
- }
278
-
279
- // Exhausted status sequence without becoming idle
280
- return { idle: false, waited, waitTimeMs: simulatedElapsedMs, pollCount, logs };
281
- }
282
-
283
- /**
284
- * Simulates the agent loop's post-stream behavior.
285
- * Shows that the idle check after sendMessageAndStream prevents
286
- * sleeping while the session is still busy.
287
- * Fully synchronous — no real timers needed.
288
- */
289
- function simulateAgentLoopIteration(options = {}) {
290
- const {
291
- sendMessageSuccess = true,
292
- sendThrew = false, // true = sendMessageAndStream threw
293
- sendThrew409 = false, // true = threw specifically with 409
294
- sessionStatusAfterSend = [null], // null = idle
295
- verbose = false,
296
- maxWaitMs = 5000,
297
- pollIntervalMs = 50,
298
- } = options;
299
-
300
- const logs = [];
301
- const actions = [];
302
- let postSendCheck = null;
303
-
304
- // Simulate sendMessageAndStream
305
- actions.push('sendMessageAndStream');
306
- let sendRejectedAs409 = false;
307
- if (sendThrew) {
308
- if (sendThrew409) {
309
- // sendMessageAndStream threw with HTTP 409
310
- sendRejectedAs409 = true;
311
- logs.push('Error: Session is busy (HTTP 409)');
312
- } else {
313
- // sendMessageAndStream threw with non-409 error (e.g., SSE connection drop)
314
- logs.push('Error: Connection reset');
315
- }
316
- } else {
317
- if (!sendMessageSuccess) {
318
- logs.push('Error in iteration');
319
- }
320
- }
321
-
322
- // Post-stream idle check (the fix from this PR)
323
- // Run for all cases EXCEPT 409 rejections, matching the
324
- // `if (!sendRejectedAs409)` gate in production code.
325
- if (!sendRejectedAs409) {
326
- actions.push('postStreamIdleCheck');
327
- postSendCheck = simulateWaitForSessionIdle(sessionStatusAfterSend, {
328
- verbose,
329
- maxWaitMs,
330
- pollIntervalMs,
331
- });
332
-
333
- if (postSendCheck.error) {
334
- // Status check failed — assumed idle, but warn operator
335
- logs.push(`Could not verify session idle state after stream ended: ${postSendCheck.error}`);
336
- } else if (postSendCheck.waited) {
337
- const waitedSeconds = Math.round(postSendCheck.waitTimeMs / 1000);
338
- if (postSendCheck.idle) {
339
- logs.push(`Session was still processing after stream ended, waited ${waitedSeconds}s for idle`);
340
- } else {
341
- const timedOutFlag = postSendCheck.timedOut ? ' (timed out waiting for idle)' : '';
342
- logs.push(`Session did not become idle after stream ended, waited ${waitedSeconds}s${timedOutFlag}`);
343
- }
344
- }
345
- }
346
-
347
- // Sleep
348
- actions.push('sleep');
349
-
350
- return {
351
- actions,
352
- logs,
353
- postSendCheck,
354
- sendRejectedAs409,
355
- sleptWhileBusy: postSendCheck ? (postSendCheck.waited && !postSendCheck.idle) : false,
356
- };
357
- }
358
-
359
- // All simulations are fully synchronous — no async IIFE needed.
360
- // This ensures the test runner can track pass/fail reliably.
361
-
362
- runTest('Session already idle: should return immediately without waiting', () => {
363
- const result = simulateWaitForSessionIdle([null]); // null = idle
364
- assertEqual(result.idle, true, 'should report idle');
365
- assertEqual(result.waited, false, 'should not have waited');
366
- assertEqual(result.pollCount, 1, 'should poll only once');
367
- });
368
-
369
- runTest('Session busy then idle: should wait and report', () => {
370
- const result = simulateWaitForSessionIdle([
371
- { type: 'busy' },
372
- { type: 'busy' },
373
- null, // becomes idle
374
- ], { verbose: true });
375
-
376
- assertEqual(result.idle, true, 'should report idle');
377
- assertEqual(result.waited, true, 'should have waited');
378
- assertEqual(result.pollCount, 3, 'should poll 3 times');
379
- assertTrue(result.logs.length > 0, 'should have logged wait message');
380
- });
381
-
382
- runTest('Session stays busy: should timeout', () => {
383
- const result = simulateWaitForSessionIdle([
384
- { type: 'busy' },
385
- { type: 'busy' },
386
- { type: 'busy' },
387
- { type: 'busy' },
388
- { type: 'busy' },
389
- ], { maxWaitMs: 100, pollIntervalMs: 30 });
390
-
391
- assertEqual(result.idle, false, 'should report not idle');
392
- assertEqual(result.waited, true, 'should have waited');
393
- });
394
-
395
- runTest('Agent loop: session idle after stream - no extra wait', () => {
396
- const result = simulateAgentLoopIteration({
397
- sessionStatusAfterSend: [null], // Immediately idle
398
- });
399
-
400
- assertEqual(result.postSendCheck.waited, false, 'should not have waited');
401
- assertEqual(result.postSendCheck.idle, true, 'should be idle');
402
- assertEqual(result.sleptWhileBusy, false, 'should not sleep while busy');
403
- // Verify correct action order
404
- assertEqual(result.actions[0], 'sendMessageAndStream', 'first action');
405
- assertEqual(result.actions[1], 'postStreamIdleCheck', 'second action');
406
- assertEqual(result.actions[2], 'sleep', 'third action');
407
- });
408
-
409
- runTest('Agent loop: session busy after stream - waits before sleep', () => {
410
- const result = simulateAgentLoopIteration({
411
- sessionStatusAfterSend: [
412
- { type: 'busy' }, // SSE stream ended but server still processing
413
- { type: 'busy' },
414
- null, // Server finishes
415
- ],
416
- });
417
-
418
- assertEqual(result.postSendCheck.waited, true, 'should have waited');
419
- assertEqual(result.postSendCheck.idle, true, 'should eventually become idle');
420
- assertEqual(result.sleptWhileBusy, false, 'should not sleep while busy');
421
- assertTrue(
422
- result.logs.some((l) => l.includes('Session was still processing')),
423
- 'should log that session was still processing',
424
- );
425
- });
426
-
427
- runTest('Agent loop: session hangs - timeout prevents infinite block', () => {
428
- const result = simulateAgentLoopIteration({
429
- sessionStatusAfterSend: [
430
- { type: 'busy' },
431
- { type: 'busy' },
432
- { type: 'busy' },
433
- { type: 'busy' },
434
- { type: 'busy' },
435
- ],
436
- maxWaitMs: 100,
437
- pollIntervalMs: 30,
438
- });
439
-
440
- assertEqual(result.postSendCheck.waited, true, 'should have waited');
441
- assertEqual(result.postSendCheck.idle, false, 'should report not idle after timeout');
442
- // Still proceeds to sleep (timeout is a safety valve, not a blocker)
443
- assertEqual(result.actions[2], 'sleep', 'should still proceed to sleep after timeout');
444
- // Should produce a warning about not becoming idle
445
- assertTrue(
446
- result.logs.some((l) => l.includes('Session did not become idle')),
447
- 'should warn that session did not become idle',
448
- );
449
- });
450
-
451
- runTest('Agent loop: HTTP 409 rejection skips idle check', () => {
452
- const result = simulateAgentLoopIteration({
453
- sendThrew: true,
454
- sendThrew409: true,
455
- sessionStatusAfterSend: [{ type: 'busy' }, null],
456
- });
457
-
458
- // When sendMessageAndStream threw with 409, the server definitively
459
- // rejected our request, so the post-stream idle check is unnecessary.
460
- assertEqual(result.sendRejectedAs409, true, 'sendRejectedAs409 should be true');
461
- assertTrue(
462
- !result.actions.includes('postStreamIdleCheck'),
463
- 'should NOT perform post-stream idle check when 409 rejected',
464
- );
465
- assertEqual(result.postSendCheck, null, 'postSendCheck should be null when skipped');
466
- // Should still proceed to sleep
467
- assertTrue(
468
- result.actions.includes('sleep'),
469
- 'should still proceed to sleep after 409 rejection',
470
- );
471
- });
472
-
473
- runTest('Agent loop: non-409 throw still gets idle check', () => {
474
- // When sendMessageAndStream threw with a non-409 error (e.g., SSE connection
475
- // drop after POST accepted), the server may still be processing, so we
476
- // should still check idle.
477
- const result = simulateAgentLoopIteration({
478
- sendThrew: true,
479
- sendThrew409: false,
480
- sessionStatusAfterSend: [{ type: 'busy' }, null],
481
- });
482
-
483
- assertEqual(result.sendRejectedAs409, false, 'sendRejectedAs409 should be false for non-409');
484
- assertTrue(
485
- result.actions.includes('postStreamIdleCheck'),
486
- 'should perform post-stream idle check for non-409 errors',
487
- );
488
- });
489
-
490
- runTest('Agent loop: sendMessageAndStream resolved with error still gets idle check', () => {
491
- // When sendMessageAndStream resolves (even with response.success=false),
492
- // the server may have partially processed the request, so we still check idle.
493
- const result = simulateAgentLoopIteration({
494
- sendMessageSuccess: false,
495
- sendThrew: false,
496
- sessionStatusAfterSend: [{ type: 'busy' }, null],
497
- });
498
-
499
- assertEqual(result.sendRejectedAs409, false, 'sendRejectedAs409 should be false when resolved');
500
- assertTrue(
501
- result.actions.includes('postStreamIdleCheck'),
502
- 'should still perform post-stream idle check when send resolved with error',
503
- );
504
- });
505
-
506
- runTest('Agent loop: status check error produces distinct warning', () => {
507
- // When waitForSessionIdle fails to check status (e.g., server unreachable),
508
- // it returns { idle: true, error: '...' }. The agent loop should produce
509
- // a distinct diagnostic message rather than the normal "waited Xs for idle" log.
510
- const result = simulateAgentLoopIteration({
511
- sendMessageSuccess: true,
512
- sessionStatusAfterSend: [{ type: 'error', message: 'ECONNREFUSED' }],
513
- });
514
-
515
- assertTrue(
516
- result.actions.includes('postStreamIdleCheck'),
517
- 'should perform post-stream idle check',
518
- );
519
- assertEqual(result.postSendCheck.idle, true, 'error case assumes idle');
520
- assertTrue(
521
- typeof result.postSendCheck.error === 'string',
522
- 'should have an error field',
523
- );
524
- // The log should contain the distinct error warning, not the normal waited message
525
- assertTrue(
526
- result.logs.some((l) => { return l.includes('Could not verify session idle state'); }),
527
- 'should log distinct error warning',
528
- );
529
- assertTrue(
530
- !result.logs.some((l) => { return l.includes('waited') && l.includes('for idle'); }),
531
- 'should NOT log the normal "waited for idle" message on error',
532
- );
533
- });
534
-
535
- // ============================================================================
536
- // SSE premature close scenario test
537
- // ============================================================================
538
-
539
- console.log('\n=== Scenario Tests: SSE premature close ===');
540
-
541
- runTest('Scenario: SSE stream ends during long tool execution', () => {
542
- // This simulates the exact bug from issue #97:
543
- // 1. Agent sends message, SSE stream starts
544
- // 2. Server starts executing a long tool (e.g., multi-file edit)
545
- // 3. SSE connection drops (network issue on iSH)
546
- // 4. sendMessageAndStream resolves (sseRes.on('end') calls finish(true))
547
- // 5. WITHOUT FIX: agent sleeps, next iteration gets 409
548
- // 6. WITH FIX: post-stream idle check catches that server is still busy
549
-
550
- const result = simulateAgentLoopIteration({
551
- sendMessageSuccess: true,
552
- sessionStatusAfterSend: [
553
- { type: 'busy', elapsed: 5000 }, // Server still running tool
554
- { type: 'busy', elapsed: 10000 }, // Still processing...
555
- { type: 'busy', elapsed: 15000 }, // Almost done...
556
- null, // Tool execution complete
557
- ],
558
- });
559
-
560
- // The post-stream check caught the still-busy session
561
- assertEqual(result.postSendCheck.waited, true, 'should detect session was still busy');
562
- assertEqual(result.postSendCheck.idle, true, 'should eventually see idle');
563
- assertEqual(result.sleptWhileBusy, false, 'should NOT sleep while session is busy');
564
-
565
- // Verify the fix logged that it caught a premature stream end
566
- assertTrue(
567
- result.logs.some((l) => l.includes('Session was still processing after stream ended')),
568
- 'should log that it caught the premature stream end',
569
- );
570
- });
571
-
572
- runTest('Scenario: SSE connection drop after POST accepted (non-409 throw)', () => {
573
- // This scenario: POST was accepted, server started processing, but SSE
574
- // connection dropped causing sendMessageAndStream to throw a non-409 error.
575
- // The idle check should still run because the server may still be working.
576
- const result = simulateAgentLoopIteration({
577
- sendThrew: true,
578
- sendThrew409: false,
579
- sessionStatusAfterSend: [
580
- { type: 'busy' }, // Server still processing after connection drop
581
- { type: 'busy' },
582
- null, // Server finishes
583
- ],
584
- });
585
-
586
- assertTrue(
587
- result.actions.includes('postStreamIdleCheck'),
588
- 'should still check idle after non-409 connection error',
589
- );
590
- assertEqual(result.postSendCheck.waited, true, 'should have waited for idle');
591
- assertEqual(result.postSendCheck.idle, true, 'should eventually become idle');
592
- });
593
-
594
- runTest('Scenario: Normal completion - no performance regression', () => {
595
- // When everything works correctly, the post-stream check should be
596
- // essentially free in terms of logic: one poll, session is already idle,
597
- // and it returns immediately without extra waiting or logging.
598
- const result = simulateAgentLoopIteration({
599
- sendMessageSuccess: true,
600
- sessionStatusAfterSend: [null], // Already idle (normal case)
601
- });
602
-
603
- assertEqual(result.postSendCheck.waited, false, 'should not wait when already idle');
604
- assertEqual(result.postSendCheck.pollCount, 1, 'should only poll once');
605
- assertEqual(result.logs.length, 0, 'should produce no extra log output in normal case');
606
- });
607
-
608
- // ============================================================================
609
- // SSE Timeout & Cleanup Tests (Issue #189/#190)
610
- // ============================================================================
611
-
612
- console.log('\n=== SSE Timeout & Cleanup Tests ===');
613
-
614
- runTest('SSE idle timeout value is set to 5 minutes', () => {
615
- assertTrue(indexContent.includes('SSE_IDLE_TIMEOUT_MS = 5 * 60 * 1000'), 'Should configure SSE_IDLE_TIMEOUT_MS as 5 minutes');
616
- });
617
-
618
- runTest('SSE timeout handle is stored in a variable', () => {
619
- assertTrue(
620
- indexContent.includes('sseTimeout = setTimeout'),
621
- 'SSE timeout handle should be stored in sseTimeout variable',
622
- );
623
- });
624
-
625
- runTest('SSE timeout is cleared in finish()', () => {
626
- assertTrue(
627
- indexContent.includes('clearTimeout(sseTimeout)'),
628
- 'finish() should clear the SSE timeout',
629
- );
630
- });
631
-
632
- runTest('SSE timeout is unref\'d to not keep process alive', () => {
633
- assertTrue(
634
- indexContent.includes('sseTimeout.unref'),
635
- 'SSE timeout should be unref\'d',
636
- );
637
- });
638
-
639
- runTest('SSE timeout rejects with error (not silent success)', () => {
640
- assertTrue(
641
- indexContent.includes('rejectWithCleanup') && indexContent.includes('safety timeout'),
642
- 'SSE timeout should reject with a descriptive timeout error',
643
- );
644
- });
645
-
646
- runTest('resetSseTimeout helper exists and is called from processEvent (issue #216)', () => {
647
- assertTrue(
648
- indexContent.includes('const resetSseTimeout'),
649
- 'Should have resetSseTimeout helper function',
650
- );
651
- // processEvent should call resetSseTimeout to reset on every SSE event
652
- const processEventIdx = indexContent.indexOf('const processEvent');
653
- assertTrue(processEventIdx > -1, 'Should find processEvent');
654
- const processEventBody = indexContent.substring(processEventIdx, processEventIdx + 500);
655
- assertTrue(
656
- processEventBody.includes('resetSseTimeout()'),
657
- 'processEvent should call resetSseTimeout() to reset idle timer on every SSE event',
658
- );
659
- });
660
-
661
- runTest('Initial SSE timeout uses resetSseTimeout (not inline setTimeout)', () => {
662
- // After sseReq.end(), the initial timeout should use the shared resetSseTimeout()
663
- const reqEndIdx = indexContent.indexOf('sseReq.end()');
664
- assertTrue(reqEndIdx > -1, 'Should find sseReq.end()');
665
- const afterReqEnd = indexContent.substring(reqEndIdx, reqEndIdx + 500);
666
- assertTrue(
667
- afterReqEnd.includes('resetSseTimeout()'),
668
- 'Initial timeout after sseReq.end() should use resetSseTimeout()',
669
- );
670
- });
671
-
672
- runTest('Error paths use rejectWithCleanup to clear timeout', () => {
673
- assertTrue(
674
- indexContent.includes('rejectWithCleanup'),
675
- 'Should have rejectWithCleanup helper',
676
- );
677
- // Find the sendMessageAndStream function body
678
- const fnIdx = indexContent.indexOf('function sendMessageAndStream');
679
- assertTrue(fnIdx > -1, 'Should find sendMessageAndStream');
680
- const fnBody = indexContent.substring(fnIdx, fnIdx + 8000);
681
- // Remove the rejectWithCleanup definition itself (which contains reject(err))
682
- const withoutDef = fnBody.replace(/const rejectWithCleanup[\s\S]*?};/, '');
683
- // Any remaining bare reject() calls would be a bug
684
- const bareReject = withoutDef.match(/[^a-zA-Z]reject\s*\(/g);
685
- assertTrue(
686
- !bareReject,
687
- 'All reject calls should use rejectWithCleanup, found bare reject(): ' + (bareReject ? bareReject.length : 0),
688
- );
689
- });
690
-
691
- runTest('cleanup() destroys POST request to prevent socket leaks', () => {
692
- // postReq should be tracked in outer scope and destroyed in cleanup()
693
- const fnIdx = indexContent.indexOf('function sendMessageAndStream');
694
- assertTrue(fnIdx > -1, 'Should find sendMessageAndStream');
695
- const fnBody = indexContent.substring(fnIdx, fnIdx + 12000);
696
- // postReq should be declared in outer scope (let postReq)
697
- assertTrue(
698
- fnBody.includes('let postReq'),
699
- 'postReq should be declared in outer scope with let',
700
- );
701
- // cleanup should destroy postReq
702
- const cleanupIdx = fnBody.indexOf('const cleanup');
703
- assertTrue(cleanupIdx > -1, 'Should find cleanup function');
704
- const cleanupBody = fnBody.substring(cleanupIdx, cleanupIdx + 400);
705
- assertTrue(
706
- cleanupBody.includes('postReq') && cleanupBody.includes('destroy'),
707
- 'cleanup() should destroy postReq to prevent socket leaks',
708
- );
709
- });
710
-
711
- runTest('SSE cooldown runs in finally block (both success and error paths)', () => {
712
- // The 500ms cooldown should be in a finally block so it runs on error paths too
713
- const agentHandlerIdx = indexContent.indexOf('async function handleAgentCommand');
714
- const sendMsgIdx = indexContent.indexOf('await sendMessageAndStream(', agentHandlerIdx);
715
- assertTrue(sendMsgIdx > agentHandlerIdx, 'sendMessageAndStream should exist in agent handler');
716
- // Find the catch and finally blocks near the sendMessageAndStream call
717
- const catchIdx = indexContent.indexOf('catch (err)', sendMsgIdx);
718
- assertTrue(catchIdx > sendMsgIdx, 'should have catch block after sendMessageAndStream');
719
- const finallyIdx = indexContent.indexOf('finally {', catchIdx);
720
- assertTrue(finallyIdx > catchIdx, 'should have finally block after catch');
721
- // Use 800-char window to accommodate multi-line comments in the finally body
722
- const finallyBody = indexContent.substring(finallyIdx, finallyIdx + 800);
723
- assertTrue(
724
- finallyBody.includes('setTimeout(resolve, 500)'),
725
- '500ms cooldown should be inside finally block',
726
- );
727
- });
728
-
729
- // ============================================================================
730
- // handleChatCommand server cleanup (Issue #190 review)
731
- // ============================================================================
732
-
733
- console.log('\n=== handleChatCommand Server Cleanup Tests ===');
734
-
735
- runTest('handleChatCommand closes server in finally block (not just success path)', () => {
736
- const chatIdx = indexContent.indexOf('async function handleChatCommand');
737
- assertTrue(chatIdx > -1, 'Should find handleChatCommand');
738
- // Find the startServer call inside handleChatCommand
739
- const startSrvIdx = indexContent.indexOf('startServer(', chatIdx);
740
- assertTrue(startSrvIdx > chatIdx, 'handleChatCommand should call startServer');
741
- // Find the finally block after the try/catch
742
- const catchIdx = indexContent.indexOf('catch (err)', startSrvIdx);
743
- assertTrue(catchIdx > startSrvIdx, 'should have catch block');
744
- const finallyIdx = indexContent.indexOf('finally {', catchIdx);
745
- assertTrue(finallyIdx > catchIdx, 'should have finally block after catch in handleChatCommand');
746
- const finallyBody = indexContent.substring(finallyIdx, finallyIdx + 500);
747
- assertTrue(
748
- finallyBody.includes('server.close()'),
749
- 'finally block should close server to prevent listener leaks',
750
- );
751
- });
752
-
753
- // ============================================================================
754
- // Final Summary
755
- // ============================================================================
756
-
757
- console.log('\n=== All Test Sections Complete ===');
758
- if (process.exitCode === 1) {
759
- console.log('Some tests failed.');
760
- } else {
761
- console.log('All tests passed!');
762
- }