@charming_groot/agent 0.1.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.
Files changed (57) hide show
  1. package/dist/agent-loop.d.ts +46 -0
  2. package/dist/agent-loop.d.ts.map +1 -0
  3. package/dist/agent-loop.js +139 -0
  4. package/dist/agent-loop.js.map +1 -0
  5. package/dist/index.d.ts +15 -0
  6. package/dist/index.d.ts.map +1 -0
  7. package/dist/index.js +9 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/message-manager.d.ts +46 -0
  10. package/dist/message-manager.d.ts.map +1 -0
  11. package/dist/message-manager.js +152 -0
  12. package/dist/message-manager.js.map +1 -0
  13. package/dist/permission.d.ts +37 -0
  14. package/dist/permission.d.ts.map +1 -0
  15. package/dist/permission.js +62 -0
  16. package/dist/permission.js.map +1 -0
  17. package/dist/session-manager.d.ts +31 -0
  18. package/dist/session-manager.d.ts.map +1 -0
  19. package/dist/session-manager.js +101 -0
  20. package/dist/session-manager.js.map +1 -0
  21. package/dist/skill-tool.d.ts +38 -0
  22. package/dist/skill-tool.d.ts.map +1 -0
  23. package/dist/skill-tool.js +112 -0
  24. package/dist/skill-tool.js.map +1 -0
  25. package/dist/sub-agent-tool.d.ts +39 -0
  26. package/dist/sub-agent-tool.d.ts.map +1 -0
  27. package/dist/sub-agent-tool.js +80 -0
  28. package/dist/sub-agent-tool.js.map +1 -0
  29. package/dist/token-counter.d.ts +16 -0
  30. package/dist/token-counter.d.ts.map +1 -0
  31. package/dist/token-counter.js +69 -0
  32. package/dist/token-counter.js.map +1 -0
  33. package/dist/tool-dispatcher.d.ts +15 -0
  34. package/dist/tool-dispatcher.d.ts.map +1 -0
  35. package/dist/tool-dispatcher.js +94 -0
  36. package/dist/tool-dispatcher.js.map +1 -0
  37. package/package.json +34 -0
  38. package/src/agent-loop.ts +210 -0
  39. package/src/index.ts +19 -0
  40. package/src/message-manager.ts +184 -0
  41. package/src/permission.ts +104 -0
  42. package/src/session-manager.ts +121 -0
  43. package/src/skill-tool.ts +155 -0
  44. package/src/sub-agent-tool.ts +122 -0
  45. package/src/token-counter.ts +79 -0
  46. package/src/tool-dispatcher.ts +124 -0
  47. package/tests/agent-loop.test.ts +372 -0
  48. package/tests/message-manager-new.test.ts +204 -0
  49. package/tests/message-manager.test.ts +195 -0
  50. package/tests/permission.test.ts +148 -0
  51. package/tests/session-manager.test.ts +106 -0
  52. package/tests/skill-tool.test.ts +119 -0
  53. package/tests/sub-agent-tool.test.ts +198 -0
  54. package/tests/token-counter.test.ts +77 -0
  55. package/tests/tool-dispatcher.test.ts +181 -0
  56. package/tsconfig.json +9 -0
  57. package/vitest.config.ts +17 -0
