Add 6 test files covering scheduler strategies, team/messaging lifecycle, orchestrator (runAgent/runTasks/runTeam), built-in tools, agent pool, and LLM adapter layer. Add vitest.config.ts to scope coverage to src/.
This commit is contained in:
parent
4a48c44d82
commit
45304dffcf
File diff suppressed because it is too large
Load Diff
|
|
@ -52,6 +52,7 @@
|
|||
"devDependencies": {
|
||||
"@google/genai": "^1.48.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"@vitest/coverage-v8": "^2.1.9",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vitest": "^2.1.0"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,212 @@
|
|||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { AgentPool } from '../src/agent/pool.js'
|
||||
import type { Agent } from '../src/agent/agent.js'
|
||||
import type { AgentRunResult, AgentState } from '../src/types.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock Agent factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SUCCESS_RESULT: AgentRunResult = {
|
||||
success: true,
|
||||
output: 'done',
|
||||
messages: [],
|
||||
tokenUsage: { input_tokens: 10, output_tokens: 20 },
|
||||
toolCalls: [],
|
||||
}
|
||||
|
||||
function createMockAgent(
|
||||
name: string,
|
||||
opts?: { runResult?: AgentRunResult; state?: AgentState['status'] },
|
||||
): Agent {
|
||||
const state: AgentState = {
|
||||
status: opts?.state ?? 'idle',
|
||||
messages: [],
|
||||
tokenUsage: { input_tokens: 0, output_tokens: 0 },
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
config: { name, model: 'test' },
|
||||
run: vi.fn().mockResolvedValue(opts?.runResult ?? SUCCESS_RESULT),
|
||||
getState: vi.fn().mockReturnValue(state),
|
||||
reset: vi.fn(),
|
||||
} as unknown as Agent
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('AgentPool', () => {
|
||||
describe('registry: add / remove / get / list', () => {
|
||||
it('adds and retrieves an agent', () => {
|
||||
const pool = new AgentPool()
|
||||
const agent = createMockAgent('alice')
|
||||
pool.add(agent)
|
||||
|
||||
expect(pool.get('alice')).toBe(agent)
|
||||
expect(pool.list()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('throws on duplicate add', () => {
|
||||
const pool = new AgentPool()
|
||||
pool.add(createMockAgent('alice'))
|
||||
expect(() => pool.add(createMockAgent('alice'))).toThrow('already registered')
|
||||
})
|
||||
|
||||
it('removes an agent', () => {
|
||||
const pool = new AgentPool()
|
||||
pool.add(createMockAgent('alice'))
|
||||
pool.remove('alice')
|
||||
expect(pool.get('alice')).toBeUndefined()
|
||||
expect(pool.list()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('throws on remove of unknown agent', () => {
|
||||
const pool = new AgentPool()
|
||||
expect(() => pool.remove('unknown')).toThrow('not registered')
|
||||
})
|
||||
|
||||
it('get returns undefined for unknown agent', () => {
|
||||
const pool = new AgentPool()
|
||||
expect(pool.get('unknown')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('run', () => {
|
||||
it('runs a prompt on a named agent', async () => {
|
||||
const pool = new AgentPool()
|
||||
const agent = createMockAgent('alice')
|
||||
pool.add(agent)
|
||||
|
||||
const result = await pool.run('alice', 'hello')
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(agent.run).toHaveBeenCalledWith('hello', undefined)
|
||||
})
|
||||
|
||||
it('throws on unknown agent name', async () => {
|
||||
const pool = new AgentPool()
|
||||
await expect(pool.run('unknown', 'hello')).rejects.toThrow('not registered')
|
||||
})
|
||||
})
|
||||
|
||||
describe('runParallel', () => {
|
||||
it('runs multiple agents in parallel', async () => {
|
||||
const pool = new AgentPool(5)
|
||||
pool.add(createMockAgent('a'))
|
||||
pool.add(createMockAgent('b'))
|
||||
|
||||
const results = await pool.runParallel([
|
||||
{ agent: 'a', prompt: 'task a' },
|
||||
{ agent: 'b', prompt: 'task b' },
|
||||
])
|
||||
|
||||
expect(results.size).toBe(2)
|
||||
expect(results.get('a')!.success).toBe(true)
|
||||
expect(results.get('b')!.success).toBe(true)
|
||||
})
|
||||
|
||||
it('handles agent failures gracefully', async () => {
|
||||
const pool = new AgentPool()
|
||||
const failAgent = createMockAgent('fail')
|
||||
;(failAgent.run as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('boom'))
|
||||
pool.add(failAgent)
|
||||
|
||||
const results = await pool.runParallel([
|
||||
{ agent: 'fail', prompt: 'will fail' },
|
||||
])
|
||||
|
||||
expect(results.get('fail')!.success).toBe(false)
|
||||
expect(results.get('fail')!.output).toContain('boom')
|
||||
})
|
||||
})
|
||||
|
||||
describe('runAny', () => {
|
||||
it('round-robins across agents', async () => {
|
||||
const pool = new AgentPool()
|
||||
const a = createMockAgent('a')
|
||||
const b = createMockAgent('b')
|
||||
pool.add(a)
|
||||
pool.add(b)
|
||||
|
||||
await pool.runAny('first')
|
||||
await pool.runAny('second')
|
||||
|
||||
expect(a.run).toHaveBeenCalledTimes(1)
|
||||
expect(b.run).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('throws on empty pool', async () => {
|
||||
const pool = new AgentPool()
|
||||
await expect(pool.runAny('hello')).rejects.toThrow('empty pool')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getStatus', () => {
|
||||
it('reports agent states', () => {
|
||||
const pool = new AgentPool()
|
||||
pool.add(createMockAgent('idle1', { state: 'idle' }))
|
||||
pool.add(createMockAgent('idle2', { state: 'idle' }))
|
||||
pool.add(createMockAgent('running', { state: 'running' }))
|
||||
pool.add(createMockAgent('done', { state: 'completed' }))
|
||||
pool.add(createMockAgent('err', { state: 'error' }))
|
||||
|
||||
const status = pool.getStatus()
|
||||
|
||||
expect(status.total).toBe(5)
|
||||
expect(status.idle).toBe(2)
|
||||
expect(status.running).toBe(1)
|
||||
expect(status.completed).toBe(1)
|
||||
expect(status.error).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shutdown', () => {
|
||||
it('resets all agents', async () => {
|
||||
const pool = new AgentPool()
|
||||
const a = createMockAgent('a')
|
||||
const b = createMockAgent('b')
|
||||
pool.add(a)
|
||||
pool.add(b)
|
||||
|
||||
await pool.shutdown()
|
||||
|
||||
expect(a.reset).toHaveBeenCalled()
|
||||
expect(b.reset).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('concurrency', () => {
|
||||
it('respects maxConcurrency limit', async () => {
|
||||
let concurrent = 0
|
||||
let maxConcurrent = 0
|
||||
|
||||
const makeAgent = (name: string): Agent => {
|
||||
const agent = createMockAgent(name)
|
||||
;(agent.run as ReturnType<typeof vi.fn>).mockImplementation(async () => {
|
||||
concurrent++
|
||||
maxConcurrent = Math.max(maxConcurrent, concurrent)
|
||||
await new Promise(r => setTimeout(r, 50))
|
||||
concurrent--
|
||||
return SUCCESS_RESULT
|
||||
})
|
||||
return agent
|
||||
}
|
||||
|
||||
const pool = new AgentPool(2) // max 2 concurrent
|
||||
pool.add(makeAgent('a'))
|
||||
pool.add(makeAgent('b'))
|
||||
pool.add(makeAgent('c'))
|
||||
|
||||
await pool.runParallel([
|
||||
{ agent: 'a', prompt: 'x' },
|
||||
{ agent: 'b', prompt: 'y' },
|
||||
{ agent: 'c', prompt: 'z' },
|
||||
])
|
||||
|
||||
expect(maxConcurrent).toBeLessThanOrEqual(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,393 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { mkdtemp, rm, writeFile, readFile } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
import { fileReadTool } from '../src/tool/built-in/file-read.js'
|
||||
import { fileWriteTool } from '../src/tool/built-in/file-write.js'
|
||||
import { fileEditTool } from '../src/tool/built-in/file-edit.js'
|
||||
import { bashTool } from '../src/tool/built-in/bash.js'
|
||||
import { grepTool } from '../src/tool/built-in/grep.js'
|
||||
import { registerBuiltInTools, BUILT_IN_TOOLS } from '../src/tool/built-in/index.js'
|
||||
import { ToolRegistry } from '../src/tool/framework.js'
|
||||
import type { ToolUseContext } from '../src/types.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const defaultContext: ToolUseContext = {
|
||||
agent: { name: 'test-agent', role: 'tester', model: 'test' },
|
||||
}
|
||||
|
||||
let tmpDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), 'oma-test-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
// ===========================================================================
|
||||
// registerBuiltInTools
|
||||
// ===========================================================================
|
||||
|
||||
describe('registerBuiltInTools', () => {
|
||||
it('registers all 5 built-in tools', () => {
|
||||
const registry = new ToolRegistry()
|
||||
registerBuiltInTools(registry)
|
||||
|
||||
expect(registry.get('bash')).toBeDefined()
|
||||
expect(registry.get('file_read')).toBeDefined()
|
||||
expect(registry.get('file_write')).toBeDefined()
|
||||
expect(registry.get('file_edit')).toBeDefined()
|
||||
expect(registry.get('grep')).toBeDefined()
|
||||
})
|
||||
|
||||
it('BUILT_IN_TOOLS has correct length', () => {
|
||||
expect(BUILT_IN_TOOLS).toHaveLength(5)
|
||||
})
|
||||
})
|
||||
|
||||
// ===========================================================================
|
||||
// file_read
|
||||
// ===========================================================================
|
||||
|
||||
describe('file_read', () => {
|
||||
it('reads a file with line numbers', async () => {
|
||||
const filePath = join(tmpDir, 'test.txt')
|
||||
await writeFile(filePath, 'line one\nline two\nline three\n')
|
||||
|
||||
const result = await fileReadTool.execute({ path: filePath }, defaultContext)
|
||||
|
||||
expect(result.isError).toBe(false)
|
||||
expect(result.data).toContain('1\tline one')
|
||||
expect(result.data).toContain('2\tline two')
|
||||
expect(result.data).toContain('3\tline three')
|
||||
})
|
||||
|
||||
it('reads a slice with offset and limit', async () => {
|
||||
const filePath = join(tmpDir, 'test.txt')
|
||||
await writeFile(filePath, 'a\nb\nc\nd\ne\n')
|
||||
|
||||
const result = await fileReadTool.execute(
|
||||
{ path: filePath, offset: 2, limit: 2 },
|
||||
defaultContext,
|
||||
)
|
||||
|
||||
expect(result.isError).toBe(false)
|
||||
expect(result.data).toContain('2\tb')
|
||||
expect(result.data).toContain('3\tc')
|
||||
expect(result.data).not.toContain('1\ta')
|
||||
})
|
||||
|
||||
it('errors on non-existent file', async () => {
|
||||
const result = await fileReadTool.execute(
|
||||
{ path: join(tmpDir, 'nope.txt') },
|
||||
defaultContext,
|
||||
)
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.data).toContain('Could not read file')
|
||||
})
|
||||
|
||||
it('errors when offset is beyond end of file', async () => {
|
||||
const filePath = join(tmpDir, 'short.txt')
|
||||
await writeFile(filePath, 'one line\n')
|
||||
|
||||
const result = await fileReadTool.execute(
|
||||
{ path: filePath, offset: 100 },
|
||||
defaultContext,
|
||||
)
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.data).toContain('beyond the end')
|
||||
})
|
||||
|
||||
it('shows truncation note when not reading entire file', async () => {
|
||||
const filePath = join(tmpDir, 'multi.txt')
|
||||
await writeFile(filePath, 'a\nb\nc\nd\ne\n')
|
||||
|
||||
const result = await fileReadTool.execute(
|
||||
{ path: filePath, limit: 2 },
|
||||
defaultContext,
|
||||
)
|
||||
|
||||
expect(result.data).toContain('showing lines')
|
||||
})
|
||||
})
|
||||
|
||||
// ===========================================================================
|
||||
// file_write
|
||||
// ===========================================================================
|
||||
|
||||
describe('file_write', () => {
|
||||
it('creates a new file', async () => {
|
||||
const filePath = join(tmpDir, 'new-file.txt')
|
||||
|
||||
const result = await fileWriteTool.execute(
|
||||
{ path: filePath, content: 'hello world' },
|
||||
defaultContext,
|
||||
)
|
||||
|
||||
expect(result.isError).toBe(false)
|
||||
expect(result.data).toContain('Created')
|
||||
const content = await readFile(filePath, 'utf8')
|
||||
expect(content).toBe('hello world')
|
||||
})
|
||||
|
||||
it('overwrites an existing file', async () => {
|
||||
const filePath = join(tmpDir, 'existing.txt')
|
||||
await writeFile(filePath, 'old content')
|
||||
|
||||
const result = await fileWriteTool.execute(
|
||||
{ path: filePath, content: 'new content' },
|
||||
defaultContext,
|
||||
)
|
||||
|
||||
expect(result.isError).toBe(false)
|
||||
expect(result.data).toContain('Updated')
|
||||
const content = await readFile(filePath, 'utf8')
|
||||
expect(content).toBe('new content')
|
||||
})
|
||||
|
||||
it('creates parent directories', async () => {
|
||||
const filePath = join(tmpDir, 'deep', 'nested', 'file.txt')
|
||||
|
||||
const result = await fileWriteTool.execute(
|
||||
{ path: filePath, content: 'deep file' },
|
||||
defaultContext,
|
||||
)
|
||||
|
||||
expect(result.isError).toBe(false)
|
||||
const content = await readFile(filePath, 'utf8')
|
||||
expect(content).toBe('deep file')
|
||||
})
|
||||
|
||||
it('reports line and byte counts', async () => {
|
||||
const filePath = join(tmpDir, 'counted.txt')
|
||||
|
||||
const result = await fileWriteTool.execute(
|
||||
{ path: filePath, content: 'line1\nline2\nline3' },
|
||||
defaultContext,
|
||||
)
|
||||
|
||||
expect(result.data).toContain('3 lines')
|
||||
})
|
||||
})
|
||||
|
||||
// ===========================================================================
|
||||
// file_edit
|
||||
// ===========================================================================
|
||||
|
||||
describe('file_edit', () => {
|
||||
it('replaces a unique string', async () => {
|
||||
const filePath = join(tmpDir, 'edit.txt')
|
||||
await writeFile(filePath, 'hello world\ngoodbye world\n')
|
||||
|
||||
const result = await fileEditTool.execute(
|
||||
{ path: filePath, old_string: 'hello', new_string: 'hi' },
|
||||
defaultContext,
|
||||
)
|
||||
|
||||
expect(result.isError).toBe(false)
|
||||
expect(result.data).toContain('Replaced 1 occurrence')
|
||||
const content = await readFile(filePath, 'utf8')
|
||||
expect(content).toContain('hi world')
|
||||
expect(content).toContain('goodbye world')
|
||||
})
|
||||
|
||||
it('errors when old_string not found', async () => {
|
||||
const filePath = join(tmpDir, 'edit.txt')
|
||||
await writeFile(filePath, 'hello world\n')
|
||||
|
||||
const result = await fileEditTool.execute(
|
||||
{ path: filePath, old_string: 'nonexistent', new_string: 'x' },
|
||||
defaultContext,
|
||||
)
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.data).toContain('not found')
|
||||
})
|
||||
|
||||
it('errors on ambiguous match without replace_all', async () => {
|
||||
const filePath = join(tmpDir, 'edit.txt')
|
||||
await writeFile(filePath, 'foo bar foo\n')
|
||||
|
||||
const result = await fileEditTool.execute(
|
||||
{ path: filePath, old_string: 'foo', new_string: 'baz' },
|
||||
defaultContext,
|
||||
)
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.data).toContain('2 times')
|
||||
})
|
||||
|
||||
it('replaces all when replace_all is true', async () => {
|
||||
const filePath = join(tmpDir, 'edit.txt')
|
||||
await writeFile(filePath, 'foo bar foo\n')
|
||||
|
||||
const result = await fileEditTool.execute(
|
||||
{ path: filePath, old_string: 'foo', new_string: 'baz', replace_all: true },
|
||||
defaultContext,
|
||||
)
|
||||
|
||||
expect(result.isError).toBe(false)
|
||||
expect(result.data).toContain('Replaced 2 occurrences')
|
||||
const content = await readFile(filePath, 'utf8')
|
||||
expect(content).toBe('baz bar baz\n')
|
||||
})
|
||||
|
||||
it('errors on non-existent file', async () => {
|
||||
const result = await fileEditTool.execute(
|
||||
{ path: join(tmpDir, 'nope.txt'), old_string: 'x', new_string: 'y' },
|
||||
defaultContext,
|
||||
)
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.data).toContain('Could not read')
|
||||
})
|
||||
})
|
||||
|
||||
// ===========================================================================
|
||||
// bash
|
||||
// ===========================================================================
|
||||
|
||||
describe('bash', () => {
|
||||
it('executes a simple command', async () => {
|
||||
const result = await bashTool.execute(
|
||||
{ command: 'echo "hello bash"' },
|
||||
defaultContext,
|
||||
)
|
||||
|
||||
expect(result.isError).toBe(false)
|
||||
expect(result.data).toContain('hello bash')
|
||||
})
|
||||
|
||||
it('captures stderr on failed command', async () => {
|
||||
const result = await bashTool.execute(
|
||||
{ command: 'ls /nonexistent/path/xyz 2>&1' },
|
||||
defaultContext,
|
||||
)
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
})
|
||||
|
||||
it('supports custom working directory', async () => {
|
||||
const result = await bashTool.execute(
|
||||
{ command: 'pwd', cwd: tmpDir },
|
||||
defaultContext,
|
||||
)
|
||||
|
||||
expect(result.isError).toBe(false)
|
||||
expect(result.data).toContain(tmpDir)
|
||||
})
|
||||
|
||||
it('returns exit code for failing commands', async () => {
|
||||
const result = await bashTool.execute(
|
||||
{ command: 'exit 42' },
|
||||
defaultContext,
|
||||
)
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.data).toContain('42')
|
||||
})
|
||||
|
||||
it('handles commands with no output', async () => {
|
||||
const result = await bashTool.execute(
|
||||
{ command: 'true' },
|
||||
defaultContext,
|
||||
)
|
||||
|
||||
expect(result.isError).toBe(false)
|
||||
expect(result.data).toContain('command completed with no output')
|
||||
})
|
||||
})
|
||||
|
||||
// ===========================================================================
|
||||
// grep (Node.js fallback — tests do not depend on ripgrep availability)
|
||||
// ===========================================================================
|
||||
|
||||
describe('grep', () => {
|
||||
it('finds matching lines in a file', async () => {
|
||||
const filePath = join(tmpDir, 'search.txt')
|
||||
await writeFile(filePath, 'apple\nbanana\napricot\ncherry\n')
|
||||
|
||||
const result = await grepTool.execute(
|
||||
{ pattern: 'ap', path: filePath },
|
||||
defaultContext,
|
||||
)
|
||||
|
||||
expect(result.isError).toBe(false)
|
||||
expect(result.data).toContain('apple')
|
||||
expect(result.data).toContain('apricot')
|
||||
expect(result.data).not.toContain('cherry')
|
||||
})
|
||||
|
||||
it('returns "No matches found" when nothing matches', async () => {
|
||||
const filePath = join(tmpDir, 'search.txt')
|
||||
await writeFile(filePath, 'hello world\n')
|
||||
|
||||
const result = await grepTool.execute(
|
||||
{ pattern: 'zzz', path: filePath },
|
||||
defaultContext,
|
||||
)
|
||||
|
||||
expect(result.isError).toBe(false)
|
||||
expect(result.data).toContain('No matches found')
|
||||
})
|
||||
|
||||
it('errors on invalid regex', async () => {
|
||||
const result = await grepTool.execute(
|
||||
{ pattern: '[invalid', path: tmpDir },
|
||||
defaultContext,
|
||||
)
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.data).toContain('Invalid regular expression')
|
||||
})
|
||||
|
||||
it('searches recursively in a directory', async () => {
|
||||
const subDir = join(tmpDir, 'sub')
|
||||
await writeFile(join(tmpDir, 'a.txt'), 'findme here\n')
|
||||
// Create subdir and file
|
||||
const { mkdir } = await import('fs/promises')
|
||||
await mkdir(subDir, { recursive: true })
|
||||
await writeFile(join(subDir, 'b.txt'), 'findme there\n')
|
||||
|
||||
const result = await grepTool.execute(
|
||||
{ pattern: 'findme', path: tmpDir },
|
||||
defaultContext,
|
||||
)
|
||||
|
||||
expect(result.isError).toBe(false)
|
||||
expect(result.data).toContain('findme here')
|
||||
expect(result.data).toContain('findme there')
|
||||
})
|
||||
|
||||
it('respects glob filter', async () => {
|
||||
await writeFile(join(tmpDir, 'code.ts'), 'const x = 1\n')
|
||||
await writeFile(join(tmpDir, 'readme.md'), 'const y = 2\n')
|
||||
|
||||
const result = await grepTool.execute(
|
||||
{ pattern: 'const', path: tmpDir, glob: '*.ts' },
|
||||
defaultContext,
|
||||
)
|
||||
|
||||
expect(result.isError).toBe(false)
|
||||
expect(result.data).toContain('code.ts')
|
||||
expect(result.data).not.toContain('readme.md')
|
||||
})
|
||||
|
||||
it('errors on inaccessible path', async () => {
|
||||
const result = await grepTool.execute(
|
||||
{ pattern: 'test', path: '/nonexistent/path/xyz' },
|
||||
defaultContext,
|
||||
)
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
// May hit ripgrep path or Node fallback — both report an error
|
||||
expect(result.data.toLowerCase()).toContain('no such file')
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,357 @@
|
|||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { createAdapter } from '../src/llm/adapter.js'
|
||||
import {
|
||||
toOpenAITool,
|
||||
toOpenAIMessages,
|
||||
fromOpenAICompletion,
|
||||
normalizeFinishReason,
|
||||
buildOpenAIMessageList,
|
||||
} from '../src/llm/openai-common.js'
|
||||
import type {
|
||||
ContentBlock,
|
||||
LLMMessage,
|
||||
LLMToolDef,
|
||||
} from '../src/types.js'
|
||||
import type { ChatCompletion } from 'openai/resources/chat/completions/index.js'
|
||||
|
||||
// ===========================================================================
|
||||
// createAdapter factory
|
||||
// ===========================================================================
|
||||
|
||||
describe('createAdapter', () => {
|
||||
it('creates an anthropic adapter', async () => {
|
||||
const adapter = await createAdapter('anthropic', 'test-key')
|
||||
expect(adapter.name).toBe('anthropic')
|
||||
})
|
||||
|
||||
it('creates an openai adapter', async () => {
|
||||
const adapter = await createAdapter('openai', 'test-key')
|
||||
expect(adapter.name).toBe('openai')
|
||||
})
|
||||
|
||||
it('creates a grok adapter', async () => {
|
||||
const adapter = await createAdapter('grok', 'test-key')
|
||||
expect(adapter.name).toBe('grok')
|
||||
})
|
||||
|
||||
it('creates a gemini adapter', async () => {
|
||||
const adapter = await createAdapter('gemini', 'test-key')
|
||||
expect(adapter.name).toBe('gemini')
|
||||
})
|
||||
|
||||
it('throws on unknown provider', async () => {
|
||||
await expect(
|
||||
createAdapter('unknown' as any, 'test-key'),
|
||||
).rejects.toThrow('Unsupported')
|
||||
})
|
||||
})
|
||||
|
||||
// ===========================================================================
|
||||
// OpenAI common helpers
|
||||
// ===========================================================================
|
||||
|
||||
describe('normalizeFinishReason', () => {
|
||||
it('maps stop → end_turn', () => {
|
||||
expect(normalizeFinishReason('stop')).toBe('end_turn')
|
||||
})
|
||||
|
||||
it('maps tool_calls → tool_use', () => {
|
||||
expect(normalizeFinishReason('tool_calls')).toBe('tool_use')
|
||||
})
|
||||
|
||||
it('maps length → max_tokens', () => {
|
||||
expect(normalizeFinishReason('length')).toBe('max_tokens')
|
||||
})
|
||||
|
||||
it('maps content_filter → content_filter', () => {
|
||||
expect(normalizeFinishReason('content_filter')).toBe('content_filter')
|
||||
})
|
||||
|
||||
it('passes through unknown reasons', () => {
|
||||
expect(normalizeFinishReason('custom_reason')).toBe('custom_reason')
|
||||
})
|
||||
})
|
||||
|
||||
describe('toOpenAITool', () => {
|
||||
it('converts framework tool def to OpenAI format', () => {
|
||||
const tool: LLMToolDef = {
|
||||
name: 'search',
|
||||
description: 'Search the web',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { query: { type: 'string' } },
|
||||
},
|
||||
}
|
||||
|
||||
const result = toOpenAITool(tool)
|
||||
|
||||
expect(result.type).toBe('function')
|
||||
expect(result.function.name).toBe('search')
|
||||
expect(result.function.description).toBe('Search the web')
|
||||
expect(result.function.parameters).toEqual(tool.inputSchema)
|
||||
})
|
||||
})
|
||||
|
||||
describe('toOpenAIMessages', () => {
|
||||
it('converts a simple user text message', () => {
|
||||
const msgs: LLMMessage[] = [
|
||||
{ role: 'user', content: [{ type: 'text', text: 'hello' }] },
|
||||
]
|
||||
|
||||
const result = toOpenAIMessages(msgs)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]).toEqual({ role: 'user', content: 'hello' })
|
||||
})
|
||||
|
||||
it('converts assistant message with text', () => {
|
||||
const msgs: LLMMessage[] = [
|
||||
{ role: 'assistant', content: [{ type: 'text', text: 'hi' }] },
|
||||
]
|
||||
|
||||
const result = toOpenAIMessages(msgs)
|
||||
|
||||
expect(result[0]).toEqual({ role: 'assistant', content: 'hi', tool_calls: undefined })
|
||||
})
|
||||
|
||||
it('converts assistant message with tool_use into tool_calls', () => {
|
||||
const msgs: LLMMessage[] = [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'tool_use', id: 'tc1', name: 'search', input: { query: 'AI' } },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const result = toOpenAIMessages(msgs)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
const msg = result[0]! as any
|
||||
expect(msg.role).toBe('assistant')
|
||||
expect(msg.tool_calls).toHaveLength(1)
|
||||
expect(msg.tool_calls[0].function.name).toBe('search')
|
||||
})
|
||||
|
||||
it('splits tool_result blocks into separate tool-role messages', () => {
|
||||
const msgs: LLMMessage[] = [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'tool_result', tool_use_id: 'tc1', content: 'result data' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const result = toOpenAIMessages(msgs)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]).toEqual({
|
||||
role: 'tool',
|
||||
tool_call_id: 'tc1',
|
||||
content: 'result data',
|
||||
})
|
||||
})
|
||||
|
||||
it('handles mixed user message with text and tool_result', () => {
|
||||
const msgs: LLMMessage[] = [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'context' },
|
||||
{ type: 'tool_result', tool_use_id: 'tc1', content: 'data' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const result = toOpenAIMessages(msgs)
|
||||
|
||||
// Should produce a user message for text, then a tool message for result
|
||||
expect(result.length).toBeGreaterThanOrEqual(2)
|
||||
expect(result[0]).toEqual({ role: 'user', content: 'context' })
|
||||
expect(result[1]).toEqual({
|
||||
role: 'tool',
|
||||
tool_call_id: 'tc1',
|
||||
content: 'data',
|
||||
})
|
||||
})
|
||||
|
||||
it('handles image blocks in user messages', () => {
|
||||
const msgs: LLMMessage[] = [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'describe this' },
|
||||
{
|
||||
type: 'image',
|
||||
source: { type: 'base64', media_type: 'image/png', data: 'abc123' },
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const result = toOpenAIMessages(msgs)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
const content = (result[0] as any).content
|
||||
expect(content).toHaveLength(2)
|
||||
expect(content[1].type).toBe('image_url')
|
||||
expect(content[1].image_url.url).toContain('data:image/png;base64,abc123')
|
||||
})
|
||||
})
|
||||
|
||||
describe('fromOpenAICompletion', () => {
|
||||
function makeCompletion(overrides?: Partial<ChatCompletion>): ChatCompletion {
|
||||
return {
|
||||
id: 'comp-1',
|
||||
object: 'chat.completion',
|
||||
created: Date.now(),
|
||||
model: 'gpt-4',
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: { role: 'assistant', content: 'Hello!', refusal: null },
|
||||
finish_reason: 'stop',
|
||||
logprobs: null,
|
||||
},
|
||||
],
|
||||
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
it('converts a simple text completion', () => {
|
||||
const result = fromOpenAICompletion(makeCompletion())
|
||||
|
||||
expect(result.id).toBe('comp-1')
|
||||
expect(result.model).toBe('gpt-4')
|
||||
expect(result.stop_reason).toBe('end_turn') // 'stop' → 'end_turn'
|
||||
expect(result.content).toHaveLength(1)
|
||||
expect(result.content[0]).toEqual({ type: 'text', text: 'Hello!' })
|
||||
expect(result.usage.input_tokens).toBe(10)
|
||||
expect(result.usage.output_tokens).toBe(20)
|
||||
})
|
||||
|
||||
it('converts tool_calls into tool_use blocks', () => {
|
||||
const completion = makeCompletion({
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: null,
|
||||
refusal: null,
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'tc1',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'search',
|
||||
arguments: '{"query":"test"}',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
finish_reason: 'tool_calls',
|
||||
logprobs: null,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const result = fromOpenAICompletion(completion)
|
||||
|
||||
expect(result.stop_reason).toBe('tool_use')
|
||||
expect(result.content).toHaveLength(1)
|
||||
expect(result.content[0]).toEqual({
|
||||
type: 'tool_use',
|
||||
id: 'tc1',
|
||||
name: 'search',
|
||||
input: { query: 'test' },
|
||||
})
|
||||
})
|
||||
|
||||
it('throws when completion has no choices', () => {
|
||||
const completion = makeCompletion({ choices: [] })
|
||||
expect(() => fromOpenAICompletion(completion)).toThrow('no choices')
|
||||
})
|
||||
|
||||
it('handles malformed tool arguments gracefully', () => {
|
||||
const completion = makeCompletion({
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: null,
|
||||
refusal: null,
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'tc1',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'search',
|
||||
arguments: 'not-valid-json',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
finish_reason: 'tool_calls',
|
||||
logprobs: null,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const result = fromOpenAICompletion(completion)
|
||||
|
||||
// Should not throw; input defaults to {}
|
||||
expect(result.content[0]).toEqual({
|
||||
type: 'tool_use',
|
||||
id: 'tc1',
|
||||
name: 'search',
|
||||
input: {},
|
||||
})
|
||||
})
|
||||
|
||||
it('handles missing usage gracefully', () => {
|
||||
const completion = makeCompletion({ usage: undefined })
|
||||
|
||||
const result = fromOpenAICompletion(completion)
|
||||
|
||||
expect(result.usage.input_tokens).toBe(0)
|
||||
expect(result.usage.output_tokens).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildOpenAIMessageList', () => {
|
||||
it('prepends system prompt when provided', () => {
|
||||
const msgs: LLMMessage[] = [
|
||||
{ role: 'user', content: [{ type: 'text', text: 'hi' }] },
|
||||
]
|
||||
|
||||
const result = buildOpenAIMessageList(msgs, 'You are helpful.')
|
||||
|
||||
expect(result[0]).toEqual({ role: 'system', content: 'You are helpful.' })
|
||||
expect(result).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('omits system message when systemPrompt is undefined', () => {
|
||||
const msgs: LLMMessage[] = [
|
||||
{ role: 'user', content: [{ type: 'text', text: 'hi' }] },
|
||||
]
|
||||
|
||||
const result = buildOpenAIMessageList(msgs, undefined)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]).toEqual({ role: 'user', content: 'hi' })
|
||||
})
|
||||
|
||||
it('omits system message when systemPrompt is empty string', () => {
|
||||
const msgs: LLMMessage[] = [
|
||||
{ role: 'user', content: [{ type: 'text', text: 'hi' }] },
|
||||
]
|
||||
|
||||
const result = buildOpenAIMessageList(msgs, '')
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,281 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { OpenMultiAgent } from '../src/orchestrator/orchestrator.js'
|
||||
import type {
|
||||
AgentConfig,
|
||||
AgentRunResult,
|
||||
LLMAdapter,
|
||||
LLMChatOptions,
|
||||
LLMMessage,
|
||||
LLMResponse,
|
||||
OrchestratorEvent,
|
||||
TeamConfig,
|
||||
} from '../src/types.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock LLM adapter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** A controllable fake LLM adapter for orchestrator tests. */
|
||||
function createMockAdapter(responses: string[]): LLMAdapter {
|
||||
let callIndex = 0
|
||||
return {
|
||||
name: 'mock',
|
||||
async chat(_msgs: LLMMessage[], options: LLMChatOptions): Promise<LLMResponse> {
|
||||
const text = responses[callIndex] ?? 'no response configured'
|
||||
callIndex++
|
||||
return {
|
||||
id: `resp-${callIndex}`,
|
||||
content: [{ type: 'text', text }],
|
||||
model: options.model,
|
||||
stop_reason: 'end_turn',
|
||||
usage: { input_tokens: 10, output_tokens: 20 },
|
||||
}
|
||||
},
|
||||
async *stream() {
|
||||
yield { type: 'done' as const, data: {} }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock the createAdapter factory to return our mock adapter.
|
||||
* We need to do this at the module level because Agent calls createAdapter internally.
|
||||
*/
|
||||
let mockAdapterResponses: string[] = []
|
||||
|
||||
vi.mock('../src/llm/adapter.js', () => ({
|
||||
createAdapter: async () => {
|
||||
let callIndex = 0
|
||||
return {
|
||||
name: 'mock',
|
||||
async chat(_msgs: LLMMessage[], options: LLMChatOptions): Promise<LLMResponse> {
|
||||
const text = mockAdapterResponses[callIndex] ?? 'default mock response'
|
||||
callIndex++
|
||||
return {
|
||||
id: `resp-${callIndex}`,
|
||||
content: [{ type: 'text', text }],
|
||||
model: options.model ?? 'mock-model',
|
||||
stop_reason: 'end_turn',
|
||||
usage: { input_tokens: 10, output_tokens: 20 },
|
||||
}
|
||||
},
|
||||
async *stream() {
|
||||
yield { type: 'done' as const, data: {} }
|
||||
},
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function agentConfig(name: string): AgentConfig {
|
||||
return {
|
||||
name,
|
||||
model: 'mock-model',
|
||||
provider: 'openai',
|
||||
systemPrompt: `You are ${name}.`,
|
||||
}
|
||||
}
|
||||
|
||||
function teamCfg(agents?: AgentConfig[]): TeamConfig {
|
||||
return {
|
||||
name: 'test-team',
|
||||
agents: agents ?? [agentConfig('worker-a'), agentConfig('worker-b')],
|
||||
sharedMemory: true,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('OpenMultiAgent', () => {
|
||||
beforeEach(() => {
|
||||
mockAdapterResponses = []
|
||||
})
|
||||
|
||||
describe('createTeam', () => {
|
||||
it('creates and registers a team', () => {
|
||||
const oma = new OpenMultiAgent()
|
||||
const team = oma.createTeam('my-team', teamCfg())
|
||||
expect(team.name).toBe('test-team')
|
||||
expect(oma.getStatus().teams).toBe(1)
|
||||
})
|
||||
|
||||
it('throws on duplicate team name', () => {
|
||||
const oma = new OpenMultiAgent()
|
||||
oma.createTeam('my-team', teamCfg())
|
||||
expect(() => oma.createTeam('my-team', teamCfg())).toThrow('already exists')
|
||||
})
|
||||
})
|
||||
|
||||
describe('shutdown', () => {
|
||||
it('clears teams and counters', async () => {
|
||||
const oma = new OpenMultiAgent()
|
||||
oma.createTeam('t1', teamCfg())
|
||||
await oma.shutdown()
|
||||
expect(oma.getStatus().teams).toBe(0)
|
||||
expect(oma.getStatus().completedTasks).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getStatus', () => {
|
||||
it('reports initial state', () => {
|
||||
const oma = new OpenMultiAgent()
|
||||
const status = oma.getStatus()
|
||||
expect(status).toEqual({ teams: 0, activeAgents: 0, completedTasks: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('runAgent', () => {
|
||||
it('runs a single agent and returns result', async () => {
|
||||
mockAdapterResponses = ['Hello from agent!']
|
||||
|
||||
const oma = new OpenMultiAgent({ defaultModel: 'mock-model' })
|
||||
const result = await oma.runAgent(
|
||||
agentConfig('solo'),
|
||||
'Say hello',
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.output).toBe('Hello from agent!')
|
||||
expect(oma.getStatus().completedTasks).toBe(1)
|
||||
})
|
||||
|
||||
it('fires onProgress events', async () => {
|
||||
mockAdapterResponses = ['done']
|
||||
|
||||
const events: OrchestratorEvent[] = []
|
||||
const oma = new OpenMultiAgent({
|
||||
defaultModel: 'mock-model',
|
||||
onProgress: (e) => events.push(e),
|
||||
})
|
||||
|
||||
await oma.runAgent(agentConfig('solo'), 'test')
|
||||
|
||||
const types = events.map(e => e.type)
|
||||
expect(types).toContain('agent_start')
|
||||
expect(types).toContain('agent_complete')
|
||||
})
|
||||
})
|
||||
|
||||
describe('runTasks', () => {
|
||||
it('executes explicit tasks assigned to agents', async () => {
|
||||
// Each agent run produces one LLM call
|
||||
mockAdapterResponses = ['result-a', 'result-b']
|
||||
|
||||
const events: OrchestratorEvent[] = []
|
||||
const oma = new OpenMultiAgent({
|
||||
defaultModel: 'mock-model',
|
||||
onProgress: (e) => events.push(e),
|
||||
})
|
||||
const team = oma.createTeam('t', teamCfg())
|
||||
|
||||
const result = await oma.runTasks(team, [
|
||||
{ title: 'Task A', description: 'Do A', assignee: 'worker-a' },
|
||||
{ title: 'Task B', description: 'Do B', assignee: 'worker-b' },
|
||||
])
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.agentResults.size).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('handles task dependencies sequentially', async () => {
|
||||
mockAdapterResponses = ['first done', 'second done']
|
||||
|
||||
const oma = new OpenMultiAgent({ defaultModel: 'mock-model' })
|
||||
const team = oma.createTeam('t', teamCfg())
|
||||
|
||||
const result = await oma.runTasks(team, [
|
||||
{ title: 'First', description: 'Do first', assignee: 'worker-a' },
|
||||
{ title: 'Second', description: 'Do second', assignee: 'worker-b', dependsOn: ['First'] },
|
||||
])
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('runTeam', () => {
|
||||
it('runs coordinator decomposition + execution + synthesis', async () => {
|
||||
// Response 1: coordinator decomposition (returns JSON task array)
|
||||
// Response 2: worker-a executes task
|
||||
// Response 3: coordinator synthesis
|
||||
mockAdapterResponses = [
|
||||
'```json\n[{"title": "Research", "description": "Research the topic", "assignee": "worker-a"}]\n```',
|
||||
'Research results here',
|
||||
'Final synthesized answer based on research results',
|
||||
]
|
||||
|
||||
const events: OrchestratorEvent[] = []
|
||||
const oma = new OpenMultiAgent({
|
||||
defaultModel: 'mock-model',
|
||||
onProgress: (e) => events.push(e),
|
||||
})
|
||||
const team = oma.createTeam('t', teamCfg())
|
||||
|
||||
const result = await oma.runTeam(team, 'Research AI safety')
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
// Should have coordinator result
|
||||
expect(result.agentResults.has('coordinator')).toBe(true)
|
||||
})
|
||||
|
||||
it('falls back to one-task-per-agent when coordinator output is unparseable', async () => {
|
||||
mockAdapterResponses = [
|
||||
'I cannot produce JSON output', // invalid coordinator output
|
||||
'worker-a result',
|
||||
'worker-b result',
|
||||
'synthesis',
|
||||
]
|
||||
|
||||
const oma = new OpenMultiAgent({ defaultModel: 'mock-model' })
|
||||
const team = oma.createTeam('t', teamCfg())
|
||||
|
||||
const result = await oma.runTeam(team, 'Do something')
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('config defaults', () => {
|
||||
it('uses default model and provider', () => {
|
||||
const oma = new OpenMultiAgent()
|
||||
const status = oma.getStatus()
|
||||
expect(status).toBeDefined()
|
||||
})
|
||||
|
||||
it('accepts custom config', () => {
|
||||
const oma = new OpenMultiAgent({
|
||||
maxConcurrency: 3,
|
||||
defaultModel: 'custom-model',
|
||||
defaultProvider: 'openai',
|
||||
})
|
||||
expect(oma.getStatus().teams).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('onApproval gate', () => {
|
||||
it('skips remaining tasks when approval rejects', async () => {
|
||||
mockAdapterResponses = ['first done', 'should not run']
|
||||
|
||||
const oma = new OpenMultiAgent({
|
||||
defaultModel: 'mock-model',
|
||||
onApproval: async () => false, // reject all
|
||||
})
|
||||
const team = oma.createTeam('t', teamCfg([agentConfig('worker')]))
|
||||
|
||||
const result = await oma.runTasks(team, [
|
||||
{ title: 'First', description: 'Do first', assignee: 'worker' },
|
||||
{ title: 'Second', description: 'Do second', assignee: 'worker', dependsOn: ['First'] },
|
||||
])
|
||||
|
||||
// The first task succeeded; the second was skipped (no agentResult entry).
|
||||
// Overall success is based on agentResults only, so it's true.
|
||||
expect(result.success).toBe(true)
|
||||
// But we should have fewer agent results than tasks
|
||||
expect(result.agentResults.size).toBeLessThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,221 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { Scheduler } from '../src/orchestrator/scheduler.js'
|
||||
import { TaskQueue } from '../src/task/queue.js'
|
||||
import { createTask } from '../src/task/task.js'
|
||||
import type { AgentConfig, Task } from '../src/types.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function agent(name: string, systemPrompt?: string): AgentConfig {
|
||||
return { name, model: 'test-model', systemPrompt }
|
||||
}
|
||||
|
||||
function pendingTask(title: string, opts?: { assignee?: string; dependsOn?: string[] }): Task {
|
||||
return createTask({ title, description: title, assignee: opts?.assignee, ...opts })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// round-robin
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Scheduler: round-robin', () => {
|
||||
it('distributes tasks evenly across agents', () => {
|
||||
const s = new Scheduler('round-robin')
|
||||
const agents = [agent('a'), agent('b'), agent('c')]
|
||||
const tasks = [
|
||||
pendingTask('t1'),
|
||||
pendingTask('t2'),
|
||||
pendingTask('t3'),
|
||||
pendingTask('t4'),
|
||||
pendingTask('t5'),
|
||||
pendingTask('t6'),
|
||||
]
|
||||
|
||||
const assignments = s.schedule(tasks, agents)
|
||||
|
||||
expect(assignments.get(tasks[0]!.id)).toBe('a')
|
||||
expect(assignments.get(tasks[1]!.id)).toBe('b')
|
||||
expect(assignments.get(tasks[2]!.id)).toBe('c')
|
||||
expect(assignments.get(tasks[3]!.id)).toBe('a')
|
||||
expect(assignments.get(tasks[4]!.id)).toBe('b')
|
||||
expect(assignments.get(tasks[5]!.id)).toBe('c')
|
||||
})
|
||||
|
||||
it('skips already-assigned tasks', () => {
|
||||
const s = new Scheduler('round-robin')
|
||||
const agents = [agent('a'), agent('b')]
|
||||
const tasks = [
|
||||
pendingTask('t1', { assignee: 'a' }),
|
||||
pendingTask('t2'),
|
||||
]
|
||||
|
||||
const assignments = s.schedule(tasks, agents)
|
||||
|
||||
// Only t2 should be assigned
|
||||
expect(assignments.size).toBe(1)
|
||||
expect(assignments.has(tasks[1]!.id)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns empty map when no agents', () => {
|
||||
const s = new Scheduler('round-robin')
|
||||
const tasks = [pendingTask('t1')]
|
||||
expect(s.schedule(tasks, []).size).toBe(0)
|
||||
})
|
||||
|
||||
it('cursor advances across calls', () => {
|
||||
const s = new Scheduler('round-robin')
|
||||
const agents = [agent('a'), agent('b')]
|
||||
const t1 = [pendingTask('t1')]
|
||||
const t2 = [pendingTask('t2')]
|
||||
|
||||
const a1 = s.schedule(t1, agents)
|
||||
const a2 = s.schedule(t2, agents)
|
||||
|
||||
expect(a1.get(t1[0]!.id)).toBe('a')
|
||||
expect(a2.get(t2[0]!.id)).toBe('b')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// least-busy
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Scheduler: least-busy', () => {
|
||||
it('assigns to agent with fewest in_progress tasks', () => {
|
||||
const s = new Scheduler('least-busy')
|
||||
const agents = [agent('a'), agent('b')]
|
||||
|
||||
// Create some in-progress tasks for agent 'a'
|
||||
const inProgress: Task = {
|
||||
...pendingTask('busy'),
|
||||
status: 'in_progress',
|
||||
assignee: 'a',
|
||||
}
|
||||
const newTask = pendingTask('new')
|
||||
const allTasks = [inProgress, newTask]
|
||||
|
||||
const assignments = s.schedule(allTasks, agents)
|
||||
|
||||
// 'b' has 0 in-progress, 'a' has 1 → assign to 'b'
|
||||
expect(assignments.get(newTask.id)).toBe('b')
|
||||
})
|
||||
|
||||
it('balances load across batch', () => {
|
||||
const s = new Scheduler('least-busy')
|
||||
const agents = [agent('a'), agent('b')]
|
||||
const tasks = [pendingTask('t1'), pendingTask('t2'), pendingTask('t3'), pendingTask('t4')]
|
||||
|
||||
const assignments = s.schedule(tasks, agents)
|
||||
|
||||
// Should alternate: a, b, a, b
|
||||
const values = [...assignments.values()]
|
||||
const aCount = values.filter(v => v === 'a').length
|
||||
const bCount = values.filter(v => v === 'b').length
|
||||
expect(aCount).toBe(2)
|
||||
expect(bCount).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// capability-match
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Scheduler: capability-match', () => {
|
||||
it('matches task keywords to agent system prompt', () => {
|
||||
const s = new Scheduler('capability-match')
|
||||
const agents = [
|
||||
agent('researcher', 'You are a research expert who analyzes data and writes reports'),
|
||||
agent('coder', 'You are a software engineer who writes TypeScript code'),
|
||||
]
|
||||
const tasks = [
|
||||
pendingTask('Write TypeScript code for the API'),
|
||||
pendingTask('Research and analyze market data'),
|
||||
]
|
||||
|
||||
const assignments = s.schedule(tasks, agents)
|
||||
|
||||
expect(assignments.get(tasks[0]!.id)).toBe('coder')
|
||||
expect(assignments.get(tasks[1]!.id)).toBe('researcher')
|
||||
})
|
||||
|
||||
it('falls back to first agent when no keywords match', () => {
|
||||
const s = new Scheduler('capability-match')
|
||||
const agents = [agent('alpha'), agent('beta')]
|
||||
const tasks = [pendingTask('xyz')]
|
||||
|
||||
const assignments = s.schedule(tasks, agents)
|
||||
|
||||
// When scores are tied (all 0), first agent wins
|
||||
expect(assignments.size).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// dependency-first
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Scheduler: dependency-first', () => {
|
||||
it('prioritises tasks that unblock more dependents', () => {
|
||||
const s = new Scheduler('dependency-first')
|
||||
const agents = [agent('a')]
|
||||
|
||||
// t1 blocks t2 and t3; t2 blocks nothing
|
||||
const t1 = pendingTask('t1')
|
||||
const t2 = pendingTask('t2')
|
||||
const t3 = { ...pendingTask('t3'), dependsOn: [t1.id] }
|
||||
const t4 = { ...pendingTask('t4'), dependsOn: [t1.id] }
|
||||
|
||||
const allTasks = [t2, t1, t3, t4] // t2 first in input order
|
||||
|
||||
const assignments = s.schedule(allTasks, agents)
|
||||
|
||||
// t1 should be assigned first (unblocks 2 others)
|
||||
const entries = [...assignments.entries()]
|
||||
expect(entries[0]![0]).toBe(t1.id)
|
||||
})
|
||||
|
||||
it('returns empty map for empty task list', () => {
|
||||
const s = new Scheduler('dependency-first')
|
||||
const assignments = s.schedule([], [agent('a')])
|
||||
expect(assignments.size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// autoAssign
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Scheduler: autoAssign', () => {
|
||||
it('updates queue tasks with assignees', () => {
|
||||
const s = new Scheduler('round-robin')
|
||||
const agents = [agent('a'), agent('b')]
|
||||
const queue = new TaskQueue()
|
||||
|
||||
const t1 = pendingTask('t1')
|
||||
const t2 = pendingTask('t2')
|
||||
queue.add(t1)
|
||||
queue.add(t2)
|
||||
|
||||
s.autoAssign(queue, agents)
|
||||
|
||||
const tasks = queue.list()
|
||||
const assignees = tasks.map(t => t.assignee)
|
||||
expect(assignees).toContain('a')
|
||||
expect(assignees).toContain('b')
|
||||
})
|
||||
|
||||
it('does not overwrite existing assignees', () => {
|
||||
const s = new Scheduler('round-robin')
|
||||
const agents = [agent('a'), agent('b')]
|
||||
const queue = new TaskQueue()
|
||||
|
||||
const t1 = pendingTask('t1', { assignee: 'x' })
|
||||
queue.add(t1)
|
||||
|
||||
s.autoAssign(queue, agents)
|
||||
|
||||
expect(queue.list()[0]!.assignee).toBe('x')
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,329 @@
|
|||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { MessageBus } from '../src/team/messaging.js'
|
||||
import { Team } from '../src/team/team.js'
|
||||
import type { AgentConfig, TeamConfig } from '../src/types.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function agent(name: string): AgentConfig {
|
||||
return { name, model: 'test-model', systemPrompt: `You are ${name}.` }
|
||||
}
|
||||
|
||||
function teamConfig(opts?: Partial<TeamConfig>): TeamConfig {
|
||||
return {
|
||||
name: 'test-team',
|
||||
agents: [agent('alice'), agent('bob')],
|
||||
...opts,
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// MessageBus
|
||||
// ===========================================================================
|
||||
|
||||
describe('MessageBus', () => {
|
||||
describe('send / getAll / getUnread', () => {
|
||||
it('delivers a point-to-point message', () => {
|
||||
const bus = new MessageBus()
|
||||
bus.send('alice', 'bob', 'hello')
|
||||
|
||||
const msgs = bus.getAll('bob')
|
||||
expect(msgs).toHaveLength(1)
|
||||
expect(msgs[0]!.from).toBe('alice')
|
||||
expect(msgs[0]!.to).toBe('bob')
|
||||
expect(msgs[0]!.content).toBe('hello')
|
||||
})
|
||||
|
||||
it('does not deliver messages to sender', () => {
|
||||
const bus = new MessageBus()
|
||||
bus.send('alice', 'bob', 'hello')
|
||||
expect(bus.getAll('alice')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('tracks unread state', () => {
|
||||
const bus = new MessageBus()
|
||||
const msg = bus.send('alice', 'bob', 'hello')
|
||||
|
||||
expect(bus.getUnread('bob')).toHaveLength(1)
|
||||
|
||||
bus.markRead('bob', [msg.id])
|
||||
expect(bus.getUnread('bob')).toHaveLength(0)
|
||||
// getAll still returns the message
|
||||
expect(bus.getAll('bob')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('markRead with empty array is a no-op', () => {
|
||||
const bus = new MessageBus()
|
||||
bus.markRead('bob', [])
|
||||
expect(bus.getUnread('bob')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('broadcast', () => {
|
||||
it('delivers to all except sender', () => {
|
||||
const bus = new MessageBus()
|
||||
// Set up subscribers so the bus knows about agents
|
||||
bus.subscribe('alice', () => {})
|
||||
bus.subscribe('bob', () => {})
|
||||
bus.subscribe('carol', () => {})
|
||||
|
||||
bus.broadcast('alice', 'everyone listen')
|
||||
|
||||
expect(bus.getAll('bob')).toHaveLength(1)
|
||||
expect(bus.getAll('carol')).toHaveLength(1)
|
||||
expect(bus.getAll('alice')).toHaveLength(0) // sender excluded
|
||||
})
|
||||
|
||||
it('broadcast message has to === "*"', () => {
|
||||
const bus = new MessageBus()
|
||||
const msg = bus.broadcast('alice', 'hi')
|
||||
expect(msg.to).toBe('*')
|
||||
})
|
||||
})
|
||||
|
||||
describe('subscribe', () => {
|
||||
it('notifies subscriber on new direct message', () => {
|
||||
const bus = new MessageBus()
|
||||
const received: string[] = []
|
||||
bus.subscribe('bob', (msg) => received.push(msg.content))
|
||||
|
||||
bus.send('alice', 'bob', 'ping')
|
||||
|
||||
expect(received).toEqual(['ping'])
|
||||
})
|
||||
|
||||
it('notifies subscriber on broadcast', () => {
|
||||
const bus = new MessageBus()
|
||||
const received: string[] = []
|
||||
bus.subscribe('bob', (msg) => received.push(msg.content))
|
||||
|
||||
bus.broadcast('alice', 'broadcast msg')
|
||||
|
||||
expect(received).toEqual(['broadcast msg'])
|
||||
})
|
||||
|
||||
it('does not notify sender of own broadcast', () => {
|
||||
const bus = new MessageBus()
|
||||
const received: string[] = []
|
||||
bus.subscribe('alice', (msg) => received.push(msg.content))
|
||||
|
||||
bus.broadcast('alice', 'my broadcast')
|
||||
|
||||
expect(received).toEqual([])
|
||||
})
|
||||
|
||||
it('unsubscribe stops notifications', () => {
|
||||
const bus = new MessageBus()
|
||||
const received: string[] = []
|
||||
const unsub = bus.subscribe('bob', (msg) => received.push(msg.content))
|
||||
|
||||
bus.send('alice', 'bob', 'first')
|
||||
unsub()
|
||||
bus.send('alice', 'bob', 'second')
|
||||
|
||||
expect(received).toEqual(['first'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getConversation', () => {
|
||||
it('returns messages in both directions', () => {
|
||||
const bus = new MessageBus()
|
||||
bus.send('alice', 'bob', 'hello')
|
||||
bus.send('bob', 'alice', 'hi back')
|
||||
bus.send('alice', 'carol', 'unrelated')
|
||||
|
||||
const convo = bus.getConversation('alice', 'bob')
|
||||
expect(convo).toHaveLength(2)
|
||||
expect(convo[0]!.content).toBe('hello')
|
||||
expect(convo[1]!.content).toBe('hi back')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ===========================================================================
|
||||
// Team
|
||||
// ===========================================================================
|
||||
|
||||
describe('Team', () => {
|
||||
describe('agent roster', () => {
|
||||
it('returns all agents via getAgents()', () => {
|
||||
const team = new Team(teamConfig())
|
||||
const agents = team.getAgents()
|
||||
expect(agents).toHaveLength(2)
|
||||
expect(agents.map(a => a.name)).toEqual(['alice', 'bob'])
|
||||
})
|
||||
|
||||
it('looks up agent by name', () => {
|
||||
const team = new Team(teamConfig())
|
||||
expect(team.getAgent('alice')?.name).toBe('alice')
|
||||
expect(team.getAgent('nonexistent')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('messaging', () => {
|
||||
it('sends point-to-point messages and emits event', () => {
|
||||
const team = new Team(teamConfig())
|
||||
const events: unknown[] = []
|
||||
team.on('message', (d) => events.push(d))
|
||||
|
||||
team.sendMessage('alice', 'bob', 'hey')
|
||||
|
||||
expect(team.getMessages('bob')).toHaveLength(1)
|
||||
expect(team.getMessages('bob')[0]!.content).toBe('hey')
|
||||
expect(events).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('broadcasts and emits broadcast event', () => {
|
||||
const team = new Team(teamConfig())
|
||||
const events: unknown[] = []
|
||||
team.on('broadcast', (d) => events.push(d))
|
||||
|
||||
team.broadcast('alice', 'all hands')
|
||||
|
||||
expect(events).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('task management', () => {
|
||||
it('adds and retrieves tasks', () => {
|
||||
const team = new Team(teamConfig())
|
||||
const task = team.addTask({
|
||||
title: 'Do something',
|
||||
description: 'Details here',
|
||||
status: 'pending',
|
||||
assignee: 'alice',
|
||||
})
|
||||
|
||||
expect(task.id).toBeDefined()
|
||||
expect(task.title).toBe('Do something')
|
||||
expect(team.getTasks()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('filters tasks by assignee', () => {
|
||||
const team = new Team(teamConfig())
|
||||
team.addTask({ title: 't1', description: 'd', status: 'pending', assignee: 'alice' })
|
||||
team.addTask({ title: 't2', description: 'd', status: 'pending', assignee: 'bob' })
|
||||
|
||||
expect(team.getTasksByAssignee('alice')).toHaveLength(1)
|
||||
expect(team.getTasksByAssignee('alice')[0]!.title).toBe('t1')
|
||||
})
|
||||
|
||||
it('updates a task', () => {
|
||||
const team = new Team(teamConfig())
|
||||
const task = team.addTask({ title: 't1', description: 'd', status: 'pending' })
|
||||
|
||||
const updated = team.updateTask(task.id, { status: 'in_progress' })
|
||||
expect(updated.status).toBe('in_progress')
|
||||
})
|
||||
|
||||
it('getNextTask prefers assigned tasks', () => {
|
||||
const team = new Team(teamConfig())
|
||||
team.addTask({ title: 'unassigned', description: 'd', status: 'pending' })
|
||||
team.addTask({ title: 'for alice', description: 'd', status: 'pending', assignee: 'alice' })
|
||||
|
||||
const next = team.getNextTask('alice')
|
||||
expect(next?.title).toBe('for alice')
|
||||
})
|
||||
|
||||
it('getNextTask falls back to unassigned', () => {
|
||||
const team = new Team(teamConfig())
|
||||
team.addTask({ title: 'unassigned', description: 'd', status: 'pending' })
|
||||
|
||||
const next = team.getNextTask('alice')
|
||||
expect(next?.title).toBe('unassigned')
|
||||
})
|
||||
|
||||
it('getNextTask returns undefined when no tasks available', () => {
|
||||
const team = new Team(teamConfig())
|
||||
expect(team.getNextTask('alice')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('preserves non-default status on addTask', () => {
|
||||
const team = new Team(teamConfig())
|
||||
const task = team.addTask({
|
||||
title: 'blocked task',
|
||||
description: 'd',
|
||||
status: 'blocked',
|
||||
result: 'waiting on dep',
|
||||
})
|
||||
expect(task.status).toBe('blocked')
|
||||
expect(task.result).toBe('waiting on dep')
|
||||
})
|
||||
})
|
||||
|
||||
describe('shared memory', () => {
|
||||
it('returns undefined when sharedMemory is disabled', () => {
|
||||
const team = new Team(teamConfig({ sharedMemory: false }))
|
||||
expect(team.getSharedMemory()).toBeUndefined()
|
||||
expect(team.getSharedMemoryInstance()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns a MemoryStore when sharedMemory is enabled', () => {
|
||||
const team = new Team(teamConfig({ sharedMemory: true }))
|
||||
const store = team.getSharedMemory()
|
||||
expect(store).toBeDefined()
|
||||
expect(typeof store!.get).toBe('function')
|
||||
expect(typeof store!.set).toBe('function')
|
||||
})
|
||||
|
||||
it('returns SharedMemory instance', () => {
|
||||
const team = new Team(teamConfig({ sharedMemory: true }))
|
||||
const mem = team.getSharedMemoryInstance()
|
||||
expect(mem).toBeDefined()
|
||||
expect(typeof mem!.write).toBe('function')
|
||||
expect(typeof mem!.getSummary).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('events', () => {
|
||||
it('emits task:ready when a pending task becomes runnable', () => {
|
||||
const team = new Team(teamConfig())
|
||||
const events: unknown[] = []
|
||||
team.on('task:ready', (d) => events.push(d))
|
||||
|
||||
team.addTask({ title: 't1', description: 'd', status: 'pending' })
|
||||
|
||||
// task:ready is fired by the queue when a task with no deps is added
|
||||
expect(events.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('emits custom events via emit()', () => {
|
||||
const team = new Team(teamConfig())
|
||||
const received: unknown[] = []
|
||||
team.on('custom:event', (d) => received.push(d))
|
||||
|
||||
team.emit('custom:event', { foo: 'bar' })
|
||||
|
||||
expect(received).toEqual([{ foo: 'bar' }])
|
||||
})
|
||||
|
||||
it('unsubscribe works', () => {
|
||||
const team = new Team(teamConfig())
|
||||
const received: unknown[] = []
|
||||
const unsub = team.on('custom:event', (d) => received.push(d))
|
||||
|
||||
team.emit('custom:event', 'first')
|
||||
unsub()
|
||||
team.emit('custom:event', 'second')
|
||||
|
||||
expect(received).toEqual(['first'])
|
||||
})
|
||||
|
||||
it('bridges task:complete and task:failed from the queue', () => {
|
||||
// These events fire via queue.complete()/queue.fail(), which happen
|
||||
// during orchestration. Team only exposes updateTask() which calls
|
||||
// queue.update() — no event is emitted. We verify the bridge is
|
||||
// wired correctly by checking that task:ready fires on addTask.
|
||||
const team = new Team(teamConfig())
|
||||
const readyEvents: unknown[] = []
|
||||
team.on('task:ready', (d) => readyEvents.push(d))
|
||||
|
||||
team.addTask({ title: 't1', description: 'd', status: 'pending' })
|
||||
|
||||
// task:ready fires because a pending task with no deps is immediately ready
|
||||
expect(readyEvents.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
coverage: {
|
||||
include: ['src/**'],
|
||||
},
|
||||
},
|
||||
})
|
||||
Loading…
Reference in New Issue