@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,1027 @@
1
+ /**
2
+ * Tests for SOUL re-injection behavior in agent mode (issue #101)
3
+ *
4
+ * Verifies:
5
+ * - Hot-reload: SOUL file changes are detected and re-sent
6
+ * - Periodic re-injection: SOUL is re-sent every N iterations to prevent drift
7
+ * - Post-compaction re-injection: SOUL is re-sent after auto-compaction
8
+ * - --reinject-interval parsing
9
+ */
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+ const os = require('os');
14
+
15
+ // Import args parser for --reinject-interval tests
16
+ const { parseArgs } = require('../lib/args-parser');
17
+
18
+ // Import simpleHash for SOUL content change detection (sandbox-compatible)
19
+ const { simpleHash } = require('../lib/hash');
20
+
21
+ // Helper to create a temporary SOUL file
22
+ function createTempSoulFile(content) {
23
+ const tmpDir = os.tmpdir();
24
+ const soulPath = path.join(tmpDir, `test-soul-reinject-${Date.now()}-${Math.random().toString(36).slice(2)}.md`);
25
+ fs.writeFileSync(soulPath, content, 'utf8');
26
+ return soulPath;
27
+ }
28
+
29
+ // Helper to clean up temp files
30
+ function removeTempFile(filePath) {
31
+ try {
32
+ if (fs.existsSync(filePath)) {
33
+ fs.unlinkSync(filePath);
34
+ }
35
+ } catch (e) {
36
+ // Ignore cleanup errors
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Simulate the per-iteration SOUL read + message decision logic with re-injection support.
42
+ * This mirrors the updated code inside the while(running) loop (issue #101):
43
+ * 1. Re-read SOUL file
44
+ * 2. Compute hash via simpleHash, detect changes
45
+ * 3. Increment iterationsSinceSoulSent
46
+ * 4. Determine message based on: first send, file change, force reinject, periodic reinject, or "keep going"
47
+ * 5. After successful send, update state accordingly
48
+ *
49
+ * @param {string} soulPath - Path to the SOUL file
50
+ * @param {object} state - Mutable state:
51
+ * { soulSent: boolean, lastSoulHash: string|null, iterationsSinceSoulSent: number, forceReinject: boolean }
52
+ * @param {object} [opts]
53
+ * @param {boolean} [opts.sendSuccess=true] - Simulate whether the send succeeds
54
+ * @param {number} [opts.reinjectInterval=10] - SOUL_REINJECT_INTERVAL
55
+ * @param {boolean} [opts.isNewSession=true] - Whether this is a new session or resumed
56
+ * @returns {{ message, soulSent, error, sendingSoul, readContent, soulChanged, shouldReinject, forceReinject, soulTrigger }}
57
+ */
58
+ function simulateIteration(soulPath, state, opts) {
59
+ const sendSuccess = (opts && opts.sendSuccess !== undefined) ? opts.sendSuccess : true;
60
+ const SOUL_REINJECT_INTERVAL = (opts && opts.reinjectInterval) || 10;
61
+
62
+ let soulContent;
63
+ try {
64
+ soulContent = fs.readFileSync(soulPath, 'utf8');
65
+ } catch (err) {
66
+ return {
67
+ message: null,
68
+ soulSent: state.soulSent,
69
+ error: err,
70
+ readContent: null,
71
+ soulChanged: false,
72
+ shouldReinject: false,
73
+ forceReinject: state.forceReinject,
74
+ soulTrigger: null,
75
+ };
76
+ }
77
+
78
+ // Compute hash (state mutation deferred until after successful send)
79
+ const currentSoulHash = simpleHash(soulContent);
80
+ const soulChanged = state.lastSoulHash !== null && currentSoulHash !== state.lastSoulHash;
81
+
82
+ // Tentatively compute periodic check
83
+ const tentativeCounter = state.iterationsSinceSoulSent + 1;
84
+ const shouldReinject = state.soulSent && tentativeCounter >= SOUL_REINJECT_INTERVAL;
85
+
86
+ const sendingSoul = !state.soulSent;
87
+ let message;
88
+ let soulTrigger = null;
89
+ const isNewSession = (opts && opts.isNewSession !== undefined) ? opts.isNewSession : true;
90
+ if (sendingSoul) {
91
+ if (isNewSession) {
92
+ message = soulContent;
93
+ } else {
94
+ message = 'You are resuming from a previous session. Here are your instructions:\n\n' + soulContent;
95
+ }
96
+ soulTrigger = 'first';
97
+ } else if (soulChanged) {
98
+ message = 'SOUL instructions have been updated. Here are the new instructions:\n\n' + soulContent;
99
+ soulTrigger = 'hotReload';
100
+ } else if (state.forceReinject) {
101
+ message = 'Reminder -- here are your core instructions. Follow them:\n\n' + soulContent;
102
+ soulTrigger = 'compaction';
103
+ } else if (shouldReinject) {
104
+ message = 'Reminder -- here are your core instructions. Follow them:\n\n' + soulContent;
105
+ soulTrigger = 'periodic';
106
+ } else {
107
+ message = 'keep going';
108
+ }
109
+
110
+ // Simulate send success/failure -- only commit state on success
111
+ if (sendSuccess) {
112
+ state.lastSoulHash = currentSoulHash;
113
+ if (soulTrigger === 'first') {
114
+ state.soulSent = true;
115
+ state.iterationsSinceSoulSent = 0;
116
+ } else if (soulTrigger === 'hotReload') {
117
+ state.iterationsSinceSoulSent = 0;
118
+ } else if (soulTrigger === 'compaction') {
119
+ state.iterationsSinceSoulSent = 0;
120
+ } else if (soulTrigger === 'periodic') {
121
+ state.iterationsSinceSoulSent = 0;
122
+ } else {
123
+ // "keep going"
124
+ state.iterationsSinceSoulSent++;
125
+ }
126
+ // Any successful SOUL delivery clears forceReinject
127
+ if (soulTrigger) {
128
+ state.forceReinject = false;
129
+ }
130
+ } else if (!soulTrigger) {
131
+ // Failed "keep going" -- advance counter and hash anyway
132
+ state.iterationsSinceSoulSent++;
133
+ state.lastSoulHash = currentSoulHash;
134
+ }
135
+ // On failure with a soulTrigger: state is NOT mutated, so the trigger retries
136
+
137
+ return {
138
+ message,
139
+ soulSent: state.soulSent,
140
+ error: null,
141
+ sendingSoul,
142
+ readContent: soulContent,
143
+ soulChanged,
144
+ shouldReinject,
145
+ forceReinject: state.forceReinject,
146
+ soulTrigger,
147
+ };
148
+ }
149
+
150
+ /** Create a fresh state object for a new session */
151
+ function newSessionState() {
152
+ return {
153
+ soulSent: false,
154
+ lastSoulHash: null,
155
+ iterationsSinceSoulSent: 0,
156
+ forceReinject: false,
157
+ };
158
+ }
159
+
160
+ /** Create a fresh state object for a resumed session (issue #194: soulSent=false to force re-injection) */
161
+ function resumedSessionState() {
162
+ return {
163
+ soulSent: false,
164
+ lastSoulHash: null,
165
+ iterationsSinceSoulSent: 0,
166
+ forceReinject: false,
167
+ };
168
+ }
169
+
170
+ // =============================================================================
171
+ // Args Parser Tests
172
+ // =============================================================================
173
+
174
+ describe('--reinject-interval argument parsing', () => {
175
+ test('default reinjectInterval is null when not provided', () => {
176
+ const args = parseArgs(['agent', 'SOUL.md']);
177
+ expect(args.reinjectInterval).toBeNull();
178
+ });
179
+
180
+ test('parses --reinject-interval with valid integer', () => {
181
+ const args = parseArgs(['agent', '--reinject-interval', '5', 'SOUL.md']);
182
+ expect(args.reinjectInterval).toBe(5);
183
+ });
184
+
185
+ test('parses --reinject-interval 1 (minimum)', () => {
186
+ const args = parseArgs(['agent', '--reinject-interval', '1', 'SOUL.md']);
187
+ expect(args.reinjectInterval).toBe(1);
188
+ });
189
+
190
+ test('parses --reinject-interval 100 (large value)', () => {
191
+ const args = parseArgs(['agent', '--reinject-interval', '100', 'SOUL.md']);
192
+ expect(args.reinjectInterval).toBe(100);
193
+ });
194
+
195
+ test('--reinject-interval appears in flagsWithValues (not treated as SOUL path)', () => {
196
+ const args = parseArgs(['agent', '--reinject-interval', '5', 'SOUL.md']);
197
+ // If --reinject-interval is in flagsWithValues, '5' is consumed as its value,
198
+ // and 'SOUL.md' is correctly captured as the positional arg
199
+ expect(args.reinjectInterval).toBe(5);
200
+ expect(args.commandArgs[0]).toBe('SOUL.md');
201
+ });
202
+
203
+ test('--reinject-interval with missing value exits', () => {
204
+ const mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {
205
+ throw new Error('process.exit called');
206
+ });
207
+ const mockError = jest.spyOn(console, 'error').mockImplementation(() => {});
208
+
209
+ expect(() => parseArgs(['agent', '--reinject-interval'])).toThrow('process.exit called');
210
+
211
+ mockExit.mockRestore();
212
+ mockError.mockRestore();
213
+ });
214
+
215
+ test('--reinject-interval with non-integer exits', () => {
216
+ const mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {
217
+ throw new Error('process.exit called');
218
+ });
219
+ const mockError = jest.spyOn(console, 'error').mockImplementation(() => {});
220
+
221
+ expect(() => parseArgs(['agent', '--reinject-interval', 'abc'])).toThrow('process.exit called');
222
+
223
+ mockExit.mockRestore();
224
+ mockError.mockRestore();
225
+ });
226
+
227
+ test('--reinject-interval with zero exits', () => {
228
+ const mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {
229
+ throw new Error('process.exit called');
230
+ });
231
+ const mockError = jest.spyOn(console, 'error').mockImplementation(() => {});
232
+
233
+ expect(() => parseArgs(['agent', '--reinject-interval', '0'])).toThrow('process.exit called');
234
+
235
+ mockExit.mockRestore();
236
+ mockError.mockRestore();
237
+ });
238
+
239
+ test('--reinject-interval with negative value exits', () => {
240
+ const mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {
241
+ throw new Error('process.exit called');
242
+ });
243
+ const mockError = jest.spyOn(console, 'error').mockImplementation(() => {});
244
+
245
+ expect(() => parseArgs(['agent', '--reinject-interval', '-1'])).toThrow('process.exit called');
246
+
247
+ mockExit.mockRestore();
248
+ mockError.mockRestore();
249
+ });
250
+
251
+ test('--reinject-interval with partially numeric value exits', () => {
252
+ const mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {
253
+ throw new Error('process.exit called');
254
+ });
255
+ const mockError = jest.spyOn(console, 'error').mockImplementation(() => {});
256
+
257
+ expect(() => parseArgs(['agent', '--reinject-interval', '10abc'])).toThrow('process.exit called');
258
+
259
+ mockExit.mockRestore();
260
+ mockError.mockRestore();
261
+ });
262
+
263
+ test('--reinject-interval with scientific notation exits', () => {
264
+ const mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {
265
+ throw new Error('process.exit called');
266
+ });
267
+ const mockError = jest.spyOn(console, 'error').mockImplementation(() => {});
268
+
269
+ expect(() => parseArgs(['agent', '--reinject-interval', '1e3'])).toThrow('process.exit called');
270
+
271
+ mockExit.mockRestore();
272
+ mockError.mockRestore();
273
+ });
274
+ });
275
+
276
+ // =============================================================================
277
+ // Hot-reload Tests (SOUL file change detection)
278
+ // =============================================================================
279
+
280
+ describe('SOUL hot-reload on file change (issue #101)', () => {
281
+ test('detects SOUL file change and sends updated content', () => {
282
+ const soulPath = createTempSoulFile('# SOUL v1\nDo task A');
283
+ try {
284
+ const state = newSessionState();
285
+
286
+ // Iteration 1: first send
287
+ const r1 = simulateIteration(soulPath, state);
288
+ expect(r1.message).toBe('# SOUL v1\nDo task A');
289
+ expect(r1.sendingSoul).toBe(true);
290
+ expect(r1.soulChanged).toBe(false); // No previous hash to compare
291
+
292
+ // Iteration 2: keep going (no change)
293
+ const r2 = simulateIteration(soulPath, state);
294
+ expect(r2.message).toBe('keep going');
295
+ expect(r2.soulChanged).toBe(false);
296
+
297
+ // User edits SOUL file
298
+ fs.writeFileSync(soulPath, '# SOUL v2\nDo task B instead', 'utf8');
299
+
300
+ // Iteration 3: detects change and sends updated SOUL
301
+ const r3 = simulateIteration(soulPath, state);
302
+ expect(r3.soulChanged).toBe(true);
303
+ expect(r3.message).toContain('SOUL instructions have been updated');
304
+ expect(r3.message).toContain('# SOUL v2');
305
+ expect(r3.message).toContain('Do task B instead');
306
+
307
+ // Iteration 4: back to keep going (no change)
308
+ const r4 = simulateIteration(soulPath, state);
309
+ expect(r4.message).toBe('keep going');
310
+ expect(r4.soulChanged).toBe(false);
311
+ } finally {
312
+ removeTempFile(soulPath);
313
+ }
314
+ });
315
+
316
+ test('multiple SOUL file changes are each detected', () => {
317
+ const soulPath = createTempSoulFile('v1');
318
+ try {
319
+ const state = newSessionState();
320
+
321
+ simulateIteration(soulPath, state); // first send
322
+ simulateIteration(soulPath, state); // keep going
323
+
324
+ // Change 1
325
+ fs.writeFileSync(soulPath, 'v2', 'utf8');
326
+ const r1 = simulateIteration(soulPath, state);
327
+ expect(r1.soulChanged).toBe(true);
328
+ expect(r1.message).toContain('v2');
329
+
330
+ // Change 2
331
+ fs.writeFileSync(soulPath, 'v3', 'utf8');
332
+ const r2 = simulateIteration(soulPath, state);
333
+ expect(r2.soulChanged).toBe(true);
334
+ expect(r2.message).toContain('v3');
335
+
336
+ // No change
337
+ const r3 = simulateIteration(soulPath, state);
338
+ expect(r3.soulChanged).toBe(false);
339
+ expect(r3.message).toBe('keep going');
340
+ } finally {
341
+ removeTempFile(soulPath);
342
+ }
343
+ });
344
+
345
+ test('SOUL change resets iterationsSinceSoulSent counter', () => {
346
+ const soulPath = createTempSoulFile('original');
347
+ try {
348
+ const state = newSessionState();
349
+
350
+ simulateIteration(soulPath, state); // first send
351
+ // Run 5 more iterations
352
+ for (let i = 0; i < 5; i++) {
353
+ simulateIteration(soulPath, state);
354
+ }
355
+ expect(state.iterationsSinceSoulSent).toBe(5);
356
+
357
+ // Change SOUL
358
+ fs.writeFileSync(soulPath, 'updated', 'utf8');
359
+ simulateIteration(soulPath, state);
360
+ expect(state.iterationsSinceSoulSent).toBe(0); // Reset after change
361
+ } finally {
362
+ removeTempFile(soulPath);
363
+ }
364
+ });
365
+
366
+ test('SOUL file change on first iteration (before first send) does not count as change', () => {
367
+ const soulPath = createTempSoulFile('initial content');
368
+ try {
369
+ const state = newSessionState();
370
+
371
+ // First iteration: no previous hash, so soulChanged should be false
372
+ const r1 = simulateIteration(soulPath, state);
373
+ expect(r1.soulChanged).toBe(false);
374
+ expect(r1.sendingSoul).toBe(true);
375
+ expect(r1.message).toBe('initial content');
376
+ } finally {
377
+ removeTempFile(soulPath);
378
+ }
379
+ });
380
+ });
381
+
382
+ // =============================================================================
383
+ // Periodic Re-injection Tests
384
+ // =============================================================================
385
+
386
+ describe('Periodic SOUL re-injection (issue #101)', () => {
387
+ test('re-injects SOUL after N iterations (default 10)', () => {
388
+ const soulPath = createTempSoulFile('# Core Instructions');
389
+ try {
390
+ const state = newSessionState();
391
+
392
+ // First send
393
+ simulateIteration(soulPath, state);
394
+ expect(state.soulSent).toBe(true);
395
+
396
+ // Iterations 2-10: keep going
397
+ for (let i = 0; i < 9; i++) {
398
+ const r = simulateIteration(soulPath, state);
399
+ expect(r.message).toBe('keep going');
400
+ }
401
+
402
+ // Iteration 11: periodic re-injection (10 iterations since last SOUL send)
403
+ const r = simulateIteration(soulPath, state);
404
+ expect(r.shouldReinject).toBe(true);
405
+ expect(r.message).toContain('Reminder -- here are your core instructions');
406
+ expect(r.message).toContain('# Core Instructions');
407
+ } finally {
408
+ removeTempFile(soulPath);
409
+ }
410
+ });
411
+
412
+ test('re-injection counter resets after periodic re-injection', () => {
413
+ const soulPath = createTempSoulFile('# SOUL');
414
+ try {
415
+ const state = newSessionState();
416
+
417
+ simulateIteration(soulPath, state); // first send
418
+
419
+ // Run to first re-injection (10 iterations)
420
+ for (let i = 0; i < 9; i++) simulateIteration(soulPath, state);
421
+ const r1 = simulateIteration(soulPath, state);
422
+ expect(r1.shouldReinject).toBe(true);
423
+ expect(state.iterationsSinceSoulSent).toBe(0);
424
+
425
+ // Next 9 iterations should be "keep going"
426
+ for (let i = 0; i < 9; i++) {
427
+ const r = simulateIteration(soulPath, state);
428
+ expect(r.message).toBe('keep going');
429
+ }
430
+
431
+ // 10th iteration after reset: re-inject again
432
+ const r2 = simulateIteration(soulPath, state);
433
+ expect(r2.shouldReinject).toBe(true);
434
+ expect(r2.message).toContain('Reminder');
435
+ } finally {
436
+ removeTempFile(soulPath);
437
+ }
438
+ });
439
+
440
+ test('custom reinject interval of 3', () => {
441
+ const soulPath = createTempSoulFile('# Custom Interval');
442
+ try {
443
+ const state = newSessionState();
444
+ const opts = { reinjectInterval: 3 };
445
+
446
+ simulateIteration(soulPath, state, opts); // first send
447
+
448
+ // 2 keep-going iterations
449
+ simulateIteration(soulPath, state, opts);
450
+ simulateIteration(soulPath, state, opts);
451
+
452
+ // 3rd iteration since last SOUL send: re-inject
453
+ const r = simulateIteration(soulPath, state, opts);
454
+ expect(r.shouldReinject).toBe(true);
455
+ expect(r.message).toContain('Reminder');
456
+ } finally {
457
+ removeTempFile(soulPath);
458
+ }
459
+ });
460
+
461
+ test('custom reinject interval of 1 (every iteration)', () => {
462
+ const soulPath = createTempSoulFile('# Always');
463
+ try {
464
+ const state = newSessionState();
465
+ const opts = { reinjectInterval: 1 };
466
+
467
+ simulateIteration(soulPath, state, opts); // first send
468
+
469
+ // Every subsequent iteration should re-inject
470
+ for (let i = 0; i < 5; i++) {
471
+ const r = simulateIteration(soulPath, state, opts);
472
+ expect(r.message).toContain('Reminder');
473
+ }
474
+ } finally {
475
+ removeTempFile(soulPath);
476
+ }
477
+ });
478
+
479
+ test('resumed session sends SOUL on first iteration with resume prefix (issue #194)', () => {
480
+ const soulPath = createTempSoulFile('# SOUL');
481
+ try {
482
+ const state = resumedSessionState();
483
+
484
+ // First iteration of resumed session: should send SOUL with resume prefix
485
+ const r = simulateIteration(soulPath, state, { isNewSession: false });
486
+ expect(r.sendingSoul).toBe(true);
487
+ expect(r.soulTrigger).toBe('first');
488
+ expect(r.message).toContain('You are resuming from a previous session');
489
+ expect(r.message).toContain('# SOUL');
490
+ expect(state.soulSent).toBe(true);
491
+ } finally {
492
+ removeTempFile(soulPath);
493
+ }
494
+ });
495
+ });
496
+
497
+ // =============================================================================
498
+ // Post-Compaction Re-injection Tests
499
+ // =============================================================================
500
+
501
+ describe('Post-compaction SOUL re-injection (issue #101)', () => {
502
+ test('forceReinject causes SOUL re-send on next iteration', () => {
503
+ const soulPath = createTempSoulFile('# SOUL Instructions');
504
+ try {
505
+ const state = newSessionState();
506
+
507
+ simulateIteration(soulPath, state); // first send
508
+ simulateIteration(soulPath, state); // keep going
509
+
510
+ // Simulate compaction: set forceReinject
511
+ state.forceReinject = true;
512
+
513
+ const r = simulateIteration(soulPath, state);
514
+ expect(r.message).toContain('Reminder -- here are your core instructions');
515
+ expect(r.message).toContain('# SOUL Instructions');
516
+ expect(state.forceReinject).toBe(false); // Flag is cleared after use
517
+ } finally {
518
+ removeTempFile(soulPath);
519
+ }
520
+ });
521
+
522
+ test('forceReinject resets iterationsSinceSoulSent', () => {
523
+ const soulPath = createTempSoulFile('# SOUL');
524
+ try {
525
+ const state = newSessionState();
526
+
527
+ simulateIteration(soulPath, state); // first send
528
+
529
+ // Run 7 iterations
530
+ for (let i = 0; i < 7; i++) simulateIteration(soulPath, state);
531
+ expect(state.iterationsSinceSoulSent).toBe(7);
532
+
533
+ // Compaction happens
534
+ state.forceReinject = true;
535
+ simulateIteration(soulPath, state);
536
+ expect(state.iterationsSinceSoulSent).toBe(0);
537
+ } finally {
538
+ removeTempFile(soulPath);
539
+ }
540
+ });
541
+
542
+ test('forceReinject is only consumed once', () => {
543
+ const soulPath = createTempSoulFile('# SOUL');
544
+ try {
545
+ const state = newSessionState();
546
+
547
+ simulateIteration(soulPath, state); // first send
548
+ simulateIteration(soulPath, state); // keep going
549
+
550
+ state.forceReinject = true;
551
+
552
+ // First iteration after compaction: re-inject
553
+ const r1 = simulateIteration(soulPath, state);
554
+ expect(r1.message).toContain('Reminder');
555
+
556
+ // Next iteration: back to keep going
557
+ const r2 = simulateIteration(soulPath, state);
558
+ expect(r2.message).toBe('keep going');
559
+ } finally {
560
+ removeTempFile(soulPath);
561
+ }
562
+ });
563
+
564
+ test('forceReinject takes priority over periodic re-injection timing', () => {
565
+ const soulPath = createTempSoulFile('# SOUL');
566
+ try {
567
+ const state = newSessionState();
568
+ const opts = { reinjectInterval: 5 };
569
+
570
+ simulateIteration(soulPath, state, opts); // first send
571
+
572
+ // Run 4 iterations (1 less than reinject interval)
573
+ for (let i = 0; i < 4; i++) simulateIteration(soulPath, state, opts);
574
+
575
+ // Both forceReinject and periodic would trigger -- forceReinject takes priority
576
+ state.forceReinject = true;
577
+ state.iterationsSinceSoulSent = 10; // Would also trigger periodic
578
+
579
+ const r = simulateIteration(soulPath, state, opts);
580
+ expect(r.message).toContain('Reminder');
581
+ expect(state.forceReinject).toBe(false);
582
+ } finally {
583
+ removeTempFile(soulPath);
584
+ }
585
+ });
586
+ });
587
+
588
+ // =============================================================================
589
+ // Priority Order Tests
590
+ // =============================================================================
591
+
592
+ describe('SOUL message priority order (issue #101)', () => {
593
+ test('first send takes highest priority', () => {
594
+ const soulPath = createTempSoulFile('# SOUL');
595
+ try {
596
+ const state = newSessionState();
597
+ state.forceReinject = true; // Would trigger reinject
598
+ state.iterationsSinceSoulSent = 100; // Would trigger periodic
599
+
600
+ const r = simulateIteration(soulPath, state);
601
+ // First send (sendingSoul) takes priority over all other conditions
602
+ expect(r.sendingSoul).toBe(true);
603
+ expect(r.message).toBe('# SOUL');
604
+ expect(r.message).not.toContain('Reminder');
605
+ expect(r.message).not.toContain('updated');
606
+ } finally {
607
+ removeTempFile(soulPath);
608
+ }
609
+ });
610
+
611
+ test('SOUL file change takes priority over forceReinject and periodic', () => {
612
+ const soulPath = createTempSoulFile('v1');
613
+ try {
614
+ const state = newSessionState();
615
+
616
+ simulateIteration(soulPath, state); // first send
617
+ simulateIteration(soulPath, state); // establish hash
618
+
619
+ // Set up conditions for all three triggers
620
+ fs.writeFileSync(soulPath, 'v2', 'utf8');
621
+ state.forceReinject = true;
622
+ state.iterationsSinceSoulSent = 100;
623
+
624
+ const r = simulateIteration(soulPath, state);
625
+ expect(r.soulChanged).toBe(true);
626
+ expect(r.message).toContain('SOUL instructions have been updated');
627
+ expect(r.message).toContain('v2');
628
+ } finally {
629
+ removeTempFile(soulPath);
630
+ }
631
+ });
632
+
633
+ test('forceReinject takes priority over periodic re-injection', () => {
634
+ const soulPath = createTempSoulFile('# SOUL');
635
+ try {
636
+ const state = newSessionState();
637
+ const opts = { reinjectInterval: 2 };
638
+
639
+ simulateIteration(soulPath, state, opts); // first send
640
+
641
+ // Set up both force and periodic
642
+ state.forceReinject = true;
643
+ state.iterationsSinceSoulSent = 100;
644
+
645
+ const r = simulateIteration(soulPath, state, opts);
646
+ // forceReinject is consumed (set to false) -- verifies force path was taken
647
+ expect(state.forceReinject).toBe(false);
648
+ expect(r.message).toContain('Reminder');
649
+ } finally {
650
+ removeTempFile(soulPath);
651
+ }
652
+ });
653
+
654
+ test('hot-reload clears forceReinject to prevent redundant post-compaction reminder', () => {
655
+ const soulPath = createTempSoulFile('v1');
656
+ try {
657
+ const state = newSessionState();
658
+
659
+ simulateIteration(soulPath, state); // first send
660
+ simulateIteration(soulPath, state); // keep going (establishes hash)
661
+
662
+ // Compaction sets forceReinject, AND user also edits SOUL file
663
+ state.forceReinject = true;
664
+ fs.writeFileSync(soulPath, 'v2', 'utf8');
665
+
666
+ // Hot-reload fires (higher priority than forceReinject)
667
+ const r1 = simulateIteration(soulPath, state);
668
+ expect(r1.soulTrigger).toBe('hotReload');
669
+ expect(r1.message).toContain('SOUL instructions have been updated');
670
+ expect(r1.message).toContain('v2');
671
+ // forceReinject should be cleared because SOUL was delivered
672
+ expect(state.forceReinject).toBe(false);
673
+
674
+ // Next iteration: just "keep going" (no redundant compaction reminder)
675
+ const r2 = simulateIteration(soulPath, state);
676
+ expect(r2.message).toBe('keep going');
677
+ expect(r2.soulTrigger).toBeNull();
678
+ } finally {
679
+ removeTempFile(soulPath);
680
+ }
681
+ });
682
+
683
+ test('periodic re-injection clears forceReinject', () => {
684
+ const soulPath = createTempSoulFile('# SOUL');
685
+ try {
686
+ const state = newSessionState();
687
+ const opts = { reinjectInterval: 2 };
688
+
689
+ simulateIteration(soulPath, state, opts); // first send
690
+ simulateIteration(soulPath, state, opts); // keep going
691
+
692
+ // Set forceReinject AND counter triggers periodic
693
+ state.forceReinject = true;
694
+
695
+ // forceReinject fires (higher priority) and clears itself
696
+ const r = simulateIteration(soulPath, state, opts);
697
+ expect(r.soulTrigger).toBe('compaction');
698
+ expect(state.forceReinject).toBe(false);
699
+ } finally {
700
+ removeTempFile(soulPath);
701
+ }
702
+ });
703
+ });
704
+
705
+ // =============================================================================
706
+ // Edge Cases
707
+ // =============================================================================
708
+
709
+ describe('SOUL re-injection edge cases (issue #101)', () => {
710
+ test('SOUL file becomes unreadable does not crash hash tracking', () => {
711
+ const soulPath = createTempSoulFile('# SOUL');
712
+ try {
713
+ const state = newSessionState();
714
+
715
+ simulateIteration(soulPath, state); // first send
716
+ const hashAfterFirstSend = state.lastSoulHash;
717
+
718
+ // File becomes unreadable
719
+ fs.unlinkSync(soulPath);
720
+ const r = simulateIteration(soulPath, state);
721
+ expect(r.error).not.toBeNull();
722
+ expect(state.lastSoulHash).toBe(hashAfterFirstSend); // Hash unchanged
723
+
724
+ // File comes back with same content
725
+ fs.writeFileSync(soulPath, '# SOUL', 'utf8');
726
+ const r2 = simulateIteration(soulPath, state);
727
+ expect(r2.soulChanged).toBe(false); // Same content, no change
728
+ expect(r2.message).toBe('keep going');
729
+ } finally {
730
+ removeTempFile(soulPath);
731
+ }
732
+ });
733
+
734
+ test('SOUL file becomes unreadable then returns with different content', () => {
735
+ const soulPath = createTempSoulFile('# SOUL v1');
736
+ try {
737
+ const state = newSessionState();
738
+
739
+ simulateIteration(soulPath, state); // first send
740
+ simulateIteration(soulPath, state); // establish hash
741
+
742
+ // File becomes unreadable
743
+ fs.unlinkSync(soulPath);
744
+ simulateIteration(soulPath, state); // error, hash unchanged
745
+
746
+ // File comes back with DIFFERENT content
747
+ fs.writeFileSync(soulPath, '# SOUL v2', 'utf8');
748
+ const r = simulateIteration(soulPath, state);
749
+ expect(r.soulChanged).toBe(true);
750
+ expect(r.message).toContain('SOUL instructions have been updated');
751
+ expect(r.message).toContain('# SOUL v2');
752
+ } finally {
753
+ removeTempFile(soulPath);
754
+ }
755
+ });
756
+
757
+ test('empty SOUL file is handled correctly', () => {
758
+ const soulPath = createTempSoulFile('');
759
+ try {
760
+ const state = newSessionState();
761
+
762
+ const r = simulateIteration(soulPath, state);
763
+ expect(r.message).toBe('');
764
+ expect(r.sendingSoul).toBe(true);
765
+ expect(state.lastSoulHash).not.toBeNull();
766
+ } finally {
767
+ removeTempFile(soulPath);
768
+ }
769
+ });
770
+
771
+ test('very large reinject interval (1000) works correctly', () => {
772
+ const soulPath = createTempSoulFile('# SOUL');
773
+ try {
774
+ const state = newSessionState();
775
+ const opts = { reinjectInterval: 1000 };
776
+
777
+ simulateIteration(soulPath, state, opts); // first send
778
+
779
+ // Run 999 iterations
780
+ for (let i = 0; i < 999; i++) {
781
+ const r = simulateIteration(soulPath, state, opts);
782
+ expect(r.message).toBe('keep going');
783
+ }
784
+
785
+ // 1000th iteration: re-inject
786
+ const r = simulateIteration(soulPath, state, opts);
787
+ expect(r.shouldReinject).toBe(true);
788
+ expect(r.message).toContain('Reminder');
789
+ } finally {
790
+ removeTempFile(soulPath);
791
+ }
792
+ });
793
+ });
794
+
795
+ // =============================================================================
796
+ // Failed Send Retry Tests (deferred state mutation)
797
+ // =============================================================================
798
+
799
+ describe('SOUL re-injection retries on send failure (issue #101)', () => {
800
+ test('failed hot-reload send retries on next iteration', () => {
801
+ const soulPath = createTempSoulFile('v1');
802
+ try {
803
+ const state = newSessionState();
804
+
805
+ simulateIteration(soulPath, state); // first send
806
+ simulateIteration(soulPath, state); // keep going (establishes hash)
807
+
808
+ // Change SOUL file
809
+ fs.writeFileSync(soulPath, 'v2', 'utf8');
810
+
811
+ // Send fails -- hot-reload should NOT be consumed
812
+ const r1 = simulateIteration(soulPath, state, { sendSuccess: false });
813
+ expect(r1.soulTrigger).toBe('hotReload');
814
+ expect(r1.message).toContain('SOUL instructions have been updated');
815
+ // Hash should NOT have been committed
816
+ expect(state.lastSoulHash).not.toBe(simpleHash('v2'));
817
+
818
+ // Next iteration: hot-reload retries because hash wasn't committed
819
+ const r2 = simulateIteration(soulPath, state);
820
+ expect(r2.soulChanged).toBe(true);
821
+ expect(r2.soulTrigger).toBe('hotReload');
822
+ expect(r2.message).toContain('SOUL instructions have been updated');
823
+ expect(r2.message).toContain('v2');
824
+ } finally {
825
+ removeTempFile(soulPath);
826
+ }
827
+ });
828
+
829
+ test('failed forceReinject send retries on next iteration', () => {
830
+ const soulPath = createTempSoulFile('# SOUL');
831
+ try {
832
+ const state = newSessionState();
833
+
834
+ simulateIteration(soulPath, state); // first send
835
+ simulateIteration(soulPath, state); // keep going
836
+
837
+ // Simulate compaction
838
+ state.forceReinject = true;
839
+
840
+ // Send fails -- forceReinject should NOT be consumed
841
+ const r1 = simulateIteration(soulPath, state, { sendSuccess: false });
842
+ expect(r1.soulTrigger).toBe('compaction');
843
+ expect(state.forceReinject).toBe(true); // NOT consumed
844
+
845
+ // Next iteration: forceReinject retries
846
+ const r2 = simulateIteration(soulPath, state);
847
+ expect(r2.soulTrigger).toBe('compaction');
848
+ expect(r2.message).toContain('Reminder');
849
+ expect(state.forceReinject).toBe(false); // NOW consumed
850
+ } finally {
851
+ removeTempFile(soulPath);
852
+ }
853
+ });
854
+
855
+ test('failed periodic reinject send retries on next iteration', () => {
856
+ const soulPath = createTempSoulFile('# SOUL');
857
+ try {
858
+ const state = newSessionState();
859
+ const opts = { reinjectInterval: 3 };
860
+
861
+ simulateIteration(soulPath, state, opts); // first send
862
+
863
+ // Run 2 keep-going iterations
864
+ simulateIteration(soulPath, state, opts);
865
+ simulateIteration(soulPath, state, opts);
866
+
867
+ // 3rd iteration: periodic re-inject, but send fails
868
+ const r1 = simulateIteration(soulPath, state, { ...opts, sendSuccess: false });
869
+ expect(r1.soulTrigger).toBe('periodic');
870
+ expect(r1.message).toContain('Reminder');
871
+ // Counter should NOT have been reset
872
+ expect(state.iterationsSinceSoulSent).toBe(2); // Still at pre-trigger value
873
+
874
+ // Next iteration: periodic re-inject retries (counter still >= interval)
875
+ const r2 = simulateIteration(soulPath, state, opts);
876
+ expect(r2.soulTrigger).toBe('periodic');
877
+ expect(r2.message).toContain('Reminder');
878
+ expect(state.iterationsSinceSoulSent).toBe(0); // NOW reset
879
+ } finally {
880
+ removeTempFile(soulPath);
881
+ }
882
+ });
883
+
884
+ test('failed first SOUL send retries (existing behavior preserved)', () => {
885
+ const soulPath = createTempSoulFile('# SOUL');
886
+ try {
887
+ const state = newSessionState();
888
+
889
+ // Send fails
890
+ const r1 = simulateIteration(soulPath, state, { sendSuccess: false });
891
+ expect(r1.soulTrigger).toBe('first');
892
+ expect(r1.message).toBe('# SOUL');
893
+ expect(state.soulSent).toBe(false); // NOT marked as sent
894
+
895
+ // Retry succeeds
896
+ const r2 = simulateIteration(soulPath, state);
897
+ expect(r2.soulTrigger).toBe('first');
898
+ expect(r2.message).toBe('# SOUL');
899
+ expect(state.soulSent).toBe(true); // NOW marked as sent
900
+ } finally {
901
+ removeTempFile(soulPath);
902
+ }
903
+ });
904
+
905
+ test('failed send does not advance counter for keep-going on SOUL trigger', () => {
906
+ const soulPath = createTempSoulFile('# SOUL');
907
+ try {
908
+ const state = newSessionState();
909
+ const opts = { reinjectInterval: 5 };
910
+
911
+ simulateIteration(soulPath, state, opts); // first send
912
+
913
+ // Run 4 keep-going iterations
914
+ for (let i = 0; i < 4; i++) simulateIteration(soulPath, state, opts);
915
+ expect(state.iterationsSinceSoulSent).toBe(4);
916
+
917
+ // Trigger periodic (counter = 5 >= interval 5), but fail
918
+ const r = simulateIteration(soulPath, state, { ...opts, sendSuccess: false });
919
+ expect(r.soulTrigger).toBe('periodic');
920
+ // Counter should NOT have advanced past the pre-trigger value
921
+ expect(state.iterationsSinceSoulSent).toBe(4);
922
+ } finally {
923
+ removeTempFile(soulPath);
924
+ }
925
+ });
926
+ });
927
+
928
+ // =============================================================================
929
+ // Resumed Session SOUL Re-injection Tests (Issue #194)
930
+ // =============================================================================
931
+
932
+ describe('SOUL re-injection on resumed session (issue #194)', () => {
933
+ test('resumed session message includes resume prefix', () => {
934
+ const soulPath = createTempSoulFile('Do the work');
935
+ try {
936
+ const state = resumedSessionState();
937
+
938
+ const r = simulateIteration(soulPath, state, { isNewSession: false });
939
+ expect(r.message).toBe('You are resuming from a previous session. Here are your instructions:\n\n' + 'Do the work');
940
+ } finally {
941
+ removeTempFile(soulPath);
942
+ }
943
+ });
944
+
945
+ test('new session does NOT include resume prefix', () => {
946
+ const soulPath = createTempSoulFile('Do the work');
947
+ try {
948
+ const state = newSessionState();
949
+
950
+ const r = simulateIteration(soulPath, state, { isNewSession: true });
951
+ expect(r.message).toBe('Do the work');
952
+ expect(r.message).not.toContain('resuming');
953
+ } finally {
954
+ removeTempFile(soulPath);
955
+ }
956
+ });
957
+
958
+ test('resumed session periodic re-injection works after first send', () => {
959
+ const soulPath = createTempSoulFile('# SOUL');
960
+ try {
961
+ const state = resumedSessionState();
962
+ const opts = { reinjectInterval: 3, isNewSession: false };
963
+
964
+ // First iteration: SOUL sent with resume prefix
965
+ const r1 = simulateIteration(soulPath, state, opts);
966
+ expect(r1.sendingSoul).toBe(true);
967
+ expect(state.soulSent).toBe(true);
968
+
969
+ // Next 2 iterations: keep going
970
+ simulateIteration(soulPath, state, opts);
971
+ simulateIteration(soulPath, state, opts);
972
+
973
+ // 4th iteration (3 since last SOUL): periodic re-injection
974
+ const r4 = simulateIteration(soulPath, state, opts);
975
+ expect(r4.shouldReinject).toBe(true);
976
+ expect(r4.message).toContain('Reminder');
977
+ } finally {
978
+ removeTempFile(soulPath);
979
+ }
980
+ });
981
+
982
+ test('resumed session hot-reload works after first send', () => {
983
+ const soulPath = createTempSoulFile('v1');
984
+ try {
985
+ const state = resumedSessionState();
986
+ const opts = { isNewSession: false };
987
+
988
+ // First iteration: SOUL sent
989
+ simulateIteration(soulPath, state, opts);
990
+
991
+ // Keep going to establish hash
992
+ simulateIteration(soulPath, state, opts);
993
+
994
+ // Change SOUL file
995
+ fs.writeFileSync(soulPath, 'v2', 'utf8');
996
+
997
+ // Hot-reload detected
998
+ const r = simulateIteration(soulPath, state, opts);
999
+ expect(r.soulChanged).toBe(true);
1000
+ expect(r.message).toContain('SOUL instructions have been updated');
1001
+ expect(r.message).toContain('v2');
1002
+ } finally {
1003
+ removeTempFile(soulPath);
1004
+ }
1005
+ });
1006
+
1007
+ test('failed first send on resumed session retries', () => {
1008
+ const soulPath = createTempSoulFile('# SOUL');
1009
+ try {
1010
+ const state = resumedSessionState();
1011
+ const opts = { isNewSession: false, sendSuccess: false };
1012
+
1013
+ // First iteration fails
1014
+ const r1 = simulateIteration(soulPath, state, opts);
1015
+ expect(r1.soulTrigger).toBe('first');
1016
+ expect(state.soulSent).toBe(false); // NOT marked as sent
1017
+
1018
+ // Retry succeeds
1019
+ const r2 = simulateIteration(soulPath, state, { isNewSession: false });
1020
+ expect(r2.soulTrigger).toBe('first');
1021
+ expect(r2.message).toContain('You are resuming from a previous session');
1022
+ expect(state.soulSent).toBe(true); // NOW marked as sent
1023
+ } finally {
1024
+ removeTempFile(soulPath);
1025
+ }
1026
+ });
1027
+ });