@flowcodex/core 0.3.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 (97) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +9 -0
  3. package/dist/index-LbxYtxxS.d.ts +560 -0
  4. package/dist/index.d.ts +995 -0
  5. package/dist/index.js +3840 -0
  6. package/dist/index.js.map +1 -0
  7. package/dist/kernel/index.d.ts +1 -0
  8. package/dist/kernel/index.js +551 -0
  9. package/dist/kernel/index.js.map +1 -0
  10. package/package.json +39 -0
  11. package/src/agent/agent-loop.ts +254 -0
  12. package/src/agent/context.ts +99 -0
  13. package/src/agent/conversation-state.ts +44 -0
  14. package/src/agent/provider-runner.ts +241 -0
  15. package/src/agent/system-prompt-builder.ts +193 -0
  16. package/src/execution/compactor.ts +256 -0
  17. package/src/execution/index.ts +7 -0
  18. package/src/execution/output-serializer.ts +90 -0
  19. package/src/execution/schema-validator.ts +124 -0
  20. package/src/execution/tool-executor.ts +276 -0
  21. package/src/execution/tool-registry.ts +104 -0
  22. package/src/index.ts +215 -0
  23. package/src/infrastructure/catalog-parser.ts +218 -0
  24. package/src/infrastructure/index.ts +16 -0
  25. package/src/infrastructure/path-resolver.ts +123 -0
  26. package/src/infrastructure/provider-factory.ts +116 -0
  27. package/src/infrastructure/provider-presets.ts +19 -0
  28. package/src/infrastructure/retry-policy.ts +50 -0
  29. package/src/infrastructure/secret-scrubber.ts +67 -0
  30. package/src/infrastructure/token-counter.ts +156 -0
  31. package/src/infrastructure/tracer.ts +23 -0
  32. package/src/kernel/container.ts +166 -0
  33. package/src/kernel/events.ts +323 -0
  34. package/src/kernel/index.ts +18 -0
  35. package/src/kernel/pipeline.ts +152 -0
  36. package/src/kernel/run-controller.ts +85 -0
  37. package/src/kernel/tokens.ts +21 -0
  38. package/src/security/index.ts +13 -0
  39. package/src/security/permission-policy.ts +273 -0
  40. package/src/session/audit-log.ts +201 -0
  41. package/src/session/auth-service.ts +178 -0
  42. package/src/session/index.ts +26 -0
  43. package/src/session/secret-vault.ts +183 -0
  44. package/src/session/session-store.ts +339 -0
  45. package/src/session/types.ts +100 -0
  46. package/src/types/blocks.ts +56 -0
  47. package/src/types/context.ts +54 -0
  48. package/src/types/errors.ts +359 -0
  49. package/src/types/index.ts +34 -0
  50. package/src/types/provider.ts +58 -0
  51. package/src/types/tool.ts +39 -0
  52. package/src/utils/error.ts +3 -0
  53. package/src/utils/fs.ts +185 -0
  54. package/src/utils/image-resize.ts +76 -0
  55. package/src/utils/ssrf-guard.ts +133 -0
  56. package/src/utils/ulid.ts +72 -0
  57. package/src/utils/version-check.ts +59 -0
  58. package/tests/agent-loop.test.ts +490 -0
  59. package/tests/audit-log.test.ts +199 -0
  60. package/tests/auth-service.test.ts +170 -0
  61. package/tests/blocks.test.ts +79 -0
  62. package/tests/catalog-parser.test.ts +174 -0
  63. package/tests/compactor.test.ts +180 -0
  64. package/tests/container.test.ts +224 -0
  65. package/tests/conversation-state.test.ts +75 -0
  66. package/tests/errors.test.ts +429 -0
  67. package/tests/events-v021.test.ts +60 -0
  68. package/tests/events-v022.test.ts +75 -0
  69. package/tests/events.test.ts +340 -0
  70. package/tests/fixtures/large-image.png +0 -0
  71. package/tests/fixtures/small-image.png +0 -0
  72. package/tests/fs-utils.test.ts +164 -0
  73. package/tests/image-resize.test.ts +51 -0
  74. package/tests/output-serializer.test.ts +79 -0
  75. package/tests/path-resolver.test.ts +91 -0
  76. package/tests/permission-policy.test.ts +174 -0
  77. package/tests/pipeline.test.ts +193 -0
  78. package/tests/provider-factory.test.ts +245 -0
  79. package/tests/provider-runner.test.ts +535 -0
  80. package/tests/retry-policy.test.ts +104 -0
  81. package/tests/run-controller.test.ts +115 -0
  82. package/tests/sanity.test.ts +26 -0
  83. package/tests/schema-validator.test.ts +109 -0
  84. package/tests/secret-scrubber.test.ts +133 -0
  85. package/tests/secret-vault.test.ts +130 -0
  86. package/tests/session-store.test.ts +429 -0
  87. package/tests/ssrf-guard.test.ts +112 -0
  88. package/tests/system-prompt-builder.test.ts +116 -0
  89. package/tests/token-counter.test.ts +163 -0
  90. package/tests/tokens.test.ts +42 -0
  91. package/tests/tool-executor.test.ts +452 -0
  92. package/tests/tool-registry.test.ts +143 -0
  93. package/tests/tracer.test.ts +32 -0
  94. package/tests/ulid.test.ts +53 -0
  95. package/tests/version-check.test.ts +57 -0
  96. package/tsconfig.json +11 -0
  97. package/tsup.config.ts +16 -0
