@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,372 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { AgentLoop } from '../src/agent-loop.js';
3
+ import type {
4
+ ILlmProvider,
5
+ ITool,
6
+ LlmResponse,
7
+ Message,
8
+ StreamEvent,
9
+ ToolDescription,
10
+ AgentConfig,
11
+ } from '@charming_groot/core';
12
+ import { Registry } from '@charming_groot/core';
13
+
14
+ const TEST_CONFIG: AgentConfig = {
15
+ provider: { providerId: 'test', model: 'test', auth: { type: 'api-key' as const, apiKey: 'test' }, maxTokens: 4096, temperature: 0.7 },
16
+ maxIterations: 10,
17
+ workingDirectory: '/tmp',
18
+ systemPrompt: 'You are a test agent.',
19
+ };
20
+
21
+ function createMockProvider(responses: LlmResponse[]): ILlmProvider {
22
+ let callIndex = 0;
23
+ return {
24
+ providerId: 'mock',
25
+ chat: vi.fn().mockImplementation(async () => {
26
+ const response = responses[callIndex];
27
+ callIndex++;
28
+ return response;
29
+ }),
30
+ stream: vi.fn() as unknown as ILlmProvider['stream'],
31
+ };
32
+ }
33
+
34
+ function createMockTool(name: string): ITool {
35
+ return {
36
+ name,
37
+ requiresPermission: false,
38
+ describe: () => ({ name, description: 'Mock', parameters: [] }),
39
+ execute: vi.fn().mockResolvedValue({ success: true, output: `${name} done` }),
40
+ };
41
+ }
42
+
43
+ describe('AgentLoop', () => {
44
+ it('should return response on end_turn', async () => {
45
+ const provider = createMockProvider([
46
+ {
47
+ content: 'Hello!',
48
+ stopReason: 'end_turn',
49
+ toolCalls: [],
50
+ usage: { inputTokens: 10, outputTokens: 5 },
51
+ },
52
+ ]);
53
+
54
+ const toolRegistry = new Registry<ITool>('Tool');
55
+ const agent = new AgentLoop({
56
+ provider,
57
+ toolRegistry,
58
+ config: TEST_CONFIG,
59
+ });
60
+
61
+ const result = await agent.run('Hi');
62
+ expect(result.content).toBe('Hello!');
63
+ expect(result.iterations).toBe(1);
64
+ expect(result.aborted).toBe(false);
65
+ });
66
+
67
+ it('should handle tool call loop', async () => {
68
+ const provider = createMockProvider([
69
+ {
70
+ content: 'Let me read the file.',
71
+ stopReason: 'tool_use',
72
+ toolCalls: [
73
+ { id: 'tc-1', name: 'file_read', arguments: '{"path":"test.txt"}' },
74
+ ],
75
+ usage: { inputTokens: 10, outputTokens: 15 },
76
+ },
77
+ {
78
+ content: 'The file says hello.',
79
+ stopReason: 'end_turn',
80
+ toolCalls: [],
81
+ usage: { inputTokens: 30, outputTokens: 10 },
82
+ },
83
+ ]);
84
+
85
+ const toolRegistry = new Registry<ITool>('Tool');
86
+ toolRegistry.register('file_read', createMockTool('file_read'));
87
+
88
+ const agent = new AgentLoop({
89
+ provider,
90
+ toolRegistry,
91
+ config: TEST_CONFIG,
92
+ });
93
+
94
+ const result = await agent.run('Read test.txt');
95
+ expect(result.content).toBe('The file says hello.');
96
+ expect(result.iterations).toBe(2);
97
+ expect(provider.chat).toHaveBeenCalledTimes(2);
98
+ });
99
+
100
+ it('should emit agent:start and agent:end events', async () => {
101
+ const provider = createMockProvider([
102
+ {
103
+ content: 'Done',
104
+ stopReason: 'end_turn',
105
+ toolCalls: [],
106
+ usage: { inputTokens: 5, outputTokens: 5 },
107
+ },
108
+ ]);
109
+
110
+ const toolRegistry = new Registry<ITool>('Tool');
111
+ const agent = new AgentLoop({
112
+ provider,
113
+ toolRegistry,
114
+ config: TEST_CONFIG,
115
+ });
116
+
117
+ const startHandler = vi.fn();
118
+ const endHandler = vi.fn();
119
+ agent.eventBus.on('agent:start', startHandler);
120
+ agent.eventBus.on('agent:end', endHandler);
121
+
122
+ await agent.run('test');
123
+ expect(startHandler).toHaveBeenCalledOnce();
124
+ expect(endHandler).toHaveBeenCalledOnce();
125
+ });
126
+
127
+ it('should stop after maxIterations', async () => {
128
+ const infiniteToolResponse: LlmResponse = {
129
+ content: 'Calling tool again',
130
+ stopReason: 'tool_use',
131
+ toolCalls: [{ id: 'tc-1', name: 'file_read', arguments: '{}' }],
132
+ usage: { inputTokens: 10, outputTokens: 10 },
133
+ };
134
+
135
+ const responses = Array.from({ length: 10 }, () => infiniteToolResponse);
136
+ const provider = createMockProvider(responses);
137
+
138
+ const toolRegistry = new Registry<ITool>('Tool');
139
+ toolRegistry.register('file_read', createMockTool('file_read'));
140
+
141
+ const agent = new AgentLoop({
142
+ provider,
143
+ toolRegistry,
144
+ config: { ...TEST_CONFIG, maxIterations: 3 },
145
+ });
146
+
147
+ const result = await agent.run('loop forever');
148
+ expect(result.iterations).toBe(3);
149
+ });
150
+
151
+ it('should handle abort', async () => {
152
+ const provider = createMockProvider([
153
+ {
154
+ content: 'Processing...',
155
+ stopReason: 'tool_use',
156
+ toolCalls: [{ id: 'tc-1', name: 'slow_tool', arguments: '{}' }],
157
+ usage: { inputTokens: 10, outputTokens: 10 },
158
+ },
159
+ ]);
160
+
161
+ const toolRegistry = new Registry<ITool>('Tool');
162
+ const slowTool: ITool = {
163
+ name: 'slow_tool',
164
+ requiresPermission: false,
165
+ describe: () => ({ name: 'slow_tool', description: '', parameters: [] }),
166
+ execute: async () => {
167
+ // Simulate slow work
168
+ return { success: true, output: 'done' };
169
+ },
170
+ };
171
+ toolRegistry.register('slow_tool', slowTool);
172
+
173
+ const agent = new AgentLoop({
174
+ provider,
175
+ toolRegistry,
176
+ config: TEST_CONFIG,
177
+ });
178
+
179
+ // Abort before running
180
+ agent.abort('test');
181
+ await expect(agent.run('test')).rejects.toThrow('aborted');
182
+ });
183
+
184
+ it('should emit agent:error on failure', async () => {
185
+ const provider: ILlmProvider = {
186
+ providerId: 'mock',
187
+ chat: vi.fn().mockRejectedValue(new Error('API down')),
188
+ stream: vi.fn() as unknown as ILlmProvider['stream'],
189
+ };
190
+
191
+ const toolRegistry = new Registry<ITool>('Tool');
192
+ const agent = new AgentLoop({
193
+ provider,
194
+ toolRegistry,
195
+ config: TEST_CONFIG,
196
+ });
197
+
198
+ const errorHandler = vi.fn();
199
+ agent.eventBus.on('agent:error', errorHandler);
200
+
201
+ await expect(agent.run('test')).rejects.toThrow('API down');
202
+ expect(errorHandler).toHaveBeenCalledOnce();
203
+ });
204
+
205
+ it('should have unique runId', () => {
206
+ const toolRegistry = new Registry<ITool>('Tool');
207
+ const provider = createMockProvider([]);
208
+ const agent1 = new AgentLoop({ provider, toolRegistry, config: TEST_CONFIG });
209
+ const agent2 = new AgentLoop({ provider, toolRegistry, config: TEST_CONFIG });
210
+ expect(agent1.runId).not.toBe(agent2.runId);
211
+ });
212
+
213
+ it('should use streaming when enabled', async () => {
214
+ const finalResponse: LlmResponse = {
215
+ content: 'Hello world',
216
+ stopReason: 'end_turn',
217
+ toolCalls: [],
218
+ usage: { inputTokens: 10, outputTokens: 5 },
219
+ };
220
+
221
+ const streamEvents: StreamEvent[] = [
222
+ { type: 'text_delta', content: 'Hello' },
223
+ { type: 'text_delta', content: ' world' },
224
+ { type: 'done', response: finalResponse },
225
+ ];
226
+
227
+ const provider: ILlmProvider = {
228
+ providerId: 'mock',
229
+ chat: vi.fn(),
230
+ async *stream() {
231
+ for (const event of streamEvents) {
232
+ yield event;
233
+ }
234
+ },
235
+ };
236
+
237
+ const toolRegistry = new Registry<ITool>('Tool');
238
+ const agent = new AgentLoop({
239
+ provider,
240
+ toolRegistry,
241
+ config: TEST_CONFIG,
242
+ streaming: true,
243
+ });
244
+
245
+ const chunks: string[] = [];
246
+ agent.eventBus.on('llm:stream', (payload) => {
247
+ chunks.push(payload.chunk);
248
+ });
249
+
250
+ const result = await agent.run('Hi');
251
+ expect(result.content).toBe('Hello world');
252
+ expect(chunks).toEqual(['Hello', ' world']);
253
+ expect(provider.chat).not.toHaveBeenCalled();
254
+ });
255
+
256
+ it('should use systemPromptBuilder to rebuild prompt each iteration', async () => {
257
+ let buildCount = 0;
258
+ const provider = createMockProvider([
259
+ {
260
+ content: 'Calling tool',
261
+ stopReason: 'tool_use',
262
+ toolCalls: [{ id: 'tc-1', name: 'file_read', arguments: '{}' }],
263
+ usage: { inputTokens: 10, outputTokens: 10 },
264
+ },
265
+ {
266
+ content: 'Done',
267
+ stopReason: 'end_turn',
268
+ toolCalls: [],
269
+ usage: { inputTokens: 10, outputTokens: 5 },
270
+ },
271
+ ]);
272
+
273
+ const toolRegistry = new Registry<ITool>('Tool');
274
+ toolRegistry.register('file_read', createMockTool('file_read'));
275
+
276
+ const agent = new AgentLoop({
277
+ provider,
278
+ toolRegistry,
279
+ config: { ...TEST_CONFIG, systemPrompt: undefined },
280
+ systemPromptBuilder: () => {
281
+ buildCount++;
282
+ return `Dynamic prompt v${buildCount}`;
283
+ },
284
+ });
285
+
286
+ await agent.run('test');
287
+ // Builder called once per iteration (2 iterations)
288
+ expect(buildCount).toBe(2);
289
+
290
+ // Last call should have the latest prompt
291
+ const lastCall = (provider.chat as ReturnType<typeof vi.fn>).mock.calls[1];
292
+ const messages = lastCall[0] as Message[];
293
+ expect(messages[0]?.role).toBe('system');
294
+ expect(messages[0]?.content).toBe('Dynamic prompt v2');
295
+ });
296
+
297
+ it('should support async systemPromptBuilder', async () => {
298
+ const provider = createMockProvider([
299
+ {
300
+ content: 'Hi',
301
+ stopReason: 'end_turn',
302
+ toolCalls: [],
303
+ usage: { inputTokens: 10, outputTokens: 5 },
304
+ },
305
+ ]);
306
+
307
+ const toolRegistry = new Registry<ITool>('Tool');
308
+ const agent = new AgentLoop({
309
+ provider,
310
+ toolRegistry,
311
+ config: { ...TEST_CONFIG, systemPrompt: undefined },
312
+ systemPromptBuilder: async () => {
313
+ return 'Async prompt';
314
+ },
315
+ });
316
+
317
+ await agent.run('Hello');
318
+ const chatCall = (provider.chat as ReturnType<typeof vi.fn>).mock.calls[0];
319
+ const messages = chatCall[0] as Message[];
320
+ expect(messages[0]?.role).toBe('system');
321
+ expect(messages[0]?.content).toBe('Async prompt');
322
+ });
323
+
324
+ it('should prefer systemPromptBuilder over static systemPrompt', async () => {
325
+ const provider = createMockProvider([
326
+ {
327
+ content: 'Hi',
328
+ stopReason: 'end_turn',
329
+ toolCalls: [],
330
+ usage: { inputTokens: 10, outputTokens: 5 },
331
+ },
332
+ ]);
333
+
334
+ const toolRegistry = new Registry<ITool>('Tool');
335
+ const agent = new AgentLoop({
336
+ provider,
337
+ toolRegistry,
338
+ config: TEST_CONFIG, // has systemPrompt: 'You are a test agent.'
339
+ systemPromptBuilder: () => 'Builder wins',
340
+ });
341
+
342
+ await agent.run('Hello');
343
+ const chatCall = (provider.chat as ReturnType<typeof vi.fn>).mock.calls[0];
344
+ const messages = chatCall[0] as Message[];
345
+ expect(messages[0]?.role).toBe('system');
346
+ expect(messages[0]?.content).toBe('Builder wins');
347
+ });
348
+
349
+ it('should include system prompt in messages', async () => {
350
+ const provider = createMockProvider([
351
+ {
352
+ content: 'Hi',
353
+ stopReason: 'end_turn',
354
+ toolCalls: [],
355
+ usage: { inputTokens: 10, outputTokens: 5 },
356
+ },
357
+ ]);
358
+
359
+ const toolRegistry = new Registry<ITool>('Tool');
360
+ const agent = new AgentLoop({
361
+ provider,
362
+ toolRegistry,
363
+ config: TEST_CONFIG,
364
+ });
365
+
366
+ await agent.run('Hello');
367
+ const chatCall = (provider.chat as ReturnType<typeof vi.fn>).mock.calls[0];
368
+ const messages = chatCall[0] as Message[];
369
+ expect(messages[0]?.role).toBe('system');
370
+ expect(messages[0]?.content).toBe('You are a test agent.');
371
+ });
372
+ });
@@ -0,0 +1,204 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { MessageManager } from '../src/message-manager.js';
3
+ import type { Message } from '@charming_groot/core';
4
+
5
+ describe('MessageManager — 기본 동작', () => {
6
+ let manager: MessageManager;
7
+
8
+ beforeEach(() => {
9
+ manager = new MessageManager();
10
+ });
11
+
12
+ it('초기 상태는 메시지가 없다', () => {
13
+ expect(manager.messageCount).toBe(0);
14
+ expect(manager.getMessages()).toHaveLength(0);
15
+ });
16
+
17
+ it('user 메시지를 추가하고 반환한다', () => {
18
+ manager.addUserMessage('hello');
19
+ const msgs = manager.getMessages();
20
+ expect(msgs).toHaveLength(1);
21
+ expect(msgs[0].role).toBe('user');
22
+ expect(msgs[0].content).toBe('hello');
23
+ });
24
+
25
+ it('assistant 메시지를 tool call과 함께 추가한다', () => {
26
+ manager.addAssistantMessage('running tool', [
27
+ { id: 'tc1', name: 'get_schema', arguments: '{}' },
28
+ ]);
29
+ const msg = manager.getMessages()[0];
30
+ expect(msg.role).toBe('assistant');
31
+ expect(msg.toolCalls).toHaveLength(1);
32
+ expect(msg.toolCalls![0].name).toBe('get_schema');
33
+ });
34
+
35
+ it('tool result를 추가한다', () => {
36
+ manager.addToolResults(
37
+ new Map([['tc1', { success: true, output: '{"table":"users"}' }]])
38
+ );
39
+ const msg = manager.getMessages()[0];
40
+ expect(msg.toolResults).toHaveLength(1);
41
+ expect(msg.toolResults![0].content).toBe('{"table":"users"}');
42
+ });
43
+
44
+ it('tool 실패 결과를 Error: 접두사와 함께 저장한다', () => {
45
+ manager.addToolResults(
46
+ new Map([['tc1', { success: false, error: 'connection refused' }]])
47
+ );
48
+ const msg = manager.getMessages()[0];
49
+ expect(msg.toolResults![0].content).toBe('Error: connection refused');
50
+ });
51
+
52
+ it('setSystemMessage는 첫 system 메시지를 교체한다', () => {
53
+ manager.addSystemMessage('original');
54
+ manager.setSystemMessage('updated');
55
+ const msgs = manager.getMessages();
56
+ expect(msgs.filter(m => m.role === 'system')).toHaveLength(1);
57
+ expect(msgs[0].content).toBe('updated');
58
+ });
59
+
60
+ it('setSystemMessage는 system 메시지가 없으면 맨 앞에 삽입한다', () => {
61
+ manager.addUserMessage('first user');
62
+ manager.setSystemMessage('system prompt');
63
+ expect(manager.getMessages()[0].role).toBe('system');
64
+ });
65
+
66
+ it('clear() 후 메시지가 없다', () => {
67
+ manager.addUserMessage('hello');
68
+ manager.clear();
69
+ expect(manager.messageCount).toBe(0);
70
+ });
71
+
72
+ it('getLastMessage()는 마지막 메시지를 반환한다', () => {
73
+ manager.addUserMessage('first');
74
+ manager.addUserMessage('last');
75
+ expect(manager.getLastMessage()?.content).toBe('last');
76
+ });
77
+
78
+ it('getMessages()는 불변 복사본을 반환한다', () => {
79
+ manager.addUserMessage('original');
80
+ const msgs = manager.getMessages() as Message[];
81
+ msgs.push({ role: 'user', content: 'injected' });
82
+ expect(manager.messageCount).toBe(1);
83
+ });
84
+ });
85
+
86
+ describe('MessageManager — 토큰 카운팅', () => {
87
+ it('totalTokens는 빈 상태에서 양수를 반환한다 (reply overhead)', () => {
88
+ const manager = new MessageManager();
89
+ expect(manager.totalTokens).toBeGreaterThanOrEqual(3);
90
+ });
91
+
92
+ it('메시지 추가 시 totalTokens가 증가한다', () => {
93
+ const manager = new MessageManager();
94
+ const before = manager.totalTokens;
95
+ manager.addUserMessage('this is a test message with some content');
96
+ expect(manager.totalTokens).toBeGreaterThan(before);
97
+ });
98
+ });
99
+
100
+ describe('MessageManager — serialize / restore', () => {
101
+ it('직렬화 후 복원하면 동일한 메시지를 반환한다', () => {
102
+ const manager = new MessageManager();
103
+ manager.addSystemMessage('you are a helpful assistant');
104
+ manager.addUserMessage('hello');
105
+ manager.addAssistantMessage('hi there', [
106
+ { id: 'tc1', name: 'search', arguments: '{"q":"test"}' },
107
+ ]);
108
+
109
+ const json = manager.serialize();
110
+ const restored = new MessageManager();
111
+ restored.restore(json);
112
+
113
+ expect(restored.messageCount).toBe(manager.messageCount);
114
+ expect(restored.getMessages()[0].content).toBe('you are a helpful assistant');
115
+ expect(restored.getMessages()[2].toolCalls![0].name).toBe('search');
116
+ });
117
+
118
+ it('잘못된 JSON으로 restore하면 예외를 던진다', () => {
119
+ const manager = new MessageManager();
120
+ expect(() => manager.restore('not-json')).toThrow();
121
+ });
122
+
123
+ it('배열이 아닌 JSON으로 restore하면 예외를 던진다', () => {
124
+ const manager = new MessageManager();
125
+ expect(() => manager.restore('{"key":"value"}')).toThrow('expected an array');
126
+ });
127
+ });
128
+
129
+ describe('MessageManager — compressIfNeeded', () => {
130
+ it('토큰이 budget 이하이면 압축하지 않는다', () => {
131
+ const manager = new MessageManager({ maxHistoryTokens: 100_000 });
132
+ manager.addUserMessage('short message');
133
+ expect(manager.compressIfNeeded()).toBe(0);
134
+ });
135
+
136
+ it('budget 초과 시 압축하고 압축된 메시지 수를 반환한다', () => {
137
+ // 아주 작은 budget으로 강제 압축
138
+ const manager = new MessageManager({
139
+ maxHistoryTokens: 10,
140
+ keepRecentMessages: 2,
141
+ });
142
+ for (let i = 0; i < 6; i++) {
143
+ manager.addUserMessage(`message number ${i} with some content to push token count up`);
144
+ }
145
+ const compressed = manager.compressIfNeeded();
146
+ expect(compressed).toBeGreaterThan(0);
147
+ });
148
+
149
+ it('압축 후 system 메시지는 유지된다', () => {
150
+ const manager = new MessageManager({
151
+ maxHistoryTokens: 10,
152
+ keepRecentMessages: 2,
153
+ });
154
+ manager.addSystemMessage('system prompt must survive');
155
+ for (let i = 0; i < 5; i++) {
156
+ manager.addUserMessage(`message ${i} with enough content to exceed the tiny budget`);
157
+ }
158
+ manager.compressIfNeeded();
159
+ const msgs = manager.getMessages();
160
+ expect(msgs.some(m => m.role === 'system' && m.content === 'system prompt must survive')).toBe(true);
161
+ });
162
+
163
+ it('압축 후 최근 메시지는 원본 그대로 유지된다', () => {
164
+ const manager = new MessageManager({
165
+ maxHistoryTokens: 10,
166
+ keepRecentMessages: 2,
167
+ });
168
+ for (let i = 0; i < 4; i++) {
169
+ manager.addUserMessage(`message ${i} padding content to exceed the token budget limit`);
170
+ }
171
+ manager.addUserMessage('KEEP THIS ONE');
172
+ manager.addUserMessage('AND THIS ONE');
173
+
174
+ manager.compressIfNeeded();
175
+
176
+ const msgs = manager.getMessages();
177
+ const kept = msgs.filter(m => m.role !== 'system' && !m.content.includes('[Context summary'));
178
+ expect(kept.some(m => m.content === 'KEEP THIS ONE')).toBe(true);
179
+ expect(kept.some(m => m.content === 'AND THIS ONE')).toBe(true);
180
+ });
181
+
182
+ it('다이제스트에 압축된 메시지 수가 표시된다', () => {
183
+ const manager = new MessageManager({
184
+ maxHistoryTokens: 10,
185
+ keepRecentMessages: 1,
186
+ });
187
+ for (let i = 0; i < 4; i++) {
188
+ manager.addUserMessage(`message ${i} with padding content to force compression of history`);
189
+ }
190
+ manager.compressIfNeeded();
191
+
192
+ const msgs = manager.getMessages();
193
+ const summary = msgs.find(m => m.content.includes('[Context summary'));
194
+ expect(summary).toBeDefined();
195
+ expect(summary!.content).toMatch(/\d+ earlier messages compressed/);
196
+ });
197
+
198
+ it('압축할 메시지가 없으면 0을 반환한다', () => {
199
+ const manager = new MessageManager({ maxHistoryTokens: 10, keepRecentMessages: 10 });
200
+ manager.addUserMessage('only message');
201
+ // keepRecentMessages >= total → nothing to summarize
202
+ expect(manager.compressIfNeeded()).toBe(0);
203
+ });
204
+ });