426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
328 lines
11 KiB
TypeScript
328 lines
11 KiB
TypeScript
/**
|
|
* Unit tests for the text-generation plumbing — model resolution, native tool
|
|
* and message normalization, structured-output vs tools precedence, and
|
|
* streaming vs generateText routing. The AI SDK, provider factory, and
|
|
* detection are all mocked, so no live model is called.
|
|
*/
|
|
|
|
import type { GenerateTextResult, IAgentRuntime, TextStreamResult } from "@elizaos/core";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const { generateTextMock, streamTextMock, createOpenAICompatibleMock, detectLMStudioMock } =
|
|
vi.hoisted(() => ({
|
|
generateTextMock: vi.fn(),
|
|
streamTextMock: vi.fn(),
|
|
createOpenAICompatibleMock: vi.fn(),
|
|
detectLMStudioMock: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("ai", () => ({
|
|
embed: vi.fn(),
|
|
generateObject: vi.fn(),
|
|
generateText: (...args: unknown[]) => generateTextMock(...args),
|
|
streamText: (...args: unknown[]) => streamTextMock(...args),
|
|
jsonSchema: vi.fn((schema: unknown) => schema),
|
|
Output: {
|
|
object: vi.fn((spec: unknown) => ({ kind: "output.object", spec })),
|
|
},
|
|
}));
|
|
|
|
vi.mock("@ai-sdk/openai-compatible", () => ({
|
|
createOpenAICompatible: (options: unknown) => createOpenAICompatibleMock(options),
|
|
}));
|
|
|
|
vi.mock("../utils/detect", () => ({
|
|
detectLMStudio: (...args: unknown[]) => detectLMStudioMock(...args),
|
|
}));
|
|
|
|
import {
|
|
handleResponseHandler,
|
|
handleTextLarge,
|
|
handleTextSmall,
|
|
normalizeNativeTools,
|
|
} from "../models/text";
|
|
|
|
function createRuntime(overrides: Record<string, string> = {}): IAgentRuntime {
|
|
const settings: Record<string, string> = {
|
|
LMSTUDIO_SMALL_MODEL: "manual-small",
|
|
LMSTUDIO_LARGE_MODEL: "manual-large",
|
|
...overrides,
|
|
};
|
|
const runtime = {
|
|
character: { system: "system prompt" },
|
|
emitEvent: vi.fn(),
|
|
getSetting: vi.fn((key: string) => settings[key] ?? null),
|
|
};
|
|
return runtime as IAgentRuntime;
|
|
}
|
|
|
|
function expectGenerateTextResult(value: unknown): asserts value is GenerateTextResult {
|
|
expect(value).toEqual(expect.objectContaining({ text: expect.any(String) }));
|
|
}
|
|
|
|
describe("LM Studio text plumbing shape", () => {
|
|
beforeEach(() => {
|
|
generateTextMock.mockReset();
|
|
streamTextMock.mockReset();
|
|
streamTextMock.mockImplementation(() => ({
|
|
textStream: (async function* () {})(),
|
|
text: Promise.resolve(""),
|
|
usage: Promise.resolve(undefined),
|
|
finishReason: Promise.resolve(undefined),
|
|
}));
|
|
createOpenAICompatibleMock.mockReset();
|
|
createOpenAICompatibleMock.mockImplementation(() => {
|
|
const provider = vi.fn((modelId: string) => ({ modelId }));
|
|
return Object.assign(provider, {
|
|
languageModel: vi.fn((modelId: string) => ({ modelId })),
|
|
chatModel: vi.fn((modelId: string) => ({ modelId })),
|
|
completionModel: vi.fn((modelId: string) => ({ modelId })),
|
|
embeddingModel: vi.fn((modelId: string) => ({ modelId })),
|
|
textEmbeddingModel: vi.fn((modelId: string) => ({ modelId })),
|
|
imageModel: vi.fn((modelId: string) => ({ modelId })),
|
|
});
|
|
});
|
|
detectLMStudioMock.mockReset();
|
|
detectLMStudioMock.mockResolvedValue({
|
|
available: true,
|
|
baseURL: "http://localhost:1234/v1",
|
|
models: [{ id: "auto-detected-model" }],
|
|
});
|
|
});
|
|
|
|
it("instantiates the OpenAI-compatible client with LM Studio's name and base URL", async () => {
|
|
generateTextMock.mockResolvedValue({
|
|
text: "ack",
|
|
toolCalls: [],
|
|
finishReason: "stop",
|
|
usage: { inputTokens: 5, outputTokens: 8 },
|
|
});
|
|
|
|
await handleTextSmall(createRuntime(), { prompt: "hi" } as never);
|
|
|
|
expect(createOpenAICompatibleMock).toHaveBeenCalledTimes(1);
|
|
const opts = createOpenAICompatibleMock.mock.calls[0][0] as Record<string, unknown>;
|
|
expect(opts.name).toBe("lmstudio");
|
|
expect(opts.baseURL).toBe("http://localhost:1234/v1");
|
|
expect(opts.apiKey).toBeUndefined();
|
|
});
|
|
|
|
it("forwards apiKey as bearer when configured", async () => {
|
|
generateTextMock.mockResolvedValue({
|
|
text: "",
|
|
toolCalls: [],
|
|
finishReason: "stop",
|
|
usage: undefined,
|
|
});
|
|
|
|
await handleTextSmall(createRuntime({ LMSTUDIO_API_KEY: "sk-lm" }), {
|
|
prompt: "hi",
|
|
} as never);
|
|
|
|
const opts = createOpenAICompatibleMock.mock.calls[0][0] as Record<string, unknown>;
|
|
expect(opts.apiKey).toBe("sk-lm");
|
|
});
|
|
|
|
it("uses LMSTUDIO_LARGE_MODEL for TEXT_LARGE", async () => {
|
|
generateTextMock.mockResolvedValue({
|
|
text: "ok",
|
|
toolCalls: [],
|
|
finishReason: "stop",
|
|
usage: undefined,
|
|
});
|
|
|
|
await handleTextLarge(createRuntime(), { prompt: "p" } as never);
|
|
const args = generateTextMock.mock.calls[0][0] as { model: { modelId: string } };
|
|
expect(args.model.modelId).toBe("manual-large");
|
|
});
|
|
|
|
it("uses LMSTUDIO_SMALL_MODEL for TEXT_SMALL", async () => {
|
|
generateTextMock.mockResolvedValue({
|
|
text: "ok",
|
|
toolCalls: [],
|
|
finishReason: "stop",
|
|
usage: undefined,
|
|
});
|
|
|
|
await handleTextSmall(createRuntime(), { prompt: "p" } as never);
|
|
const args = generateTextMock.mock.calls[0][0] as { model: { modelId: string } };
|
|
expect(args.model.modelId).toBe("manual-small");
|
|
});
|
|
|
|
it("falls back to the first /v1/models entry when no override is set", async () => {
|
|
generateTextMock.mockResolvedValue({
|
|
text: "ok",
|
|
toolCalls: [],
|
|
finishReason: "stop",
|
|
usage: undefined,
|
|
});
|
|
|
|
const runtime = createRuntime();
|
|
(runtime.getSetting as ReturnType<typeof vi.fn>).mockImplementation(() => null);
|
|
|
|
await handleTextSmall(runtime, { prompt: "p" } as never);
|
|
const args = generateTextMock.mock.calls[0][0] as { model: { modelId: string } };
|
|
expect(args.model.modelId).toBe("auto-detected-model");
|
|
});
|
|
|
|
it("throws when model discovery has no usable models", async () => {
|
|
detectLMStudioMock.mockResolvedValueOnce({
|
|
available: false,
|
|
baseURL: "http://localhost:1234/v1",
|
|
error: "unexpected /v1/models response shape",
|
|
});
|
|
const runtime = createRuntime();
|
|
(runtime.getSetting as ReturnType<typeof vi.fn>).mockImplementation(() => null);
|
|
|
|
await expect(handleTextSmall(runtime, { prompt: "p" } as never)).rejects.toThrow();
|
|
expect(generateTextMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("forwards native tools to generateText and returns a GenerateTextResult shape", async () => {
|
|
generateTextMock.mockResolvedValue({
|
|
text: "ack",
|
|
toolCalls: [{ toolCallId: "c1", toolName: "lookup", input: { q: "x" } }],
|
|
finishReason: "stop",
|
|
usage: { inputTokens: 1, outputTokens: 2 },
|
|
});
|
|
|
|
const result = await handleTextSmall(createRuntime(), {
|
|
prompt: "use a tool",
|
|
tools: { lookup: { description: "Lookup", inputSchema: { type: "object" } } },
|
|
} as never);
|
|
|
|
expectGenerateTextResult(result);
|
|
expect(generateTextMock).toHaveBeenCalledTimes(1);
|
|
expect(result.text).toBe("ack");
|
|
expect(result.toolCalls).toHaveLength(1);
|
|
expect(result.toolCalls?.[0]).toMatchObject({
|
|
id: "c1",
|
|
name: "lookup",
|
|
arguments: { q: "x" },
|
|
});
|
|
expect(result.usage).toEqual({
|
|
promptTokens: 1,
|
|
completionTokens: 2,
|
|
totalTokens: 3,
|
|
});
|
|
});
|
|
|
|
it("infers root array schemas for native tool parameters", () => {
|
|
const tools = normalizeNativeTools([
|
|
{
|
|
name: "select_items",
|
|
description: "Select items",
|
|
parameters: { items: { type: "string" } },
|
|
},
|
|
]) as Record<string, { inputSchema: unknown }>;
|
|
|
|
expect(tools.select_items.inputSchema).toEqual({
|
|
type: "array",
|
|
items: { type: "string" },
|
|
});
|
|
});
|
|
|
|
it("rejects native tool arrays with nameless entries before calling the provider", () => {
|
|
expect(() => normalizeNativeTools([{ description: "missing name" }])).toThrow(
|
|
/missing a name/i
|
|
);
|
|
});
|
|
|
|
it("serializes circular native message content instead of aborting generation", async () => {
|
|
generateTextMock.mockResolvedValue({
|
|
text: "ok",
|
|
toolCalls: [],
|
|
finishReason: "stop",
|
|
usage: undefined,
|
|
});
|
|
const circular: Record<string, unknown> = { value: "x" };
|
|
circular.self = circular;
|
|
|
|
const result = await handleTextSmall(createRuntime(), {
|
|
messages: [{ role: "user", content: circular }],
|
|
} as never);
|
|
|
|
expect(result).toEqual(expect.objectContaining({ text: "ok" }));
|
|
const callArg = generateTextMock.mock.calls[0][0] as { messages: Array<{ content: unknown }> };
|
|
expect(callArg.messages[0]?.content).toBe("[unserializable content]");
|
|
});
|
|
|
|
it("omits structured output when tools and responseSchema are both set", async () => {
|
|
generateTextMock.mockResolvedValue({
|
|
text: "tool-only",
|
|
toolCalls: [],
|
|
finishReason: "stop",
|
|
usage: undefined,
|
|
});
|
|
|
|
await handleTextSmall(createRuntime(), {
|
|
prompt: "p",
|
|
tools: { lookup: { description: "L", inputSchema: { type: "object" } } },
|
|
responseSchema: { type: "object", properties: {} },
|
|
} as never);
|
|
|
|
const callArg = generateTextMock.mock.calls[0][0] as Record<string, unknown>;
|
|
expect(callArg.output).toBeUndefined();
|
|
expect(callArg.tools).toBeDefined();
|
|
});
|
|
|
|
it("uses streamText when stream=true without schema or tools", async () => {
|
|
streamTextMock.mockImplementation(() => ({
|
|
textStream: (async function* () {
|
|
yield "a";
|
|
yield "b";
|
|
})(),
|
|
text: Promise.resolve("ab"),
|
|
usage: Promise.resolve({ inputTokens: 1, outputTokens: 1 }),
|
|
finishReason: Promise.resolve("stop"),
|
|
}));
|
|
|
|
const result = await handleTextSmall(createRuntime(), {
|
|
prompt: "hello",
|
|
stream: true,
|
|
} as never);
|
|
|
|
expect(streamTextMock).toHaveBeenCalledTimes(1);
|
|
expect(generateTextMock).not.toHaveBeenCalled();
|
|
const stream = result as TextStreamResult;
|
|
const chunks: string[] = [];
|
|
for await (const c of stream.textStream) {
|
|
chunks.push(c);
|
|
}
|
|
expect(chunks).toEqual(["a", "b"]);
|
|
await expect(stream.text).resolves.toBe("ab");
|
|
});
|
|
|
|
it("falls back to generateText when stream=true with structured output", async () => {
|
|
generateTextMock.mockResolvedValue({
|
|
text: "",
|
|
output: { foo: "bar" },
|
|
finishReason: "stop",
|
|
usage: undefined,
|
|
});
|
|
|
|
const out = await handleTextSmall(createRuntime(), {
|
|
prompt: "p",
|
|
stream: true,
|
|
responseSchema: { type: "object", properties: { foo: { type: "string" } } },
|
|
} as never);
|
|
|
|
expect(generateTextMock).toHaveBeenCalledTimes(1);
|
|
expect(JSON.parse(out as string)).toEqual({ foo: "bar" });
|
|
});
|
|
|
|
it("throws when generateText fails (no fabricated reply)", async () => {
|
|
generateTextMock.mockRejectedValue(
|
|
Object.assign(new Error("loaded model is unavailable"), {
|
|
statusCode: 500,
|
|
responseBody: '{"error":"no model loaded"}',
|
|
url: "http://localhost:1234/v1/chat/completions",
|
|
})
|
|
);
|
|
|
|
await expect(handleResponseHandler(createRuntime(), { prompt: "p" } as never)).rejects.toThrow(
|
|
"loaded model is unavailable"
|
|
);
|
|
});
|
|
});
|