mirror of
https://github.com/JackChen-me/open-multi-agent.git
synced 2026-10-22 02:53:26 -05:00
* 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/.
138 lines
6.1 KiB
TypeScript
138 lines
6.1 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import {
|
|
DEFAULT_REASONING_FALLBACK_MAX_CHARS,
|
|
NO_TRUNCATION,
|
|
reasoningBlockToInlineText,
|
|
} from '../src/llm/reasoning-fallback.js'
|
|
import type { ReasoningBlock } from '../src/types.js'
|
|
|
|
describe('reasoningBlockToInlineText', () => {
|
|
it('wraps non-empty text in <thinking> tags', () => {
|
|
const block: ReasoningBlock = { type: 'reasoning', text: 'plan first' }
|
|
expect(reasoningBlockToInlineText(block)).toBe('<thinking>plan first</thinking>')
|
|
})
|
|
|
|
it('returns empty string for non-redacted blocks with empty text', () => {
|
|
const block: ReasoningBlock = { type: 'reasoning', text: '' }
|
|
expect(reasoningBlockToInlineText(block)).toBe('')
|
|
})
|
|
|
|
it('emits the [redacted] placeholder when redactedData is non-empty', () => {
|
|
const block: ReasoningBlock = {
|
|
type: 'reasoning',
|
|
text: '',
|
|
redactedData: 'opaque-encrypted-blob',
|
|
}
|
|
expect(reasoningBlockToInlineText(block)).toBe('<thinking>[redacted]</thinking>')
|
|
})
|
|
|
|
it('prefers the redacted placeholder over text when both are present', () => {
|
|
// Anthropic should never emit both, but the behaviour must be deterministic
|
|
// if a future provider does — never leak text alongside a redacted marker.
|
|
const block: ReasoningBlock = {
|
|
type: 'reasoning',
|
|
text: 'some plaintext',
|
|
redactedData: 'opaque',
|
|
}
|
|
expect(reasoningBlockToInlineText(block)).toBe('<thinking>[redacted]</thinking>')
|
|
})
|
|
|
|
it('treats empty redactedData as absent (falls through to text path)', () => {
|
|
const block: ReasoningBlock = {
|
|
type: 'reasoning',
|
|
text: 'visible',
|
|
redactedData: '',
|
|
}
|
|
expect(reasoningBlockToInlineText(block)).toBe('<thinking>visible</thinking>')
|
|
})
|
|
|
|
describe('truncation', () => {
|
|
it('leaves text under maxChars unchanged', () => {
|
|
const block: ReasoningBlock = { type: 'reasoning', text: 'short' }
|
|
const out = reasoningBlockToInlineText(block, { maxChars: 100 })
|
|
expect(out).toBe('<thinking>short</thinking>')
|
|
})
|
|
|
|
it('produces a head+tail excerpt with marker when over maxChars', () => {
|
|
const text = 'A'.repeat(60) + 'M'.repeat(60) + 'Z'.repeat(60)
|
|
const block: ReasoningBlock = { type: 'reasoning', text }
|
|
const out = reasoningBlockToInlineText(block, { maxChars: 60 })
|
|
|
|
expect(out.startsWith('<thinking>')).toBe(true)
|
|
expect(out.endsWith('</thinking>')).toBe(true)
|
|
expect(out).toContain('...[truncated]...')
|
|
// Head should preserve the leading 'A's; tail should preserve the
|
|
// trailing 'Z's. Middle 'M's get dropped by definition of head+tail.
|
|
expect(out).toContain('A')
|
|
expect(out).toContain('Z')
|
|
expect(out).not.toContain('M')
|
|
|
|
// Inner text length (excluding wrapper) is bounded by maxChars.
|
|
const inner = out.slice('<thinking>'.length, -'</thinking>'.length)
|
|
expect(inner.length).toBeLessThanOrEqual(60)
|
|
})
|
|
|
|
it('falls back to a head slice when maxChars is smaller than the marker', () => {
|
|
const text = 'abcdefghij'
|
|
const block: ReasoningBlock = { type: 'reasoning', text }
|
|
// Marker is "...[truncated]..." (17 chars); ask for budget below that.
|
|
const out = reasoningBlockToInlineText(block, { maxChars: 3 })
|
|
expect(out).toBe('<thinking>abc</thinking>')
|
|
})
|
|
|
|
it('clamps non-finite maxChars to 1 (except positive Infinity)', () => {
|
|
const block: ReasoningBlock = { type: 'reasoning', text: 'abc' }
|
|
expect(reasoningBlockToInlineText(block, { maxChars: NaN })).toBe('<thinking>a</thinking>')
|
|
expect(reasoningBlockToInlineText(block, { maxChars: -Infinity })).toBe('<thinking>a</thinking>')
|
|
})
|
|
|
|
it('treats positive Infinity maxChars as no truncation', () => {
|
|
// The resolver maps Infinity → MAX_SAFE_INTEGER so text.length <= maxChars
|
|
// is always true and the text passes through unchanged. This is the
|
|
// sentinel callers use when AgentConfig.compressReasoningText is `false`.
|
|
const text = 'x'.repeat(5000)
|
|
const block: ReasoningBlock = { type: 'reasoning', text }
|
|
expect(reasoningBlockToInlineText(block, { maxChars: Infinity })).toBe(`<thinking>${text}</thinking>`)
|
|
})
|
|
|
|
it('NO_TRUNCATION constant behaves identically to Number.POSITIVE_INFINITY', () => {
|
|
// Runtime invariant: this guards against a future refactor that
|
|
// accidentally removes the special-case branch in resolveMaxChars or
|
|
// re-binds NO_TRUNCATION to a different value. Without this assertion
|
|
// the JSDoc contract on `maxChars` could silently drift from runtime.
|
|
expect(NO_TRUNCATION).toBe(Number.POSITIVE_INFINITY)
|
|
const text = 'y'.repeat(3000)
|
|
const block: ReasoningBlock = { type: 'reasoning', text }
|
|
expect(reasoningBlockToInlineText(block, { maxChars: NO_TRUNCATION })).toBe(
|
|
`<thinking>${text}</thinking>`,
|
|
)
|
|
})
|
|
|
|
it('clamps maxChars below 1 to 1', () => {
|
|
const block: ReasoningBlock = { type: 'reasoning', text: 'abc' }
|
|
expect(reasoningBlockToInlineText(block, { maxChars: 0 })).toBe('<thinking>a</thinking>')
|
|
expect(reasoningBlockToInlineText(block, { maxChars: -10 })).toBe('<thinking>a</thinking>')
|
|
})
|
|
|
|
it('uses DEFAULT_REASONING_FALLBACK_MAX_CHARS when maxChars is omitted', () => {
|
|
const text = 'X'.repeat(DEFAULT_REASONING_FALLBACK_MAX_CHARS + 100)
|
|
const block: ReasoningBlock = { type: 'reasoning', text }
|
|
const out = reasoningBlockToInlineText(block)
|
|
const inner = out.slice('<thinking>'.length, -'</thinking>'.length)
|
|
expect(inner.length).toBeLessThanOrEqual(DEFAULT_REASONING_FALLBACK_MAX_CHARS)
|
|
})
|
|
})
|
|
|
|
describe('one-way invariant', () => {
|
|
it('always emits a string output (no parsing back to ReasoningBlock)', () => {
|
|
// This test is a documentation marker rather than a behaviour check:
|
|
// the helper has no inverse, by design. If a future contributor adds
|
|
// an `inlineTextToReasoningBlock` function, it would violate the
|
|
// safety contract documented in src/llm/reasoning-fallback.ts.
|
|
const block: ReasoningBlock = { type: 'reasoning', text: 'x' }
|
|
const out = reasoningBlockToInlineText(block)
|
|
expect(typeof out).toBe('string')
|
|
})
|
|
})
|
|
})
|