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

373 lines
13 KiB
TypeScript

/**
* Verifies the sub-agent entity lifecycle (#15102): every router post is
* attributed to ONE shared per-agent entity (per-session identity lives on
* each memory's content.metadata), and the legacy per-session entities that
* earlier router versions minted are unlinked from room participants —
* hidden from every entities-in-room consumer — without touching the entity
* rows or the transcript memories that FK them. Deterministic harness (no
* live model); the real-adapter FK/cascade semantics are pinned in
* plugin-sql's sub-agent-entity-unlink.real.test.ts and plugin-inmemorydb's
* participant-unlink contract test.
*/
import type { Entity, HandlerCallback, Memory, UUID } from "@elizaos/core";
import { describe, expect, it, vi } from "vitest";
import {
isLegacySubAgentEntityMetadata,
SubAgentRouter,
} from "../../src/services/sub-agent-router.js";
import type { SessionInfo } from "../../src/services/types.js";
const ROOM = "11111111-2222-3333-4444-555555555555" as UUID;
const WORLD = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" as UUID;
const USER = "ffffffff-1111-2222-3333-444444444444" as UUID;
const PARENT_MSG = "99999999-8888-7777-6666-555555555555" as UUID;
const AGENT_ID = "00000000-0000-0000-0000-000000000001" as UUID;
const SESSION_A = "01234567-89ab-cdef-0123-456789abcdef";
const SESSION_B = "fedcba98-7654-3210-fedc-ba9876543210";
function makeSession(
id: string,
label: string,
// Distinct per session by default: two sessions for the SAME origin message
// are a retry lineage and the cross-session completion dedupe absorbs the
// second post — these tests model two independent user requests.
messageId: UUID = PARENT_MSG,
): SessionInfo {
const now = new Date("2026-07-06T12:00:00.000Z");
return {
id,
name: label,
agentType: "codex",
workdir: "/tmp/wf",
status: "ready",
approvalPreset: "standard",
createdAt: now,
lastActivityAt: now,
metadata: {
label,
roomId: ROOM,
worldId: WORLD,
userId: USER,
messageId,
source: "telegram",
},
};
}
function makeAcp(sessions: SessionInfo[]): {
service: Record<string, unknown>;
emit: (sessionId: string, event: string, data: unknown) => void;
} {
let handler:
| ((sessionId: string, event: string, data: unknown) => void)
| undefined;
const byId = new Map(sessions.map((s) => [s.id, s]));
const service = {
onSessionEvent: vi.fn(
(fn: (sessionId: string, event: string, data: unknown) => void) => {
handler = fn;
return () => {
handler = undefined;
};
},
),
stopSession: vi.fn(async () => {}),
getSession: vi.fn(async (id: string) => byId.get(id) ?? null),
listSessions: vi.fn(async () => sessions),
updateSessionMetadata: vi.fn(async () => {}),
getChangedPaths: vi.fn(() => [] as string[]),
sendToSession: vi.fn(async () => ({})),
};
return {
service,
emit(sessionId, event, data) {
handler?.(sessionId, event, data);
},
};
}
function makeRuntime(opts: {
acp: unknown;
roomEntities?: Entity[];
deleteParticipantsImpl?: () => Promise<boolean>;
}) {
const handleMessage = vi.fn<
(
runtime: unknown,
memory: Memory,
callback?: HandlerCallback,
) => Promise<unknown>
>(async () => ({}));
const createEntity = vi.fn(async () => true);
const addParticipant = vi.fn(async () => true);
const getEntitiesForRoom = vi.fn(async (_roomId: string) => [
...(opts.roomEntities ?? []),
]);
const deleteParticipants = vi.fn(
opts.deleteParticipantsImpl ?? (async () => true),
);
const reportError = vi.fn();
const runtime = {
agentId: AGENT_ID,
logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
getService: vi.fn(() => opts.acp ?? null),
getSetting: vi.fn(() => undefined),
createMemory: vi.fn(async () => undefined),
createEntity,
addParticipant,
getEntitiesForRoom,
deleteParticipants,
reportError,
emitEvent: vi.fn(async () => undefined),
messageService: { handleMessage },
} as never;
return {
runtime,
handleMessage,
createEntity,
addParticipant,
getEntitiesForRoom,
deleteParticipants,
reportError,
};
}
const flush = () => new Promise((r) => setImmediate(r));
/** The shared entityId is a private derivation; capture it through the same
* public behavior every consumer sees (the createEntity call). */
async function resolveSharedEntityId(): Promise<UUID> {
const session = makeSession(SESSION_A, "probe");
const acp = makeAcp([session]);
const { runtime, createEntity } = makeRuntime({ acp: acp.service });
const router = await SubAgentRouter.start(runtime);
acp.emit(SESSION_A, "task_complete", { response: "done" });
await flush();
await router.stop();
const entity = createEntity.mock.calls[0]?.[0] as { id: UUID } | undefined;
if (!entity) throw new Error("expected createEntity to be called");
return entity.id;
}
function legacyEntity(id: string, sessionId: string, label: string): Entity {
return {
id: id as UUID,
agentId: AGENT_ID,
names: [`sub-agent: ${label}`],
metadata: {
sub_agent: { subAgentSessionId: sessionId, subAgentAgentType: "codex" },
},
};
}
function humanEntity(id: string, name: string): Entity {
return { id: id as UUID, agentId: AGENT_ID, names: [name] };
}
describe("shared sub-agent entity (forward path)", () => {
it("attributes every session's posts to ONE shared entity while each memory keeps its own sessionId", async () => {
const sessions = [
makeSession(SESSION_A, "task-a"),
makeSession(
SESSION_B,
"task-b",
"88888888-7777-6666-5555-444444444444" as UUID,
),
];
const acp = makeAcp(sessions);
const { runtime, handleMessage, createEntity, addParticipant } =
makeRuntime({ acp: acp.service });
const router = await SubAgentRouter.start(runtime);
acp.emit(SESSION_A, "task_complete", { response: "result A" });
await flush();
acp.emit(SESSION_B, "task_complete", { response: "result B" });
await flush();
// One deterministic entity id across BOTH sessions — entity growth is
// O(1) per agent, not O(sessions).
const createdIds = createEntity.mock.calls.map(
(c) => (c[0] as { id: UUID }).id,
);
expect(createdIds).toHaveLength(2);
expect(new Set(createdIds).size).toBe(1);
const sharedId = createdIds[0];
// The shared entity carries a static shared marker and a generic name —
// never per-session data (a sessionId here is exactly what the legacy
// sweep classifies as stale).
for (const call of createEntity.mock.calls) {
const entity = call[0] as Entity;
expect(entity.names).toEqual(["sub-agents"]);
expect(entity.metadata).toEqual({ sub_agent: { shared: true } });
expect(isLegacySubAgentEntityMetadata(entity.metadata)).toBe(false);
}
// Participation and memory attribution both use the shared id, and
// per-session identity survives on each memory's metadata.
for (const call of addParticipant.mock.calls) {
expect(call[0]).toBe(sharedId);
}
expect(handleMessage).toHaveBeenCalledTimes(2);
const posted = handleMessage.mock.calls.map((c) => c[1] as Memory);
const sessionIds = posted.map(
(m) =>
(m.content?.metadata as Record<string, unknown>)?.subAgentSessionId,
);
expect(posted.every((m) => m.entityId === sharedId)).toBe(true);
expect(new Set(sessionIds)).toEqual(new Set([SESSION_A, SESSION_B]));
const labels = posted.map(
(m) => (m.content?.metadata as Record<string, unknown>)?.subAgentLabel,
);
expect(new Set(labels)).toEqual(new Set(["task-a", "task-b"]));
await router.stop();
});
});
describe("legacy per-session entity sweep (migration path)", () => {
it("unlinks exactly the marker-bearing entities — humans and the shared entity survive, and nothing is deleted from the entities table", async () => {
const sharedId = await resolveSharedEntityId();
const legacyA = legacyEntity(
"10000000-0000-0000-0000-00000000000a",
"dead-session-a",
"old task a",
);
const legacyB = legacyEntity(
"10000000-0000-0000-0000-00000000000b",
"dead-session-b",
"old task b",
);
const human = humanEntity("20000000-0000-0000-0000-000000000001", "nubs");
const shared: Entity = {
id: sharedId,
agentId: AGENT_ID,
names: ["sub-agents"],
metadata: { sub_agent: { shared: true } },
};
// An entity with an UNMARKED metadata bag (e.g. connector-created) must
// never classify as stale, whatever its name looks like.
const nameOnly: Entity = {
id: "20000000-0000-0000-0000-000000000002" as UUID,
agentId: AGENT_ID,
names: ["sub-agent: imposter (no marker)"],
};
const session = makeSession(SESSION_A, "fresh-task");
const acp = makeAcp([session]);
const { runtime, getEntitiesForRoom, deleteParticipants } = makeRuntime({
acp: acp.service,
roomEntities: [legacyA, human, shared, legacyB, nameOnly],
});
const router = await SubAgentRouter.start(runtime);
acp.emit(SESSION_A, "task_complete", { response: "done" });
await flush();
expect(deleteParticipants).toHaveBeenCalledTimes(1);
const pairs = deleteParticipants.mock.calls[0]?.[0] as Array<{
entityId: UUID;
roomId: UUID;
}>;
expect(new Set(pairs.map((p) => p.entityId))).toEqual(
new Set([legacyA.id, legacyB.id]),
);
expect(pairs.every((p) => p.roomId === ROOM)).toBe(true);
// Memoized: a second event in the same room performs no second room scan.
const scans = getEntitiesForRoom.mock.calls.length;
acp.emit(SESSION_A, "task_complete", { response: "done again" });
await flush();
expect(getEntitiesForRoom.mock.calls.length).toBe(scans);
expect(deleteParticipants).toHaveBeenCalledTimes(1);
await router.stop();
});
it("sweeps the origin room on the session's FIRST event (spawn-time heal, before any completion)", async () => {
const legacy = legacyEntity(
"10000000-0000-0000-0000-00000000000c",
"dead-session-c",
"old task c",
);
const session = makeSession(SESSION_A, "streaming-task");
const acp = makeAcp([session]);
const { runtime, deleteParticipants, handleMessage } = makeRuntime({
acp: acp.service,
roomEntities: [legacy],
});
const router = await SubAgentRouter.start(runtime);
// "message" is a streamed non-inject event — nothing is posted, but the
// polluted origin room heals immediately.
acp.emit(SESSION_A, "message", { text: "working…" });
await flush();
expect(handleMessage).not.toHaveBeenCalled();
expect(deleteParticipants).toHaveBeenCalledTimes(1);
const pairs = deleteParticipants.mock.calls[0]?.[0] as Array<{
entityId: UUID;
}>;
expect(pairs.map((p) => p.entityId)).toEqual([legacy.id]);
await router.stop();
});
it("surfaces a failed sweep via reportError, drops the memo, and retries on the next event", async () => {
const legacy = legacyEntity(
"10000000-0000-0000-0000-00000000000d",
"dead-session-d",
"old task d",
);
let calls = 0;
const session = makeSession(SESSION_A, "retry-task");
const acp = makeAcp([session]);
const { runtime, deleteParticipants, reportError } = makeRuntime({
acp: acp.service,
roomEntities: [legacy],
deleteParticipantsImpl: async () => {
calls += 1;
if (calls === 1) throw new Error("db offline");
return true;
},
});
const router = await SubAgentRouter.start(runtime);
acp.emit(SESSION_A, "task_complete", { response: "first" });
await flush();
expect(reportError).toHaveBeenCalledWith(
"SubAgentRouter.sweepLegacySubAgentParticipants",
expect.any(Error),
{ roomId: ROOM },
);
// Memo was dropped, so the next event retries — and this time succeeds.
acp.emit(SESSION_A, "task_complete", { response: "second" });
await flush();
expect(deleteParticipants).toHaveBeenCalledTimes(2);
expect(reportError).toHaveBeenCalledTimes(1);
await router.stop();
});
});
describe("isLegacySubAgentEntityMetadata", () => {
it("classifies only the structural per-session creation marker as legacy", () => {
expect(
isLegacySubAgentEntityMetadata({
sub_agent: { subAgentSessionId: "abc", subAgentAgentType: "codex" },
}),
).toBe(true);
expect(
isLegacySubAgentEntityMetadata({ sub_agent: { shared: true } }),
).toBe(false);
expect(isLegacySubAgentEntityMetadata(undefined)).toBe(false);
expect(isLegacySubAgentEntityMetadata({})).toBe(false);
expect(isLegacySubAgentEntityMetadata({ sub_agent: "abc" })).toBe(false);
expect(isLegacySubAgentEntityMetadata({ sub_agent: ["abc"] })).toBe(false);
expect(
isLegacySubAgentEntityMetadata({ sub_agent: { subAgentSessionId: 7 } }),
).toBe(false);
});
});