@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,163 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { DefaultTokenCounter } from '../src/infrastructure/token-counter.js';
3
+
4
+ describe('DefaultTokenCounter', () => {
5
+ describe('estimate', () => {
6
+ it('estimates using 3.5 ratio for Anthropic models', () => {
7
+ const tc = new DefaultTokenCounter();
8
+ expect(tc.estimate('hello world', 'claude-sonnet-4-6')).toBe(Math.ceil(11 / 3.5));
9
+ });
10
+
11
+ it('estimates using 4.0 ratio for non-Anthropic models', () => {
12
+ const tc = new DefaultTokenCounter();
13
+ expect(tc.estimate('hello world', 'gpt-4o')).toBe(Math.ceil(11 / 4));
14
+ });
15
+
16
+ it('uses default ratio when no model specified', () => {
17
+ const tc = new DefaultTokenCounter();
18
+ expect(tc.estimate('hello world')).toBe(Math.ceil(11 / 4));
19
+ });
20
+
21
+ it('returns at least 1 for empty string', () => {
22
+ const tc = new DefaultTokenCounter();
23
+ expect(tc.estimate('')).toBe(1);
24
+ });
25
+ });
26
+
27
+ describe('account + total', () => {
28
+ it('accumulates input and output tokens', () => {
29
+ const tc = new DefaultTokenCounter();
30
+ tc.account({ input: 100, output: 50 });
31
+ tc.account({ input: 200, output: 75 });
32
+ expect(tc.total()).toBe(425);
33
+ });
34
+
35
+ it('tracks cache tokens separately', () => {
36
+ const tc = new DefaultTokenCounter();
37
+ tc.account({ input: 100, output: 50, cache_read: 80, cache_creation: 20 });
38
+ expect(tc.total()).toBe(150);
39
+ });
40
+ });
41
+
42
+ describe('request tokens', () => {
43
+ it('setRequestTokens + currentRequestTokens', () => {
44
+ const tc = new DefaultTokenCounter();
45
+ tc.setRequestTokens(5000);
46
+ expect(tc.currentRequestTokens()).toBe(5000);
47
+ });
48
+ });
49
+
50
+ describe('estimateCost', () => {
51
+ it('returns 0 when pricing not set', () => {
52
+ const tc = new DefaultTokenCounter();
53
+ expect(tc.estimateCost('claude-sonnet-4-6', { input: 1000, output: 500 })).toBe(0);
54
+ });
55
+
56
+ it('calculates cost from pricing', () => {
57
+ const tc = new DefaultTokenCounter();
58
+ tc.setPricing('claude-sonnet-4-6', {
59
+ input: 3,
60
+ output: 15,
61
+ cacheRead: 0.3,
62
+ cacheWrite: 3.75,
63
+ });
64
+ const cost = tc.estimateCost('claude-sonnet-4-6', {
65
+ input: 1_000_000,
66
+ output: 1_000_000,
67
+ cache_read: 1_000_000,
68
+ cache_creation: 1_000_000,
69
+ });
70
+ expect(cost).toBeCloseTo(3 + 15 + 0.3 + 3.75, 2);
71
+ });
72
+
73
+ it('handles missing cache fields', () => {
74
+ const tc = new DefaultTokenCounter();
75
+ tc.setPricing('test-model', {
76
+ input: 1,
77
+ output: 2,
78
+ cacheRead: 0.1,
79
+ cacheWrite: 1.25,
80
+ });
81
+ const cost = tc.estimateCost('test-model', { input: 1_000_000, output: 1_000_000 });
82
+ expect(cost).toBeCloseTo(3, 2);
83
+ });
84
+ });
85
+
86
+ describe('calibration', () => {
87
+ it('starts with anthropic ratio for claude models', () => {
88
+ const tc = new DefaultTokenCounter();
89
+ tc.calibrate('claude-sonnet-4-6');
90
+ expect(tc.estimate('hello', 'claude-sonnet-4-6')).toBe(Math.ceil(5 / 3.5));
91
+ });
92
+
93
+ it('starts with default ratio for non-claude', () => {
94
+ const tc = new DefaultTokenCounter();
95
+ tc.calibrate('gpt-4o');
96
+ expect(tc.estimate('hello', 'gpt-4o')).toBe(Math.ceil(5 / 4));
97
+ });
98
+ });
99
+
100
+ describe('estimateRequestTokens', () => {
101
+ it('returns at least 1 for empty system and messages', () => {
102
+ const tc = new DefaultTokenCounter();
103
+ expect(tc.estimateRequestTokens([], [])).toBe(1);
104
+ });
105
+
106
+ it('counts system text blocks', () => {
107
+ const tc = new DefaultTokenCounter();
108
+ const tokens = tc.estimateRequestTokens(
109
+ [{ type: 'text', text: 'hello world' }],
110
+ [],
111
+ undefined,
112
+ 'claude-sonnet-4-6',
113
+ );
114
+ expect(tokens).toBe(Math.ceil(11 / 3.5));
115
+ });
116
+
117
+ it('counts message text, tool_use input, and tool_result content', () => {
118
+ const tc = new DefaultTokenCounter();
119
+ const tokens = tc.estimateRequestTokens(
120
+ [],
121
+ [
122
+ { role: 'user', content: 'abc' },
123
+ {
124
+ role: 'assistant',
125
+ content: [
126
+ { type: 'text', text: 'de' },
127
+ { type: 'tool_use', id: '1', name: 'read', input: { path: '/x' } },
128
+ ],
129
+ },
130
+ {
131
+ role: 'user',
132
+ content: [{ type: 'tool_result', tool_use_id: '1', content: 'result text' }],
133
+ },
134
+ ],
135
+ undefined,
136
+ 'claude-sonnet-4-6',
137
+ );
138
+ // 'abc'(3) + 'de'(2) + JSON({path:'/x'}) + 'result text'(11)
139
+ const toolUseJson = JSON.stringify({ path: '/x' }).length;
140
+ const chars = 3 + 2 + toolUseJson + 11;
141
+ expect(tokens).toBe(Math.ceil(chars / 3.5));
142
+ });
143
+
144
+ it('counts tool schemas when provided (raises estimate vs no tools)', () => {
145
+ const tc = new DefaultTokenCounter();
146
+ const system = [{ type: 'text', text: 'sys' }] as const;
147
+ const without = tc.estimateRequestTokens(system, [], undefined, 'claude-sonnet-4-6');
148
+ const withTools = tc.estimateRequestTokens(
149
+ system,
150
+ [],
151
+ [{ name: 'read', description: 'desc', inputSchema: { type: 'object', properties: { path: { type: 'string' } } } }],
152
+ 'claude-sonnet-4-6',
153
+ );
154
+ expect(withTools).toBeGreaterThan(without);
155
+ });
156
+
157
+ it('uses default ratio when model omitted', () => {
158
+ const tc = new DefaultTokenCounter();
159
+ const tokens = tc.estimateRequestTokens([{ type: 'text', text: 'hello world' }], []);
160
+ expect(tokens).toBe(Math.ceil(11 / 4));
161
+ });
162
+ });
163
+ });
@@ -0,0 +1,42 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { TOKENS } from '../src/kernel/tokens.js';
3
+ import { Container } from '../src/kernel/container.js';
4
+
5
+ describe('TOKENS', () => {
6
+ it('exports exactly 15 tokens', () => {
7
+ const keys = Object.keys(TOKENS);
8
+ expect(keys).toHaveLength(15);
9
+ });
10
+
11
+ it('each token is a unique symbol', () => {
12
+ const symbols = Object.values(TOKENS);
13
+ const set = new Set(symbols);
14
+ expect(set.size).toBe(15);
15
+ });
16
+
17
+ it('all expected tokens are present', () => {
18
+ expect(TOKENS).toHaveProperty('Logger');
19
+ expect(TOKENS).toHaveProperty('TokenCounter');
20
+ expect(TOKENS).toHaveProperty('SessionStore');
21
+ expect(TOKENS).toHaveProperty('ConfigStore');
22
+ expect(TOKENS).toHaveProperty('ConfigLoader');
23
+ expect(TOKENS).toHaveProperty('PermissionPolicy');
24
+ expect(TOKENS).toHaveProperty('Compactor');
25
+ expect(TOKENS).toHaveProperty('PathResolver');
26
+ expect(TOKENS).toHaveProperty('Renderer');
27
+ expect(TOKENS).toHaveProperty('InputReader');
28
+ expect(TOKENS).toHaveProperty('ErrorHandler');
29
+ expect(TOKENS).toHaveProperty('RetryPolicy');
30
+ expect(TOKENS).toHaveProperty('SystemPromptBuilder');
31
+ expect(TOKENS).toHaveProperty('SecretScrubber');
32
+ expect(TOKENS).toHaveProperty('ProviderRunner');
33
+ });
34
+
35
+ it('tokens work with Container', () => {
36
+ const c = new Container();
37
+ c.bind(TOKENS.Logger, () => ({ log: () => {} }));
38
+ expect(c.has(TOKENS.Logger)).toBe(true);
39
+ const logger = c.resolve(TOKENS.Logger);
40
+ expect(logger).toEqual({ log: expect.any(Function) });
41
+ });
42
+ });
@@ -0,0 +1,452 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { promises as fsp } from 'node:fs';
3
+ import * as path from 'node:path';
4
+ import * as os from 'node:os';
5
+ import { ToolExecutor } from '../src/execution/tool-executor.js';
6
+ import { ToolRegistry } from '../src/execution/tool-registry.js';
7
+ import { AgentContext } from '../src/agent/context.js';
8
+ import type { Tool } from '../src/types/tool.js';
9
+
10
+ let tmpDir: string;
11
+
12
+ async function setupTmp(): Promise<string> {
13
+ tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'fcx-exec-'));
14
+ return tmpDir;
15
+ }
16
+
17
+ async function cleanupTmp(): Promise<void> {
18
+ await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
19
+ }
20
+
21
+ function makeCtx(dir: string): AgentContext {
22
+ return new AgentContext({
23
+ model: { provider: 'test', model: 'test-model' },
24
+ projectRoot: dir,
25
+ cwd: dir,
26
+ });
27
+ }
28
+
29
+ function makeTool(
30
+ name: string,
31
+ executeFn: (input: unknown, ctx: unknown, opts: { signal: AbortSignal }) => Promise<unknown>,
32
+ opts: Partial<Tool> = {},
33
+ ): Tool {
34
+ return {
35
+ name,
36
+ description: `test ${name}`,
37
+ inputSchema: { type: 'object', properties: {}, required: [] },
38
+ permission: 'auto',
39
+ mutating: false,
40
+ ...opts,
41
+ async execute(input, ctx, execOpts) {
42
+ return executeFn(input, ctx, execOpts);
43
+ },
44
+ };
45
+ }
46
+
47
+ describe('ToolExecutor', () => {
48
+ beforeEach(async () => {
49
+ await setupTmp();
50
+ });
51
+ afterEach(async () => {
52
+ await cleanupTmp();
53
+ });
54
+
55
+ it('executes a single tool and returns result', async () => {
56
+ const reg = new ToolRegistry();
57
+ reg.register(
58
+ makeTool('echo', async (input) => ({ echo: (input as { msg: string }).msg })),
59
+ 'test',
60
+ );
61
+ const exec = new ToolExecutor(reg);
62
+ const ctx = makeCtx(tmpDir);
63
+
64
+ const { results } = await exec.executeBatch(
65
+ [{ id: 't1', name: 'echo', input: { msg: 'hello' } }],
66
+ ctx,
67
+ );
68
+
69
+ expect(results).toHaveLength(1);
70
+ expect(results[0]!.block.type).toBe('tool_result');
71
+ expect(results[0]!.block.is_error).toBeFalsy();
72
+ expect(results[0]!.block.content).toContain('hello');
73
+ });
74
+
75
+ it('returns error result for unknown tool', async () => {
76
+ const reg = new ToolRegistry();
77
+ const exec = new ToolExecutor(reg);
78
+ const ctx = makeCtx(tmpDir);
79
+
80
+ const { results } = await exec.executeBatch(
81
+ [{ id: 't1', name: 'missing', input: {} }],
82
+ ctx,
83
+ );
84
+
85
+ expect(results[0]!.block.is_error).toBe(true);
86
+ const content = results[0]!.block.content;
87
+ expect(typeof content === 'string' ? content : '').toContain('not registered');
88
+ });
89
+
90
+ it('returns error result for schema validation failure', async () => {
91
+ const reg = new ToolRegistry();
92
+ reg.register(
93
+ {
94
+ name: 'greet',
95
+ description: 'greets',
96
+ inputSchema: {
97
+ type: 'object',
98
+ properties: { name: { type: 'string' } },
99
+ required: ['name'],
100
+ },
101
+ permission: 'auto',
102
+ mutating: false,
103
+ async execute(input) {
104
+ return `Hello ${(input as { name: string }).name}`;
105
+ },
106
+ },
107
+ 'test',
108
+ );
109
+ const exec = new ToolExecutor(reg);
110
+ const ctx = makeCtx(tmpDir);
111
+
112
+ const { results } = await exec.executeBatch(
113
+ [{ id: 't1', name: 'greet', input: {} }],
114
+ ctx,
115
+ );
116
+
117
+ expect(results[0]!.block.is_error).toBe(true);
118
+ const content = results[0]!.block.content;
119
+ expect(typeof content === 'string' ? content : '').toContain('Validation errors');
120
+ expect(typeof content === 'string' ? content : '').toContain('name');
121
+ });
122
+
123
+ it('returns error result when tool throws', async () => {
124
+ const reg = new ToolRegistry();
125
+ reg.register(
126
+ makeTool('boom', async () => {
127
+ throw new Error('kaboom');
128
+ }),
129
+ 'test',
130
+ );
131
+ const exec = new ToolExecutor(reg);
132
+ const ctx = makeCtx(tmpDir);
133
+
134
+ const { results } = await exec.executeBatch(
135
+ [{ id: 't1', name: 'boom', input: {} }],
136
+ ctx,
137
+ );
138
+
139
+ expect(results[0]!.block.is_error).toBe(true);
140
+ const content = results[0]!.block.content;
141
+ expect(typeof content === 'string' ? content : '').toContain('kaboom');
142
+ });
143
+
144
+ it('runs read-only tools in parallel (smart strategy)', async () => {
145
+ const reg = new ToolRegistry();
146
+ const order: string[] = [];
147
+ reg.register(
148
+ makeTool(
149
+ 'ro1',
150
+ async () => {
151
+ order.push('ro1-start');
152
+ await new Promise((r) => setTimeout(r, 50));
153
+ order.push('ro1-end');
154
+ return 'ok';
155
+ },
156
+ { mutating: false },
157
+ ),
158
+ 'test',
159
+ );
160
+ reg.register(
161
+ makeTool(
162
+ 'ro2',
163
+ async () => {
164
+ order.push('ro2-start');
165
+ await new Promise((r) => setTimeout(r, 50));
166
+ order.push('ro2-end');
167
+ return 'ok';
168
+ },
169
+ { mutating: false },
170
+ ),
171
+ 'test',
172
+ );
173
+ const exec = new ToolExecutor(reg);
174
+ const ctx = makeCtx(tmpDir);
175
+
176
+ await exec.executeBatch(
177
+ [
178
+ { id: 't1', name: 'ro1', input: {} },
179
+ { id: 't2', name: 'ro2', input: {} },
180
+ ],
181
+ ctx,
182
+ 'smart',
183
+ );
184
+
185
+ const starts = order.filter((s) => s.endsWith('-start'));
186
+ expect(starts).toContain('ro1-start');
187
+ expect(starts).toContain('ro2-start');
188
+ expect(starts).toHaveLength(2);
189
+ });
190
+
191
+ it('runs mutating tools sequentially (smart strategy)', async () => {
192
+ const reg = new ToolRegistry();
193
+ const order: string[] = [];
194
+ reg.register(
195
+ makeTool(
196
+ 'mut1',
197
+ async () => {
198
+ order.push('mut1-start');
199
+ await new Promise((r) => setTimeout(r, 30));
200
+ order.push('mut1-end');
201
+ return 'ok';
202
+ },
203
+ { mutating: true },
204
+ ),
205
+ 'test',
206
+ );
207
+ reg.register(
208
+ makeTool(
209
+ 'mut2',
210
+ async () => {
211
+ order.push('mut2-start');
212
+ await new Promise((r) => setTimeout(r, 30));
213
+ order.push('mut2-end');
214
+ return 'ok';
215
+ },
216
+ { mutating: true },
217
+ ),
218
+ 'test',
219
+ );
220
+ const exec = new ToolExecutor(reg);
221
+ const ctx = makeCtx(tmpDir);
222
+
223
+ await exec.executeBatch(
224
+ [
225
+ { id: 't1', name: 'mut1', input: {} },
226
+ { id: 't2', name: 'mut2', input: {} },
227
+ ],
228
+ ctx,
229
+ 'smart',
230
+ );
231
+
232
+ expect(order).toEqual([
233
+ 'mut1-start',
234
+ 'mut1-end',
235
+ 'mut2-start',
236
+ 'mut2-end',
237
+ ]);
238
+ });
239
+
240
+ it('preserves original order in results', async () => {
241
+ const reg = new ToolRegistry();
242
+ reg.register(makeTool('a', async () => 'A', { mutating: false }), 'test');
243
+ reg.register(makeTool('b', async () => 'B', { mutating: true }), 'test');
244
+ reg.register(makeTool('c', async () => 'C', { mutating: false }), 'test');
245
+ const exec = new ToolExecutor(reg);
246
+ const ctx = makeCtx(tmpDir);
247
+
248
+ const { results } = await exec.executeBatch(
249
+ [
250
+ { id: 't1', name: 'a', input: {} },
251
+ { id: 't2', name: 'b', input: {} },
252
+ { id: 't3', name: 'c', input: {} },
253
+ ],
254
+ ctx,
255
+ 'smart',
256
+ );
257
+
258
+ expect(results.map((r) => r.toolUseId)).toEqual(['t1', 't2', 't3']);
259
+ expect(results.map((r) => r.block.content)).toEqual(['A', 'B', 'C']);
260
+ });
261
+
262
+ it('does not short-circuit on failure', async () => {
263
+ const reg = new ToolRegistry();
264
+ reg.register(
265
+ makeTool('fail', async () => {
266
+ throw new Error('oops');
267
+ }),
268
+ 'test',
269
+ );
270
+ reg.register(makeTool('ok', async () => 'still runs'), 'test');
271
+ const exec = new ToolExecutor(reg);
272
+ const ctx = makeCtx(tmpDir);
273
+
274
+ const { results } = await exec.executeBatch(
275
+ [
276
+ { id: 't1', name: 'fail', input: {} },
277
+ { id: 't2', name: 'ok', input: {} },
278
+ ],
279
+ ctx,
280
+ );
281
+
282
+ expect(results).toHaveLength(2);
283
+ expect(results[0]!.block.is_error).toBe(true);
284
+ expect(results[1]!.block.is_error).toBeFalsy();
285
+ expect(results[1]!.block.content).toBe('still runs');
286
+ });
287
+
288
+ it('respects output budget cap', async () => {
289
+ const reg = new ToolRegistry();
290
+ reg.register(
291
+ makeTool('big', async () => 'X'.repeat(2000)),
292
+ 'test',
293
+ );
294
+ const exec = new ToolExecutor(reg, { perIterationOutputCapBytes: 500 });
295
+ const ctx = makeCtx(tmpDir);
296
+
297
+ const { results, remainingBudget } = await exec.executeBatch(
298
+ [{ id: 't1', name: 'big', input: {} }],
299
+ ctx,
300
+ );
301
+
302
+ expect(remainingBudget).toBe(0);
303
+ const content = results[0]!.block.content;
304
+ expect(typeof content === 'string' ? content.length : 0).toBeLessThan(2000);
305
+ expect(typeof content === 'string' ? content : '').toContain('[truncated');
306
+ });
307
+
308
+ it('aborts tool on parent signal', async () => {
309
+ const reg = new ToolRegistry();
310
+ const controller = new AbortController();
311
+ reg.register(
312
+ makeTool(
313
+ 'slow',
314
+ async (_, _ctx, opts) => {
315
+ for (let i = 0; i < 100; i++) {
316
+ if (opts.signal.aborted) throw new Error('aborted');
317
+ await new Promise((r) => setTimeout(r, 20));
318
+ }
319
+ return 'done';
320
+ },
321
+ ),
322
+ 'test',
323
+ );
324
+ const exec = new ToolExecutor(reg);
325
+ const ctx = new AgentContext({
326
+ model: { provider: 'test', model: 'test-model' },
327
+ projectRoot: tmpDir,
328
+ signal: controller.signal,
329
+ });
330
+
331
+ setTimeout(() => controller.abort(), 30);
332
+
333
+ const { results } = await exec.executeBatch(
334
+ [{ id: 't1', name: 'slow', input: {} }],
335
+ ctx,
336
+ );
337
+
338
+ expect(results[0]!.block.is_error).toBe(true);
339
+ const content = results[0]!.block.content;
340
+ expect(typeof content === 'string' ? content : '').toContain('aborted');
341
+ });
342
+
343
+ it('sequential strategy runs all in order', async () => {
344
+ const reg = new ToolRegistry();
345
+ const order: string[] = [];
346
+ reg.register(
347
+ makeTool(
348
+ 'a',
349
+ async () => {
350
+ order.push('a');
351
+ return 'A';
352
+ },
353
+ { mutating: false },
354
+ ),
355
+ 'test',
356
+ );
357
+ reg.register(
358
+ makeTool(
359
+ 'b',
360
+ async () => {
361
+ order.push('b');
362
+ return 'B';
363
+ },
364
+ { mutating: false },
365
+ ),
366
+ 'test',
367
+ );
368
+ const exec = new ToolExecutor(reg);
369
+ const ctx = makeCtx(tmpDir);
370
+
371
+ await exec.executeBatch(
372
+ [
373
+ { id: 't1', name: 'a', input: {} },
374
+ { id: 't2', name: 'b', input: {} },
375
+ ],
376
+ ctx,
377
+ 'sequential',
378
+ );
379
+
380
+ expect(order).toEqual(['a', 'b']);
381
+ });
382
+
383
+ it('parallel strategy runs all concurrently', async () => {
384
+ const reg = new ToolRegistry();
385
+ reg.register(
386
+ makeTool(
387
+ 'mut',
388
+ async () => {
389
+ await new Promise((r) => setTimeout(r, 30));
390
+ return 'ok';
391
+ },
392
+ { mutating: true },
393
+ ),
394
+ 'test',
395
+ );
396
+ const exec = new ToolExecutor(reg);
397
+ const ctx = makeCtx(tmpDir);
398
+
399
+ const start = Date.now();
400
+ await exec.executeBatch(
401
+ [
402
+ { id: 't1', name: 'mut', input: {} },
403
+ { id: 't2', name: 'mut', input: {} },
404
+ ],
405
+ ctx,
406
+ 'parallel',
407
+ );
408
+ const elapsed = Date.now() - start;
409
+
410
+ expect(elapsed).toBeLessThan(55);
411
+ });
412
+
413
+ it('scrubs secrets from tool output', async () => {
414
+ const reg = new ToolRegistry();
415
+ reg.register(
416
+ makeTool('leak', async () => 'key=sk-ant-api03-1234567890abcdef'),
417
+ 'test',
418
+ );
419
+ const exec = new ToolExecutor(reg);
420
+ const ctx = makeCtx(tmpDir);
421
+
422
+ const { results } = await exec.executeBatch(
423
+ [{ id: 't1', name: 'leak', input: {} }],
424
+ ctx,
425
+ );
426
+
427
+ const content = results[0]!.block.content;
428
+ expect(typeof content === 'string' ? content : '').not.toContain('sk-ant-api03-1234567890abcdef');
429
+ expect(typeof content === 'string' ? content : '').toContain('[REDACTED');
430
+ });
431
+
432
+ it('scrubs secrets from error messages', async () => {
433
+ const reg = new ToolRegistry();
434
+ reg.register(
435
+ makeTool('boom', async () => {
436
+ throw new Error('token=sk-ant-api03-' + 'a'.repeat(95));
437
+ }),
438
+ 'test',
439
+ );
440
+ const exec = new ToolExecutor(reg);
441
+ const ctx = makeCtx(tmpDir);
442
+
443
+ const { results } = await exec.executeBatch(
444
+ [{ id: 't1', name: 'boom', input: {} }],
445
+ ctx,
446
+ );
447
+
448
+ const content = results[0]!.block.content;
449
+ expect(typeof content === 'string' ? content : '').not.toContain('sk-ant-api03-' + 'a'.repeat(95));
450
+ expect(typeof content === 'string' ? content : '').toContain('[REDACTED');
451
+ });
452
+ });