@@ -0,0 +1,195 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { MessageManager } from '../src/message-manager.js';
3
+
4
+ describe('MessageManager', () => {
5
+ let manager: MessageManager;
6
+
7
+ beforeEach(() => {
8
+ manager = new MessageManager();
9
+ });
10
+
11
+ it('should start empty', () => {
12
+ expect(manager.messageCount).toBe(0);
13
+ expect(manager.getMessages()).toEqual([]);
14
+ });
15
+
16
+ it('should add system message', () => {
17
+ manager.addSystemMessage('You are helpful.');
18
+ expect(manager.messageCount).toBe(1);
19
+ const msgs = manager.getMessages();
20
+ expect(msgs[0]?.role).toBe('system');
21
+ expect(msgs[0]?.content).toBe('You are helpful.');
22
+ });
23
+
24
+ it('should add user message', () => {
25
+ manager.addUserMessage('Hello');
26
+ const msgs = manager.getMessages();
27
+ expect(msgs[0]?.role).toBe('user');
28
+ expect(msgs[0]?.content).toBe('Hello');
29
+ });
30
+
31
+ it('should add assistant message', () => {
32
+ manager.addAssistantMessage('Hi there!');
33
+ const msgs = manager.getMessages();
34
+ expect(msgs[0]?.role).toBe('assistant');
35
+ expect(msgs[0]?.content).toBe('Hi there!');
36
+ });
37
+
38
+ it('should add assistant message with tool calls', () => {
39
+ const toolCalls = [
40
+ { id: 'tc-1', name: 'file_read', arguments: '{"path":"test.txt"}' },
41
+ ];
42
+ manager.addAssistantMessage('Let me read that.', toolCalls);
43
+ const msgs = manager.getMessages();
44
+ expect(msgs[0]?.toolCalls).toHaveLength(1);
45
+ expect(msgs[0]?.toolCalls?.[0]?.name).toBe('file_read');
46
+ });
47
+
48
+ it('should add tool results', () => {
49
+ const results = new Map([
50
+ ['tc-1', { success: true, output: 'file content' }],
51
+ ]);
52
+ manager.addToolResults(results);
53
+ const msgs = manager.getMessages();
54
+ expect(msgs[0]?.toolResults).toHaveLength(1);
55
+ expect(msgs[0]?.toolResults?.[0]?.toolCallId).toBe('tc-1');
56
+ expect(msgs[0]?.toolResults?.[0]?.content).toBe('file content');
57
+ });
58
+
59
+ it('should format error tool results', () => {
60
+ const results = new Map([
61
+ ['tc-1', { success: false, output: '', error: 'File not found' }],
62
+ ]);
63
+ manager.addToolResults(results);
64
+ const msgs = manager.getMessages();
65
+ expect(msgs[0]?.toolResults?.[0]?.content).toContain('Error: File not found');
66
+ });
67
+
68
+ it('should return last message', () => {
69
+ manager.addUserMessage('first');
70
+ manager.addAssistantMessage('second');
71
+ expect(manager.getLastMessage()?.content).toBe('second');
72
+ });
73
+
74
+ it('should return undefined for empty last message', () => {
75
+ expect(manager.getLastMessage()).toBeUndefined();
76
+ });
77
+
78
+ it('should clear all messages', () => {
79
+ manager.addUserMessage('hello');
80
+ manager.addAssistantMessage('hi');
81
+ manager.clear();
82
+ expect(manager.messageCount).toBe(0);
83
+ });
84
+
85
+ it('should not compress when under budget', () => {
86
+ manager.addSystemMessage('system');
87
+ manager.addUserMessage('hello');
88
+ manager.addAssistantMessage('hi');
89
+ const compressed = manager.compressIfNeeded();
90
+ expect(compressed).toBe(0);
91
+ expect(manager.messageCount).toBe(3);
92
+ });
93
+
94
+ it('should compress when over budget', () => {
95
+ // Use a manager with very low token limit to force compression
96
+ const small = new MessageManager({ maxHistoryTokens: 50, keepRecentMessages: 2 });
97
+ small.addSystemMessage('system');
98
+ // Add many messages to exceed 50 tokens
99
+ for (let i = 0; i < 20; i++) {
100
+ small.addUserMessage(`User message number ${i} with some extra content to add tokens`);
101
+ small.addAssistantMessage(`Response number ${i} with detailed explanation text`);
102
+ }
103
+ const before = small.messageCount;
104
+ const compressed = small.compressIfNeeded();
105
+ expect(compressed).toBeGreaterThan(0);
106
+ expect(small.messageCount).toBeLessThan(before);
107
+ // System message preserved
108
+ const msgs = small.getMessages();
109
+ expect(msgs[0]?.role).toBe('system');
110
+ // Summary message present
111
+ expect(msgs[1]?.content).toContain('Context summary');
112
+ });
113
+
114
+ it('should preserve system messages during compression', () => {
115
+ const small = new MessageManager({ maxHistoryTokens: 30, keepRecentMessages: 2 });
116
+ small.addSystemMessage('You are helpful.');
117
+ for (let i = 0; i < 15; i++) {
118
+ small.addUserMessage(`msg ${i} ${'x'.repeat(50)}`);
119
+ small.addAssistantMessage(`reply ${i} ${'y'.repeat(50)}`);
120
+ }
121
+ small.compressIfNeeded();
122
+ const msgs = small.getMessages();
123
+ expect(msgs[0]?.role).toBe('system');
124
+ expect(msgs[0]?.content).toBe('You are helpful.');
125
+ });
126
+
127
+ it('should replace existing system message with setSystemMessage', () => {
128
+ manager.addSystemMessage('Original prompt');
129
+ manager.addUserMessage('hello');
130
+ manager.setSystemMessage('Updated prompt');
131
+ const msgs = manager.getMessages();
132
+ expect(msgs[0]?.role).toBe('system');
133
+ expect(msgs[0]?.content).toBe('Updated prompt');
134
+ expect(manager.messageCount).toBe(2); // no extra message added
135
+ });
136
+
137
+ it('should insert system message at position 0 if none exists', () => {
138
+ manager.addUserMessage('hello');
139
+ manager.setSystemMessage('Injected prompt');
140
+ const msgs = manager.getMessages();
141
+ expect(msgs[0]?.role).toBe('system');
142
+ expect(msgs[0]?.content).toBe('Injected prompt');
143
+ expect(msgs[1]?.role).toBe('user');
144
+ expect(manager.messageCount).toBe(2);
145
+ });
146
+
147
+ it('should serialize and restore messages', () => {
148
+ manager.addSystemMessage('You are helpful.');
149
+ manager.addUserMessage('Hello');
150
+ manager.addAssistantMessage('Hi!', [
151
+ { id: 'tc-1', name: 'file_read', arguments: '{"path":"a.txt"}' },
152
+ ]);
153
+
154
+ const json = manager.serialize();
155
+ const restored = new MessageManager();
156
+ restored.restore(json);
157
+
158
+ expect(restored.messageCount).toBe(3);
159
+ const msgs = restored.getMessages();
160
+ expect(msgs[0]?.role).toBe('system');
161
+ expect(msgs[1]?.content).toBe('Hello');
162
+ expect(msgs[2]?.toolCalls?.[0]?.name).toBe('file_read');
163
+ });
164
+
165
+ it('should clear existing messages on restore', () => {
166
+ manager.addUserMessage('old');
167
+ manager.restore(JSON.stringify([{ role: 'user', content: 'new' }]));
168
+ expect(manager.messageCount).toBe(1);
169
+ expect(manager.getMessages()[0]?.content).toBe('new');
170
+ });
171
+
172
+ it('should throw on invalid serialized data', () => {
173
+ expect(() => manager.restore('"not an array"')).toThrow('expected an array');
174
+ });
175
+
176
+ it('should skip malformed entries during restore', () => {
177
+ const json = JSON.stringify([
178
+ { role: 'user', content: 'valid' },
179
+ { bad: 'entry' },
180
+ { role: 123, content: 'invalid role' },
181
+ ]);
182
+ manager.restore(json);
183
+ expect(manager.messageCount).toBe(1);
184
+ expect(manager.getMessages()[0]?.content).toBe('valid');
185
+ });
186
+
187
+ it('should return a copy of messages', () => {
188
+ manager.addUserMessage('hello');
189
+ const msgs = manager.getMessages();
190
+ expect(msgs).toHaveLength(1);
191
+ // Adding more shouldn't affect returned array
192
+ manager.addUserMessage('world');
193
+ expect(msgs).toHaveLength(1);
194
+ });
195
+ });
@@ -0,0 +1,148 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { PermissionManager } from '../src/permission.js';
3
+ import type { ApprovalLevel } from '../src/permission.js';
4
+ import type { ITool } from '@charming_groot/core';
5
+
6
+ function createMockTool(name: string, requiresPermission: boolean): ITool {
7
+ return {
8
+ name,
9
+ requiresPermission,
10
+ describe: () => ({ name, description: '', parameters: [] }),
11
+ execute: async () => ({ success: true, output: '' }),
12
+ };
13
+ }
14
+
15
+ describe('PermissionManager', () => {
16
+ it('should auto-approve tools that do not require permission', async () => {
17
+ const manager = new PermissionManager();
18
+ const tool = createMockTool('file_read', false);
19
+ expect(await manager.checkPermission(tool)).toBe(true);
20
+ });
21
+
22
+ it('should auto-approve with default handler', async () => {
23
+ const manager = new PermissionManager();
24
+ const tool = createMockTool('shell_exec', true);
25
+ expect(await manager.checkPermission(tool)).toBe(true);
26
+ });
27
+
28
+ it('should pass params to handler', async () => {
29
+ const handler = vi.fn().mockResolvedValue('session');
30
+ const manager = new PermissionManager(handler);
31
+ const tool = createMockTool('shell_exec', true);
32
+ const params = { command: 'ls -la' };
33
+
34
+ await manager.checkPermission(tool, params);
35
+ expect(handler).toHaveBeenCalledWith('shell_exec', params);
36
+ });
37
+
38
+ it('should deny when handler returns false', async () => {
39
+ const handler = vi.fn().mockResolvedValue(false);
40
+ const manager = new PermissionManager(handler);
41
+ const tool = createMockTool('shell_exec', true);
42
+ expect(await manager.checkPermission(tool, { command: 'rm -rf /' })).toBe(false);
43
+ });
44
+
45
+ it('should deny when handler returns "deny"', async () => {
46
+ const handler = vi.fn().mockResolvedValue('deny' as ApprovalLevel);
47
+ const manager = new PermissionManager(handler);
48
+ const tool = createMockTool('shell_exec', true);
49
+ expect(await manager.checkPermission(tool)).toBe(false);
50
+ });
51
+
52
+ // --- Approval levels ---
53
+
54
+ it('should cache on "session" level', async () => {
55
+ const handler = vi.fn().mockResolvedValue('session' as ApprovalLevel);
56
+ const manager = new PermissionManager(handler);
57
+ const tool = createMockTool('shell_exec', true);
58
+
59
+ await manager.checkPermission(tool, { command: 'ls' });
60
+ await manager.checkPermission(tool, { command: 'pwd' });
61
+
62
+ // Handler called only once — second call uses cache
63
+ expect(handler).toHaveBeenCalledTimes(1);
64
+ });
65
+
66
+ it('should NOT cache on "once" level', async () => {
67
+ const handler = vi.fn().mockResolvedValue('once' as ApprovalLevel);
68
+ const manager = new PermissionManager(handler);
69
+ const tool = createMockTool('shell_exec', true);
70
+
71
+ await manager.checkPermission(tool, { command: 'ls' });
72
+ await manager.checkPermission(tool, { command: 'pwd' });
73
+
74
+ // Handler called every time
75
+ expect(handler).toHaveBeenCalledTimes(2);
76
+ });
77
+
78
+ it('should cache on "always" level and invoke persist callback', async () => {
79
+ const handler = vi.fn().mockResolvedValue('always' as ApprovalLevel);
80
+ const onPersist = vi.fn();
81
+ const manager = new PermissionManager(handler, onPersist);
82
+ const tool = createMockTool('shell_exec', true);
83
+
84
+ await manager.checkPermission(tool, { command: 'ls' });
85
+ expect(onPersist).toHaveBeenCalledWith('shell_exec');
86
+
87
+ await manager.checkPermission(tool, { command: 'pwd' });
88
+ expect(handler).toHaveBeenCalledTimes(1); // cached
89
+ });
90
+
91
+ it('should treat boolean true as "session"', async () => {
92
+ const handler = vi.fn().mockResolvedValue(true);
93
+ const manager = new PermissionManager(handler);
94
+ const tool = createMockTool('shell_exec', true);
95
+
96
+ await manager.checkPermission(tool);
97
+ await manager.checkPermission(tool);
98
+
99
+ expect(handler).toHaveBeenCalledTimes(1); // cached like session
100
+ });
101
+
102
+ // --- allowTool / revokeTool ---
103
+
104
+ it('should skip handler for pre-allowed tools', async () => {
105
+ const handler = vi.fn().mockResolvedValue('deny');
106
+ const manager = new PermissionManager(handler);
107
+ manager.allowTool('shell_exec');
108
+ const tool = createMockTool('shell_exec', true);
109
+ expect(await manager.checkPermission(tool)).toBe(true);
110
+ expect(handler).not.toHaveBeenCalled();
111
+ });
112
+
113
+ it('should support allowTool with "always" level', () => {
114
+ const manager = new PermissionManager();
115
+ manager.allowTool('shell_exec', 'always');
116
+ expect(manager.isAllowed('shell_exec')).toBe(true);
117
+ });
118
+
119
+ it('should revoke tool permission from both levels', () => {
120
+ const manager = new PermissionManager();
121
+ manager.allowTool('a', 'session');
122
+ manager.allowTool('b', 'always');
123
+ expect(manager.isAllowed('a')).toBe(true);
124
+ expect(manager.isAllowed('b')).toBe(true);
125
+ manager.revokeTool('a');
126
+ manager.revokeTool('b');
127
+ expect(manager.isAllowed('a')).toBe(false);
128
+ expect(manager.isAllowed('b')).toBe(false);
129
+ });
130
+
131
+ it('should clear session only', () => {
132
+ const manager = new PermissionManager();
133
+ manager.allowTool('a', 'session');
134
+ manager.allowTool('b', 'always');
135
+ manager.clearSession();
136
+ expect(manager.isAllowed('a')).toBe(false);
137
+ expect(manager.isAllowed('b')).toBe(true); // always survives
138
+ });
139
+
140
+ it('should clear all', () => {
141
+ const manager = new PermissionManager();
142
+ manager.allowTool('a', 'session');
143
+ manager.allowTool('b', 'always');
144
+ manager.clearAll();
145
+ expect(manager.isAllowed('a')).toBe(false);
146
+ expect(manager.isAllowed('b')).toBe(false);
147
+ });
148
+ });
@@ -0,0 +1,106 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { mkdtemp, rm } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+ import { SessionManager } from '../src/session-manager.js';
6
+ import { MessageManager } from '../src/message-manager.js';
7
+
8
+ describe('SessionManager', () => {
9
+ let sessionsDir: string;
10
+ let sm: SessionManager;
11
+
12
+ beforeEach(async () => {
13
+ sessionsDir = await mkdtemp(join(tmpdir(), 'session-test-'));
14
+ sm = new SessionManager(sessionsDir);
15
+ });
16
+
17
+ afterEach(async () => {
18
+ await rm(sessionsDir, { recursive: true, force: true });
19
+ });
20
+
21
+ it('should save and load a session', async () => {
22
+ const manager = new MessageManager();
23
+ manager.addSystemMessage('You are helpful.');
24
+ manager.addUserMessage('Hello');
25
+ manager.addAssistantMessage('Hi!');
26
+
27
+ await sm.save('test-1', manager);
28
+
29
+ const restored = new MessageManager();
30
+ const meta = await sm.load('test-1', restored);
31
+
32
+ expect(restored.messageCount).toBe(3);
33
+ const msgs = restored.getMessages();
34
+ expect(msgs[0]?.role).toBe('system');
35
+ expect(msgs[1]?.content).toBe('Hello');
36
+ expect(msgs[2]?.content).toBe('Hi!');
37
+ expect(meta.sessionId).toBe('test-1');
38
+ expect(meta.createdAt).toBeTruthy();
39
+ });
40
+
41
+ it('should preserve createdAt on re-save', async () => {
42
+ const manager = new MessageManager();
43
+ manager.addUserMessage('first');
44
+
45
+ await sm.save('test-2', manager);
46
+ const meta1 = await sm.load('test-2', new MessageManager());
47
+
48
+ // Wait a tiny bit, then save again
49
+ manager.addUserMessage('second');
50
+ await sm.save('test-2', manager);
51
+ const meta2 = await sm.load('test-2', new MessageManager());
52
+
53
+ expect(meta2.createdAt).toBe(meta1.createdAt);
54
+ expect(meta2.updatedAt >= meta1.updatedAt).toBe(true);
55
+ });
56
+
57
+ it('should check existence', async () => {
58
+ expect(await sm.exists('nope')).toBe(false);
59
+
60
+ const manager = new MessageManager();
61
+ manager.addUserMessage('hi');
62
+ await sm.save('exists-1', manager);
63
+
64
+ expect(await sm.exists('exists-1')).toBe(true);
65
+ });
66
+
67
+ it('should list sessions sorted by updatedAt', async () => {
68
+ const m1 = new MessageManager();
69
+ m1.addUserMessage('session A');
70
+ await sm.save('a', m1);
71
+
72
+ const m2 = new MessageManager();
73
+ m2.addUserMessage('session B');
74
+ await sm.save('b', m2);
75
+
76
+ const list = await sm.list();
77
+ expect(list).toHaveLength(2);
78
+ // Most recent first
79
+ expect(list[0]?.sessionId).toBe('b');
80
+ expect(list[1]?.sessionId).toBe('a');
81
+ });
82
+
83
+ it('should return empty list for non-existent directory', async () => {
84
+ const badSm = new SessionManager('/tmp/nonexistent-session-dir-xyz');
85
+ const list = await badSm.list();
86
+ expect(list).toEqual([]);
87
+ });
88
+
89
+ it('should throw on load for non-existent session', async () => {
90
+ const manager = new MessageManager();
91
+ await expect(sm.load('missing', manager)).rejects.toThrow();
92
+ });
93
+
94
+ it('should sanitize sessionId to prevent path traversal', async () => {
95
+ const manager = new MessageManager();
96
+ manager.addUserMessage('sneaky');
97
+ // Malicious session ID with path traversal
98
+ await sm.save('../../../etc/passwd', manager);
99
+
100
+ // Should be saved safely inside sessionsDir
101
+ expect(await sm.exists('../../../etc/passwd')).toBe(true);
102
+ const list = await sm.list();
103
+ expect(list).toHaveLength(1);
104
+ expect(list[0]?.sessionId).toBe('../../../etc/passwd');
105
+ });
106
+ });
@@ -0,0 +1,119 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { SkillTool, type SkillEntry, type SkillProvider } from '../src/skill-tool.js';
3
+ import type { AgentConfig } from '@charming_groot/core';
4
+ import { RunContext } from '@charming_groot/core';
5
+
6
+ const TEST_CONFIG: AgentConfig = {
7
+ provider: { providerId: 'test', model: 'test', auth: { type: 'api-key' as const, apiKey: 'test' }, maxTokens: 4096, temperature: 0.7 },
8
+ maxIterations: 10,
9
+ workingDirectory: '/tmp',
10
+ };
11
+
12
+ function createRegistry(skills: SkillEntry[]): SkillProvider {
13
+ return {
14
+ get: (name: string) => skills.find((s) => s.name === name),
15
+ getAll: () => skills,
16
+ };
17
+ }
18
+
19
+ const SAMPLE_SKILLS: SkillEntry[] = [
20
+ {
21
+ name: 'code-review',
22
+ description: 'Review code for quality and bugs',
23
+ tools: ['file_read', 'content_search'],
24
+ prompt: 'You are a code reviewer. Analyze code for bugs, style issues, and improvements.',
25
+ rules: ['no-console-log'],
26
+ },
27
+ {
28
+ name: 'deploy',
29
+ description: 'Deploy application to production',
30
+ tools: ['shell_exec', 'file_read'],
31
+ prompt: 'You are a deployment specialist. Follow the deployment checklist carefully.',
32
+ rules: ['require-approval', 'dry-run-first'],
33
+ },
34
+ ];
35
+
36
+ describe('SkillTool', () => {
37
+ const context = new RunContext(TEST_CONFIG);
38
+
39
+ it('should describe itself correctly', () => {
40
+ const tool = new SkillTool(createRegistry([]));
41
+ const desc = tool.describe();
42
+ expect(desc.name).toBe('skill');
43
+ expect(desc.parameters.length).toBeGreaterThanOrEqual(2);
44
+ });
45
+
46
+ it('should list available skills', async () => {
47
+ const tool = new SkillTool(createRegistry(SAMPLE_SKILLS));
48
+ const result = await tool.execute({ action: 'list' }, context);
49
+
50
+ expect(result.success).toBe(true);
51
+ expect(result.output).toContain('code-review');
52
+ expect(result.output).toContain('deploy');
53
+ expect(result.output).toContain('file_read');
54
+ });
55
+
56
+ it('should return empty message when no skills', async () => {
57
+ const tool = new SkillTool(createRegistry([]));
58
+ const result = await tool.execute({ action: 'list' }, context);
59
+
60
+ expect(result.success).toBe(true);
61
+ expect(result.output).toContain('No skills available');
62
+ });
63
+
64
+ it('should invoke a skill by name', async () => {
65
+ const tool = new SkillTool(createRegistry(SAMPLE_SKILLS));
66
+ const result = await tool.execute({ action: 'invoke', name: 'code-review' }, context);
67
+
68
+ expect(result.success).toBe(true);
69
+ expect(result.output).toContain('code-review');
70
+ expect(result.output).toContain('code reviewer');
71
+ expect(result.output).toContain('file_read, content_search');
72
+ expect(result.output).toContain('no-console-log');
73
+ expect(result.metadata?.['skillName']).toBe('code-review');
74
+ });
75
+
76
+ it('should include user input in invocation', async () => {
77
+ const tool = new SkillTool(createRegistry(SAMPLE_SKILLS));
78
+ const result = await tool.execute(
79
+ { action: 'invoke', name: 'deploy', input: 'Deploy v2.1.0 to staging' },
80
+ context,
81
+ );
82
+
83
+ expect(result.success).toBe(true);
84
+ expect(result.output).toContain('Deploy v2.1.0 to staging');
85
+ expect(result.output).toContain('deployment specialist');
86
+ });
87
+
88
+ it('should return error for unknown skill', async () => {
89
+ const tool = new SkillTool(createRegistry(SAMPLE_SKILLS));
90
+ const result = await tool.execute({ action: 'invoke', name: 'nonexistent' }, context);
91
+
92
+ expect(result.success).toBe(false);
93
+ expect(result.error).toContain('not found');
94
+ });
95
+
96
+ it('should return error for missing name on invoke', async () => {
97
+ const tool = new SkillTool(createRegistry(SAMPLE_SKILLS));
98
+ const result = await tool.execute({ action: 'invoke' }, context);
99
+
100
+ expect(result.success).toBe(false);
101
+ expect(result.error).toContain('Missing "name"');
102
+ });
103
+
104
+ it('should return error for unknown action', async () => {
105
+ const tool = new SkillTool(createRegistry(SAMPLE_SKILLS));
106
+ const result = await tool.execute({ action: 'delete' }, context);
107
+
108
+ expect(result.success).toBe(false);
109
+ expect(result.error).toContain('Unknown action');
110
+ });
111
+
112
+ it('should return metadata with tools and rules on invoke', async () => {
113
+ const tool = new SkillTool(createRegistry(SAMPLE_SKILLS));
114
+ const result = await tool.execute({ action: 'invoke', name: 'deploy' }, context);
115
+
116
+ expect(result.metadata?.['tools']).toEqual(['shell_exec', 'file_read']);
117
+ expect(result.metadata?.['rules']).toEqual(['require-approval', 'dry-run-first']);
118
+ });
119
+ });