@jackchen_me/open-multi-agent 0.2.0 → 1.0.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 (104) hide show
  1. package/.github/workflows/ci.yml +1 -1
  2. package/CLAUDE.md +11 -3
  3. package/README.md +87 -20
  4. package/README_zh.md +85 -25
  5. package/dist/agent/agent.d.ts +15 -1
  6. package/dist/agent/agent.d.ts.map +1 -1
  7. package/dist/agent/agent.js +144 -10
  8. package/dist/agent/agent.js.map +1 -1
  9. package/dist/agent/loop-detector.d.ts +39 -0
  10. package/dist/agent/loop-detector.d.ts.map +1 -0
  11. package/dist/agent/loop-detector.js +122 -0
  12. package/dist/agent/loop-detector.js.map +1 -0
  13. package/dist/agent/pool.d.ts +2 -1
  14. package/dist/agent/pool.d.ts.map +1 -1
  15. package/dist/agent/pool.js +4 -2
  16. package/dist/agent/pool.js.map +1 -1
  17. package/dist/agent/runner.d.ts +23 -1
  18. package/dist/agent/runner.d.ts.map +1 -1
  19. package/dist/agent/runner.js +113 -12
  20. package/dist/agent/runner.js.map +1 -1
  21. package/dist/index.d.ts +3 -1
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +2 -0
  24. package/dist/index.js.map +1 -1
  25. package/dist/llm/adapter.d.ts +4 -1
  26. package/dist/llm/adapter.d.ts.map +1 -1
  27. package/dist/llm/adapter.js +11 -0
  28. package/dist/llm/adapter.js.map +1 -1
  29. package/dist/llm/copilot.d.ts.map +1 -1
  30. package/dist/llm/copilot.js +2 -1
  31. package/dist/llm/copilot.js.map +1 -1
  32. package/dist/llm/gemini.d.ts +65 -0
  33. package/dist/llm/gemini.d.ts.map +1 -0
  34. package/dist/llm/gemini.js +317 -0
  35. package/dist/llm/gemini.js.map +1 -0
  36. package/dist/llm/grok.d.ts +21 -0
  37. package/dist/llm/grok.d.ts.map +1 -0
  38. package/dist/llm/grok.js +24 -0
  39. package/dist/llm/grok.js.map +1 -0
  40. package/dist/llm/openai-common.d.ts +8 -1
  41. package/dist/llm/openai-common.d.ts.map +1 -1
  42. package/dist/llm/openai-common.js +35 -2
  43. package/dist/llm/openai-common.js.map +1 -1
  44. package/dist/llm/openai.d.ts +1 -1
  45. package/dist/llm/openai.d.ts.map +1 -1
  46. package/dist/llm/openai.js +20 -2
  47. package/dist/llm/openai.js.map +1 -1
  48. package/dist/orchestrator/orchestrator.d.ts.map +1 -1
  49. package/dist/orchestrator/orchestrator.js +89 -9
  50. package/dist/orchestrator/orchestrator.js.map +1 -1
  51. package/dist/task/queue.d.ts +31 -2
  52. package/dist/task/queue.d.ts.map +1 -1
  53. package/dist/task/queue.js +69 -2
  54. package/dist/task/queue.js.map +1 -1
  55. package/dist/tool/text-tool-extractor.d.ts +32 -0
  56. package/dist/tool/text-tool-extractor.d.ts.map +1 -0
  57. package/dist/tool/text-tool-extractor.js +187 -0
  58. package/dist/tool/text-tool-extractor.js.map +1 -0
  59. package/dist/types.d.ts +139 -7
  60. package/dist/types.d.ts.map +1 -1
  61. package/dist/utils/trace.d.ts +12 -0
  62. package/dist/utils/trace.d.ts.map +1 -0
  63. package/dist/utils/trace.js +30 -0
  64. package/dist/utils/trace.js.map +1 -0
  65. package/examples/06-local-model.ts +1 -0
  66. package/examples/08-gemma4-local.ts +76 -87
  67. package/examples/09-structured-output.ts +73 -0
  68. package/examples/10-task-retry.ts +132 -0
  69. package/examples/11-trace-observability.ts +133 -0
  70. package/examples/12-grok.ts +154 -0
  71. package/examples/13-gemini.ts +48 -0
  72. package/package.json +11 -1
  73. package/src/agent/agent.ts +159 -10
  74. package/src/agent/loop-detector.ts +137 -0
  75. package/src/agent/pool.ts +9 -2
  76. package/src/agent/runner.ts +148 -19
  77. package/src/index.ts +15 -0
  78. package/src/llm/adapter.ts +12 -1
  79. package/src/llm/copilot.ts +2 -1
  80. package/src/llm/gemini.ts +378 -0
  81. package/src/llm/grok.ts +29 -0
  82. package/src/llm/openai-common.ts +41 -2
  83. package/src/llm/openai.ts +23 -3
  84. package/src/orchestrator/orchestrator.ts +105 -11
  85. package/src/task/queue.ts +73 -3
  86. package/src/tool/text-tool-extractor.ts +219 -0
  87. package/src/types.ts +157 -6
  88. package/src/utils/trace.ts +34 -0
  89. package/tests/agent-hooks.test.ts +473 -0
  90. package/tests/agent-pool.test.ts +212 -0
  91. package/tests/approval.test.ts +464 -0
  92. package/tests/built-in-tools.test.ts +393 -0
  93. package/tests/gemini-adapter.test.ts +97 -0
  94. package/tests/grok-adapter.test.ts +74 -0
  95. package/tests/llm-adapters.test.ts +357 -0
  96. package/tests/loop-detection.test.ts +456 -0
  97. package/tests/openai-fallback.test.ts +159 -0
  98. package/tests/orchestrator.test.ts +281 -0
  99. package/tests/scheduler.test.ts +221 -0
  100. package/tests/team-messaging.test.ts +329 -0
  101. package/tests/text-tool-extractor.test.ts +170 -0
  102. package/tests/trace.test.ts +453 -0
  103. package/vitest.config.ts +9 -0
  104. package/examples/09-gemma4-auto-orchestration.ts +0 -162
