@jackchen_me/open-multi-agent 1.0.0 → 1.0.1
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.
- package/package.json +8 -2
- package/.github/ISSUE_TEMPLATE/bug_report.md +0 -40
- package/.github/ISSUE_TEMPLATE/feature_request.md +0 -23
- package/.github/pull_request_template.md +0 -14
- package/.github/workflows/ci.yml +0 -23
- package/CLAUDE.md +0 -80
- package/CODE_OF_CONDUCT.md +0 -48
- package/CONTRIBUTING.md +0 -72
- package/DECISIONS.md +0 -43
- package/README_zh.md +0 -277
- package/SECURITY.md +0 -17
- package/examples/01-single-agent.ts +0 -131
- package/examples/02-team-collaboration.ts +0 -167
- package/examples/03-task-pipeline.ts +0 -201
- package/examples/04-multi-model-team.ts +0 -261
- package/examples/05-copilot-test.ts +0 -49
- package/examples/06-local-model.ts +0 -200
- package/examples/07-fan-out-aggregate.ts +0 -209
- package/examples/08-gemma4-local.ts +0 -192
- package/examples/09-structured-output.ts +0 -73
- package/examples/10-task-retry.ts +0 -132
- package/examples/11-trace-observability.ts +0 -133
- package/examples/12-grok.ts +0 -154
- package/examples/13-gemini.ts +0 -48
- package/src/agent/agent.ts +0 -622
- package/src/agent/loop-detector.ts +0 -137
- package/src/agent/pool.ts +0 -285
- package/src/agent/runner.ts +0 -542
- package/src/agent/structured-output.ts +0 -126
- package/src/index.ts +0 -182
- package/src/llm/adapter.ts +0 -98
- package/src/llm/anthropic.ts +0 -389
- package/src/llm/copilot.ts +0 -552
- package/src/llm/gemini.ts +0 -378
- package/src/llm/grok.ts +0 -29
- package/src/llm/openai-common.ts +0 -294
- package/src/llm/openai.ts +0 -292
- package/src/memory/shared.ts +0 -181
- package/src/memory/store.ts +0 -124
- package/src/orchestrator/orchestrator.ts +0 -1071
- package/src/orchestrator/scheduler.ts +0 -352
- package/src/task/queue.ts +0 -464
- package/src/task/task.ts +0 -239
- package/src/team/messaging.ts +0 -232
- package/src/team/team.ts +0 -334
- package/src/tool/built-in/bash.ts +0 -187
- package/src/tool/built-in/file-edit.ts +0 -154
- package/src/tool/built-in/file-read.ts +0 -105
- package/src/tool/built-in/file-write.ts +0 -81
- package/src/tool/built-in/grep.ts +0 -362
- package/src/tool/built-in/index.ts +0 -50
- package/src/tool/executor.ts +0 -178
- package/src/tool/framework.ts +0 -557
- package/src/tool/text-tool-extractor.ts +0 -219
- package/src/types.ts +0 -542
- package/src/utils/semaphore.ts +0 -89
- package/src/utils/trace.ts +0 -34
- package/tests/agent-hooks.test.ts +0 -473
- package/tests/agent-pool.test.ts +0 -212
- package/tests/approval.test.ts +0 -464
- package/tests/built-in-tools.test.ts +0 -393
- package/tests/gemini-adapter.test.ts +0 -97
- package/tests/grok-adapter.test.ts +0 -74
- package/tests/llm-adapters.test.ts +0 -357
- package/tests/loop-detection.test.ts +0 -456
- package/tests/openai-fallback.test.ts +0 -159
- package/tests/orchestrator.test.ts +0 -281
- package/tests/scheduler.test.ts +0 -221
- package/tests/semaphore.test.ts +0 -57
- package/tests/shared-memory.test.ts +0 -122
- package/tests/structured-output.test.ts +0 -331
- package/tests/task-queue.test.ts +0 -244
- package/tests/task-retry.test.ts +0 -368
- package/tests/task-utils.test.ts +0 -155
- package/tests/team-messaging.test.ts +0 -329
- package/tests/text-tool-extractor.test.ts +0 -170
- package/tests/tool-executor.test.ts +0 -193
- package/tests/trace.test.ts +0 -453
- package/tsconfig.json +0 -25
- package/vitest.config.ts +0 -9
|
@@ -1,393 +0,0 @@
|
|
|
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
|
-
})
|
|
@@ -1,97 +0,0 @@
|
|
|
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
|
-
})
|
|
@@ -1,74 +0,0 @@
|
|
|
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
|
-
})
|