@studio-foundation/runner 0.3.0-beta.1 → 0.3.0-beta.6

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 (78) hide show
  1. package/package.json +6 -3
  2. package/ARCHITECTURE.md +0 -53
  3. package/configs/agents/analyst.agent.yaml +0 -31
  4. package/configs/agents/code-generator.agent.yaml +0 -31
  5. package/configs/agents/generic.agent.yaml +0 -23
  6. package/src/__tests__/script-executor.test.ts +0 -180
  7. package/src/index.ts +0 -68
  8. package/src/integrations/integration-loader.test.ts +0 -88
  9. package/src/integrations/integration-loader.ts +0 -68
  10. package/src/middleware/anonymization.ts +0 -38
  11. package/src/plugins/index.ts +0 -4
  12. package/src/plugins/mcp-client.test.ts +0 -148
  13. package/src/plugins/mcp-client.ts +0 -128
  14. package/src/plugins/oauth-provider.test.ts +0 -167
  15. package/src/plugins/oauth-provider.ts +0 -175
  16. package/src/plugins/plugin-loader.test.ts +0 -114
  17. package/src/plugins/plugin-loader.ts +0 -90
  18. package/src/prompt-builder.test.ts +0 -167
  19. package/src/prompt-builder.ts +0 -332
  20. package/src/providers/anthropic.test.ts +0 -101
  21. package/src/providers/anthropic.ts +0 -135
  22. package/src/providers/mock.ts +0 -57
  23. package/src/providers/ollama.test.ts +0 -166
  24. package/src/providers/ollama.ts +0 -152
  25. package/src/providers/openai-responses.ts +0 -212
  26. package/src/providers/openai.test.ts +0 -67
  27. package/src/providers/openai.ts +0 -139
  28. package/src/providers/provider.ts +0 -54
  29. package/src/providers/registry.ts +0 -77
  30. package/src/runner.test.ts +0 -343
  31. package/src/runner.ts +0 -396
  32. package/src/script-executor.ts +0 -107
  33. package/src/tools/builtin/git.ts +0 -311
  34. package/src/tools/builtin/patch.ts +0 -257
  35. package/src/tools/builtin/repo-manager.ts +0 -142
  36. package/src/tools/builtin/search.ts +0 -108
  37. package/src/tools/builtin/shell.ts +0 -82
  38. package/src/tools/builtin/studio-run.ts +0 -73
  39. package/src/tools/builtin/web-search.test.ts +0 -122
  40. package/src/tools/builtin/web-search.ts +0 -101
  41. package/src/tools/errors.test.ts +0 -12
  42. package/src/tools/errors.ts +0 -6
  43. package/src/tools/plugin-loader.test.ts +0 -130
  44. package/src/tools/plugin-loader.ts +0 -203
  45. package/src/tools/skills/README.md +0 -49
  46. package/src/tools/skills/skill-loader.test.ts +0 -106
  47. package/src/tools/skills/skill-loader.ts +0 -62
  48. package/src/tools/tool-executor.test.ts +0 -88
  49. package/src/tools/tool-executor.ts +0 -84
  50. package/src/tools/tool-registry.ts +0 -130
  51. package/src/tools/yaml-executor.ts +0 -120
  52. package/src/utils/race-signal.test.ts +0 -50
  53. package/src/utils/race-signal.ts +0 -17
  54. package/templates/integrations/linear.integration.yaml +0 -35
  55. package/templates/integrations/slack.integration.yaml +0 -22
  56. package/templates/integrations/webhook.integration.yaml +0 -17
  57. package/templates/tools/git.tool.yaml +0 -80
  58. package/templates/tools/repo-manager.tool.yaml +0 -64
  59. package/templates/tools/search.tool.yaml +0 -22
  60. package/templates/tools/shell.tool.yaml +0 -19
  61. package/templates/tools/web-search.tool.yaml +0 -24
  62. package/tests/anonymization-middleware.test.ts +0 -61
  63. package/tests/anthropic.test.ts +0 -87
  64. package/tests/apply-patch.test.ts +0 -355
  65. package/tests/fixtures/tools/test-builtin.tool.yaml +0 -14
  66. package/tests/fixtures/tools/test-shell.tool.yaml +0 -19
  67. package/tests/mock-provider.test.ts +0 -104
  68. package/tests/openai.test.ts +0 -72
  69. package/tests/plugin-loader.test.ts +0 -54
  70. package/tests/prompt-builder.test.ts +0 -468
  71. package/tests/runner-anonymization.test.ts +0 -89
  72. package/tests/runner.test.ts +0 -885
  73. package/tests/studio-run.test.ts +0 -94
  74. package/tests/tool-executor.test.ts +0 -115
  75. package/tests/tool-registry.test.ts +0 -84
  76. package/tests/yaml-executor.test.ts +0 -76
  77. package/tsconfig.json +0 -20
  78. package/vitest.config.ts +0 -7