@@ -0,0 +1,393 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2
+ import { mkdtemp, rm, writeFile, readFile } from 'fs/promises'
3
+ import { join } from 'path'
4
+ import { tmpdir } from 'os'
5
+ import { fileReadTool } from '../src/tool/built-in/file-read.js'
6
+ import { fileWriteTool } from '../src/tool/built-in/file-write.js'
7
+ import { fileEditTool } from '../src/tool/built-in/file-edit.js'
8
+ import { bashTool } from '../src/tool/built-in/bash.js'
9
+ import { grepTool } from '../src/tool/built-in/grep.js'
10
+ import { registerBuiltInTools, BUILT_IN_TOOLS } from '../src/tool/built-in/index.js'
11
+ import { ToolRegistry } from '../src/tool/framework.js'
12
+ import type { ToolUseContext } from '../src/types.js'
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Helpers
16
+ // ---------------------------------------------------------------------------
17
+
18
+ const defaultContext: ToolUseContext = {
19
+ agent: { name: 'test-agent', role: 'tester', model: 'test' },
20
+ }
21
+
22
+ let tmpDir: string
23
+
24
+ beforeEach(async () => {
25
+ tmpDir = await mkdtemp(join(tmpdir(), 'oma-test-'))
26
+ })
27
+
28
+ afterEach(async () => {
29
+ await rm(tmpDir, { recursive: true, force: true })
30
+ })
31
+
32
+ // ===========================================================================
33
+ // registerBuiltInTools
34
+ // ===========================================================================
35
+
36
+ describe('registerBuiltInTools', () => {
37
+ it('registers all 5 built-in tools', () => {
38
+ const registry = new ToolRegistry()
39
+ registerBuiltInTools(registry)
40
+
41
+ expect(registry.get('bash')).toBeDefined()
42
+ expect(registry.get('file_read')).toBeDefined()
43
+ expect(registry.get('file_write')).toBeDefined()
44
+ expect(registry.get('file_edit')).toBeDefined()
45
+ expect(registry.get('grep')).toBeDefined()
46
+ })
47
+
48
+ it('BUILT_IN_TOOLS has correct length', () => {
49
+ expect(BUILT_IN_TOOLS).toHaveLength(5)
50
+ })
51
+ })
52
+
53
+ // ===========================================================================
54
+ // file_read
55
+ // ===========================================================================
56
+
57
+ describe('file_read', () => {
58
+ it('reads a file with line numbers', async () => {
59
+ const filePath = join(tmpDir, 'test.txt')
60
+ await writeFile(filePath, 'line one\nline two\nline three\n')
61
+
62
+ const result = await fileReadTool.execute({ path: filePath }, defaultContext)
63
+
64
+ expect(result.isError).toBe(false)
65
+ expect(result.data).toContain('1\tline one')
66
+ expect(result.data).toContain('2\tline two')
67
+ expect(result.data).toContain('3\tline three')
68
+ })
69
+
70
+ it('reads a slice with offset and limit', async () => {
71
+ const filePath = join(tmpDir, 'test.txt')
72
+ await writeFile(filePath, 'a\nb\nc\nd\ne\n')
73
+
74
+ const result = await fileReadTool.execute(
75
+ { path: filePath, offset: 2, limit: 2 },
76
+ defaultContext,
77
+ )
78
+
79
+ expect(result.isError).toBe(false)
80
+ expect(result.data).toContain('2\tb')
81
+ expect(result.data).toContain('3\tc')
82
+ expect(result.data).not.toContain('1\ta')
83
+ })
84
+
85
+ it('errors on non-existent file', async () => {
86
+ const result = await fileReadTool.execute(
87
+ { path: join(tmpDir, 'nope.txt') },
88
+ defaultContext,
89
+ )
90
+
91
+ expect(result.isError).toBe(true)
92
+ expect(result.data).toContain('Could not read file')
93
+ })
94
+
95
+ it('errors when offset is beyond end of file', async () => {
96
+ const filePath = join(tmpDir, 'short.txt')
97
+ await writeFile(filePath, 'one line\n')
98
+
99
+ const result = await fileReadTool.execute(
100
+ { path: filePath, offset: 100 },
101
+ defaultContext,
102
+ )
103
+
104
+ expect(result.isError).toBe(true)
105
+ expect(result.data).toContain('beyond the end')
106
+ })
107
+
108
+ it('shows truncation note when not reading entire file', async () => {
109
+ const filePath = join(tmpDir, 'multi.txt')
110
+ await writeFile(filePath, 'a\nb\nc\nd\ne\n')
111
+
112
+ const result = await fileReadTool.execute(
113
+ { path: filePath, limit: 2 },
114
+ defaultContext,
115
+ )
116
+
117
+ expect(result.data).toContain('showing lines')
118
+ })
119
+ })
120
+
121
+ // ===========================================================================
122
+ // file_write
123
+ // ===========================================================================
124
+
125
+ describe('file_write', () => {
126
+ it('creates a new file', async () => {
127
+ const filePath = join(tmpDir, 'new-file.txt')
128
+
129
+ const result = await fileWriteTool.execute(
130
+ { path: filePath, content: 'hello world' },
131
+ defaultContext,
132
+ )
133
+
134
+ expect(result.isError).toBe(false)
135
+ expect(result.data).toContain('Created')
136
+ const content = await readFile(filePath, 'utf8')
137
+ expect(content).toBe('hello world')
138
+ })
139
+
140
+ it('overwrites an existing file', async () => {
141
+ const filePath = join(tmpDir, 'existing.txt')
142
+ await writeFile(filePath, 'old content')
143
+
144
+ const result = await fileWriteTool.execute(
145
+ { path: filePath, content: 'new content' },
146
+ defaultContext,
147
+ )
148
+
149
+ expect(result.isError).toBe(false)
150
+ expect(result.data).toContain('Updated')
151
+ const content = await readFile(filePath, 'utf8')
152
+ expect(content).toBe('new content')
153
+ })
154
+
155
+ it('creates parent directories', async () => {
156
+ const filePath = join(tmpDir, 'deep', 'nested', 'file.txt')
157
+
158
+ const result = await fileWriteTool.execute(
159
+ { path: filePath, content: 'deep file' },
160
+ defaultContext,
161
+ )
162
+
163
+ expect(result.isError).toBe(false)
164
+ const content = await readFile(filePath, 'utf8')
165
+ expect(content).toBe('deep file')
166
+ })
167
+
168
+ it('reports line and byte counts', async () => {
169
+ const filePath = join(tmpDir, 'counted.txt')
170
+
171
+ const result = await fileWriteTool.execute(
172
+ { path: filePath, content: 'line1\nline2\nline3' },
173
+ defaultContext,
174
+ )
175
+
176
+ expect(result.data).toContain('3 lines')
177
+ })
178
+ })
179
+
180
+ // ===========================================================================
181
+ // file_edit
182
+ // ===========================================================================
183
+
184
+ describe('file_edit', () => {
185
+ it('replaces a unique string', async () => {
186
+ const filePath = join(tmpDir, 'edit.txt')
187
+ await writeFile(filePath, 'hello world\ngoodbye world\n')
188
+
189
+ const result = await fileEditTool.execute(
190
+ { path: filePath, old_string: 'hello', new_string: 'hi' },
191
+ defaultContext,
192
+ )
193
+
194
+ expect(result.isError).toBe(false)
195
+ expect(result.data).toContain('Replaced 1 occurrence')
196
+ const content = await readFile(filePath, 'utf8')
197
+ expect(content).toContain('hi world')
198
+ expect(content).toContain('goodbye world')
199
+ })
200
+
201
+ it('errors when old_string not found', async () => {
202
+ const filePath = join(tmpDir, 'edit.txt')
203
+ await writeFile(filePath, 'hello world\n')
204
+
205
+ const result = await fileEditTool.execute(
206
+ { path: filePath, old_string: 'nonexistent', new_string: 'x' },
207
+ defaultContext,
208
+ )
209
+
210
+ expect(result.isError).toBe(true)
211
+ expect(result.data).toContain('not found')
212
+ })
213
+
214
+ it('errors on ambiguous match without replace_all', async () => {
215
+ const filePath = join(tmpDir, 'edit.txt')
216
+ await writeFile(filePath, 'foo bar foo\n')
217
+
218
+ const result = await fileEditTool.execute(
219
+ { path: filePath, old_string: 'foo', new_string: 'baz' },
220
+ defaultContext,
221
+ )
222
+
223
+ expect(result.isError).toBe(true)
224
+ expect(result.data).toContain('2 times')
225
+ })
226
+
227
+ it('replaces all when replace_all is true', async () => {
228
+ const filePath = join(tmpDir, 'edit.txt')
229
+ await writeFile(filePath, 'foo bar foo\n')
230
+
231
+ const result = await fileEditTool.execute(
232
+ { path: filePath, old_string: 'foo', new_string: 'baz', replace_all: true },
233
+ defaultContext,
234
+ )
235
+
236
+ expect(result.isError).toBe(false)
237
+ expect(result.data).toContain('Replaced 2 occurrences')
238
+ const content = await readFile(filePath, 'utf8')
239
+ expect(content).toBe('baz bar baz\n')
240
+ })
241
+
242
+ it('errors on non-existent file', async () => {
243
+ const result = await fileEditTool.execute(
244
+ { path: join(tmpDir, 'nope.txt'), old_string: 'x', new_string: 'y' },
245
+ defaultContext,
246
+ )
247
+
248
+ expect(result.isError).toBe(true)
249
+ expect(result.data).toContain('Could not read')
250
+ })
251
+ })
252
+
253
+ // ===========================================================================
254
+ // bash
255
+ // ===========================================================================
256
+
257
+ describe('bash', () => {
258
+ it('executes a simple command', async () => {
259
+ const result = await bashTool.execute(
260
+ { command: 'echo "hello bash"' },
261
+ defaultContext,
262
+ )
263
+
264
+ expect(result.isError).toBe(false)
265
+ expect(result.data).toContain('hello bash')
266
+ })
267
+
268
+ it('captures stderr on failed command', async () => {
269
+ const result = await bashTool.execute(
270
+ { command: 'ls /nonexistent/path/xyz 2>&1' },
271
+ defaultContext,
272
+ )
273
+
274
+ expect(result.isError).toBe(true)
275
+ })
276
+
277
+ it('supports custom working directory', async () => {
278
+ const result = await bashTool.execute(
279
+ { command: 'pwd', cwd: tmpDir },
280
+ defaultContext,
281
+ )
282
+
283
+ expect(result.isError).toBe(false)
284
+ expect(result.data).toContain(tmpDir)
285
+ })
286
+
287
+ it('returns exit code for failing commands', async () => {
288
+ const result = await bashTool.execute(
289
+ { command: 'exit 42' },
290
+ defaultContext,
291
+ )
292
+
293
+ expect(result.isError).toBe(true)
294
+ expect(result.data).toContain('42')
295
+ })
296
+
297
+ it('handles commands with no output', async () => {
298
+ const result = await bashTool.execute(
299
+ { command: 'true' },
300
+ defaultContext,
301
+ )
302
+
303
+ expect(result.isError).toBe(false)
304
+ expect(result.data).toContain('command completed with no output')
305
+ })
306
+ })
307
+
308
+ // ===========================================================================
309
+ // grep (Node.js fallback — tests do not depend on ripgrep availability)
310
+ // ===========================================================================
311
+
312
+ describe('grep', () => {
313
+ it('finds matching lines in a file', async () => {
314
+ const filePath = join(tmpDir, 'search.txt')
315
+ await writeFile(filePath, 'apple\nbanana\napricot\ncherry\n')
316
+
317
+ const result = await grepTool.execute(
318
+ { pattern: 'ap', path: filePath },
319
+ defaultContext,
320
+ )
321
+
322
+ expect(result.isError).toBe(false)
323
+ expect(result.data).toContain('apple')
324
+ expect(result.data).toContain('apricot')
325
+ expect(result.data).not.toContain('cherry')
326
+ })
327
+
328
+ it('returns "No matches found" when nothing matches', async () => {
329
+ const filePath = join(tmpDir, 'search.txt')
330
+ await writeFile(filePath, 'hello world\n')
331
+
332
+ const result = await grepTool.execute(
333
+ { pattern: 'zzz', path: filePath },
334
+ defaultContext,
335
+ )
336
+
337
+ expect(result.isError).toBe(false)
338
+ expect(result.data).toContain('No matches found')
339
+ })
340
+
341
+ it('errors on invalid regex', async () => {
342
+ const result = await grepTool.execute(
343
+ { pattern: '[invalid', path: tmpDir },
344
+ defaultContext,
345
+ )
346
+
347
+ expect(result.isError).toBe(true)
348
+ expect(result.data).toContain('Invalid regular expression')
349
+ })
350
+
351
+ it('searches recursively in a directory', async () => {
352
+ const subDir = join(tmpDir, 'sub')
353
+ await writeFile(join(tmpDir, 'a.txt'), 'findme here\n')
354
+ // Create subdir and file
355
+ const { mkdir } = await import('fs/promises')
356
+ await mkdir(subDir, { recursive: true })
357
+ await writeFile(join(subDir, 'b.txt'), 'findme there\n')
358
+
359
+ const result = await grepTool.execute(
360
+ { pattern: 'findme', path: tmpDir },
361
+ defaultContext,
362
+ )
363
+
364
+ expect(result.isError).toBe(false)
365
+ expect(result.data).toContain('findme here')
366
+ expect(result.data).toContain('findme there')
367
+ })
368
+
369
+ it('respects glob filter', async () => {
370
+ await writeFile(join(tmpDir, 'code.ts'), 'const x = 1\n')
371
+ await writeFile(join(tmpDir, 'readme.md'), 'const y = 2\n')
372
+
373
+ const result = await grepTool.execute(
374
+ { pattern: 'const', path: tmpDir, glob: '*.ts' },
375
+ defaultContext,
376
+ )
377
+
378
+ expect(result.isError).toBe(false)
379
+ expect(result.data).toContain('code.ts')
380
+ expect(result.data).not.toContain('readme.md')
381
+ })
382
+
383
+ it('errors on inaccessible path', async () => {
384
+ const result = await grepTool.execute(
385
+ { pattern: 'test', path: '/nonexistent/path/xyz' },
386
+ defaultContext,
387
+ )
388
+
389
+ expect(result.isError).toBe(true)
390
+ // May hit ripgrep path or Node fallback — both report an error
391
+ expect(result.data.toLowerCase()).toContain('no such file')
392
+ })
393
+ })
@@ -0,0 +1,97 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Mock GoogleGenAI constructor (must be hoisted for Vitest)
5
+ // ---------------------------------------------------------------------------
6
+ const GoogleGenAIMock = vi.hoisted(() => vi.fn())
7
+
8
+ vi.mock('@google/genai', () => ({
9
+ GoogleGenAI: GoogleGenAIMock,
10
+ FunctionCallingConfigMode: { AUTO: 'AUTO' },
11
+ }))
12
+
13
+ import { GeminiAdapter } from '../src/llm/gemini.js'
14
+ import { createAdapter } from '../src/llm/adapter.js'
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // GeminiAdapter tests
18
+ // ---------------------------------------------------------------------------
19
+
20
+ describe('GeminiAdapter', () => {
21
+ beforeEach(() => {
22
+ GoogleGenAIMock.mockClear()
23
+ })
24
+
25
+ it('has name "gemini"', () => {
26
+ const adapter = new GeminiAdapter()
27
+ expect(adapter.name).toBe('gemini')
28
+ })
29
+
30
+ it('uses GEMINI_API_KEY by default', () => {
31
+ const originalGemini = process.env['GEMINI_API_KEY']
32
+ const originalGoogle = process.env['GOOGLE_API_KEY']
33
+ process.env['GEMINI_API_KEY'] = 'gemini-env-key'
34
+ delete process.env['GOOGLE_API_KEY']
35
+
36
+ try {
37
+ new GeminiAdapter()
38
+ expect(GoogleGenAIMock).toHaveBeenCalledWith(
39
+ expect.objectContaining({
40
+ apiKey: 'gemini-env-key',
41
+ }),
42
+ )
43
+ } finally {
44
+ if (originalGemini === undefined) {
45
+ delete process.env['GEMINI_API_KEY']
46
+ } else {
47
+ process.env['GEMINI_API_KEY'] = originalGemini
48
+ }
49
+ if (originalGoogle === undefined) {
50
+ delete process.env['GOOGLE_API_KEY']
51
+ } else {
52
+ process.env['GOOGLE_API_KEY'] = originalGoogle
53
+ }
54
+ }
55
+ })
56
+
57
+ it('falls back to GOOGLE_API_KEY when GEMINI_API_KEY is unset', () => {
58
+ const originalGemini = process.env['GEMINI_API_KEY']
59
+ const originalGoogle = process.env['GOOGLE_API_KEY']
60
+ delete process.env['GEMINI_API_KEY']
61
+ process.env['GOOGLE_API_KEY'] = 'google-env-key'
62
+
63
+ try {
64
+ new GeminiAdapter()
65
+ expect(GoogleGenAIMock).toHaveBeenCalledWith(
66
+ expect.objectContaining({
67
+ apiKey: 'google-env-key',
68
+ }),
69
+ )
70
+ } finally {
71
+ if (originalGemini === undefined) {
72
+ delete process.env['GEMINI_API_KEY']
73
+ } else {
74
+ process.env['GEMINI_API_KEY'] = originalGemini
75
+ }
76
+ if (originalGoogle === undefined) {
77
+ delete process.env['GOOGLE_API_KEY']
78
+ } else {
79
+ process.env['GOOGLE_API_KEY'] = originalGoogle
80
+ }
81
+ }
82
+ })
83
+
84
+ it('allows overriding apiKey explicitly', () => {
85
+ new GeminiAdapter('explicit-key')
86
+ expect(GoogleGenAIMock).toHaveBeenCalledWith(
87
+ expect.objectContaining({
88
+ apiKey: 'explicit-key',
89
+ }),
90
+ )
91
+ })
92
+
93
+ it('createAdapter("gemini") returns GeminiAdapter instance', async () => {
94
+ const adapter = await createAdapter('gemini')
95
+ expect(adapter).toBeInstanceOf(GeminiAdapter)
96
+ })
97
+ })
@@ -0,0 +1,74 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Mock OpenAI constructor (must be hoisted for Vitest)
5
+ // ---------------------------------------------------------------------------
6
+ const OpenAIMock = vi.hoisted(() => vi.fn())
7
+
8
+ vi.mock('openai', () => ({
9
+ default: OpenAIMock,
10
+ }))
11
+
12
+ import { GrokAdapter } from '../src/llm/grok.js'
13
+ import { createAdapter } from '../src/llm/adapter.js'
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // GrokAdapter tests
17
+ // ---------------------------------------------------------------------------
18
+
19
+ describe('GrokAdapter', () => {
20
+ beforeEach(() => {
21
+ OpenAIMock.mockClear()
22
+ })
23
+
24
+ it('has name "grok"', () => {
25
+ const adapter = new GrokAdapter()
26
+ expect(adapter.name).toBe('grok')
27
+ })
28
+
29
+ it('uses XAI_API_KEY by default', () => {
30
+ const original = process.env['XAI_API_KEY']
31
+ process.env['XAI_API_KEY'] = 'xai-test-key-123'
32
+
33
+ try {
34
+ new GrokAdapter()
35
+ expect(OpenAIMock).toHaveBeenCalledWith(
36
+ expect.objectContaining({
37
+ apiKey: 'xai-test-key-123',
38
+ baseURL: 'https://api.x.ai/v1',
39
+ })
40
+ )
41
+ } finally {
42
+ if (original === undefined) {
43
+ delete process.env['XAI_API_KEY']
44
+ } else {
45
+ process.env['XAI_API_KEY'] = original
46
+ }
47
+ }
48
+ })
49
+
50
+ it('uses official xAI baseURL by default', () => {
51
+ new GrokAdapter('some-key')
52
+ expect(OpenAIMock).toHaveBeenCalledWith(
53
+ expect.objectContaining({
54
+ apiKey: 'some-key',
55
+ baseURL: 'https://api.x.ai/v1',
56
+ })
57
+ )
58
+ })
59
+
60
+ it('allows overriding apiKey and baseURL', () => {
61
+ new GrokAdapter('custom-key', 'https://custom.endpoint/v1')
62
+ expect(OpenAIMock).toHaveBeenCalledWith(
63
+ expect.objectContaining({
64
+ apiKey: 'custom-key',
65
+ baseURL: 'https://custom.endpoint/v1',
66
+ })
67
+ )
68
+ })
69
+
70
+ it('createAdapter("grok") returns GrokAdapter instance', async () => {
71
+ const adapter = await createAdapter('grok')
72
+ expect(adapter).toBeInstanceOf(GrokAdapter)
73
+ })
74
+ })