@@ -0,0 +1,340 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { EventBus, ScopedEventBus, type EventName } from '../src/kernel/events.js';
3
+
4
+ describe('EventBus', () => {
5
+ describe('on + emit', () => {
6
+ it('delivers events to listeners', () => {
7
+ const bus = new EventBus();
8
+ const fn = vi.fn();
9
+ bus.on('session.started', fn);
10
+ bus.emit('session.started', { id: 'test-123' });
11
+ expect(fn).toHaveBeenCalledWith({ id: 'test-123' });
12
+ });
13
+
14
+ it('delivers to multiple listeners', () => {
15
+ const bus = new EventBus();
16
+ const fn1 = vi.fn();
17
+ const fn2 = vi.fn();
18
+ bus.on('session.started', fn1);
19
+ bus.on('session.started', fn2);
20
+ bus.emit('session.started', { id: 'x' });
21
+ expect(fn1).toHaveBeenCalled();
22
+ expect(fn2).toHaveBeenCalled();
23
+ });
24
+
25
+ it('returns an unsubscribe function', () => {
26
+ const bus = new EventBus();
27
+ const fn = vi.fn();
28
+ const unsub = bus.on('session.started', fn);
29
+ unsub();
30
+ bus.emit('session.started', { id: 'x' });
31
+ expect(fn).not.toHaveBeenCalled();
32
+ });
33
+ });
34
+
35
+ describe('off', () => {
36
+ it('removes a specific listener', () => {
37
+ const bus = new EventBus();
38
+ const fn1 = vi.fn();
39
+ const fn2 = vi.fn();
40
+ bus.on('session.started', fn1);
41
+ bus.on('session.started', fn2);
42
+ bus.off('session.started', fn1);
43
+ bus.emit('session.started', { id: 'x' });
44
+ expect(fn1).not.toHaveBeenCalled();
45
+ expect(fn2).toHaveBeenCalled();
46
+ });
47
+ });
48
+
49
+ describe('once', () => {
50
+ it('fires only once', () => {
51
+ const bus = new EventBus();
52
+ const fn = vi.fn();
53
+ bus.once('session.started', fn);
54
+ bus.emit('session.started', { id: 'a' });
55
+ bus.emit('session.started', { id: 'b' });
56
+ expect(fn).toHaveBeenCalledTimes(1);
57
+ });
58
+
59
+ it('unsubscribe before fire prevents it', () => {
60
+ const bus = new EventBus();
61
+ const fn = vi.fn();
62
+ const unsub = bus.once('session.started', fn);
63
+ unsub();
64
+ bus.emit('session.started', { id: 'x' });
65
+ expect(fn).not.toHaveBeenCalled();
66
+ });
67
+ });
68
+
69
+ describe('onAny (wildcard)', () => {
70
+ it('receives all events', () => {
71
+ const bus = new EventBus();
72
+ const fn = vi.fn();
73
+ bus.onAny(fn);
74
+ bus.emit('session.started', { id: 'x' });
75
+ bus.emit('tool.started', { name: 'read', id: '1' });
76
+ expect(fn).toHaveBeenCalledTimes(2);
77
+ expect(fn).toHaveBeenNthCalledWith(1, 'session.started', { id: 'x' });
78
+ });
79
+
80
+ it('returns unsubscribe', () => {
81
+ const bus = new EventBus();
82
+ const fn = vi.fn();
83
+ const unsub = bus.onAny(fn);
84
+ unsub();
85
+ bus.emit('session.started', { id: 'x' });
86
+ expect(fn).not.toHaveBeenCalled();
87
+ });
88
+ });
89
+
90
+ describe('onPattern', () => {
91
+ it('matches prefix.* events', () => {
92
+ const bus = new EventBus();
93
+ const fn = vi.fn();
94
+ bus.onPattern('provider.*', fn);
95
+ bus.emit('provider.text_delta', { text: 'hi' });
96
+ bus.emit('tool.started', { name: 'read', id: '1' });
97
+ expect(fn).toHaveBeenCalledTimes(1);
98
+ expect(fn).toHaveBeenCalledWith('provider.text_delta', { text: 'hi' });
99
+ });
100
+
101
+ it('* matches everything', () => {
102
+ const bus = new EventBus();
103
+ const fn = vi.fn();
104
+ bus.onPattern('*', fn);
105
+ bus.emit('session.started', { id: 'x' });
106
+ bus.emit('error', { err: new Error('e'), phase: 'test' });
107
+ expect(fn).toHaveBeenCalledTimes(2);
108
+ });
109
+ });
110
+
111
+ describe('onRegex', () => {
112
+ it('matches by regex', () => {
113
+ const bus = new EventBus();
114
+ const fn = vi.fn();
115
+ bus.onRegex(/^ctx\./, fn);
116
+ bus.emit('ctx.pct', { load: 0.5, tokens: 100, maxContext: 200000 });
117
+ bus.emit('tool.started', { name: 'r', id: '1' });
118
+ expect(fn).toHaveBeenCalledTimes(1);
119
+ });
120
+ });
121
+
122
+ describe('emitCustom', () => {
123
+ it('delivers to wildcard only', () => {
124
+ const bus = new EventBus();
125
+ const fn = vi.fn();
126
+ bus.onAny(fn);
127
+ bus.emitCustom('my.custom.event', { data: 42 });
128
+ expect(fn).toHaveBeenCalledWith('my.custom.event', { data: 42 });
129
+ });
130
+
131
+ it('no-op when no wildcards', () => {
132
+ const bus = new EventBus();
133
+ expect(() => bus.emitCustom('custom', { x: 1 })).not.toThrow();
134
+ });
135
+ });
136
+
137
+ describe('listener exceptions are swallowed', () => {
138
+ it('does not throw when a listener throws', () => {
139
+ const bus = new EventBus();
140
+ bus.on('session.started', () => { throw new Error('boom'); });
141
+ const fn2 = vi.fn();
142
+ bus.on('session.started', fn2);
143
+ expect(() => bus.emit('session.started', { id: 'x' })).not.toThrow();
144
+ expect(fn2).toHaveBeenCalled();
145
+ });
146
+
147
+ it('logs to logger when set', () => {
148
+ const bus = new EventBus();
149
+ const logFn = vi.fn();
150
+ bus.setLogger({ error: logFn });
151
+ bus.on('session.started', () => { throw new Error('boom'); });
152
+ bus.emit('session.started', { id: 'x' });
153
+ expect(logFn).toHaveBeenCalled();
154
+ });
155
+ });
156
+
157
+ describe('listenerCount', () => {
158
+ it('counts listeners for a specific event', () => {
159
+ const bus = new EventBus();
160
+ bus.on('session.started', () => {});
161
+ bus.on('session.started', () => {});
162
+ bus.on('tool.started', () => {});
163
+ expect(bus.listenerCount('session.started')).toBe(2);
164
+ expect(bus.listenerCount('tool.started')).toBe(1);
165
+ });
166
+
167
+ it('counts all listeners when no event given', () => {
168
+ const bus = new EventBus();
169
+ bus.on('session.started', () => {});
170
+ bus.on('tool.started', () => {});
171
+ expect(bus.listenerCount()).toBe(2);
172
+ });
173
+ });
174
+
175
+ describe('hasListenerFor', () => {
176
+ it('returns true for named listeners', () => {
177
+ const bus = new EventBus();
178
+ bus.on('session.started', () => {});
179
+ expect(bus.hasListenerFor('session.started')).toBe(true);
180
+ });
181
+
182
+ it('returns true for wildcard matches', () => {
183
+ const bus = new EventBus();
184
+ bus.onPattern('session.*', () => {});
185
+ expect(bus.hasListenerFor('session.started')).toBe(true);
186
+ });
187
+
188
+ it('returns false when no listeners', () => {
189
+ const bus = new EventBus();
190
+ expect(bus.hasListenerFor('session.started')).toBe(false);
191
+ });
192
+ });
193
+
194
+ describe('clear', () => {
195
+ it('removes all listeners and wildcards', () => {
196
+ const bus = new EventBus();
197
+ bus.on('session.started', () => {});
198
+ bus.onAny(() => {});
199
+ bus.clear();
200
+ expect(bus.listenerCount()).toBe(0);
201
+ expect(bus.wildcardCount()).toBe(0);
202
+ });
203
+ });
204
+
205
+ describe('all 25 event types', () => {
206
+ it('EventMap has exactly 25 keys', () => {
207
+ const events: EventName[] = [
208
+ 'session.started', 'session.ended', 'session.damaged',
209
+ 'iteration.started', 'iteration.completed', 'iteration.limit_reached',
210
+ 'provider.response', 'provider.text_delta', 'provider.thinking_delta',
211
+ 'provider.tool_use_start', 'provider.tool_use_input_delta', 'provider.tool_use_stop',
212
+ 'provider.stream_error', 'provider.retry', 'provider.error',
213
+ 'tool.started', 'tool.progress', 'tool.confirm_needed', 'tool.executed',
214
+ 'ctx.pct', 'ctx.max_context',
215
+ 'token.threshold', 'token.accounted', 'token.cost_estimate_unavailable',
216
+ 'error',
217
+ ];
218
+ expect(events).toHaveLength(25);
219
+ for (const ev of events) {
220
+ const fn = vi.fn();
221
+ const bus = new EventBus();
222
+ bus.on(ev, fn);
223
+ // just verify it compiles + registers without error
224
+ expect(bus.listenerCount(ev)).toBe(1);
225
+ }
226
+ });
227
+ });
228
+ });
229
+
230
+ describe('ScopedEventBus', () => {
231
+ it('tracks registrations', () => {
232
+ const bus = new ScopedEventBus();
233
+ bus.on('session.started', () => {});
234
+ bus.on('tool.started', () => {});
235
+ expect(bus.scopedListenerCount).toBe(2);
236
+ });
237
+
238
+ it('teardown removes all tracked listeners', () => {
239
+ const bus = new ScopedEventBus();
240
+ const fn = vi.fn();
241
+ bus.on('session.started', fn);
242
+ bus.teardown();
243
+ bus.emit('session.started', { id: 'x' });
244
+ expect(fn).not.toHaveBeenCalled();
245
+ expect(bus.scopedListenerCount).toBe(0);
246
+ });
247
+
248
+ it('teardown is idempotent', () => {
249
+ const bus = new ScopedEventBus();
250
+ bus.on('session.started', () => {});
251
+ bus.teardown();
252
+ expect(() => bus.teardown()).not.toThrow();
253
+ });
254
+
255
+ it('Symbol.dispose calls teardown', () => {
256
+ const bus = new ScopedEventBus();
257
+ bus.on('session.started', () => {});
258
+ bus[Symbol.dispose]();
259
+ expect(bus.scopedListenerCount).toBe(0);
260
+ });
261
+
262
+ it('once is tracked and cleaned on fire', () => {
263
+ const bus = new ScopedEventBus();
264
+ const fn = vi.fn();
265
+ bus.once('session.started', fn);
266
+ expect(bus.scopedListenerCount).toBe(1);
267
+ bus.emit('session.started', { id: 'x' });
268
+ expect(fn).toHaveBeenCalledTimes(1);
269
+ expect(bus.scopedListenerCount).toBe(0);
270
+ });
271
+
272
+ it('onAny is tracked', () => {
273
+ const bus = new ScopedEventBus();
274
+ bus.onAny(() => {});
275
+ expect(bus.scopedListenerCount).toBe(1);
276
+ bus.teardown();
277
+ expect(bus.scopedListenerCount).toBe(0);
278
+ });
279
+
280
+ it('onPattern is tracked', () => {
281
+ const bus = new ScopedEventBus();
282
+ bus.onPattern('tool.*', () => {});
283
+ expect(bus.scopedListenerCount).toBe(1);
284
+ bus.teardown();
285
+ expect(bus.scopedListenerCount).toBe(0);
286
+ });
287
+
288
+ it('manual unsub removes from tracking', () => {
289
+ const bus = new ScopedEventBus();
290
+ const unsub = bus.on('session.started', () => {});
291
+ expect(bus.scopedListenerCount).toBe(1);
292
+ unsub();
293
+ expect(bus.scopedListenerCount).toBe(0);
294
+ });
295
+ });
296
+
297
+ describe('v0.3.0 events', () => {
298
+ it('delivers permission.persisted with allow/deny action and scope', () => {
299
+ const bus = new EventBus();
300
+ const fn = vi.fn();
301
+ bus.on('permission.persisted', fn);
302
+ bus.emit('permission.persisted', {
303
+ action: 'allow',
304
+ tool: 'bash',
305
+ pattern: 'pnpm *',
306
+ scope: 'trust',
307
+ });
308
+ expect(fn).toHaveBeenCalledWith({
309
+ action: 'allow',
310
+ tool: 'bash',
311
+ pattern: 'pnpm *',
312
+ scope: 'trust',
313
+ });
314
+ });
315
+
316
+ it('delivers compaction.fired with before/after/level', () => {
317
+ const bus = new EventBus();
318
+ const fn = vi.fn();
319
+ bus.on('compaction.fired', fn);
320
+ bus.emit('compaction.fired', { before: 18000, after: 12000, level: 'soft', aggressive: false });
321
+ expect(fn).toHaveBeenCalledWith({ before: 18000, after: 12000, level: 'soft', aggressive: false });
322
+ });
323
+
324
+ it('delivers compaction.failed with reason and attemptedLevel', () => {
325
+ const bus = new EventBus();
326
+ const fn = vi.fn();
327
+ bus.on('compaction.failed', fn);
328
+ bus.emit('compaction.failed', { reason: 'noop below delta', attemptedLevel: 'soft' });
329
+ expect(fn).toHaveBeenCalledWith({ reason: 'noop below delta', attemptedLevel: 'soft' });
330
+ });
331
+
332
+ it('onPattern matches compaction.* for both compaction events', () => {
333
+ const bus = new EventBus();
334
+ const fn = vi.fn();
335
+ bus.onPattern('compaction.*', (_event, _payload) => fn());
336
+ bus.emit('compaction.fired', { before: 1, after: 1, level: 'soft', aggressive: false });
337
+ bus.emit('compaction.failed', { reason: 'x', attemptedLevel: 'hard' });
338
+ expect(fn).toHaveBeenCalledTimes(2);
339
+ });
340
+ });
Binary file
Binary file
@@ -0,0 +1,164 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ atomicWrite,
4
+ isBinaryBuffer,
5
+ detectNewlineStyle,
6
+ normalizeToLf,
7
+ toStyle,
8
+ compileGlob,
9
+ unifiedDiff,
10
+ stripAnsi,
11
+ } from '../src/utils/fs.js';
12
+ import { promises as fsp } from 'node:fs';
13
+ import * as path from 'node:path';
14
+ import * as os from 'node:os';
15
+
16
+ describe('utils/fs', () => {
17
+ describe('atomicWrite', () => {
18
+ it('writes content to file', async () => {
19
+ const tmp = await fsp.mkdtemp(path.join(os.tmpdir(), 'fcx-fs-'));
20
+ const file = path.join(tmp, 'test.txt');
21
+ await atomicWrite(file, 'hello world');
22
+ const content = await fsp.readFile(file, 'utf8');
23
+ expect(content).toBe('hello world');
24
+ await fsp.rm(tmp, { recursive: true, force: true });
25
+ });
26
+
27
+ it('overwrites existing file', async () => {
28
+ const tmp = await fsp.mkdtemp(path.join(os.tmpdir(), 'fcx-fs-'));
29
+ const file = path.join(tmp, 'test.txt');
30
+ await atomicWrite(file, 'old');
31
+ await atomicWrite(file, 'new');
32
+ const content = await fsp.readFile(file, 'utf8');
33
+ expect(content).toBe('new');
34
+ await fsp.rm(tmp, { recursive: true, force: true });
35
+ });
36
+ });
37
+
38
+ describe('isBinaryBuffer', () => {
39
+ it('returns false for text buffer', () => {
40
+ expect(isBinaryBuffer(Buffer.from('hello world'))).toBe(false);
41
+ });
42
+
43
+ it('returns true for buffer with null byte', () => {
44
+ expect(isBinaryBuffer(Buffer.from([0x68, 0x00, 0x65]))).toBe(true);
45
+ });
46
+
47
+ it('returns false for empty buffer', () => {
48
+ expect(isBinaryBuffer(Buffer.alloc(0))).toBe(false);
49
+ });
50
+ });
51
+
52
+ describe('newline utils', () => {
53
+ it('detectNewlineStyle: LF', () => {
54
+ expect(detectNewlineStyle('line1\nline2')).toBe('lf');
55
+ });
56
+
57
+ it('detectNewlineStyle: CRLF', () => {
58
+ expect(detectNewlineStyle('line1\r\nline2')).toBe('crlf');
59
+ });
60
+
61
+ it('normalizeToLf: converts CRLF', () => {
62
+ expect(normalizeToLf('a\r\nb\r\nc')).toBe('a\nb\nc');
63
+ });
64
+
65
+ it('normalizeToLf: converts bare CR', () => {
66
+ expect(normalizeToLf('a\rb')).toBe('a\nb');
67
+ });
68
+
69
+ it('toStyle: LF stays LF', () => {
70
+ expect(toStyle('a\nb', 'lf')).toBe('a\nb');
71
+ });
72
+
73
+ it('toStyle: LF to CRLF', () => {
74
+ expect(toStyle('a\nb', 'crlf')).toBe('a\r\nb');
75
+ });
76
+
77
+ it('roundtrip: normalize then restore', () => {
78
+ const original = 'a\r\nb\r\nc';
79
+ const style = detectNewlineStyle(original);
80
+ const normalized = normalizeToLf(original);
81
+ const restored = toStyle(normalized, style);
82
+ expect(restored).toBe(original);
83
+ });
84
+ });
85
+
86
+ describe('compileGlob', () => {
87
+ it('matches simple filename', () => {
88
+ const re = compileGlob('*.ts');
89
+ expect(re.test('foo.ts')).toBe(true);
90
+ expect(re.test('foo.js')).toBe(false);
91
+ });
92
+
93
+ it('matches double-star recursively', () => {
94
+ const re = compileGlob('**/*.ts');
95
+ expect(re.test('src/foo.ts')).toBe(true);
96
+ expect(re.test('src/nested/deep/bar.ts')).toBe(true);
97
+ expect(re.test('foo.js')).toBe(false);
98
+ });
99
+
100
+ it('matches literal path', () => {
101
+ const re = compileGlob('src/index.ts');
102
+ expect(re.test('src/index.ts')).toBe(true);
103
+ expect(re.test('src/other.ts')).toBe(false);
104
+ });
105
+
106
+ it('matches single star within path segment', () => {
107
+ const re = compileGlob('src/*.ts');
108
+ expect(re.test('src/foo.ts')).toBe(true);
109
+ expect(re.test('src/nested/foo.ts')).toBe(false);
110
+ });
111
+
112
+ it('matches question mark', () => {
113
+ const re = compileGlob('foo?.ts');
114
+ expect(re.test('foo1.ts')).toBe(true);
115
+ expect(re.test('foo12.ts')).toBe(false);
116
+ });
117
+
118
+ it('escapes regex special chars', () => {
119
+ const re = compileGglob_safe('foo.bar');
120
+ expect(re.test('foo.bar')).toBe(true);
121
+ expect(re.test('fooXbar')).toBe(false);
122
+ });
123
+ });
124
+
125
+ describe('unifiedDiff', () => {
126
+ it('returns empty for identical text', () => {
127
+ expect(unifiedDiff('hello', 'hello')).toBe('');
128
+ });
129
+
130
+ it('shows added lines', () => {
131
+ const diff = unifiedDiff('a\nb', 'a\nb\nc');
132
+ expect(diff).toContain('+++');
133
+ expect(diff).toContain('@@');
134
+ expect(diff).toContain('+c');
135
+ });
136
+
137
+ it('shows removed lines', () => {
138
+ const diff = unifiedDiff('a\nb\nc', 'a\nb');
139
+ expect(diff).toContain('---');
140
+ expect(diff).toContain('-c');
141
+ });
142
+
143
+ it('includes file headers', () => {
144
+ const diff = unifiedDiff('a', 'b', { fromFile: 'old.txt', toFile: 'new.txt' });
145
+ expect(diff).toContain('--- old.txt');
146
+ expect(diff).toContain('+++ new.txt');
147
+ });
148
+ });
149
+
150
+ describe('stripAnsi', () => {
151
+ it('removes ANSI escape codes', () => {
152
+ const colored = '\x1b[31mred text\x1b[0m';
153
+ expect(stripAnsi(colored)).toBe('red text');
154
+ });
155
+
156
+ it('leaves plain text unchanged', () => {
157
+ expect(stripAnsi('plain text')).toBe('plain text');
158
+ });
159
+ });
160
+ });
161
+
162
+ function compileGglob_safe(pattern: string): RegExp {
163
+ return compileGlob(pattern);
164
+ }
@@ -0,0 +1,51 @@
1
+ import { describe, it, expect, beforeAll } from 'vitest';
2
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
3
+ import * as path from 'node:path';
4
+ import { resizeImageBuffer } from '../src/utils/image-resize.js';
5
+ import { PhotonImage } from '@silvia-odwyer/photon';
6
+
7
+ const FIXTURE_DIR = path.join(__dirname, 'fixtures');
8
+ const SMALL_PATH = path.join(FIXTURE_DIR, 'small-image.png');
9
+ const LARGE_PATH = path.join(FIXTURE_DIR, 'large-image.png');
10
+
11
+ beforeAll(() => {
12
+ mkdirSync(FIXTURE_DIR, { recursive: true });
13
+ try {
14
+ readFileSync(LARGE_PATH);
15
+ } catch {
16
+ const raw = new Uint8Array(200 * 150 * 4);
17
+ const img = new PhotonImage(raw, 200, 150);
18
+ writeFileSync(LARGE_PATH, Buffer.from(img.get_bytes()));
19
+ img.free();
20
+ }
21
+ });
22
+
23
+ describe('resizeImageBuffer', () => {
24
+ it('returns resized: false for small image', () => {
25
+ const buf = readFileSync(SMALL_PATH);
26
+ const result = resizeImageBuffer(buf, { maxWidth: 1568, maxHeight: 1568 });
27
+ expect(result.resized).toBe(false);
28
+ expect(result.buffer).toBe(buf);
29
+ });
30
+
31
+ it('returns resized: true for large image', () => {
32
+ const buf = readFileSync(LARGE_PATH);
33
+ const result = resizeImageBuffer(buf, { maxWidth: 100, maxHeight: 100 });
34
+ expect(result.resized).toBe(true);
35
+ expect(result.mediaType).toBe('image/jpeg');
36
+ expect(result.buffer.length).toBeLessThan(buf.length);
37
+ });
38
+
39
+ it('does not upscale small image', () => {
40
+ const buf = readFileSync(SMALL_PATH);
41
+ const result = resizeImageBuffer(buf, { maxWidth: 5000, maxHeight: 5000 });
42
+ expect(result.resized).toBe(false);
43
+ });
44
+
45
+ it('applies quality setting', () => {
46
+ const buf = readFileSync(LARGE_PATH);
47
+ const high = resizeImageBuffer(buf, { maxWidth: 100, maxHeight: 100, quality: 95 });
48
+ const low = resizeImageBuffer(buf, { maxWidth: 100, maxHeight: 100, quality: 10 });
49
+ expect(high.buffer.length).toBeGreaterThan(low.buffer.length);
50
+ });
51
+ });
@@ -0,0 +1,79 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { createToolOutputSerializer } from '../src/execution/output-serializer.js';
3
+
4
+ describe('OutputSerializer', () => {
5
+ const ser = createToolOutputSerializer();
6
+
7
+ it('serialize: string as-is', () => {
8
+ expect(ser.serialize('hello')).toBe('hello');
9
+ });
10
+
11
+ it('serialize: null/undefined to empty', () => {
12
+ expect(ser.serialize(null)).toBe('');
13
+ expect(ser.serialize(undefined)).toBe('');
14
+ });
15
+
16
+ it('serialize: number/boolean to string', () => {
17
+ expect(ser.serialize(42)).toBe('42');
18
+ expect(ser.serialize(true)).toBe('true');
19
+ });
20
+
21
+ it('serialize: array joined with newline', () => {
22
+ expect(ser.serialize(['a', 'b', 'c'])).toBe('a\nb\nc');
23
+ });
24
+
25
+ it('serialize: object with text field unwrapped', () => {
26
+ expect(ser.serialize({ text: 'hello' })).toBe('hello');
27
+ });
28
+
29
+ it('serialize: other object to JSON', () => {
30
+ const result = ser.serialize({ foo: 1, bar: 'baz' });
31
+ expect(JSON.parse(result)).toEqual({ foo: 1, bar: 'baz' });
32
+ });
33
+
34
+ it('enforceCap: under budget returns as-is', () => {
35
+ const { text, newBudget } = ser.enforceCap('hello', 1000);
36
+ expect(text).toBe('hello');
37
+ expect(newBudget).toBe(995);
38
+ });
39
+
40
+ it('enforceCap: over budget truncates with marker', () => {
41
+ const big = 'A'.repeat(5000);
42
+ const { text, newBudget } = ser.enforceCap(big, 1000);
43
+ expect(newBudget).toBe(0);
44
+ expect(text).toContain('[truncated');
45
+ expect(text).toContain('bytes]');
46
+ expect(text.length).toBeLessThan(big.length);
47
+ expect(text.startsWith('A')).toBe(true);
48
+ });
49
+
50
+ it('enforceCap: zero remaining produces marker only', () => {
51
+ const { text, newBudget } = ser.enforceCap('hello', 0);
52
+ expect(newBudget).toBe(0);
53
+ expect(text).toContain('[truncated');
54
+ });
55
+
56
+ it('truncateForEvent: short text unchanged', () => {
57
+ expect(ser.truncateForEvent('short')).toBe('short');
58
+ });
59
+
60
+ it('truncateForEvent: long text truncated', () => {
61
+ const long = 'A'.repeat(500);
62
+ const result = ser.truncateForEvent(long, 100);
63
+ expect(result.length).toBeLessThan(long.length);
64
+ expect(result).toContain('…');
65
+ });
66
+
67
+ it('truncateForEvent: default maxChars is 400', () => {
68
+ const long = 'A'.repeat(500);
69
+ const result = ser.truncateForEvent(long);
70
+ expect(result).toContain('…');
71
+ });
72
+
73
+ it('enforceCap: preserves head and tail', () => {
74
+ const text = 'HEAD' + 'X'.repeat(3000) + 'TAIL';
75
+ const { text: result } = ser.enforceCap(text, 200);
76
+ expect(result.startsWith('HEAD')).toBe(true);
77
+ expect(result.endsWith('TAIL')).toBe(true);
78
+ });
79
+ });