@pennyfarthing/core 10.3.1 → 10.4.0
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/README.md +3 -3
- package/package.json +1 -1
- package/packages/core/dist/cli/commands/init.d.ts.map +1 -1
- package/packages/core/dist/cli/commands/init.js +3 -0
- package/packages/core/dist/cli/commands/init.js.map +1 -1
- package/packages/core/dist/cli/commands/update.d.ts.map +1 -1
- package/packages/core/dist/cli/commands/update.js +62 -122
- package/packages/core/dist/cli/commands/update.js.map +1 -1
- package/packages/core/dist/cli/commands/version-sentinel.test.d.ts +18 -0
- package/packages/core/dist/cli/commands/version-sentinel.test.d.ts.map +1 -0
- package/packages/core/dist/cli/commands/version-sentinel.test.js +120 -0
- package/packages/core/dist/cli/commands/version-sentinel.test.js.map +1 -0
- package/packages/core/dist/cli/utils/manifest.d.ts +1 -0
- package/packages/core/dist/cli/utils/manifest.d.ts.map +1 -1
- package/packages/core/dist/cli/utils/manifest.js.map +1 -1
- package/packages/core/dist/cli/utils/migrations.d.ts +88 -0
- package/packages/core/dist/cli/utils/migrations.d.ts.map +1 -0
- package/packages/core/dist/cli/utils/migrations.js +105 -0
- package/packages/core/dist/cli/utils/migrations.js.map +1 -0
- package/packages/core/dist/cli/utils/migrations.test.d.ts +23 -0
- package/packages/core/dist/cli/utils/migrations.test.d.ts.map +1 -0
- package/packages/core/dist/cli/utils/migrations.test.js +319 -0
- package/packages/core/dist/cli/utils/migrations.test.js.map +1 -0
- package/packages/core/dist/cli/utils/version-sentinel.d.ts +32 -0
- package/packages/core/dist/cli/utils/version-sentinel.d.ts.map +1 -0
- package/packages/core/dist/cli/utils/version-sentinel.js +49 -0
- package/packages/core/dist/cli/utils/version-sentinel.js.map +1 -0
- package/packages/core/dist/workflow/context-watch.d.ts +81 -0
- package/packages/core/dist/workflow/context-watch.d.ts.map +1 -0
- package/packages/core/dist/workflow/context-watch.js +236 -0
- package/packages/core/dist/workflow/context-watch.js.map +1 -0
- package/packages/core/dist/workflow/context-watch.test.d.ts +2 -0
- package/packages/core/dist/workflow/context-watch.test.d.ts.map +1 -0
- package/packages/core/dist/workflow/context-watch.test.js +747 -0
- package/packages/core/dist/workflow/context-watch.test.js.map +1 -0
- package/pennyfarthing-dist/agents/dev.md +47 -0
- package/pennyfarthing_scripts/__pycache__/schema_validation_hook.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/workflow.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/prime/__pycache__/version_sentinel.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/prime/version_sentinel.py +104 -0
- package/pennyfarthing_scripts/sprint/__pycache__/archive_epic.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/__pycache__/story_finish.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/archive_epic.py +9 -1
- package/pennyfarthing_scripts/tests/__pycache__/conftest.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_version_sentinel.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/test_version_sentinel.py +126 -0
|
@@ -0,0 +1,747 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for Story 95-6: Context-watch Observation Scope
|
|
3
|
+
*
|
|
4
|
+
* RED state tests for periodic conversation summary delivery to backseat agent.
|
|
5
|
+
* These tests cover all acceptance criteria:
|
|
6
|
+
*
|
|
7
|
+
* AC1: Backseat receives periodic conversation summaries at configurable turn intervals
|
|
8
|
+
* AC2: Default interval: every 5 tool calls
|
|
9
|
+
* AC3: Summaries capture primary agent's current focus, decisions, and approach
|
|
10
|
+
* AC4: Summary generation does not block primary agent's conversation flow
|
|
11
|
+
* AC5: Combined token overhead stays under 25% per phase
|
|
12
|
+
* AC6: Context accumulates — later summaries reference earlier ones
|
|
13
|
+
*
|
|
14
|
+
* Run with: npm test
|
|
15
|
+
*/
|
|
16
|
+
import { describe, it, beforeEach, afterEach } from 'node:test';
|
|
17
|
+
import assert from 'node:assert';
|
|
18
|
+
import { mkdirSync, rmSync, existsSync, writeFileSync } from 'node:fs';
|
|
19
|
+
import { join, dirname } from 'node:path';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
// Import the module under test (does not exist yet — will cause import failure)
|
|
22
|
+
import { incrementTurnCounter, readTurnCounter, resetTurnCounter, shouldTriggerSummary, writeContextSnapshot, readContextSnapshot, startContextWatcher, stopContextWatcher, } from './context-watch.js';
|
|
23
|
+
// Import observation writer for integration tests
|
|
24
|
+
import { initObservationFile, parseObservationFile, } from './observation-writer.js';
|
|
25
|
+
// Get directory for test fixtures
|
|
26
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
27
|
+
const TEST_DIR = join(__dirname, '__test_context_watch__');
|
|
28
|
+
const SESSION_DIR = join(TEST_DIR, '.session');
|
|
29
|
+
// =============================================================================
|
|
30
|
+
// Test Fixtures
|
|
31
|
+
// =============================================================================
|
|
32
|
+
const DEFAULT_POLL_MS = 100; // Fast for testing
|
|
33
|
+
const DEFAULT_CONFIG = {
|
|
34
|
+
sessionDir: SESSION_DIR,
|
|
35
|
+
storyId: '95-6',
|
|
36
|
+
agent: 'architect',
|
|
37
|
+
persona: 'Will Bailey',
|
|
38
|
+
phase: 'implement',
|
|
39
|
+
pollIntervalMs: DEFAULT_POLL_MS,
|
|
40
|
+
turnInterval: 5,
|
|
41
|
+
};
|
|
42
|
+
const OBS_CONFIG = {
|
|
43
|
+
storyId: '95-6',
|
|
44
|
+
agent: 'architect',
|
|
45
|
+
persona: 'Will Bailey',
|
|
46
|
+
phase: 'implement',
|
|
47
|
+
sessionDir: SESSION_DIR,
|
|
48
|
+
};
|
|
49
|
+
function counterPath() {
|
|
50
|
+
return join(SESSION_DIR, '.tandem-turn-counter');
|
|
51
|
+
}
|
|
52
|
+
function snapshotPath() {
|
|
53
|
+
return join(SESSION_DIR, `${DEFAULT_CONFIG.storyId}-tandem-context.md`);
|
|
54
|
+
}
|
|
55
|
+
// =============================================================================
|
|
56
|
+
// Setup / Teardown
|
|
57
|
+
// =============================================================================
|
|
58
|
+
describe('95-6: Context-watch Observation Scope', () => {
|
|
59
|
+
beforeEach(() => {
|
|
60
|
+
if (existsSync(TEST_DIR)) {
|
|
61
|
+
rmSync(TEST_DIR, { recursive: true });
|
|
62
|
+
}
|
|
63
|
+
mkdirSync(SESSION_DIR, { recursive: true });
|
|
64
|
+
});
|
|
65
|
+
afterEach(() => {
|
|
66
|
+
if (existsSync(TEST_DIR)) {
|
|
67
|
+
rmSync(TEST_DIR, { recursive: true });
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
// ===========================================================================
|
|
71
|
+
// AC1: Backseat receives periodic conversation summaries at configurable
|
|
72
|
+
// turn intervals
|
|
73
|
+
// ===========================================================================
|
|
74
|
+
describe('AC1: Periodic summaries at configurable intervals', () => {
|
|
75
|
+
it('should increment turn counter on each call', () => {
|
|
76
|
+
const r1 = incrementTurnCounter(SESSION_DIR);
|
|
77
|
+
assert.ok(r1.success, `incrementTurnCounter failed: ${r1.error}`);
|
|
78
|
+
assert.strictEqual(r1.data, 1, 'First increment should return 1');
|
|
79
|
+
const r2 = incrementTurnCounter(SESSION_DIR);
|
|
80
|
+
assert.ok(r2.success);
|
|
81
|
+
assert.strictEqual(r2.data, 2, 'Second increment should return 2');
|
|
82
|
+
const r3 = incrementTurnCounter(SESSION_DIR);
|
|
83
|
+
assert.ok(r3.success);
|
|
84
|
+
assert.strictEqual(r3.data, 3, 'Third increment should return 3');
|
|
85
|
+
});
|
|
86
|
+
it('should read current turn counter value', () => {
|
|
87
|
+
incrementTurnCounter(SESSION_DIR);
|
|
88
|
+
incrementTurnCounter(SESSION_DIR);
|
|
89
|
+
incrementTurnCounter(SESSION_DIR);
|
|
90
|
+
const result = readTurnCounter(SESSION_DIR);
|
|
91
|
+
assert.ok(result.success);
|
|
92
|
+
assert.strictEqual(result.data, 3);
|
|
93
|
+
});
|
|
94
|
+
it('should return 0 when counter file does not exist', () => {
|
|
95
|
+
const result = readTurnCounter(SESSION_DIR);
|
|
96
|
+
assert.ok(result.success);
|
|
97
|
+
assert.strictEqual(result.data, 0, 'No counter file should return 0');
|
|
98
|
+
});
|
|
99
|
+
it('should reset turn counter to 0', () => {
|
|
100
|
+
incrementTurnCounter(SESSION_DIR);
|
|
101
|
+
incrementTurnCounter(SESSION_DIR);
|
|
102
|
+
incrementTurnCounter(SESSION_DIR);
|
|
103
|
+
const resetResult = resetTurnCounter(SESSION_DIR);
|
|
104
|
+
assert.ok(resetResult.success);
|
|
105
|
+
const readResult = readTurnCounter(SESSION_DIR);
|
|
106
|
+
assert.ok(readResult.success);
|
|
107
|
+
assert.strictEqual(readResult.data, 0, 'Counter should be 0 after reset');
|
|
108
|
+
});
|
|
109
|
+
it('should trigger summary at configurable interval', () => {
|
|
110
|
+
// With interval=3, should trigger at 3, 6, 9...
|
|
111
|
+
assert.strictEqual(shouldTriggerSummary(1, 3), false);
|
|
112
|
+
assert.strictEqual(shouldTriggerSummary(2, 3), false);
|
|
113
|
+
assert.strictEqual(shouldTriggerSummary(3, 3), true, 'Should trigger at turn 3 with interval 3');
|
|
114
|
+
assert.strictEqual(shouldTriggerSummary(4, 3), false);
|
|
115
|
+
assert.strictEqual(shouldTriggerSummary(5, 3), false);
|
|
116
|
+
assert.strictEqual(shouldTriggerSummary(6, 3), true, 'Should trigger at turn 6 with interval 3');
|
|
117
|
+
});
|
|
118
|
+
it('should not trigger at turn 0', () => {
|
|
119
|
+
assert.strictEqual(shouldTriggerSummary(0, 5), false);
|
|
120
|
+
});
|
|
121
|
+
it('should write context snapshot when watcher triggers at interval', async () => {
|
|
122
|
+
const initResult = initObservationFile(OBS_CONFIG);
|
|
123
|
+
assert.ok(initResult.success);
|
|
124
|
+
assert.ok(initResult.data);
|
|
125
|
+
// Create a session file with some content for the watcher to read
|
|
126
|
+
const sessionFile = join(SESSION_DIR, '95-6-session.md');
|
|
127
|
+
writeFileSync(sessionFile, '# Session\n## Dev Assessment\nWorking on feature X\n');
|
|
128
|
+
// Simulate enough turns to trigger (interval=5, so 5 increments)
|
|
129
|
+
for (let i = 0; i < 5; i++) {
|
|
130
|
+
incrementTurnCounter(SESSION_DIR);
|
|
131
|
+
}
|
|
132
|
+
const watchResult = await startContextWatcher({
|
|
133
|
+
...DEFAULT_CONFIG,
|
|
134
|
+
observationFilePath: initResult.data.path,
|
|
135
|
+
sessionFilePath: sessionFile,
|
|
136
|
+
});
|
|
137
|
+
assert.ok(watchResult.success, `startContextWatcher failed: ${watchResult.error}`);
|
|
138
|
+
assert.ok(watchResult.data);
|
|
139
|
+
// Wait for at least one poll cycle
|
|
140
|
+
await new Promise(resolve => setTimeout(resolve, DEFAULT_POLL_MS * 5));
|
|
141
|
+
await stopContextWatcher(watchResult.data);
|
|
142
|
+
// Should have written at least one context observation
|
|
143
|
+
const parsed = parseObservationFile(initResult.data.path);
|
|
144
|
+
assert.ok(parsed.success);
|
|
145
|
+
assert.ok(parsed.data);
|
|
146
|
+
assert.ok(parsed.data.entries.length > 0, 'Should write observation when turn counter hits interval');
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
// ===========================================================================
|
|
150
|
+
// AC2: Default interval: every 5 tool calls
|
|
151
|
+
// ===========================================================================
|
|
152
|
+
describe('AC2: Default interval of 5', () => {
|
|
153
|
+
it('should use default interval of 5 when not specified', () => {
|
|
154
|
+
// Turns 1-4 should not trigger
|
|
155
|
+
assert.strictEqual(shouldTriggerSummary(1, 5), false);
|
|
156
|
+
assert.strictEqual(shouldTriggerSummary(2, 5), false);
|
|
157
|
+
assert.strictEqual(shouldTriggerSummary(3, 5), false);
|
|
158
|
+
assert.strictEqual(shouldTriggerSummary(4, 5), false);
|
|
159
|
+
// Turn 5 should trigger
|
|
160
|
+
assert.strictEqual(shouldTriggerSummary(5, 5), true);
|
|
161
|
+
});
|
|
162
|
+
it('startContextWatcher should default to turnInterval 5', async () => {
|
|
163
|
+
const sessionFile = join(SESSION_DIR, '95-6-session.md');
|
|
164
|
+
writeFileSync(sessionFile, '# Session\nContent here\n');
|
|
165
|
+
const initResult = initObservationFile(OBS_CONFIG);
|
|
166
|
+
assert.ok(initResult.success);
|
|
167
|
+
assert.ok(initResult.data);
|
|
168
|
+
// Set counter to 4 (not yet at 5)
|
|
169
|
+
for (let i = 0; i < 4; i++) {
|
|
170
|
+
incrementTurnCounter(SESSION_DIR);
|
|
171
|
+
}
|
|
172
|
+
const watchResult = await startContextWatcher({
|
|
173
|
+
sessionDir: SESSION_DIR,
|
|
174
|
+
storyId: '95-6',
|
|
175
|
+
agent: 'architect',
|
|
176
|
+
persona: 'Will Bailey',
|
|
177
|
+
phase: 'implement',
|
|
178
|
+
pollIntervalMs: DEFAULT_POLL_MS,
|
|
179
|
+
// turnInterval NOT specified — should default to 5
|
|
180
|
+
observationFilePath: initResult.data.path,
|
|
181
|
+
sessionFilePath: sessionFile,
|
|
182
|
+
});
|
|
183
|
+
assert.ok(watchResult.success);
|
|
184
|
+
assert.ok(watchResult.data);
|
|
185
|
+
await new Promise(resolve => setTimeout(resolve, DEFAULT_POLL_MS * 3));
|
|
186
|
+
await stopContextWatcher(watchResult.data);
|
|
187
|
+
// At turn 4, should NOT have triggered (not at interval yet)
|
|
188
|
+
const parsed = parseObservationFile(initResult.data.path);
|
|
189
|
+
assert.ok(parsed.success);
|
|
190
|
+
assert.ok(parsed.data);
|
|
191
|
+
assert.strictEqual(parsed.data.entries.length, 0, 'Should not trigger at turn 4 with default interval 5');
|
|
192
|
+
});
|
|
193
|
+
it('should trigger at exactly turn 5 with default interval', async () => {
|
|
194
|
+
const sessionFile = join(SESSION_DIR, '95-6-session.md');
|
|
195
|
+
writeFileSync(sessionFile, '# Session\nDev is implementing feature X\n');
|
|
196
|
+
const initResult = initObservationFile(OBS_CONFIG);
|
|
197
|
+
assert.ok(initResult.success);
|
|
198
|
+
assert.ok(initResult.data);
|
|
199
|
+
// Set counter to exactly 5
|
|
200
|
+
for (let i = 0; i < 5; i++) {
|
|
201
|
+
incrementTurnCounter(SESSION_DIR);
|
|
202
|
+
}
|
|
203
|
+
const watchResult = await startContextWatcher({
|
|
204
|
+
sessionDir: SESSION_DIR,
|
|
205
|
+
storyId: '95-6',
|
|
206
|
+
agent: 'architect',
|
|
207
|
+
persona: 'Will Bailey',
|
|
208
|
+
phase: 'implement',
|
|
209
|
+
pollIntervalMs: DEFAULT_POLL_MS,
|
|
210
|
+
observationFilePath: initResult.data.path,
|
|
211
|
+
sessionFilePath: sessionFile,
|
|
212
|
+
});
|
|
213
|
+
assert.ok(watchResult.success);
|
|
214
|
+
assert.ok(watchResult.data);
|
|
215
|
+
await new Promise(resolve => setTimeout(resolve, DEFAULT_POLL_MS * 5));
|
|
216
|
+
await stopContextWatcher(watchResult.data);
|
|
217
|
+
const parsed = parseObservationFile(initResult.data.path);
|
|
218
|
+
assert.ok(parsed.success);
|
|
219
|
+
assert.ok(parsed.data);
|
|
220
|
+
assert.ok(parsed.data.entries.length > 0, 'Should trigger at turn 5 with default interval');
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
// ===========================================================================
|
|
224
|
+
// AC3: Summaries capture primary agent's current focus, decisions, approach
|
|
225
|
+
// ===========================================================================
|
|
226
|
+
describe('AC3: Summary content quality', () => {
|
|
227
|
+
it('should write context snapshot from session file content', () => {
|
|
228
|
+
const sessionContent = [
|
|
229
|
+
'# Session: 95-6',
|
|
230
|
+
'## Dev Assessment',
|
|
231
|
+
'Working on migrating NotificationService to React hooks.',
|
|
232
|
+
'Completed useNotification hook.',
|
|
233
|
+
'Now integrating with NotificationPanel.',
|
|
234
|
+
].join('\n');
|
|
235
|
+
const sessionFile = join(SESSION_DIR, '95-6-session.md');
|
|
236
|
+
writeFileSync(sessionFile, sessionContent);
|
|
237
|
+
const result = writeContextSnapshot({
|
|
238
|
+
sessionDir: SESSION_DIR,
|
|
239
|
+
storyId: '95-6',
|
|
240
|
+
sessionFilePath: sessionFile,
|
|
241
|
+
turnCount: 5,
|
|
242
|
+
});
|
|
243
|
+
assert.ok(result.success, `writeContextSnapshot failed: ${result.error}`);
|
|
244
|
+
assert.ok(result.data, 'Should return snapshot data');
|
|
245
|
+
assert.ok(result.data.content.length > 0, 'Snapshot content should not be empty');
|
|
246
|
+
});
|
|
247
|
+
it('context snapshot should include session content', () => {
|
|
248
|
+
const sessionContent = '# Session\n## Status\nRefactoring authentication module.\n';
|
|
249
|
+
const sessionFile = join(SESSION_DIR, '95-6-session.md');
|
|
250
|
+
writeFileSync(sessionFile, sessionContent);
|
|
251
|
+
const result = writeContextSnapshot({
|
|
252
|
+
sessionDir: SESSION_DIR,
|
|
253
|
+
storyId: '95-6',
|
|
254
|
+
sessionFilePath: sessionFile,
|
|
255
|
+
turnCount: 10,
|
|
256
|
+
});
|
|
257
|
+
assert.ok(result.success);
|
|
258
|
+
assert.ok(result.data);
|
|
259
|
+
// The snapshot should contain information derived from the session
|
|
260
|
+
assert.ok(result.data.content.length > 10, 'Snapshot should contain substantive content from session');
|
|
261
|
+
});
|
|
262
|
+
it('context snapshot should include turn count', () => {
|
|
263
|
+
const sessionFile = join(SESSION_DIR, '95-6-session.md');
|
|
264
|
+
writeFileSync(sessionFile, '# Session\nSome content\n');
|
|
265
|
+
const result = writeContextSnapshot({
|
|
266
|
+
sessionDir: SESSION_DIR,
|
|
267
|
+
storyId: '95-6',
|
|
268
|
+
sessionFilePath: sessionFile,
|
|
269
|
+
turnCount: 15,
|
|
270
|
+
});
|
|
271
|
+
assert.ok(result.success);
|
|
272
|
+
assert.ok(result.data);
|
|
273
|
+
// Snapshot should reference turn number
|
|
274
|
+
assert.ok(result.data.content.includes('15'), 'Snapshot should reference current turn count');
|
|
275
|
+
});
|
|
276
|
+
it('observations should use context-watch trigger type', async () => {
|
|
277
|
+
const sessionFile = join(SESSION_DIR, '95-6-session.md');
|
|
278
|
+
writeFileSync(sessionFile, '# Session\nDev is working on API endpoint\n');
|
|
279
|
+
const initResult = initObservationFile(OBS_CONFIG);
|
|
280
|
+
assert.ok(initResult.success);
|
|
281
|
+
assert.ok(initResult.data);
|
|
282
|
+
// Set counter to trigger (5)
|
|
283
|
+
for (let i = 0; i < 5; i++) {
|
|
284
|
+
incrementTurnCounter(SESSION_DIR);
|
|
285
|
+
}
|
|
286
|
+
const watchResult = await startContextWatcher({
|
|
287
|
+
...DEFAULT_CONFIG,
|
|
288
|
+
observationFilePath: initResult.data.path,
|
|
289
|
+
sessionFilePath: sessionFile,
|
|
290
|
+
});
|
|
291
|
+
assert.ok(watchResult.success);
|
|
292
|
+
assert.ok(watchResult.data);
|
|
293
|
+
await new Promise(resolve => setTimeout(resolve, DEFAULT_POLL_MS * 5));
|
|
294
|
+
await stopContextWatcher(watchResult.data);
|
|
295
|
+
const parsed = parseObservationFile(initResult.data.path);
|
|
296
|
+
assert.ok(parsed.success);
|
|
297
|
+
assert.ok(parsed.data);
|
|
298
|
+
assert.ok(parsed.data.entries.length > 0, 'Should have observation entry');
|
|
299
|
+
const entry = parsed.data.entries[0];
|
|
300
|
+
assert.strictEqual(entry.triggerType, 'context-watch', 'Trigger type should be context-watch');
|
|
301
|
+
});
|
|
302
|
+
it('trigger detail should include turn information', async () => {
|
|
303
|
+
const sessionFile = join(SESSION_DIR, '95-6-session.md');
|
|
304
|
+
writeFileSync(sessionFile, '# Session\nContent\n');
|
|
305
|
+
const initResult = initObservationFile(OBS_CONFIG);
|
|
306
|
+
assert.ok(initResult.success);
|
|
307
|
+
assert.ok(initResult.data);
|
|
308
|
+
for (let i = 0; i < 5; i++) {
|
|
309
|
+
incrementTurnCounter(SESSION_DIR);
|
|
310
|
+
}
|
|
311
|
+
const watchResult = await startContextWatcher({
|
|
312
|
+
...DEFAULT_CONFIG,
|
|
313
|
+
observationFilePath: initResult.data.path,
|
|
314
|
+
sessionFilePath: sessionFile,
|
|
315
|
+
});
|
|
316
|
+
assert.ok(watchResult.success);
|
|
317
|
+
assert.ok(watchResult.data);
|
|
318
|
+
await new Promise(resolve => setTimeout(resolve, DEFAULT_POLL_MS * 5));
|
|
319
|
+
await stopContextWatcher(watchResult.data);
|
|
320
|
+
const parsed = parseObservationFile(initResult.data.path);
|
|
321
|
+
assert.ok(parsed.success);
|
|
322
|
+
assert.ok(parsed.data);
|
|
323
|
+
assert.ok(parsed.data.entries.length > 0);
|
|
324
|
+
const entry = parsed.data.entries[0];
|
|
325
|
+
assert.ok(entry.triggerDetail.includes('turn') || entry.triggerDetail.includes('5'), `Trigger detail should reference turn info, got: "${entry.triggerDetail}"`);
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
// ===========================================================================
|
|
329
|
+
// AC4: Summary generation does not block primary agent's conversation flow
|
|
330
|
+
// ===========================================================================
|
|
331
|
+
describe('AC4: Non-blocking', () => {
|
|
332
|
+
it('incrementTurnCounter should complete within 10ms', () => {
|
|
333
|
+
mkdirSync(SESSION_DIR, { recursive: true });
|
|
334
|
+
const start = Date.now();
|
|
335
|
+
const result = incrementTurnCounter(SESSION_DIR);
|
|
336
|
+
const elapsed = Date.now() - start;
|
|
337
|
+
assert.ok(result.success);
|
|
338
|
+
assert.ok(elapsed < 10, `incrementTurnCounter took ${elapsed}ms, expected < 10ms`);
|
|
339
|
+
});
|
|
340
|
+
it('shouldTriggerSummary should be a pure function (no I/O)', () => {
|
|
341
|
+
const start = Date.now();
|
|
342
|
+
for (let i = 0; i < 10000; i++) {
|
|
343
|
+
shouldTriggerSummary(i, 5);
|
|
344
|
+
}
|
|
345
|
+
const elapsed = Date.now() - start;
|
|
346
|
+
assert.ok(elapsed < 50, `10000 calls to shouldTriggerSummary took ${elapsed}ms — should be pure`);
|
|
347
|
+
});
|
|
348
|
+
it('writeContextSnapshot should complete within 50ms', () => {
|
|
349
|
+
const sessionFile = join(SESSION_DIR, '95-6-session.md');
|
|
350
|
+
writeFileSync(sessionFile, '# Session\nContent for snapshot\n');
|
|
351
|
+
const start = Date.now();
|
|
352
|
+
const result = writeContextSnapshot({
|
|
353
|
+
sessionDir: SESSION_DIR,
|
|
354
|
+
storyId: '95-6',
|
|
355
|
+
sessionFilePath: sessionFile,
|
|
356
|
+
turnCount: 5,
|
|
357
|
+
});
|
|
358
|
+
const elapsed = Date.now() - start;
|
|
359
|
+
assert.ok(result.success);
|
|
360
|
+
assert.ok(elapsed < 50, `writeContextSnapshot took ${elapsed}ms, expected < 50ms`);
|
|
361
|
+
});
|
|
362
|
+
it('startContextWatcher should return handle immediately', async () => {
|
|
363
|
+
const sessionFile = join(SESSION_DIR, '95-6-session.md');
|
|
364
|
+
writeFileSync(sessionFile, '# Session\nContent\n');
|
|
365
|
+
const start = Date.now();
|
|
366
|
+
const result = await startContextWatcher({
|
|
367
|
+
...DEFAULT_CONFIG,
|
|
368
|
+
sessionFilePath: sessionFile,
|
|
369
|
+
});
|
|
370
|
+
const elapsed = Date.now() - start;
|
|
371
|
+
assert.ok(result.success, `startContextWatcher failed: ${result.error}`);
|
|
372
|
+
assert.ok(result.data);
|
|
373
|
+
assert.ok(elapsed < 1000, `startContextWatcher took ${elapsed}ms, expected < 1000ms`);
|
|
374
|
+
if (result.data) {
|
|
375
|
+
await stopContextWatcher(result.data);
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
it('incrementTurnCounter should not throw on missing session dir', () => {
|
|
379
|
+
const result = incrementTurnCounter('/nonexistent/session/dir');
|
|
380
|
+
assert.strictEqual(result.success, false);
|
|
381
|
+
assert.ok(result.error, 'Should have error message');
|
|
382
|
+
});
|
|
383
|
+
});
|
|
384
|
+
// ===========================================================================
|
|
385
|
+
// AC5: Combined token overhead stays under 25% per phase
|
|
386
|
+
// ===========================================================================
|
|
387
|
+
describe('AC5: Token overhead budget', () => {
|
|
388
|
+
it('context snapshot should be within size budget', () => {
|
|
389
|
+
// A large session file
|
|
390
|
+
const sessionContent = 'Line of session content.\n'.repeat(100);
|
|
391
|
+
const sessionFile = join(SESSION_DIR, '95-6-session.md');
|
|
392
|
+
writeFileSync(sessionFile, sessionContent);
|
|
393
|
+
const result = writeContextSnapshot({
|
|
394
|
+
sessionDir: SESSION_DIR,
|
|
395
|
+
storyId: '95-6',
|
|
396
|
+
sessionFilePath: sessionFile,
|
|
397
|
+
turnCount: 10,
|
|
398
|
+
});
|
|
399
|
+
assert.ok(result.success);
|
|
400
|
+
assert.ok(result.data);
|
|
401
|
+
// Snapshot content should be bounded — not a verbatim copy of the whole session
|
|
402
|
+
// 2000 chars ≈ 500 tokens, reasonable for a context summary
|
|
403
|
+
assert.ok(result.data.content.length <= 2000, `Snapshot should be bounded, got ${result.data.content.length} chars`);
|
|
404
|
+
});
|
|
405
|
+
it('context snapshot should be smaller than source session content', () => {
|
|
406
|
+
// Large session file
|
|
407
|
+
const sessionContent = 'Detailed session content with implementation notes.\n'.repeat(200);
|
|
408
|
+
const sessionFile = join(SESSION_DIR, '95-6-session.md');
|
|
409
|
+
writeFileSync(sessionFile, sessionContent);
|
|
410
|
+
const result = writeContextSnapshot({
|
|
411
|
+
sessionDir: SESSION_DIR,
|
|
412
|
+
storyId: '95-6',
|
|
413
|
+
sessionFilePath: sessionFile,
|
|
414
|
+
turnCount: 25,
|
|
415
|
+
});
|
|
416
|
+
assert.ok(result.success);
|
|
417
|
+
assert.ok(result.data);
|
|
418
|
+
assert.ok(result.data.content.length < sessionContent.length, 'Snapshot should be smaller than full session content');
|
|
419
|
+
});
|
|
420
|
+
it('multiple summaries over a phase should stay within token budget', () => {
|
|
421
|
+
const sessionFile = join(SESSION_DIR, '95-6-session.md');
|
|
422
|
+
writeFileSync(sessionFile, 'Session content for budget test.\n'.repeat(50));
|
|
423
|
+
// Simulate 10 summaries (50 turns / interval 5 = 10 summaries)
|
|
424
|
+
let totalChars = 0;
|
|
425
|
+
for (let turn = 5; turn <= 50; turn += 5) {
|
|
426
|
+
const result = writeContextSnapshot({
|
|
427
|
+
sessionDir: SESSION_DIR,
|
|
428
|
+
storyId: '95-6',
|
|
429
|
+
sessionFilePath: sessionFile,
|
|
430
|
+
turnCount: turn,
|
|
431
|
+
});
|
|
432
|
+
assert.ok(result.success);
|
|
433
|
+
assert.ok(result.data);
|
|
434
|
+
totalChars += result.data.content.length;
|
|
435
|
+
}
|
|
436
|
+
// 10 summaries × ~500 tokens max = ~5000 tokens ≈ 20000 chars
|
|
437
|
+
// 25% of a 100K token phase = 25K tokens = ~100K chars
|
|
438
|
+
// So 20K chars is well within budget
|
|
439
|
+
assert.ok(totalChars < 30000, `Total snapshot chars across 10 summaries: ${totalChars}, should be < 30000`);
|
|
440
|
+
});
|
|
441
|
+
it('writeContextSnapshot should report estimated token count', () => {
|
|
442
|
+
const sessionFile = join(SESSION_DIR, '95-6-session.md');
|
|
443
|
+
writeFileSync(sessionFile, '# Session\nSome content for estimation.\n');
|
|
444
|
+
const result = writeContextSnapshot({
|
|
445
|
+
sessionDir: SESSION_DIR,
|
|
446
|
+
storyId: '95-6',
|
|
447
|
+
sessionFilePath: sessionFile,
|
|
448
|
+
turnCount: 5,
|
|
449
|
+
});
|
|
450
|
+
assert.ok(result.success);
|
|
451
|
+
assert.ok(result.data);
|
|
452
|
+
assert.ok(typeof result.data.estimatedTokens === 'number', 'Should include estimated token count');
|
|
453
|
+
assert.ok(result.data.estimatedTokens > 0, 'Token estimate should be positive');
|
|
454
|
+
});
|
|
455
|
+
});
|
|
456
|
+
// ===========================================================================
|
|
457
|
+
// AC6: Context accumulates — later summaries reference earlier ones
|
|
458
|
+
// ===========================================================================
|
|
459
|
+
describe('AC6: Context accumulation', () => {
|
|
460
|
+
it('readContextSnapshot should return previous snapshot', () => {
|
|
461
|
+
const sessionFile = join(SESSION_DIR, '95-6-session.md');
|
|
462
|
+
writeFileSync(sessionFile, '# Session\nFirst phase of work\n');
|
|
463
|
+
// Write first snapshot
|
|
464
|
+
writeContextSnapshot({
|
|
465
|
+
sessionDir: SESSION_DIR,
|
|
466
|
+
storyId: '95-6',
|
|
467
|
+
sessionFilePath: sessionFile,
|
|
468
|
+
turnCount: 5,
|
|
469
|
+
});
|
|
470
|
+
// Read it back
|
|
471
|
+
const readResult = readContextSnapshot(SESSION_DIR, '95-6');
|
|
472
|
+
assert.ok(readResult.success, `readContextSnapshot failed: ${readResult.error}`);
|
|
473
|
+
assert.ok(readResult.data);
|
|
474
|
+
assert.ok(readResult.data.content.length > 0, 'Should return previous snapshot content');
|
|
475
|
+
});
|
|
476
|
+
it('readContextSnapshot should return empty for no prior snapshot', () => {
|
|
477
|
+
const result = readContextSnapshot(SESSION_DIR, '95-6');
|
|
478
|
+
assert.ok(result.success);
|
|
479
|
+
assert.ok(result.data);
|
|
480
|
+
assert.strictEqual(result.data.content, '', 'No prior snapshot should return empty content');
|
|
481
|
+
});
|
|
482
|
+
it('later snapshots should include reference to prior context', () => {
|
|
483
|
+
const sessionFile = join(SESSION_DIR, '95-6-session.md');
|
|
484
|
+
// First snapshot at turn 5
|
|
485
|
+
writeFileSync(sessionFile, '# Session\nDev is setting up authentication module\n');
|
|
486
|
+
writeContextSnapshot({
|
|
487
|
+
sessionDir: SESSION_DIR,
|
|
488
|
+
storyId: '95-6',
|
|
489
|
+
sessionFilePath: sessionFile,
|
|
490
|
+
turnCount: 5,
|
|
491
|
+
});
|
|
492
|
+
// Second snapshot at turn 10 — session has more content
|
|
493
|
+
writeFileSync(sessionFile, '# Session\nDev is setting up authentication module\n## Progress\nJWT implementation complete. Now adding refresh tokens.\n');
|
|
494
|
+
const result = writeContextSnapshot({
|
|
495
|
+
sessionDir: SESSION_DIR,
|
|
496
|
+
storyId: '95-6',
|
|
497
|
+
sessionFilePath: sessionFile,
|
|
498
|
+
turnCount: 10,
|
|
499
|
+
});
|
|
500
|
+
assert.ok(result.success);
|
|
501
|
+
assert.ok(result.data);
|
|
502
|
+
// The second snapshot should be aware it follows a prior one
|
|
503
|
+
// (either by including turn range or referencing prior summary)
|
|
504
|
+
assert.ok(result.data.content.includes('10') || result.data.content.includes('turn'), 'Later snapshot should reference its turn context');
|
|
505
|
+
});
|
|
506
|
+
it('watcher should produce multiple observations across intervals', async () => {
|
|
507
|
+
const sessionFile = join(SESSION_DIR, '95-6-session.md');
|
|
508
|
+
writeFileSync(sessionFile, '# Session\nDev working on feature\n');
|
|
509
|
+
const initResult = initObservationFile(OBS_CONFIG);
|
|
510
|
+
assert.ok(initResult.success);
|
|
511
|
+
assert.ok(initResult.data);
|
|
512
|
+
// Set counter to 5 (first trigger)
|
|
513
|
+
for (let i = 0; i < 5; i++) {
|
|
514
|
+
incrementTurnCounter(SESSION_DIR);
|
|
515
|
+
}
|
|
516
|
+
const watchResult = await startContextWatcher({
|
|
517
|
+
...DEFAULT_CONFIG,
|
|
518
|
+
observationFilePath: initResult.data.path,
|
|
519
|
+
sessionFilePath: sessionFile,
|
|
520
|
+
});
|
|
521
|
+
assert.ok(watchResult.success);
|
|
522
|
+
assert.ok(watchResult.data);
|
|
523
|
+
// Wait for first observation
|
|
524
|
+
await new Promise(resolve => setTimeout(resolve, DEFAULT_POLL_MS * 5));
|
|
525
|
+
// Increment to 10 (second trigger)
|
|
526
|
+
for (let i = 0; i < 5; i++) {
|
|
527
|
+
incrementTurnCounter(SESSION_DIR);
|
|
528
|
+
}
|
|
529
|
+
// Update session to simulate progress
|
|
530
|
+
writeFileSync(sessionFile, '# Session\nDev working on feature\n## Update\nTests now passing\n');
|
|
531
|
+
// Wait for second observation
|
|
532
|
+
await new Promise(resolve => setTimeout(resolve, DEFAULT_POLL_MS * 5));
|
|
533
|
+
await stopContextWatcher(watchResult.data);
|
|
534
|
+
const parsed = parseObservationFile(initResult.data.path);
|
|
535
|
+
assert.ok(parsed.success);
|
|
536
|
+
assert.ok(parsed.data);
|
|
537
|
+
assert.ok(parsed.data.entries.length >= 2, `Should have at least 2 observations for 2 intervals, got ${parsed.data.entries.length}`);
|
|
538
|
+
});
|
|
539
|
+
it('accumulated observations should not duplicate content', async () => {
|
|
540
|
+
const sessionFile = join(SESSION_DIR, '95-6-session.md');
|
|
541
|
+
writeFileSync(sessionFile, '# Session\nUnique content for dedup test\n');
|
|
542
|
+
const initResult = initObservationFile(OBS_CONFIG);
|
|
543
|
+
assert.ok(initResult.success);
|
|
544
|
+
assert.ok(initResult.data);
|
|
545
|
+
// First trigger at turn 5
|
|
546
|
+
for (let i = 0; i < 5; i++) {
|
|
547
|
+
incrementTurnCounter(SESSION_DIR);
|
|
548
|
+
}
|
|
549
|
+
const watchResult = await startContextWatcher({
|
|
550
|
+
...DEFAULT_CONFIG,
|
|
551
|
+
observationFilePath: initResult.data.path,
|
|
552
|
+
sessionFilePath: sessionFile,
|
|
553
|
+
});
|
|
554
|
+
assert.ok(watchResult.success);
|
|
555
|
+
assert.ok(watchResult.data);
|
|
556
|
+
await new Promise(resolve => setTimeout(resolve, DEFAULT_POLL_MS * 5));
|
|
557
|
+
// Trigger again at turn 10 — same session content
|
|
558
|
+
for (let i = 0; i < 5; i++) {
|
|
559
|
+
incrementTurnCounter(SESSION_DIR);
|
|
560
|
+
}
|
|
561
|
+
await new Promise(resolve => setTimeout(resolve, DEFAULT_POLL_MS * 5));
|
|
562
|
+
await stopContextWatcher(watchResult.data);
|
|
563
|
+
const parsed = parseObservationFile(initResult.data.path);
|
|
564
|
+
assert.ok(parsed.success);
|
|
565
|
+
assert.ok(parsed.data);
|
|
566
|
+
if (parsed.data.entries.length >= 2) {
|
|
567
|
+
// Second observation should not be identical to first
|
|
568
|
+
assert.notStrictEqual(parsed.data.entries[0].observation, parsed.data.entries[1].observation, 'Accumulated observations should differ (even if session unchanged, turn number differs)');
|
|
569
|
+
}
|
|
570
|
+
});
|
|
571
|
+
});
|
|
572
|
+
// ===========================================================================
|
|
573
|
+
// Result objects per framework pattern
|
|
574
|
+
// ===========================================================================
|
|
575
|
+
describe('Result objects', () => {
|
|
576
|
+
it('incrementTurnCounter should return {success, data?, error?}', () => {
|
|
577
|
+
const result = incrementTurnCounter(SESSION_DIR);
|
|
578
|
+
assert.ok(typeof result === 'object');
|
|
579
|
+
assert.ok('success' in result, 'Result must have success field');
|
|
580
|
+
assert.ok('data' in result || result.success === false);
|
|
581
|
+
});
|
|
582
|
+
it('readTurnCounter should return {success, data?, error?}', () => {
|
|
583
|
+
const result = readTurnCounter(SESSION_DIR);
|
|
584
|
+
assert.ok(typeof result === 'object');
|
|
585
|
+
assert.ok('success' in result);
|
|
586
|
+
});
|
|
587
|
+
it('writeContextSnapshot should return {success, data?, error?}', () => {
|
|
588
|
+
const sessionFile = join(SESSION_DIR, '95-6-session.md');
|
|
589
|
+
writeFileSync(sessionFile, '# Session\nContent\n');
|
|
590
|
+
const result = writeContextSnapshot({
|
|
591
|
+
sessionDir: SESSION_DIR,
|
|
592
|
+
storyId: '95-6',
|
|
593
|
+
sessionFilePath: sessionFile,
|
|
594
|
+
turnCount: 5,
|
|
595
|
+
});
|
|
596
|
+
assert.ok(typeof result === 'object');
|
|
597
|
+
assert.ok('success' in result);
|
|
598
|
+
});
|
|
599
|
+
it('startContextWatcher should return {success, data?: ContextWatchHandle, error?}', async () => {
|
|
600
|
+
const sessionFile = join(SESSION_DIR, '95-6-session.md');
|
|
601
|
+
writeFileSync(sessionFile, '# Session\nContent\n');
|
|
602
|
+
const result = await startContextWatcher({
|
|
603
|
+
...DEFAULT_CONFIG,
|
|
604
|
+
sessionFilePath: sessionFile,
|
|
605
|
+
});
|
|
606
|
+
assert.ok(typeof result === 'object');
|
|
607
|
+
assert.ok('success' in result);
|
|
608
|
+
if (result.success && result.data) {
|
|
609
|
+
assert.ok(typeof result.data === 'object');
|
|
610
|
+
await stopContextWatcher(result.data);
|
|
611
|
+
}
|
|
612
|
+
});
|
|
613
|
+
it('stopContextWatcher should return {success, error?}', async () => {
|
|
614
|
+
const sessionFile = join(SESSION_DIR, '95-6-session.md');
|
|
615
|
+
writeFileSync(sessionFile, '# Session\nContent\n');
|
|
616
|
+
const watchResult = await startContextWatcher({
|
|
617
|
+
...DEFAULT_CONFIG,
|
|
618
|
+
sessionFilePath: sessionFile,
|
|
619
|
+
});
|
|
620
|
+
assert.ok(watchResult.success);
|
|
621
|
+
assert.ok(watchResult.data);
|
|
622
|
+
const stopResult = await stopContextWatcher(watchResult.data);
|
|
623
|
+
assert.ok(typeof stopResult === 'object');
|
|
624
|
+
assert.ok('success' in stopResult);
|
|
625
|
+
assert.ok(stopResult.success);
|
|
626
|
+
});
|
|
627
|
+
});
|
|
628
|
+
// ===========================================================================
|
|
629
|
+
// Error resilience
|
|
630
|
+
// ===========================================================================
|
|
631
|
+
describe('Error resilience', () => {
|
|
632
|
+
it('incrementTurnCounter should handle missing session dir', () => {
|
|
633
|
+
const result = incrementTurnCounter('/nonexistent/session/dir');
|
|
634
|
+
assert.strictEqual(result.success, false);
|
|
635
|
+
assert.ok(result.error);
|
|
636
|
+
});
|
|
637
|
+
it('writeContextSnapshot should handle missing session file', () => {
|
|
638
|
+
const result = writeContextSnapshot({
|
|
639
|
+
sessionDir: SESSION_DIR,
|
|
640
|
+
storyId: '95-6',
|
|
641
|
+
sessionFilePath: '/nonexistent/session.md',
|
|
642
|
+
turnCount: 5,
|
|
643
|
+
});
|
|
644
|
+
assert.strictEqual(result.success, false);
|
|
645
|
+
assert.ok(result.error);
|
|
646
|
+
});
|
|
647
|
+
it('watcher should continue polling after snapshot write error', async () => {
|
|
648
|
+
const sessionFile = join(SESSION_DIR, '95-6-session.md');
|
|
649
|
+
writeFileSync(sessionFile, '# Session\nContent\n');
|
|
650
|
+
// Set counter to trigger
|
|
651
|
+
for (let i = 0; i < 5; i++) {
|
|
652
|
+
incrementTurnCounter(SESSION_DIR);
|
|
653
|
+
}
|
|
654
|
+
// Start watcher with bad observation file path
|
|
655
|
+
const watchResult = await startContextWatcher({
|
|
656
|
+
...DEFAULT_CONFIG,
|
|
657
|
+
observationFilePath: '/nonexistent/obs.md',
|
|
658
|
+
sessionFilePath: sessionFile,
|
|
659
|
+
});
|
|
660
|
+
assert.ok(watchResult.success, 'Watcher should start even with bad observation path');
|
|
661
|
+
assert.ok(watchResult.data);
|
|
662
|
+
// Wait for poll cycles — watcher should NOT crash
|
|
663
|
+
await new Promise(resolve => setTimeout(resolve, DEFAULT_POLL_MS * 5));
|
|
664
|
+
const stopResult = await stopContextWatcher(watchResult.data);
|
|
665
|
+
assert.ok(stopResult.success, 'Should stop cleanly even after write errors');
|
|
666
|
+
});
|
|
667
|
+
it('stopContextWatcher should be idempotent', async () => {
|
|
668
|
+
const sessionFile = join(SESSION_DIR, '95-6-session.md');
|
|
669
|
+
writeFileSync(sessionFile, '# Session\nContent\n');
|
|
670
|
+
const watchResult = await startContextWatcher({
|
|
671
|
+
...DEFAULT_CONFIG,
|
|
672
|
+
sessionFilePath: sessionFile,
|
|
673
|
+
});
|
|
674
|
+
assert.ok(watchResult.success);
|
|
675
|
+
assert.ok(watchResult.data);
|
|
676
|
+
const stop1 = await stopContextWatcher(watchResult.data);
|
|
677
|
+
assert.ok(stop1.success);
|
|
678
|
+
const stop2 = await stopContextWatcher(watchResult.data);
|
|
679
|
+
assert.ok(stop2.success, 'Second stop should also succeed (idempotent)');
|
|
680
|
+
});
|
|
681
|
+
it('readContextSnapshot should handle missing snapshot file', () => {
|
|
682
|
+
const result = readContextSnapshot(SESSION_DIR, '95-6');
|
|
683
|
+
assert.ok(result.success, 'Missing snapshot is not an error — just empty');
|
|
684
|
+
assert.ok(result.data);
|
|
685
|
+
assert.strictEqual(result.data.content, '');
|
|
686
|
+
});
|
|
687
|
+
});
|
|
688
|
+
// ===========================================================================
|
|
689
|
+
// Edge cases
|
|
690
|
+
// ===========================================================================
|
|
691
|
+
describe('Edge cases', () => {
|
|
692
|
+
it('should handle empty session file', () => {
|
|
693
|
+
const sessionFile = join(SESSION_DIR, '95-6-session.md');
|
|
694
|
+
writeFileSync(sessionFile, '');
|
|
695
|
+
const result = writeContextSnapshot({
|
|
696
|
+
sessionDir: SESSION_DIR,
|
|
697
|
+
storyId: '95-6',
|
|
698
|
+
sessionFilePath: sessionFile,
|
|
699
|
+
turnCount: 5,
|
|
700
|
+
});
|
|
701
|
+
assert.ok(result.success, 'Empty session should not cause error');
|
|
702
|
+
assert.ok(result.data);
|
|
703
|
+
});
|
|
704
|
+
it('should handle very large session file', () => {
|
|
705
|
+
const sessionFile = join(SESSION_DIR, '95-6-session.md');
|
|
706
|
+
const largeContent = 'A line of session content with details.\n'.repeat(5000);
|
|
707
|
+
writeFileSync(sessionFile, largeContent);
|
|
708
|
+
const start = Date.now();
|
|
709
|
+
const result = writeContextSnapshot({
|
|
710
|
+
sessionDir: SESSION_DIR,
|
|
711
|
+
storyId: '95-6',
|
|
712
|
+
sessionFilePath: sessionFile,
|
|
713
|
+
turnCount: 50,
|
|
714
|
+
});
|
|
715
|
+
const elapsed = Date.now() - start;
|
|
716
|
+
assert.ok(result.success);
|
|
717
|
+
assert.ok(elapsed < 100, `Large session snapshot took ${elapsed}ms, expected < 100ms`);
|
|
718
|
+
// Snapshot should still be bounded
|
|
719
|
+
assert.ok(result.data.content.length <= 2000, `Snapshot from large session should still be bounded, got ${result.data.content.length}`);
|
|
720
|
+
});
|
|
721
|
+
it('should handle concurrent reads of session file', () => {
|
|
722
|
+
const sessionFile = join(SESSION_DIR, '95-6-session.md');
|
|
723
|
+
writeFileSync(sessionFile, '# Session\nContent\n');
|
|
724
|
+
// Multiple reads should not fail
|
|
725
|
+
const results = [];
|
|
726
|
+
for (let i = 0; i < 10; i++) {
|
|
727
|
+
results.push(writeContextSnapshot({
|
|
728
|
+
sessionDir: SESSION_DIR,
|
|
729
|
+
storyId: '95-6',
|
|
730
|
+
sessionFilePath: sessionFile,
|
|
731
|
+
turnCount: i + 1,
|
|
732
|
+
}));
|
|
733
|
+
}
|
|
734
|
+
for (const r of results) {
|
|
735
|
+
assert.ok(r.success, 'Concurrent snapshot writes should all succeed');
|
|
736
|
+
}
|
|
737
|
+
});
|
|
738
|
+
it('counter file should handle non-numeric content gracefully', () => {
|
|
739
|
+
writeFileSync(counterPath(), 'not-a-number\n');
|
|
740
|
+
const result = readTurnCounter(SESSION_DIR);
|
|
741
|
+
// Should either return 0 or handle gracefully
|
|
742
|
+
assert.ok(result.success, 'Non-numeric counter should not throw');
|
|
743
|
+
assert.strictEqual(typeof result.data, 'number');
|
|
744
|
+
});
|
|
745
|
+
});
|
|
746
|
+
});
|
|
747
|
+
//# sourceMappingURL=context-watch.test.js.map
|