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
344 lines
13 KiB
TypeScript
344 lines
13 KiB
TypeScript
/**
|
|
* E2E: voice → entity binding round-trip (issue #8234).
|
|
*
|
|
* Proves the full production chain across BOTH plugins with no test doubles
|
|
* in the seam: a recognized voice turn flows
|
|
*
|
|
* plugin-local-inference `emitVoiceTurnObserved`
|
|
* → AgentRuntime.emitEvent(VOICE_TURN_OBSERVED)
|
|
* → plugin-lifeops `handleVoiceTurnObserved` (the handler registered on
|
|
* `personalAssistantPlugin.events`) → `VoiceObserver` → real `EntityStore`
|
|
* merge engine on a real PGLite-backed runtime
|
|
* → AgentRuntime.emitEvent(VOICE_ENTITY_BOUND)
|
|
* → plugin-local-inference `handleVoiceEntityBound` (the handler
|
|
* registered on `localInferencePlugin.events`)
|
|
* → `VoiceProfileStore.bindEntity` persists `entityId` to disk.
|
|
*
|
|
* Also covers the two user-facing runtime paths the issue called out as
|
|
* missing:
|
|
* - the `IDENTIFY_SPEAKER` agent action ("that was Sam"), and
|
|
* - the HTTP bind/unbind routes served from `localInferencePlugin.routes`.
|
|
*
|
|
* The only injected pieces are storage locations: the voice-profile store
|
|
* roots in a temp dir (instead of `~/.eliza/voice-profiles`) and PGLite
|
|
* stands in for Postgres. The lifeops side builds its observer through the
|
|
* real `LifeOpsRepository` — no `setVoiceObserverFactory` override.
|
|
*/
|
|
|
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
import http from "node:http";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import { KNOWLEDGE_GRAPH_SERVICE, KnowledgeGraphService } from "@elizaos/agent";
|
|
import {
|
|
type AgentRuntime,
|
|
EventType,
|
|
type Memory,
|
|
type Plugin,
|
|
} from "@elizaos/core";
|
|
// plugin-local-inference modules are imported by relative source path:
|
|
// the package's subpath export aliases resolve to (possibly stale) dist
|
|
// bundles in the test graph, while the root barrel resolves to src — mixing
|
|
// them would split module identity and the injectable store seams
|
|
// (`setVoiceEntityBindingStore` etc.) would target the wrong module copy.
|
|
import {
|
|
identifySpeakerAction,
|
|
localInferencePlugin,
|
|
} from "@elizaos/plugin-local-inference";
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import {
|
|
createRealTestRuntime,
|
|
type RealTestRuntimeResult,
|
|
} from "../../../packages/test/helpers/real-runtime.ts";
|
|
import {
|
|
handleVoiceProfilesManagementRoutes,
|
|
setVoiceProfilesManagementStore,
|
|
} from "../../plugin-local-inference/src/routes/voice-profiles-management-routes.js";
|
|
import {
|
|
handleVoiceSpeakerProfileRoutes,
|
|
setVoiceSpeakerProfileStore,
|
|
} from "../../plugin-local-inference/src/routes/voice-speaker-profile-routes.js";
|
|
import {
|
|
emitVoiceTurnObserved,
|
|
handleVoiceEntityBound,
|
|
setVoiceEntityBindingStore,
|
|
} from "../../plugin-local-inference/src/runtime/voice-entity-binding.js";
|
|
import { VoiceProfileStore } from "../../plugin-local-inference/src/services/voice/profile-store.js";
|
|
import { WESPEAKER_RESNET34_LM_INT8_MODEL_ID } from "../../plugin-local-inference/src/services/voice/speaker/encoder.js";
|
|
import { EntityStore } from "../src/lifeops/entities/store.js";
|
|
import { handleVoiceTurnObserved } from "../src/lifeops/entities/voice-observer-bridge.js";
|
|
import { LifeOpsRepository } from "../src/lifeops/repository.js";
|
|
|
|
const MODEL = WESPEAKER_RESNET34_LM_INT8_MODEL_ID;
|
|
|
|
function unit(values: number[]): Float32Array {
|
|
let sumSq = 0;
|
|
for (const v of values) sumSq += v * v;
|
|
const inv = sumSq > 0 ? 1 / Math.sqrt(sumSq) : 1;
|
|
return new Float32Array(values.map((v) => v * inv));
|
|
}
|
|
|
|
/**
|
|
* The seam under test: the production handlers both plugins register. The
|
|
* local-inference handler array is taken from the plugin object itself; the
|
|
* lifeops handler is the same `handleVoiceTurnObserved` that
|
|
* `personalAssistantPlugin.events` registers (asserted by the registration test in
|
|
* `src/lifeops/entities/voice-observer-bridge.test.ts` — importing the full
|
|
* lifeops plugin barrel here would drag the `@elizaos/agent` server graph
|
|
* into the e2e lane). Registering a thin seam plugin skips both plugins'
|
|
* unrelated init (model downloads, schedulers, connectors).
|
|
*/
|
|
function buildSeamPlugin(): Plugin {
|
|
const boundHandlers =
|
|
localInferencePlugin.events?.[EventType.VOICE_ENTITY_BOUND];
|
|
if (!boundHandlers?.length) {
|
|
throw new Error(
|
|
"VOICE_ENTITY_BOUND handler is not registered on localInferencePlugin",
|
|
);
|
|
}
|
|
return {
|
|
name: "voice-binding-seam-e2e",
|
|
description:
|
|
"Registers the real voice-binding event handlers from both plugins",
|
|
events: {
|
|
[EventType.VOICE_TURN_OBSERVED]: [handleVoiceTurnObserved],
|
|
[EventType.VOICE_ENTITY_BOUND]: boundHandlers,
|
|
},
|
|
} as Plugin;
|
|
}
|
|
|
|
describe("voice → entity binding round-trip (issue #8234)", () => {
|
|
let runtime: AgentRuntime;
|
|
let testResult: RealTestRuntimeResult;
|
|
let tmpRoot: string;
|
|
let store: VoiceProfileStore;
|
|
let entityStore: EntityStore;
|
|
|
|
beforeAll(async () => {
|
|
tmpRoot = mkdtempSync(path.join(tmpdir(), "voice-binding-e2e-"));
|
|
store = new VoiceProfileStore({ rootDir: tmpRoot });
|
|
await store.init();
|
|
setVoiceEntityBindingStore(store);
|
|
|
|
testResult = await createRealTestRuntime({
|
|
characterName: "voice-binding-e2e",
|
|
plugins: [buildSeamPlugin()],
|
|
});
|
|
runtime = testResult.runtime;
|
|
await runtime.registerService(KnowledgeGraphService);
|
|
await runtime.getServiceLoadPromise(KNOWLEDGE_GRAPH_SERVICE);
|
|
await LifeOpsRepository.bootstrapSchema(runtime);
|
|
entityStore = new EntityStore(runtime, runtime.agentId);
|
|
await entityStore.ensureSelf();
|
|
}, 180_000);
|
|
|
|
afterAll(async () => {
|
|
setVoiceEntityBindingStore(null);
|
|
await testResult?.cleanup();
|
|
rmSync(tmpRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
it("registers the binding seam on the local-inference plugin object", () => {
|
|
// (The lifeops side — personalAssistantPlugin.events[VOICE_TURN_OBSERVED] —
|
|
// is asserted in src/lifeops/entities/voice-observer-bridge.test.ts.)
|
|
expect(
|
|
localInferencePlugin.events?.[EventType.VOICE_ENTITY_BOUND],
|
|
).toContain(handleVoiceEntityBound);
|
|
expect(
|
|
localInferencePlugin.actions?.some((a) => a.name === "IDENTIFY_SPEAKER"),
|
|
).toBe(true);
|
|
|
|
// The HTTP bind paths must be on `plugin.routes` (rawPath) — no server
|
|
// forwards these namespaces to the local-inference route dispatcher.
|
|
const routes = (localInferencePlugin.routes ?? []).map(
|
|
(r) => `${r.type} ${r.path}`,
|
|
);
|
|
expect(routes).toContain("POST /v1/voice/speaker-profiles/:id/bind");
|
|
expect(routes).toContain("POST /v1/voice/speaker-profiles/:id/unbind");
|
|
expect(routes).toContain("POST /api/voice/profiles/:id/bind");
|
|
expect(routes).toContain("POST /api/voice/profiles/:id/unbind");
|
|
expect(
|
|
(localInferencePlugin.routes ?? []).every((r) => r.rawPath === true),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("binds a self-claimed speaker through the live event round-trip", async () => {
|
|
const profile = await store.createProfile({
|
|
centroid: unit([1, 0, 0, 0]),
|
|
embeddingModel: MODEL,
|
|
imprintClusterId: "cluster_jill",
|
|
confidence: 0.9,
|
|
durationMs: 4000,
|
|
});
|
|
expect(profile.entityId).toBeNull();
|
|
|
|
// The producer half of the seam — emitEvent awaits every handler, so the
|
|
// full round trip (merge engine → VOICE_ENTITY_BOUND → bindEntity) has
|
|
// completed when this resolves.
|
|
await emitVoiceTurnObserved(runtime, {
|
|
text: "Hey there, I'm Jill",
|
|
imprintClusterId: "cluster_jill",
|
|
matchConfidence: 0.92,
|
|
matchedEntityId: null,
|
|
});
|
|
|
|
const bound = await store.get(profile.profileId);
|
|
expect(bound?.entityId).toBeTruthy();
|
|
|
|
// The entity is real and in the PGLite-backed relationship graph, with
|
|
// the voice identity attached by the merge engine.
|
|
const entity = await entityStore.get(bound?.entityId ?? "");
|
|
expect(entity?.preferredName).toBe("Jill");
|
|
expect(
|
|
entity?.identities.some(
|
|
(i) => i.platform === "voice" && i.handle === "cluster_jill",
|
|
),
|
|
).toBe(true);
|
|
|
|
// Disk persistence: a fresh store instance sees the binding.
|
|
const reloaded = new VoiceProfileStore({ rootDir: tmpRoot });
|
|
await reloaded.init();
|
|
expect((await reloaded.get(profile.profileId))?.entityId).toBe(
|
|
bound?.entityId,
|
|
);
|
|
});
|
|
|
|
it("re-observing the same cluster resolves to the same entity (cross-session memory)", async () => {
|
|
const before = await store.get(
|
|
(await store.list()).find((r) => r.imprintClusterId === "cluster_jill")
|
|
?.profileId ?? "",
|
|
);
|
|
expect(before?.entityId).toBeTruthy();
|
|
|
|
await emitVoiceTurnObserved(runtime, {
|
|
text: "morning! it's me again",
|
|
imprintClusterId: "cluster_jill",
|
|
matchConfidence: 0.95,
|
|
matchedEntityId: before?.entityId ?? null,
|
|
});
|
|
|
|
const after = await store.get(before?.profileId ?? "");
|
|
expect(after?.entityId).toBe(before?.entityId);
|
|
const entities = await entityStore.list();
|
|
expect(
|
|
entities.filter((e) =>
|
|
e.identities.some(
|
|
(i) => i.platform === "voice" && i.handle === "cluster_jill",
|
|
),
|
|
),
|
|
).toHaveLength(1);
|
|
});
|
|
|
|
it("IDENTIFY_SPEAKER binds the most recent unidentified voice by name", async () => {
|
|
const profile = await store.createProfile({
|
|
centroid: unit([0, 1, 0, 0]),
|
|
embeddingModel: MODEL,
|
|
imprintClusterId: "cluster_sam",
|
|
confidence: 0.8,
|
|
durationMs: 2500,
|
|
});
|
|
expect(profile.entityId).toBeNull();
|
|
|
|
const replies: string[] = [];
|
|
const message = {
|
|
content: { text: "that was Sam" },
|
|
} as unknown as Memory;
|
|
const result = await identifySpeakerAction.handler(
|
|
runtime,
|
|
message,
|
|
undefined,
|
|
undefined,
|
|
async (content) => {
|
|
if (typeof content.text === "string") replies.push(content.text);
|
|
return [];
|
|
},
|
|
);
|
|
|
|
expect(result?.success).toBe(true);
|
|
const bound = await store.get(profile.profileId);
|
|
expect(bound?.entityId).toBeTruthy();
|
|
const entity = await entityStore.get(bound?.entityId ?? "");
|
|
expect(entity?.preferredName).toBe("Sam");
|
|
expect(replies.join(" ")).toContain("Sam");
|
|
});
|
|
|
|
it("serves HTTP bind/unbind from the plugin route handlers", async () => {
|
|
setVoiceSpeakerProfileStore(store);
|
|
setVoiceProfilesManagementStore(store);
|
|
|
|
const server = http.createServer((req, res) => {
|
|
void (async () => {
|
|
if (await handleVoiceSpeakerProfileRoutes(req, res)) return;
|
|
if (await handleVoiceProfilesManagementRoutes(req, res)) return;
|
|
res.statusCode = 404;
|
|
res.end();
|
|
})();
|
|
});
|
|
await new Promise<void>((resolve) =>
|
|
server.listen(0, "127.0.0.1", resolve),
|
|
);
|
|
const address = server.address();
|
|
const port = typeof address === "object" && address ? address.port : 0;
|
|
const base = `http://127.0.0.1:${port}`;
|
|
|
|
try {
|
|
const profile = await store.createProfile({
|
|
centroid: unit([0, 0, 1, 0]),
|
|
embeddingModel: MODEL,
|
|
imprintClusterId: "cluster_wally",
|
|
confidence: 0.7,
|
|
durationMs: 1800,
|
|
});
|
|
const contact = await entityStore.upsert({
|
|
type: "person",
|
|
preferredName: "Wally",
|
|
identities: [],
|
|
tags: [],
|
|
visibility: "owner_agent_admin",
|
|
state: {},
|
|
});
|
|
|
|
// Bind via the speaker-profile namespace.
|
|
const bindRes = await fetch(
|
|
`${base}/v1/voice/speaker-profiles/${profile.profileId}/bind`,
|
|
{
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ entityId: contact.entityId, label: "Wally" }),
|
|
},
|
|
);
|
|
expect(bindRes.status).toBe(200);
|
|
const bindDto = (await bindRes.json()) as { entityId: string | null };
|
|
expect(bindDto.entityId).toBe(contact.entityId);
|
|
expect((await store.get(profile.profileId))?.entityId).toBe(
|
|
contact.entityId,
|
|
);
|
|
|
|
// List reflects the binding.
|
|
const listRes = await fetch(`${base}/v1/voice/speaker-profiles`);
|
|
expect(listRes.status).toBe(200);
|
|
const listDto = (await listRes.json()) as {
|
|
profiles: Array<{ profileId: string; entityId: string | null }>;
|
|
};
|
|
expect(
|
|
listDto.profiles.find((p) => p.profileId === profile.profileId)
|
|
?.entityId,
|
|
).toBe(contact.entityId);
|
|
|
|
// Unbind via the management namespace (the VoiceProfileSection UI path).
|
|
const unbindRes = await fetch(
|
|
`${base}/api/voice/profiles/${profile.profileId}/unbind`,
|
|
{ method: "POST" },
|
|
);
|
|
expect(unbindRes.status).toBe(200);
|
|
expect((await store.get(profile.profileId))?.entityId).toBeNull();
|
|
} finally {
|
|
setVoiceSpeakerProfileStore(null);
|
|
setVoiceProfilesManagementStore(null);
|
|
await new Promise<void>((resolve, reject) =>
|
|
server.close((err) => (err ? reject(err) : resolve())),
|
|
);
|
|
}
|
|
});
|
|
});
|