Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:43:05 +08:00

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())),
);
}
});
});