@@ -1,87 +0,0 @@
1
- /**
2
- * Anthropic provider tests
3
- */
4
-
5
- import { describe, it, expect, vi, beforeEach } from 'vitest';
6
-
7
- // Mock the Anthropic SDK BEFORE importing the provider
8
- const mockFinalMessage = {
9
- content: [{ type: 'text', text: 'Hello world' }],
10
- stop_reason: 'end_turn',
11
- usage: { input_tokens: 10, output_tokens: 5, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 },
12
- };
13
-
14
- const mockCreate = vi.fn().mockResolvedValue({
15
- content: [{ type: 'text', text: 'Hello world' }],
16
- stop_reason: 'end_turn',
17
- usage: { input_tokens: 10, output_tokens: 5, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 },
18
- });
19
-
20
- const mockStreamFn = vi.fn();
21
-
22
- vi.mock('@anthropic-ai/sdk', () => ({
23
- default: vi.fn(function () {
24
- return { messages: { create: mockCreate, stream: mockStreamFn } };
25
- }),
26
- }));
27
-
28
- import { AnthropicProvider } from '../src/providers/anthropic.js';
29
- import type { LLMRequest } from '@studio-foundation/contracts';
30
-
31
- const baseRequest: LLMRequest = {
32
- model: 'claude-haiku-4-5',
33
- messages: [{ role: 'user', content: 'Say hello' }],
34
- };
35
-
36
- describe('AnthropicProvider', () => {
37
- beforeEach(() => {
38
- vi.clearAllMocks();
39
- });
40
-
41
- it('calls messages.create when no onToken provided', async () => {
42
- mockCreate.mockResolvedValueOnce({
43
- content: [{ type: 'text', text: 'Hello world' }],
44
- stop_reason: 'end_turn',
45
- usage: { input_tokens: 10, output_tokens: 5, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 },
46
- });
47
- const provider = new AnthropicProvider('test-key');
48
- await provider.call(baseRequest);
49
- expect(mockCreate).toHaveBeenCalledTimes(1);
50
- expect(mockStreamFn).not.toHaveBeenCalled();
51
- });
52
-
53
- it('calls messages.stream when onToken is provided', async () => {
54
- const tokens: string[] = [];
55
- mockStreamFn.mockReturnValueOnce({
56
- on: vi.fn((event: string, handler: (text: string) => void) => {
57
- if (event === 'text') {
58
- handler('Hello');
59
- handler(' world');
60
- }
61
- }),
62
- finalMessage: vi.fn().mockResolvedValue(mockFinalMessage),
63
- });
64
-
65
- const provider = new AnthropicProvider('test-key');
66
- await provider.call(baseRequest, (t) => tokens.push(t));
67
-
68
- expect(mockStreamFn).toHaveBeenCalledTimes(1);
69
- expect(mockCreate).not.toHaveBeenCalled();
70
- expect(tokens).toEqual(['Hello', ' world']);
71
- });
72
-
73
- it('returns full LLMResponse with usage when streaming', async () => {
74
- mockStreamFn.mockReturnValueOnce({
75
- on: vi.fn((event: string, handler: (text: string) => void) => {
76
- if (event === 'text') handler('Hello world');
77
- }),
78
- finalMessage: vi.fn().mockResolvedValue(mockFinalMessage),
79
- });
80
-
81
- const provider = new AnthropicProvider('test-key');
82
- const result = await provider.call(baseRequest, () => {});
83
- expect(result.content).toBe('Hello world');
84
- expect(result.usage?.prompt_tokens).toBe(10);
85
- expect(result.usage?.completion_tokens).toBe(5);
86
- });
87
- });
@@ -1,355 +0,0 @@
1
- import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
- import * as fs from 'fs/promises';
3
- import * as path from 'path';
4
- import * as os from 'os';
5
- import { createPatchTools } from '../src/tools/builtin/patch.js';
6
- import type { Tool } from '../src/tools/tool-registry.js';
7
-
8
- describe('apply_patch tool', () => {
9
- let tmpDir: string;
10
- let tool: Tool;
11
-
12
- beforeEach(async () => {
13
- tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'patch-test-'));
14
- const tools = createPatchTools(tmpDir);
15
- tool = tools[0];
16
- });
17
-
18
- afterEach(async () => {
19
- await fs.rm(tmpDir, { recursive: true, force: true });
20
- });
21
-
22
- it('applies a simple single-hunk patch', async () => {
23
- await fs.writeFile(path.join(tmpDir, 'test.txt'), [
24
- 'line 1',
25
- 'line 2',
26
- 'line 3',
27
- 'line 4',
28
- 'line 5',
29
- ].join('\n'));
30
-
31
- const patch = [
32
- '@@ -1,5 +1,5 @@',
33
- ' line 1',
34
- ' line 2',
35
- '-line 3',
36
- '+line 3 modified',
37
- ' line 4',
38
- ' line 5',
39
- ].join('\n');
40
-
41
- const result = await tool.execute({ path: 'test.txt', patch });
42
-
43
- expect(result.success).toBe(true);
44
- expect(result.output).toMatchObject({
45
- hunks_applied: 1,
46
- hunks_total: 1,
47
- lines_added: 1,
48
- lines_removed: 1,
49
- });
50
-
51
- const content = await fs.readFile(path.join(tmpDir, 'test.txt'), 'utf-8');
52
- expect(content).toBe([
53
- 'line 1',
54
- 'line 2',
55
- 'line 3 modified',
56
- 'line 4',
57
- 'line 5',
58
- ].join('\n'));
59
- });
60
-
61
- it('applies a multi-hunk patch', async () => {
62
- await fs.writeFile(path.join(tmpDir, 'multi.txt'), [
63
- 'aaa', 'bbb', 'ccc', 'ddd', 'eee',
64
- 'fff', 'ggg', 'hhh', 'iii', 'jjj',
65
- ].join('\n'));
66
-
67
- const patch = [
68
- '@@ -1,5 +1,5 @@',
69
- ' aaa',
70
- '-bbb',
71
- '+bbb modified',
72
- ' ccc',
73
- ' ddd',
74
- ' eee',
75
- '@@ -6,5 +6,5 @@',
76
- ' fff',
77
- '-ggg',
78
- '+ggg modified',
79
- ' hhh',
80
- ' iii',
81
- ' jjj',
82
- ].join('\n');
83
-
84
- const result = await tool.execute({ path: 'multi.txt', patch });
85
-
86
- expect(result.success).toBe(true);
87
- expect(result.output).toMatchObject({
88
- hunks_applied: 2,
89
- hunks_total: 2,
90
- });
91
-
92
- const content = await fs.readFile(path.join(tmpDir, 'multi.txt'), 'utf-8');
93
- expect(content).toContain('bbb modified');
94
- expect(content).toContain('ggg modified');
95
- });
96
-
97
- it('applies an addition-only hunk', async () => {
98
- await fs.writeFile(path.join(tmpDir, 'add.txt'), [
99
- 'line 1',
100
- 'line 2',
101
- 'line 3',
102
- ].join('\n'));
103
-
104
- const patch = [
105
- '@@ -1,3 +1,5 @@',
106
- ' line 1',
107
- '+new line A',
108
- '+new line B',
109
- ' line 2',
110
- ' line 3',
111
- ].join('\n');
112
-
113
- const result = await tool.execute({ path: 'add.txt', patch });
114
-
115
- expect(result.success).toBe(true);
116
- expect(result.output).toMatchObject({
117
- lines_added: 2,
118
- lines_removed: 0,
119
- });
120
-
121
- const content = await fs.readFile(path.join(tmpDir, 'add.txt'), 'utf-8');
122
- expect(content).toBe([
123
- 'line 1',
124
- 'new line A',
125
- 'new line B',
126
- 'line 2',
127
- 'line 3',
128
- ].join('\n'));
129
- });
130
-
131
- it('applies a deletion-only hunk', async () => {
132
- await fs.writeFile(path.join(tmpDir, 'del.txt'), [
133
- 'line 1',
134
- 'line 2',
135
- 'line 3',
136
- 'line 4',
137
- ].join('\n'));
138
-
139
- const patch = [
140
- '@@ -1,4 +1,2 @@',
141
- ' line 1',
142
- '-line 2',
143
- '-line 3',
144
- ' line 4',
145
- ].join('\n');
146
-
147
- const result = await tool.execute({ path: 'del.txt', patch });
148
-
149
- expect(result.success).toBe(true);
150
- expect(result.output).toMatchObject({
151
- lines_added: 0,
152
- lines_removed: 2,
153
- });
154
-
155
- const content = await fs.readFile(path.join(tmpDir, 'del.txt'), 'utf-8');
156
- expect(content).toBe(['line 1', 'line 4'].join('\n'));
157
- });
158
-
159
- it('returns error on context mismatch', async () => {
160
- await fs.writeFile(path.join(tmpDir, 'mismatch.txt'), [
161
- 'actual line 1',
162
- 'actual line 2',
163
- 'actual line 3',
164
- ].join('\n'));
165
-
166
- const patch = [
167
- '@@ -1,3 +1,3 @@',
168
- ' wrong context',
169
- '-actual line 2',
170
- '+modified line 2',
171
- ' actual line 3',
172
- ].join('\n');
173
-
174
- const result = await tool.execute({ path: 'mismatch.txt', patch });
175
-
176
- expect(result.success).toBe(false);
177
- expect(result.error).toContain('Context mismatch');
178
- });
179
-
180
- it('returns error on file not found', async () => {
181
- const patch = [
182
- '@@ -1,1 +1,1 @@',
183
- '-old',
184
- '+new',
185
- ].join('\n');
186
-
187
- const result = await tool.execute({ path: 'nonexistent.txt', patch });
188
-
189
- expect(result.success).toBe(false);
190
- expect(result.error).toContain('File not found');
191
- });
192
-
193
- it('tolerates trailing whitespace differences in context', async () => {
194
- await fs.writeFile(path.join(tmpDir, 'ws.txt'), [
195
- 'line 1 ',
196
- 'line 2',
197
- 'line 3',
198
- ].join('\n'));
199
-
200
- const patch = [
201
- '@@ -1,3 +1,3 @@',
202
- ' line 1',
203
- '-line 2',
204
- '+line 2 modified',
205
- ' line 3',
206
- ].join('\n');
207
-
208
- const result = await tool.execute({ path: 'ws.txt', patch });
209
-
210
- expect(result.success).toBe(true);
211
- expect(result.output).toMatchObject({ hunks_applied: 1 });
212
- });
213
-
214
- it('matches by content when line number is wrong (offset matching)', async () => {
215
- await fs.writeFile(path.join(tmpDir, 'offset.txt'), [
216
- 'header 1',
217
- 'header 2',
218
- 'header 3',
219
- 'header 4',
220
- 'header 5',
221
- 'target line A',
222
- 'target line B',
223
- 'target line C',
224
- ].join('\n'));
225
-
226
- // Hunk says line 1 but content is at line 6
227
- const patch = [
228
- '@@ -1,3 +1,3 @@',
229
- ' target line A',
230
- '-target line B',
231
- '+target line B modified',
232
- ' target line C',
233
- ].join('\n');
234
-
235
- const result = await tool.execute({ path: 'offset.txt', patch });
236
-
237
- expect(result.success).toBe(true);
238
- const content = await fs.readFile(path.join(tmpDir, 'offset.txt'), 'utf-8');
239
- expect(content).toContain('target line B modified');
240
- });
241
-
242
- it('returns error on ambiguous match', async () => {
243
- await fs.writeFile(path.join(tmpDir, 'ambig.txt'), [
244
- 'unique header',
245
- 'repeat',
246
- 'target',
247
- 'repeat',
248
- 'repeat',
249
- 'target',
250
- 'repeat',
251
- ].join('\n'));
252
-
253
- // Hint at line 50 (wrong) — forces slow path scan, finds 2 matches
254
- const patch = [
255
- '@@ -50,3 +50,3 @@',
256
- ' repeat',
257
- '-target',
258
- '+target modified',
259
- ' repeat',
260
- ].join('\n');
261
-
262
- const result = await tool.execute({ path: 'ambig.txt', patch });
263
-
264
- expect(result.success).toBe(false);
265
- expect(result.error).toContain('Ambiguous');
266
- });
267
-
268
- it('ignores --- and +++ headers from LLM output', async () => {
269
- await fs.writeFile(path.join(tmpDir, 'headers.txt'), [
270
- 'line 1',
271
- 'line 2',
272
- 'line 3',
273
- ].join('\n'));
274
-
275
- const patch = [
276
- '--- a/headers.txt',
277
- '+++ b/headers.txt',
278
- '@@ -1,3 +1,3 @@',
279
- ' line 1',
280
- '-line 2',
281
- '+line 2 changed',
282
- ' line 3',
283
- ].join('\n');
284
-
285
- const result = await tool.execute({ path: 'headers.txt', patch });
286
-
287
- expect(result.success).toBe(true);
288
- const content = await fs.readFile(path.join(tmpDir, 'headers.txt'), 'utf-8');
289
- expect(content).toContain('line 2 changed');
290
- });
291
-
292
- it('returns error on invalid patch format (no hunks)', async () => {
293
- await fs.writeFile(path.join(tmpDir, 'file.txt'), 'content');
294
-
295
- const result = await tool.execute({ path: 'file.txt', patch: 'not a patch' });
296
-
297
- expect(result.success).toBe(false);
298
- expect(result.error).toContain('Invalid patch format');
299
- });
300
-
301
- // Bug: trailing '\n' in patch string → split('\n') produces a trailing ''
302
- // which was treated as a blank context line, causing "Ambiguous match" when
303
- // the file has multiple blank lines (each blank line matched the '' context).
304
- it('does not fail with ambiguous match when all-addition patch ends with newline and file has blank lines', async () => {
305
- await fs.writeFile(path.join(tmpDir, 'bug-report.txt'), [
306
- 'import A',
307
- '',
308
- 'import B',
309
- '',
310
- 'const x = 1',
311
- '',
312
- 'export default x',
313
- ].join('\n'));
314
-
315
- // All-addition patch ending with \n — the real-world failure pattern
316
- const patch = '@@ -0,0 +1,3 @@\n+new line 1\n+new line 2\n+new line 3\n';
317
-
318
- const result = await tool.execute({ path: 'bug-report.txt', patch });
319
-
320
- expect(result.success).toBe(true);
321
- const content = await fs.readFile(path.join(tmpDir, 'bug-report.txt'), 'utf-8');
322
- expect(content).toContain('new line 1');
323
- });
324
-
325
- it('applies patch with context lines correctly when patch string has trailing newline', async () => {
326
- await fs.writeFile(path.join(tmpDir, 'tn.txt'), ['line 1', 'line 2', 'line 3'].join('\n'));
327
-
328
- // Trailing \n makes split produce a trailing '' that must NOT extend the oldBlock
329
- const patch = '@@ -1,3 +1,3 @@\n line 1\n-line 2\n+line 2 modified\n line 3\n';
330
-
331
- const result = await tool.execute({ path: 'tn.txt', patch });
332
-
333
- expect(result.success).toBe(true);
334
- const content = await fs.readFile(path.join(tmpDir, 'tn.txt'), 'utf-8');
335
- expect(content).toContain('line 2 modified');
336
- });
337
-
338
- // Bug: when oldBlock is empty (pure insertion, @@ -0,0 +1,N @@), the slow-path
339
- // scan vacuously matched every file position → "Ambiguous match".
340
- it('applies pure-insertion hunk (@@ -0,0 +1,N @@) by prepending to an existing file', async () => {
341
- await fs.writeFile(path.join(tmpDir, 'prepend.txt'), [
342
- 'existing line 1',
343
- 'existing line 2',
344
- ].join('\n'));
345
-
346
- // Pure-insertion: old side has 0 lines, new side adds 2 lines
347
- const patch = '@@ -0,0 +1,2 @@\n+prepended A\n+prepended B';
348
-
349
- const result = await tool.execute({ path: 'prepend.txt', patch });
350
-
351
- expect(result.success).toBe(true);
352
- const content = await fs.readFile(path.join(tmpDir, 'prepend.txt'), 'utf-8');
353
- expect(content).toBe('prepended A\nprepended B\nexisting line 1\nexisting line 2');
354
- });
355
- });
@@ -1,14 +0,0 @@
1
- name: test_builtin
2
- description: Builtin-backed test tool
3
- version: 1
4
-
5
- commands:
6
- - name: repo_manager-list_files
7
- description: List files in the workspace
8
- parameters:
9
- path:
10
- type: string
11
- required: false
12
- execute:
13
- type: builtin
14
- parse_output: json
@@ -1,19 +0,0 @@
1
- name: test_shell
2
- description: Shell-based test tool
3
- version: 1
4
-
5
- commands:
6
- - name: test_shell-echo
7
- description: Echo a message
8
- parameters:
9
- message:
10
- type: string
11
- required: true
12
- description: Message to echo
13
- execute:
14
- type: shell
15
- command: echo {{message}}
16
- parse_output: text
17
-
18
- prompt_snippet: |
19
- You have access to a test shell tool.
@@ -1,104 +0,0 @@
1
- import { describe, it, expect, vi } from 'vitest';
2
- import { MockProvider } from '../src/providers/mock.js';
3
-
4
- const stagesMap = new Map([
5
- ['brief-analysis', {
6
- output: { summary: 'mock summary', requirements: ['req1'] },
7
- tool_calls: [],
8
- }],
9
- ['code-generation', {
10
- output: { summary: 'mock code', files_changed: ['foo.ts'] },
11
- tool_calls: [
12
- { name: 'repo_manager-write_file', arguments: { path: 'foo.ts', content: '// mock' } },
13
- ],
14
- }],
15
- ]);
16
-
17
- describe('MockProvider', () => {
18
- it('returns predefined output for a known stage', async () => {
19
- const provider = new MockProvider(stagesMap);
20
- const executeTool = vi.fn().mockResolvedValue({ result: 'ok' });
21
-
22
- const result = await provider.runAgentLoop(
23
- {
24
- model: 'mock',
25
- messages: [],
26
- stage_name: 'brief-analysis',
27
- },
28
- executeTool
29
- );
30
-
31
- expect(result.content).toBe(JSON.stringify({ summary: 'mock summary', requirements: ['req1'] }));
32
- expect(result.tool_calls).toHaveLength(0);
33
- expect(result.finish_reason).toBe('stop');
34
- expect(result.usage?.total_tokens).toBe(0);
35
- expect(executeTool).not.toHaveBeenCalled();
36
- });
37
-
38
- it('calls executeTool for each tool call in config', async () => {
39
- const provider = new MockProvider(stagesMap);
40
- const executeTool = vi.fn().mockResolvedValue({ result: 'written' });
41
-
42
- const result = await provider.runAgentLoop(
43
- {
44
- model: 'mock',
45
- messages: [],
46
- stage_name: 'code-generation',
47
- },
48
- executeTool
49
- );
50
-
51
- expect(executeTool).toHaveBeenCalledOnce();
52
- expect(executeTool).toHaveBeenCalledWith(
53
- 'repo_manager-write_file',
54
- { path: 'foo.ts', content: '// mock' },
55
- expect.any(String)
56
- );
57
- expect(result.tool_calls).toHaveLength(1);
58
- expect(result.tool_calls[0].name).toBe('repo_manager-write_file');
59
- });
60
-
61
- it('throws a clear error for unknown stage', async () => {
62
- const provider = new MockProvider(stagesMap);
63
-
64
- await expect(
65
- provider.runAgentLoop({ model: 'mock', messages: [], stage_name: 'unknown-stage' }, vi.fn())
66
- ).rejects.toThrow('Unknown mock stage: "unknown-stage"');
67
- });
68
-
69
- it('throws when stage_name is missing', async () => {
70
- const provider = new MockProvider(stagesMap);
71
-
72
- await expect(
73
- provider.runAgentLoop({ model: 'mock', messages: [] }, vi.fn())
74
- ).rejects.toThrow('MockProvider requires stage_name');
75
- });
76
-
77
- it('emits a fake token when onToken is provided', async () => {
78
- const stages = new Map([
79
- ['test-stage', { output: { result: 'ok' }, tool_calls: [] }],
80
- ]);
81
- const provider = new MockProvider(stages);
82
-
83
- const tokens: string[] = [];
84
- await provider.runAgentLoop(
85
- { model: 'mock', messages: [{ role: 'user', content: 'go' }], stage_name: 'test-stage' },
86
- async () => ({ result: 'ok' }),
87
- (token) => tokens.push(token)
88
- );
89
-
90
- expect(tokens.length).toBeGreaterThan(0);
91
- });
92
-
93
- it('does not error when onToken is not provided', async () => {
94
- const stages = new Map([
95
- ['test-stage', { output: { result: 'ok' }, tool_calls: [] }],
96
- ]);
97
- const provider = new MockProvider(stages);
98
-
99
- await expect(provider.runAgentLoop(
100
- { model: 'mock', messages: [{ role: 'user', content: 'go' }], stage_name: 'test-stage' },
101
- async () => ({ result: 'ok' }),
102
- )).resolves.toBeDefined();
103
- });
104
- });
@@ -1,72 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach } from 'vitest';
2
-
3
- // Helper: build a text chunk
4
- function textChunk(content: string) {
5
- return { choices: [{ delta: { content, tool_calls: undefined }, finish_reason: null }], usage: null };
6
- }
7
- // Helper: build a stop chunk with usage
8
- function stopChunk(usage = { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }) {
9
- return { choices: [{ delta: {}, finish_reason: 'stop' }], usage };
10
- }
11
- // Helper: async generator
12
- async function* makeStream(chunks: object[]) {
13
- for (const c of chunks) yield c;
14
- }
15
-
16
- const mockCreate = vi.fn();
17
-
18
- vi.mock('openai', () => ({
19
- default: vi.fn(function () {
20
- return { chat: { completions: { create: mockCreate } } };
21
- }),
22
- }));
23
-
24
- import { OpenAIProvider } from '../src/providers/openai.js';
25
- import type { LLMRequest } from '@studio-foundation/contracts';
26
-
27
- const baseRequest: LLMRequest = {
28
- model: 'gpt-4o-mini',
29
- messages: [{ role: 'user', content: 'Say hello' }],
30
- };
31
-
32
- describe('OpenAIProvider', () => {
33
- beforeEach(() => {
34
- vi.clearAllMocks();
35
- });
36
-
37
- it('uses non-streaming create when no onToken provided', async () => {
38
- mockCreate.mockResolvedValueOnce({
39
- choices: [{ message: { content: 'Hello', tool_calls: null }, finish_reason: 'stop' }],
40
- usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
41
- });
42
- const provider = new OpenAIProvider('test-key');
43
- await provider.call(baseRequest);
44
- expect(mockCreate).toHaveBeenCalledWith(expect.not.objectContaining({ stream: true }), expect.anything());
45
- });
46
-
47
- it('uses streaming create when onToken is provided', async () => {
48
- mockCreate.mockReturnValueOnce(makeStream([
49
- textChunk('Hello'),
50
- textChunk(' world'),
51
- stopChunk(),
52
- ]));
53
- const provider = new OpenAIProvider('test-key');
54
- const tokens: string[] = [];
55
- await provider.call(baseRequest, (t) => tokens.push(t));
56
- expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ stream: true }), expect.anything());
57
- expect(tokens).toEqual(['Hello', ' world']);
58
- });
59
-
60
- it('accumulates content and returns correct LLMResponse when streaming', async () => {
61
- mockCreate.mockReturnValueOnce(makeStream([
62
- textChunk('Hello'),
63
- textChunk(' world'),
64
- stopChunk({ prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }),
65
- ]));
66
- const provider = new OpenAIProvider('test-key');
67
- const result = await provider.call(baseRequest, () => {});
68
- expect(result.content).toBe('Hello world');
69
- expect(result.finish_reason).toBe('stop');
70
- expect(result.usage?.prompt_tokens).toBe(10);
71
- });
72
- });