codeep 1.1.36 → 1.2.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 +90 -4
- package/dist/api/index.js +64 -2
- package/dist/renderer/App.d.ts +5 -0
- package/dist/renderer/App.js +164 -227
- package/dist/renderer/components/Export.d.ts +22 -0
- package/dist/renderer/components/Export.js +64 -0
- package/dist/renderer/components/Help.js +5 -1
- package/dist/renderer/components/Logout.d.ts +29 -0
- package/dist/renderer/components/Logout.js +91 -0
- package/dist/renderer/components/Search.d.ts +30 -0
- package/dist/renderer/components/Search.js +83 -0
- package/dist/renderer/components/Settings.js +20 -0
- package/dist/renderer/components/Status.d.ts +6 -0
- package/dist/renderer/components/Status.js +20 -1
- package/dist/renderer/main.js +296 -142
- package/dist/utils/agent.d.ts +5 -0
- package/dist/utils/agent.js +238 -3
- package/dist/utils/agent.test.d.ts +1 -0
- package/dist/utils/agent.test.js +250 -0
- package/dist/utils/diffPreview.js +104 -35
- package/dist/utils/gitignore.d.ts +24 -0
- package/dist/utils/gitignore.js +161 -0
- package/dist/utils/gitignore.test.d.ts +1 -0
- package/dist/utils/gitignore.test.js +167 -0
- package/dist/utils/skills.d.ts +21 -0
- package/dist/utils/skills.js +51 -0
- package/dist/utils/smartContext.js +8 -0
- package/dist/utils/smartContext.test.d.ts +1 -0
- package/dist/utils/smartContext.test.js +382 -0
- package/dist/utils/tokenTracker.d.ts +52 -0
- package/dist/utils/tokenTracker.js +86 -0
- package/dist/utils/tools.d.ts +16 -0
- package/dist/utils/tools.js +146 -19
- package/dist/utils/tools.test.d.ts +1 -0
- package/dist/utils/tools.test.js +664 -0
- package/package.json +1 -1
package/dist/utils/agent.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Agent loop - autonomous task execution
|
|
3
3
|
*/
|
|
4
|
+
import { existsSync, readFileSync } from 'fs';
|
|
5
|
+
import { join } from 'path';
|
|
6
|
+
import { recordTokenUsage, extractOpenAIUsage, extractAnthropicUsage } from './tokenTracker.js';
|
|
4
7
|
// Debug logging helper - only logs when CODEEP_DEBUG=1
|
|
5
8
|
const debug = (...args) => {
|
|
6
9
|
if (process.env.CODEEP_DEBUG === '1') {
|
|
@@ -47,6 +50,31 @@ const DEFAULT_OPTIONS = {
|
|
|
47
50
|
maxDuration: 20 * 60 * 1000, // 20 minutes
|
|
48
51
|
usePlanning: false, // Disable task planning - causes more problems than it solves
|
|
49
52
|
};
|
|
53
|
+
/**
|
|
54
|
+
* Load project rules from .codeep/rules.md or CODEEP.md
|
|
55
|
+
* Returns the rules content formatted for system prompt, or empty string if no rules found
|
|
56
|
+
*/
|
|
57
|
+
export function loadProjectRules(projectRoot) {
|
|
58
|
+
const candidates = [
|
|
59
|
+
join(projectRoot, '.codeep', 'rules.md'),
|
|
60
|
+
join(projectRoot, 'CODEEP.md'),
|
|
61
|
+
];
|
|
62
|
+
for (const filePath of candidates) {
|
|
63
|
+
if (existsSync(filePath)) {
|
|
64
|
+
try {
|
|
65
|
+
const content = readFileSync(filePath, 'utf-8').trim();
|
|
66
|
+
if (content) {
|
|
67
|
+
debug('Loaded project rules from', filePath);
|
|
68
|
+
return `\n\n## Project Rules\nThe following rules are defined by the project owner. You MUST follow these rules:\n\n${content}`;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
debug('Failed to read project rules from', filePath, err);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return '';
|
|
77
|
+
}
|
|
50
78
|
/**
|
|
51
79
|
* Generate system prompt for agent mode (used with native tool calling)
|
|
52
80
|
*/
|
|
@@ -202,6 +230,7 @@ async function agentChat(messages, systemPrompt, onChunk, abortSignal, dynamicTi
|
|
|
202
230
|
try {
|
|
203
231
|
let endpoint;
|
|
204
232
|
let body;
|
|
233
|
+
const useStreaming = Boolean(onChunk);
|
|
205
234
|
if (protocol === 'openai') {
|
|
206
235
|
endpoint = `${baseUrl}/chat/completions`;
|
|
207
236
|
body = {
|
|
@@ -212,8 +241,9 @@ async function agentChat(messages, systemPrompt, onChunk, abortSignal, dynamicTi
|
|
|
212
241
|
],
|
|
213
242
|
tools: getOpenAITools(),
|
|
214
243
|
tool_choice: 'auto',
|
|
244
|
+
stream: useStreaming,
|
|
215
245
|
temperature: config.get('temperature'),
|
|
216
|
-
max_tokens: Math.max(config.get('maxTokens'), 16384),
|
|
246
|
+
max_tokens: Math.max(config.get('maxTokens'), 16384),
|
|
217
247
|
};
|
|
218
248
|
}
|
|
219
249
|
else {
|
|
@@ -223,8 +253,9 @@ async function agentChat(messages, systemPrompt, onChunk, abortSignal, dynamicTi
|
|
|
223
253
|
system: systemPrompt,
|
|
224
254
|
messages: messages,
|
|
225
255
|
tools: getAnthropicTools(),
|
|
256
|
+
stream: useStreaming,
|
|
226
257
|
temperature: config.get('temperature'),
|
|
227
|
-
max_tokens: Math.max(config.get('maxTokens'), 16384),
|
|
258
|
+
max_tokens: Math.max(config.get('maxTokens'), 16384),
|
|
228
259
|
};
|
|
229
260
|
}
|
|
230
261
|
const response = await fetch(endpoint, {
|
|
@@ -241,7 +272,22 @@ async function agentChat(messages, systemPrompt, onChunk, abortSignal, dynamicTi
|
|
|
241
272
|
}
|
|
242
273
|
throw new Error(`API error: ${response.status} - ${errorText}`);
|
|
243
274
|
}
|
|
275
|
+
// Streaming path — parse tool calls from SSE deltas
|
|
276
|
+
if (useStreaming && response.body) {
|
|
277
|
+
if (protocol === 'openai') {
|
|
278
|
+
return await handleOpenAIAgentStream(response.body, onChunk, model, providerId);
|
|
279
|
+
}
|
|
280
|
+
else {
|
|
281
|
+
return await handleAnthropicAgentStream(response.body, onChunk, model, providerId);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
// Non-streaming path (fallback if no body)
|
|
244
285
|
const data = await response.json();
|
|
286
|
+
// Track token usage
|
|
287
|
+
const usageExtractor = protocol === 'openai' ? extractOpenAIUsage : extractAnthropicUsage;
|
|
288
|
+
const usage = usageExtractor(data);
|
|
289
|
+
if (usage)
|
|
290
|
+
recordTokenUsage(usage, model, providerId);
|
|
245
291
|
debug('Raw API response:', JSON.stringify(data, null, 2).substring(0, 1500));
|
|
246
292
|
if (protocol === 'openai') {
|
|
247
293
|
const message = data.choices?.[0]?.message;
|
|
@@ -388,6 +434,11 @@ async function agentChatFallback(messages, systemPrompt, onChunk, abortSignal, d
|
|
|
388
434
|
}
|
|
389
435
|
else {
|
|
390
436
|
const data = await response.json();
|
|
437
|
+
// Track token usage
|
|
438
|
+
const fallbackUsageExtractor = protocol === 'openai' ? extractOpenAIUsage : extractAnthropicUsage;
|
|
439
|
+
const fallbackUsage = fallbackUsageExtractor(data);
|
|
440
|
+
if (fallbackUsage)
|
|
441
|
+
recordTokenUsage(fallbackUsage, model, providerId);
|
|
391
442
|
if (protocol === 'openai') {
|
|
392
443
|
content = data.choices?.[0]?.message?.content || '';
|
|
393
444
|
}
|
|
@@ -416,7 +467,186 @@ async function agentChatFallback(messages, systemPrompt, onChunk, abortSignal, d
|
|
|
416
467
|
}
|
|
417
468
|
}
|
|
418
469
|
/**
|
|
419
|
-
* Handle streaming response
|
|
470
|
+
* Handle OpenAI streaming response with tool call accumulation
|
|
471
|
+
*/
|
|
472
|
+
async function handleOpenAIAgentStream(body, onChunk, model, providerId) {
|
|
473
|
+
const reader = body.getReader();
|
|
474
|
+
const decoder = new TextDecoder();
|
|
475
|
+
let buffer = '';
|
|
476
|
+
let content = '';
|
|
477
|
+
// Accumulate tool calls from deltas
|
|
478
|
+
const toolCallMap = new Map();
|
|
479
|
+
let usageData = null;
|
|
480
|
+
while (true) {
|
|
481
|
+
const { done, value } = await reader.read();
|
|
482
|
+
if (done)
|
|
483
|
+
break;
|
|
484
|
+
buffer += decoder.decode(value, { stream: true });
|
|
485
|
+
const lines = buffer.split('\n');
|
|
486
|
+
buffer = lines.pop() || '';
|
|
487
|
+
for (const line of lines) {
|
|
488
|
+
if (!line.startsWith('data: '))
|
|
489
|
+
continue;
|
|
490
|
+
const data = line.slice(6);
|
|
491
|
+
if (data === '[DONE]')
|
|
492
|
+
continue;
|
|
493
|
+
try {
|
|
494
|
+
const parsed = JSON.parse(data);
|
|
495
|
+
// Track usage from final chunk
|
|
496
|
+
if (parsed.usage) {
|
|
497
|
+
usageData = parsed;
|
|
498
|
+
}
|
|
499
|
+
const delta = parsed.choices?.[0]?.delta;
|
|
500
|
+
if (!delta)
|
|
501
|
+
continue;
|
|
502
|
+
// Accumulate text content
|
|
503
|
+
if (delta.content) {
|
|
504
|
+
content += delta.content;
|
|
505
|
+
onChunk(delta.content);
|
|
506
|
+
}
|
|
507
|
+
// Accumulate tool calls
|
|
508
|
+
if (delta.tool_calls) {
|
|
509
|
+
for (const tc of delta.tool_calls) {
|
|
510
|
+
const idx = tc.index ?? 0;
|
|
511
|
+
if (!toolCallMap.has(idx)) {
|
|
512
|
+
toolCallMap.set(idx, {
|
|
513
|
+
id: tc.id || '',
|
|
514
|
+
name: tc.function?.name || '',
|
|
515
|
+
arguments: '',
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
const entry = toolCallMap.get(idx);
|
|
519
|
+
if (tc.id)
|
|
520
|
+
entry.id = tc.id;
|
|
521
|
+
if (tc.function?.name)
|
|
522
|
+
entry.name = tc.function.name;
|
|
523
|
+
if (tc.function?.arguments)
|
|
524
|
+
entry.arguments += tc.function.arguments;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
catch {
|
|
529
|
+
// Ignore parse errors
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
// Track token usage if available
|
|
534
|
+
if (usageData) {
|
|
535
|
+
const usage = extractOpenAIUsage(usageData);
|
|
536
|
+
if (usage)
|
|
537
|
+
recordTokenUsage(usage, model, providerId);
|
|
538
|
+
}
|
|
539
|
+
// Convert accumulated tool calls
|
|
540
|
+
const rawToolCalls = Array.from(toolCallMap.values()).map(tc => ({
|
|
541
|
+
id: tc.id,
|
|
542
|
+
type: 'function',
|
|
543
|
+
function: { name: tc.name, arguments: tc.arguments },
|
|
544
|
+
}));
|
|
545
|
+
const toolCalls = parseOpenAIToolCalls(rawToolCalls);
|
|
546
|
+
debug('Stream parsed tool calls:', toolCalls.length, toolCalls.map(t => t.tool));
|
|
547
|
+
// If no native tool calls, try text-based parsing
|
|
548
|
+
if (toolCalls.length === 0 && content) {
|
|
549
|
+
const textToolCalls = parseToolCalls(content);
|
|
550
|
+
if (textToolCalls.length > 0) {
|
|
551
|
+
return { content, toolCalls: textToolCalls, usedNativeTools: false };
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
return { content, toolCalls, usedNativeTools: true };
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* Handle Anthropic streaming response with tool call accumulation
|
|
558
|
+
*/
|
|
559
|
+
async function handleAnthropicAgentStream(body, onChunk, model, providerId) {
|
|
560
|
+
const reader = body.getReader();
|
|
561
|
+
const decoder = new TextDecoder();
|
|
562
|
+
let buffer = '';
|
|
563
|
+
let content = '';
|
|
564
|
+
// Accumulate content blocks for tool use
|
|
565
|
+
const contentBlocks = [];
|
|
566
|
+
let currentBlockIndex = -1;
|
|
567
|
+
let currentBlockType = '';
|
|
568
|
+
let currentToolName = '';
|
|
569
|
+
let currentToolId = '';
|
|
570
|
+
let currentToolInput = '';
|
|
571
|
+
let usageData = null;
|
|
572
|
+
while (true) {
|
|
573
|
+
const { done, value } = await reader.read();
|
|
574
|
+
if (done)
|
|
575
|
+
break;
|
|
576
|
+
buffer += decoder.decode(value, { stream: true });
|
|
577
|
+
const lines = buffer.split('\n');
|
|
578
|
+
buffer = lines.pop() || '';
|
|
579
|
+
for (const line of lines) {
|
|
580
|
+
if (!line.startsWith('data: '))
|
|
581
|
+
continue;
|
|
582
|
+
const data = line.slice(6);
|
|
583
|
+
try {
|
|
584
|
+
const parsed = JSON.parse(data);
|
|
585
|
+
// Track usage
|
|
586
|
+
if (parsed.usage) {
|
|
587
|
+
usageData = parsed;
|
|
588
|
+
}
|
|
589
|
+
if (parsed.type === 'message_delta' && parsed.usage) {
|
|
590
|
+
usageData = parsed;
|
|
591
|
+
}
|
|
592
|
+
if (parsed.type === 'content_block_start') {
|
|
593
|
+
currentBlockIndex = parsed.index;
|
|
594
|
+
const block = parsed.content_block;
|
|
595
|
+
if (block.type === 'text') {
|
|
596
|
+
currentBlockType = 'text';
|
|
597
|
+
}
|
|
598
|
+
else if (block.type === 'tool_use') {
|
|
599
|
+
currentBlockType = 'tool_use';
|
|
600
|
+
currentToolName = block.name || '';
|
|
601
|
+
currentToolId = block.id || '';
|
|
602
|
+
currentToolInput = '';
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
else if (parsed.type === 'content_block_delta') {
|
|
606
|
+
if (currentBlockType === 'text' && parsed.delta?.text) {
|
|
607
|
+
content += parsed.delta.text;
|
|
608
|
+
onChunk(parsed.delta.text);
|
|
609
|
+
}
|
|
610
|
+
else if (currentBlockType === 'tool_use' && parsed.delta?.partial_json) {
|
|
611
|
+
currentToolInput += parsed.delta.partial_json;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
else if (parsed.type === 'content_block_stop') {
|
|
615
|
+
if (currentBlockType === 'tool_use') {
|
|
616
|
+
contentBlocks.push({
|
|
617
|
+
type: 'tool_use',
|
|
618
|
+
id: currentToolId,
|
|
619
|
+
name: currentToolName,
|
|
620
|
+
input: tryParseJSON(currentToolInput),
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
currentBlockType = '';
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
catch {
|
|
627
|
+
// Ignore parse errors
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
// Track token usage
|
|
632
|
+
if (usageData) {
|
|
633
|
+
const usage = extractAnthropicUsage(usageData);
|
|
634
|
+
if (usage)
|
|
635
|
+
recordTokenUsage(usage, model, providerId);
|
|
636
|
+
}
|
|
637
|
+
const toolCalls = parseAnthropicToolCalls(contentBlocks);
|
|
638
|
+
return { content, toolCalls, usedNativeTools: true };
|
|
639
|
+
}
|
|
640
|
+
function tryParseJSON(str) {
|
|
641
|
+
try {
|
|
642
|
+
return JSON.parse(str);
|
|
643
|
+
}
|
|
644
|
+
catch {
|
|
645
|
+
return {};
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* Handle streaming response (text-based fallback)
|
|
420
650
|
*/
|
|
421
651
|
async function handleStream(body, protocol, onChunk) {
|
|
422
652
|
const reader = body.getReader();
|
|
@@ -515,6 +745,11 @@ export async function runAgent(prompt, projectContext, options = {}) {
|
|
|
515
745
|
let systemPrompt = useNativeTools
|
|
516
746
|
? getAgentSystemPrompt(projectContext)
|
|
517
747
|
: getFallbackSystemPrompt(projectContext);
|
|
748
|
+
// Inject project rules (from .codeep/rules.md or CODEEP.md)
|
|
749
|
+
const projectRules = loadProjectRules(projectContext.root);
|
|
750
|
+
if (projectRules) {
|
|
751
|
+
systemPrompt += projectRules;
|
|
752
|
+
}
|
|
518
753
|
if (smartContextStr) {
|
|
519
754
|
systemPrompt += '\n\n' + smartContextStr;
|
|
520
755
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
// Mock 'fs' before importing the module under test - use importOriginal to
|
|
3
|
+
// preserve all fs exports that transitive dependencies need (e.g. mkdirSync).
|
|
4
|
+
vi.mock('fs', async (importOriginal) => {
|
|
5
|
+
const actual = await importOriginal();
|
|
6
|
+
return {
|
|
7
|
+
...actual,
|
|
8
|
+
existsSync: vi.fn(),
|
|
9
|
+
readFileSync: vi.fn(),
|
|
10
|
+
};
|
|
11
|
+
});
|
|
12
|
+
import { existsSync, readFileSync } from 'fs';
|
|
13
|
+
import { join } from 'path';
|
|
14
|
+
import { loadProjectRules, formatAgentResult } from './agent.js';
|
|
15
|
+
// Cast mocked functions for convenience
|
|
16
|
+
const mockExistsSync = existsSync;
|
|
17
|
+
const mockReadFileSync = readFileSync;
|
|
18
|
+
describe('loadProjectRules', () => {
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
vi.clearAllMocks();
|
|
21
|
+
});
|
|
22
|
+
it('should return formatted rules when .codeep/rules.md exists', () => {
|
|
23
|
+
const projectRoot = '/my/project';
|
|
24
|
+
const rulesContent = 'Always use TypeScript.\nNo console.log in production.';
|
|
25
|
+
mockExistsSync.mockImplementation((filePath) => {
|
|
26
|
+
return filePath === join(projectRoot, '.codeep', 'rules.md');
|
|
27
|
+
});
|
|
28
|
+
mockReadFileSync.mockReturnValue(rulesContent);
|
|
29
|
+
const result = loadProjectRules(projectRoot);
|
|
30
|
+
expect(result).toContain('## Project Rules');
|
|
31
|
+
expect(result).toContain('Always use TypeScript.');
|
|
32
|
+
expect(result).toContain('No console.log in production.');
|
|
33
|
+
expect(mockExistsSync).toHaveBeenCalledWith(join(projectRoot, '.codeep', 'rules.md'));
|
|
34
|
+
expect(mockReadFileSync).toHaveBeenCalledWith(join(projectRoot, '.codeep', 'rules.md'), 'utf-8');
|
|
35
|
+
});
|
|
36
|
+
it('should fall back to CODEEP.md when .codeep/rules.md does not exist', () => {
|
|
37
|
+
const projectRoot = '/my/project';
|
|
38
|
+
const rulesContent = 'Follow the style guide.';
|
|
39
|
+
mockExistsSync.mockImplementation((filePath) => {
|
|
40
|
+
return filePath === join(projectRoot, 'CODEEP.md');
|
|
41
|
+
});
|
|
42
|
+
mockReadFileSync.mockReturnValue(rulesContent);
|
|
43
|
+
const result = loadProjectRules(projectRoot);
|
|
44
|
+
expect(result).toContain('## Project Rules');
|
|
45
|
+
expect(result).toContain('Follow the style guide.');
|
|
46
|
+
// Should have checked .codeep/rules.md first
|
|
47
|
+
expect(mockExistsSync).toHaveBeenCalledWith(join(projectRoot, '.codeep', 'rules.md'));
|
|
48
|
+
// Then checked CODEEP.md
|
|
49
|
+
expect(mockExistsSync).toHaveBeenCalledWith(join(projectRoot, 'CODEEP.md'));
|
|
50
|
+
expect(mockReadFileSync).toHaveBeenCalledWith(join(projectRoot, 'CODEEP.md'), 'utf-8');
|
|
51
|
+
});
|
|
52
|
+
it('should return empty string when neither rules file exists', () => {
|
|
53
|
+
const projectRoot = '/my/project';
|
|
54
|
+
mockExistsSync.mockReturnValue(false);
|
|
55
|
+
const result = loadProjectRules(projectRoot);
|
|
56
|
+
expect(result).toBe('');
|
|
57
|
+
expect(mockExistsSync).toHaveBeenCalledTimes(2);
|
|
58
|
+
expect(mockReadFileSync).not.toHaveBeenCalled();
|
|
59
|
+
});
|
|
60
|
+
it('should return empty string when rules file exists but is empty', () => {
|
|
61
|
+
const projectRoot = '/my/project';
|
|
62
|
+
mockExistsSync.mockImplementation((filePath) => {
|
|
63
|
+
return filePath === join(projectRoot, '.codeep', 'rules.md');
|
|
64
|
+
});
|
|
65
|
+
mockReadFileSync.mockReturnValue(' \n \n ');
|
|
66
|
+
const result = loadProjectRules(projectRoot);
|
|
67
|
+
// Empty after trim, so should skip and check next candidate
|
|
68
|
+
expect(mockExistsSync).toHaveBeenCalledWith(join(projectRoot, '.codeep', 'rules.md'));
|
|
69
|
+
// Since the first file was empty (whitespace-only), it checks the second
|
|
70
|
+
expect(mockExistsSync).toHaveBeenCalledWith(join(projectRoot, 'CODEEP.md'));
|
|
71
|
+
});
|
|
72
|
+
it('should return empty string when both files exist but are empty', () => {
|
|
73
|
+
const projectRoot = '/my/project';
|
|
74
|
+
mockExistsSync.mockReturnValue(true);
|
|
75
|
+
mockReadFileSync.mockReturnValue(' ');
|
|
76
|
+
const result = loadProjectRules(projectRoot);
|
|
77
|
+
expect(result).toBe('');
|
|
78
|
+
});
|
|
79
|
+
it('should return empty string when readFileSync throws an error', () => {
|
|
80
|
+
const projectRoot = '/my/project';
|
|
81
|
+
mockExistsSync.mockReturnValue(true);
|
|
82
|
+
mockReadFileSync.mockImplementation(() => {
|
|
83
|
+
throw new Error('EACCES: permission denied');
|
|
84
|
+
});
|
|
85
|
+
const result = loadProjectRules(projectRoot);
|
|
86
|
+
// Both candidates exist but both throw on read, so should return ''
|
|
87
|
+
expect(result).toBe('');
|
|
88
|
+
// Should have attempted to read both files
|
|
89
|
+
expect(mockReadFileSync).toHaveBeenCalledTimes(2);
|
|
90
|
+
});
|
|
91
|
+
it('should fall back to CODEEP.md when .codeep/rules.md read throws', () => {
|
|
92
|
+
const projectRoot = '/my/project';
|
|
93
|
+
mockExistsSync.mockReturnValue(true);
|
|
94
|
+
mockReadFileSync.mockImplementation((filePath) => {
|
|
95
|
+
if (filePath === join(projectRoot, '.codeep', 'rules.md')) {
|
|
96
|
+
throw new Error('EACCES: permission denied');
|
|
97
|
+
}
|
|
98
|
+
return 'Fallback rules content';
|
|
99
|
+
});
|
|
100
|
+
const result = loadProjectRules(projectRoot);
|
|
101
|
+
expect(result).toContain('## Project Rules');
|
|
102
|
+
expect(result).toContain('Fallback rules content');
|
|
103
|
+
expect(mockReadFileSync).toHaveBeenCalledTimes(2);
|
|
104
|
+
});
|
|
105
|
+
it('should prefer .codeep/rules.md over CODEEP.md when both exist', () => {
|
|
106
|
+
const projectRoot = '/my/project';
|
|
107
|
+
mockExistsSync.mockReturnValue(true);
|
|
108
|
+
mockReadFileSync.mockImplementation((filePath) => {
|
|
109
|
+
if (filePath === join(projectRoot, '.codeep', 'rules.md')) {
|
|
110
|
+
return 'Primary rules';
|
|
111
|
+
}
|
|
112
|
+
return 'Secondary rules';
|
|
113
|
+
});
|
|
114
|
+
const result = loadProjectRules(projectRoot);
|
|
115
|
+
expect(result).toContain('Primary rules');
|
|
116
|
+
expect(result).not.toContain('Secondary rules');
|
|
117
|
+
// Should only read the first file since it had content
|
|
118
|
+
expect(mockReadFileSync).toHaveBeenCalledTimes(1);
|
|
119
|
+
});
|
|
120
|
+
it('should include the MUST follow preamble in the returned string', () => {
|
|
121
|
+
const projectRoot = '/my/project';
|
|
122
|
+
mockExistsSync.mockImplementation((filePath) => {
|
|
123
|
+
return filePath === join(projectRoot, '.codeep', 'rules.md');
|
|
124
|
+
});
|
|
125
|
+
mockReadFileSync.mockReturnValue('Some rules');
|
|
126
|
+
const result = loadProjectRules(projectRoot);
|
|
127
|
+
expect(result).toContain('You MUST follow these rules');
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
describe('formatAgentResult', () => {
|
|
131
|
+
it('should format a successful result with iterations', () => {
|
|
132
|
+
const result = {
|
|
133
|
+
success: true,
|
|
134
|
+
iterations: 3,
|
|
135
|
+
actions: [],
|
|
136
|
+
finalResponse: 'All done',
|
|
137
|
+
};
|
|
138
|
+
const formatted = formatAgentResult(result);
|
|
139
|
+
expect(formatted).toContain('Agent completed in 3 iteration(s)');
|
|
140
|
+
});
|
|
141
|
+
it('should format a failed result with error message', () => {
|
|
142
|
+
const result = {
|
|
143
|
+
success: false,
|
|
144
|
+
iterations: 5,
|
|
145
|
+
actions: [],
|
|
146
|
+
finalResponse: '',
|
|
147
|
+
error: 'Exceeded maximum duration',
|
|
148
|
+
};
|
|
149
|
+
const formatted = formatAgentResult(result);
|
|
150
|
+
expect(formatted).toContain('Agent failed: Exceeded maximum duration');
|
|
151
|
+
});
|
|
152
|
+
it('should format an aborted result', () => {
|
|
153
|
+
const result = {
|
|
154
|
+
success: false,
|
|
155
|
+
iterations: 2,
|
|
156
|
+
actions: [],
|
|
157
|
+
finalResponse: 'Agent was stopped by user',
|
|
158
|
+
aborted: true,
|
|
159
|
+
};
|
|
160
|
+
const formatted = formatAgentResult(result);
|
|
161
|
+
expect(formatted).toContain('Agent was stopped by user');
|
|
162
|
+
});
|
|
163
|
+
it('should list actions when present', () => {
|
|
164
|
+
const result = {
|
|
165
|
+
success: true,
|
|
166
|
+
iterations: 2,
|
|
167
|
+
actions: [
|
|
168
|
+
{ type: 'read', target: 'src/index.ts', result: 'success', timestamp: Date.now() },
|
|
169
|
+
{ type: 'write', target: 'src/new-file.ts', result: 'success', timestamp: Date.now() },
|
|
170
|
+
{ type: 'command', target: 'npm install', result: 'error', timestamp: Date.now() },
|
|
171
|
+
],
|
|
172
|
+
finalResponse: 'Done',
|
|
173
|
+
};
|
|
174
|
+
const formatted = formatAgentResult(result);
|
|
175
|
+
expect(formatted).toContain('Actions performed:');
|
|
176
|
+
expect(formatted).toContain('read: src/index.ts');
|
|
177
|
+
expect(formatted).toContain('write: src/new-file.ts');
|
|
178
|
+
expect(formatted).toContain('command: npm install');
|
|
179
|
+
});
|
|
180
|
+
it('should show check mark for successful actions and cross for errors', () => {
|
|
181
|
+
const result = {
|
|
182
|
+
success: true,
|
|
183
|
+
iterations: 1,
|
|
184
|
+
actions: [
|
|
185
|
+
{ type: 'write', target: 'file.ts', result: 'success', timestamp: Date.now() },
|
|
186
|
+
{ type: 'edit', target: 'other.ts', result: 'error', timestamp: Date.now() },
|
|
187
|
+
],
|
|
188
|
+
finalResponse: 'Done',
|
|
189
|
+
};
|
|
190
|
+
const formatted = formatAgentResult(result);
|
|
191
|
+
const lines = formatted.split('\n');
|
|
192
|
+
const successLine = lines.find(l => l.includes('write: file.ts'));
|
|
193
|
+
const errorLine = lines.find(l => l.includes('edit: other.ts'));
|
|
194
|
+
expect(successLine).toMatch(/✓/);
|
|
195
|
+
expect(errorLine).toMatch(/✗/);
|
|
196
|
+
});
|
|
197
|
+
it('should not show actions section when there are no actions', () => {
|
|
198
|
+
const result = {
|
|
199
|
+
success: true,
|
|
200
|
+
iterations: 1,
|
|
201
|
+
actions: [],
|
|
202
|
+
finalResponse: 'Nothing to do',
|
|
203
|
+
};
|
|
204
|
+
const formatted = formatAgentResult(result);
|
|
205
|
+
expect(formatted).not.toContain('Actions performed:');
|
|
206
|
+
});
|
|
207
|
+
it('should format a failed result without aborted flag', () => {
|
|
208
|
+
const result = {
|
|
209
|
+
success: false,
|
|
210
|
+
iterations: 10,
|
|
211
|
+
actions: [
|
|
212
|
+
{ type: 'read', target: 'config.json', result: 'success', timestamp: Date.now() },
|
|
213
|
+
],
|
|
214
|
+
finalResponse: '',
|
|
215
|
+
error: 'Exceeded maximum of 10 iterations',
|
|
216
|
+
};
|
|
217
|
+
const formatted = formatAgentResult(result);
|
|
218
|
+
expect(formatted).toContain('Agent failed: Exceeded maximum of 10 iterations');
|
|
219
|
+
expect(formatted).toContain('Actions performed:');
|
|
220
|
+
expect(formatted).toContain('read: config.json');
|
|
221
|
+
});
|
|
222
|
+
it('should format result with single iteration correctly', () => {
|
|
223
|
+
const result = {
|
|
224
|
+
success: true,
|
|
225
|
+
iterations: 1,
|
|
226
|
+
actions: [],
|
|
227
|
+
finalResponse: 'Quick task',
|
|
228
|
+
};
|
|
229
|
+
const formatted = formatAgentResult(result);
|
|
230
|
+
expect(formatted).toContain('Agent completed in 1 iteration(s)');
|
|
231
|
+
});
|
|
232
|
+
it('should handle all action types', () => {
|
|
233
|
+
const actionTypes = ['read', 'write', 'edit', 'delete', 'command', 'search', 'list', 'mkdir', 'fetch'];
|
|
234
|
+
const result = {
|
|
235
|
+
success: true,
|
|
236
|
+
iterations: 5,
|
|
237
|
+
actions: actionTypes.map(type => ({
|
|
238
|
+
type,
|
|
239
|
+
target: `target-for-${type}`,
|
|
240
|
+
result: 'success',
|
|
241
|
+
timestamp: Date.now(),
|
|
242
|
+
})),
|
|
243
|
+
finalResponse: 'Done',
|
|
244
|
+
};
|
|
245
|
+
const formatted = formatAgentResult(result);
|
|
246
|
+
for (const type of actionTypes) {
|
|
247
|
+
expect(formatted).toContain(`${type}: target-for-${type}`);
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
});
|