Files
open-multi-agent/packages/core/tests/sampling-wire-format.test.ts
T
Jack Chen 1d37ce6026 refactor(repo): monorepo groundwork for an easier first run (#303)
* refactor(repo): monorepo groundwork for an easier first run

Trying OMA for the first time costs more than it should: a lot to read before anything runs, and no one-command way to watch a team actually work. The plan is to fix that with a `npm create oma` scaffold (a multi-agent demo that runs on the first try) and an npm README that leads with getting started. Neither lands cleanly until core lives in its own package, so this PR does that part first.

This is groundwork, not the payoff: nothing changes for anyone installing `@open-multi-agent/core` today. It turns the repo into an npm-workspaces monorepo with core in `packages/core/`, and splits the README into a GitHub landing page (root) and the npm package page (`packages/core/`). Doing it now, while the repo is small, avoids migrating twice.

The published package is byte-identical: same name, same three runtime dependencies, same entry points, guarded by an isolated-install smoke test. `npm ci` + lint + test (1060) + build pass; the tarball still ships only `dist/`, `README.md`, `LICENSE`, and `package.json`.

* build: restore cross-platform optional deps in package-lock.json

Regenerating the lockfile on macOS pruned the non-host rollup/esbuild optional binaries (npm/cli#4828), so Linux CI could not load @rollup/rollup-linux-x64-gnu and vitest failed to start. Rebuild the lock from the pre-migration base so every platform variant is present again. Verified with npm ci + lint + build + test (1060).

* fix(examples,docs): correct monorepo paths and anchors after the packages/core move

with-vercel-ai-sdk example (pre-existing, surfaced by the move):
- package.json: the @open-multi-agent/core file: link was file:../../, which
  resolves to packages/core/examples (no manifest). Corrected to file:../../..
  (packages/core); predev cd updated to match.
- package-lock.json regenerated: was linking ../.. and pinning a stale core
  1.4.0; now links ../../.. at 1.7.0.
- README: setup paths and the file: note updated.

docs missed by the move:
- docs/cli.md: repo-clone build output is now packages/core/dist/cli/oma.js;
  the quick-start anchor now lives in the package README.
- CONTRIBUTING.md: the architecture anchor and src/ entry-point paths now live
  under packages/core/.
2026-06-18 17:31:52 +08:00

196 lines
6.1 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { LLMMessage } from '../src/types.js'
const { mockOpenAICreate } = vi.hoisted(() => ({
mockOpenAICreate: vi.fn(),
}))
vi.mock('openai', () => ({
default: vi.fn().mockImplementation(() => ({
chat: { completions: { create: mockOpenAICreate } },
})),
}))
const { mockAnthropicCreate } = vi.hoisted(() => ({
mockAnthropicCreate: vi.fn(),
}))
vi.mock('@anthropic-ai/sdk', () => ({
default: vi.fn().mockImplementation(() => ({
messages: { create: mockAnthropicCreate },
})),
}))
// Imports happen after vi.mock so the mocked modules are picked up.
const { OpenAIAdapter } = await import('../src/llm/openai.js')
const { AnthropicAdapter } = await import('../src/llm/anthropic.js')
const userMessage: LLMMessage = {
role: 'user',
content: [{ type: 'text', text: 'hi' }],
}
const okOpenAIResponse = {
id: 'r1',
choices: [
{
index: 0,
message: { role: 'assistant' as const, content: 'ok' },
finish_reason: 'stop' as const,
},
],
model: 'gpt-test',
usage: { prompt_tokens: 1, completion_tokens: 1 },
created: 0,
object: 'chat.completion' as const,
}
const okAnthropicResponse = {
id: 'r1',
content: [{ type: 'text' as const, text: 'ok' }],
model: 'claude-test',
role: 'assistant' as const,
stop_reason: 'end_turn' as const,
type: 'message' as const,
usage: { input_tokens: 1, output_tokens: 1 },
}
beforeEach(() => {
mockOpenAICreate.mockReset()
mockAnthropicCreate.mockReset()
})
describe('OpenAI adapter wire format', () => {
it('writes top_k, min_p, frequency_penalty, presence_penalty, top_p into the chat completions payload', async () => {
mockOpenAICreate.mockResolvedValueOnce(okOpenAIResponse)
const adapter = new OpenAIAdapter('test-key')
await adapter.chat([userMessage], {
model: 'gpt-test',
maxTokens: 100,
temperature: 0.7,
topP: 0.9,
topK: 40,
minP: 0.05,
frequencyPenalty: 1.2,
presencePenalty: 0.5,
extraBody: { repetition_penalty: 1.1 },
})
expect(mockOpenAICreate).toHaveBeenCalledTimes(1)
const body = mockOpenAICreate.mock.calls[0]?.[0] as Record<string, unknown>
expect(body['top_p']).toBe(0.9)
expect(body['top_k']).toBe(40)
expect(body['min_p']).toBe(0.05)
expect(body['frequency_penalty']).toBe(1.2)
expect(body['presence_penalty']).toBe(0.5)
expect(body['repetition_penalty']).toBe(1.1)
expect(body['temperature']).toBe(0.7)
expect(body['max_tokens']).toBe(100)
expect(body['model']).toBe('gpt-test')
expect(body['stream']).toBe(false)
})
it('forwards parallelToolCalls=false as parallel_tool_calls in the chat payload', async () => {
mockOpenAICreate.mockResolvedValueOnce(okOpenAIResponse)
const adapter = new OpenAIAdapter('test-key')
await adapter.chat([userMessage], {
model: 'gpt-test',
parallelToolCalls: false,
})
const body = mockOpenAICreate.mock.calls[0]?.[0] as Record<string, unknown>
expect(body['parallel_tool_calls']).toBe(false)
})
it('forwards parallelToolCalls=true as parallel_tool_calls in the chat payload', async () => {
mockOpenAICreate.mockResolvedValueOnce(okOpenAIResponse)
const adapter = new OpenAIAdapter('test-key')
await adapter.chat([userMessage], {
model: 'gpt-test',
parallelToolCalls: true,
})
const body = mockOpenAICreate.mock.calls[0]?.[0] as Record<string, unknown>
expect(body['parallel_tool_calls']).toBe(true)
})
it('omits parallel_tool_calls when parallelToolCalls is undefined', async () => {
mockOpenAICreate.mockResolvedValueOnce(okOpenAIResponse)
const adapter = new OpenAIAdapter('test-key')
await adapter.chat([userMessage], { model: 'gpt-test' })
const body = mockOpenAICreate.mock.calls[0]?.[0] as Record<string, unknown>
expect(body['parallel_tool_calls']).toBeUndefined()
})
it('refuses to let extraBody override transport-level fields (model, stream)', async () => {
mockOpenAICreate.mockResolvedValueOnce(okOpenAIResponse)
const adapter = new OpenAIAdapter('test-key')
await adapter.chat([userMessage], {
model: 'real-model',
extraBody: { model: 'evil-override', stream: true },
})
const body = mockOpenAICreate.mock.calls[0]?.[0] as Record<string, unknown>
expect(body['model']).toBe('real-model')
expect(body['stream']).toBe(false)
})
it('lets extraBody override sampling defaults like temperature', async () => {
mockOpenAICreate.mockResolvedValueOnce(okOpenAIResponse)
const adapter = new OpenAIAdapter('test-key')
await adapter.chat([userMessage], {
model: 'm',
temperature: 0.5,
extraBody: { temperature: 0.99 },
})
const body = mockOpenAICreate.mock.calls[0]?.[0] as Record<string, unknown>
expect(body['temperature']).toBe(0.99)
})
})
describe('Anthropic adapter wire format', () => {
it('writes top_p, top_k, temperature, max_tokens into the messages payload', async () => {
mockAnthropicCreate.mockResolvedValueOnce(okAnthropicResponse)
const adapter = new AnthropicAdapter('test-key')
await adapter.chat([userMessage], {
model: 'claude-test',
maxTokens: 200,
temperature: 0.6,
topP: 0.85,
topK: 25,
extraBody: { metadata: { user_id: 'u1' } },
})
expect(mockAnthropicCreate).toHaveBeenCalledTimes(1)
const body = mockAnthropicCreate.mock.calls[0]?.[0] as Record<string, unknown>
expect(body['top_p']).toBe(0.85)
expect(body['top_k']).toBe(25)
expect(body['temperature']).toBe(0.6)
expect(body['max_tokens']).toBe(200)
expect(body['metadata']).toEqual({ user_id: 'u1' })
expect(body['model']).toBe('claude-test')
})
it('refuses to let extraBody override transport-level fields (model)', async () => {
mockAnthropicCreate.mockResolvedValueOnce(okAnthropicResponse)
const adapter = new AnthropicAdapter('test-key')
await adapter.chat([userMessage], {
model: 'claude-real',
extraBody: { model: 'evil-override' },
})
const body = mockAnthropicCreate.mock.calls[0]?.[0] as Record<string, unknown>
expect(body['model']).toBe('claude-real')
})
})