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