@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.
- package/MARKETPLACE.md +406 -0
- package/README.md +218 -21
- package/build-binary.js +591 -0
- package/build-npm.js +537 -0
- package/build.js +230 -0
- package/check-binary.js +26 -0
- package/deploy.sh +95 -0
- package/index.js +5775 -0
- package/lib/agent-registry.js +1037 -0
- package/lib/args-parser.js +837 -0
- package/lib/blessed-widget-patched.js +93 -0
- package/lib/cli-markdown.js +590 -0
- package/lib/compaction.js +153 -0
- package/lib/duration.js +94 -0
- package/lib/hash.js +22 -0
- package/lib/marketplace.js +866 -0
- package/lib/memory-config.js +166 -0
- package/lib/skill-manager.js +891 -0
- package/lib/soul.js +31 -0
- package/lib/tool-output-formatter.js +180 -0
- package/package.json +35 -33
- package/start-pave.sh +149 -0
- package/status.js +271 -0
- package/test/abort-stream.test.js +445 -0
- package/test/agent-auto-compaction.test.js +552 -0
- package/test/agent-comm-abort.test.js +95 -0
- package/test/agent-comm.test.js +598 -0
- package/test/agent-inbox.test.js +576 -0
- package/test/agent-init.test.js +264 -0
- package/test/agent-interrupt.test.js +314 -0
- package/test/agent-lifecycle.test.js +520 -0
- package/test/agent-log-files.test.js +349 -0
- package/test/agent-mode.manual-test.js +392 -0
- package/test/agent-parsing.test.js +228 -0
- package/test/agent-post-stream-idle.test.js +762 -0
- package/test/agent-registry.test.js +359 -0
- package/test/agent-rm.test.js +442 -0
- package/test/agent-spawn.test.js +933 -0
- package/test/agent-status-api.test.js +624 -0
- package/test/agent-update.test.js +435 -0
- package/test/args-parser.test.js +391 -0
- package/test/auto-compaction-chat.manual-test.js +227 -0
- package/test/auto-compaction.test.js +941 -0
- package/test/build-config.test.js +120 -0
- package/test/build-npm.test.js +388 -0
- package/test/chat-command.test.js +137 -0
- package/test/chat-leading-lines.test.js +159 -0
- package/test/config-flag.test.js +272 -0
- package/test/cursor-drift.test.js +135 -0
- package/test/debug-require.js +23 -0
- package/test/dir-migration.test.js +323 -0
- package/test/duration.test.js +229 -0
- package/test/ghostty-term.test.js +202 -0
- package/test/http500-backoff.test.js +854 -0
- package/test/integration.test.js +86 -0
- package/test/memory-guard-env.test.js +220 -0
- package/test/pr233-fixes.test.js +259 -0
- package/test/run-agent-init.js +297 -0
- package/test/run-all.js +64 -0
- package/test/run-config-flag.js +159 -0
- package/test/run-cursor-drift.js +82 -0
- package/test/run-session-path.js +154 -0
- package/test/run-tests.js +643 -0
- package/test/sandbox-redirect.test.js +202 -0
- package/test/session-path.test.js +132 -0
- package/test/shebang-strip.test.js +241 -0
- package/test/soul-reinject.test.js +1027 -0
- package/test/soul-reread.test.js +281 -0
- package/test/tool-output-formatter.test.js +486 -0
- package/test/tool-output-gating.test.js +143 -0
- package/test/tool-states.test.js +167 -0
- package/test/tools-flag.test.js +65 -0
- package/test/tui-attach.test.js +1255 -0
- package/test/tui-compaction.test.js +354 -0
- package/test/tui-wrap.test.js +568 -0
- package/test-binary.js +52 -0
- package/test-binary2.js +36 -0
- package/LICENSE +0 -21
- package/pave.js +0 -2
- package/sandbox/SandboxRunner.js +0 -1
- package/sandbox/pave-run.js +0 -2
- package/sandbox/permission.js +0 -1
- 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
|
+
});
|