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

352 lines
12 KiB
TypeScript

/**
* Approval queue integration test (WS6).
*
* Drives the real `PgApprovalQueue` against a PGlite-backed runtime.
* Exercises:
* - enqueue → approve → markExecuting → markDone (happy path)
* - enqueue → reject
* - enqueue (expired in past) → purgeExpired → markExpired noop rejected
* - invalid transitions throw ApprovalStateTransitionError
*
* Run: bunx vitest run eliza/plugins/plugin-personal-assistant/test/approval-queue.integration.test.ts
*/
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { AgentRuntime } from "@elizaos/core";
import { AgentEventService, parseInteractionBlocks } from "@elizaos/core";
import { schedulingPlugin } from "@elizaos/plugin-scheduling";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { createRealTestRuntime } from "../../../packages/test/helpers/real-runtime.ts";
import { createApprovalQueue } from "../src/lifeops/approval-queue.js";
import {
type ApprovalEnqueueInput,
ApprovalNotFoundError,
type ApprovalQueue,
ApprovalStateTransitionError,
} from "../src/lifeops/approval-queue.types.js";
import { LifeOpsRepository } from "../src/lifeops/repository.js";
import { personalAssistantPlugin } from "../src/plugin.js";
let runtime: AgentRuntime;
let cleanup: () => Promise<void>;
let queue: ApprovalQueue;
let isolatedStateDir: string;
let isolatedConfigPath: string;
const isolatedEnvKeys = [
"ELIZA_STATE_DIR",
"ELIZA_CONFIG_PATH",
"ELIZA_PERSIST_CONFIG_PATH",
"ELIZAOS_CLOUD_API_KEY",
"ELIZAOS_CLOUD_BASE_URL",
] as const;
const previousEnv = new Map<string, string | undefined>();
function setIsolatedEnv(): void {
isolatedStateDir = mkdtempSync(join(tmpdir(), "approval-queue-state-"));
isolatedConfigPath = join(isolatedStateDir, "eliza.json");
writeFileSync(
isolatedConfigPath,
JSON.stringify({ logging: { level: "error" } }),
"utf8",
);
for (const key of isolatedEnvKeys) {
previousEnv.set(key, process.env[key]);
}
process.env.ELIZA_STATE_DIR = isolatedStateDir;
process.env.ELIZA_CONFIG_PATH = isolatedConfigPath;
process.env.ELIZA_PERSIST_CONFIG_PATH = isolatedConfigPath;
delete process.env.ELIZA_STATE_DIR;
delete process.env.ELIZA_CONFIG_PATH;
delete process.env.ELIZA_PERSIST_CONFIG_PATH;
delete process.env.ELIZAOS_CLOUD_API_KEY;
delete process.env.ELIZAOS_CLOUD_BASE_URL;
}
function restoreEnv(): void {
for (const key of isolatedEnvKeys) {
const value = previousEnv.get(key);
if (value === undefined) {
delete process.env[key];
continue;
}
process.env[key] = value;
}
}
function messageInput(
overrides: Partial<ApprovalEnqueueInput> = {},
): ApprovalEnqueueInput {
return {
requestedBy: "agent:lifeops",
subjectUserId: "owner-123",
action: "send_message",
payload: {
action: "send_message",
recipient: "+15555551212",
body: "Hello!",
replyToMessageId: null,
},
channel: "sms",
reason: "agent wants to confirm before sending",
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
...overrides,
};
}
beforeAll(async () => {
setIsolatedEnv();
const result = await createRealTestRuntime({
plugins: [schedulingPlugin, personalAssistantPlugin],
});
runtime = result.runtime;
cleanup = result.cleanup;
// The enqueue chat-post (#14733) resolves the agent-event service off the
// runtime; register the real one when the test runtime lacks it. Service
// registration is lazy, so force the start (the agent server does the same
// at boot when the WS bridge subscribes).
if (!runtime.getService(AgentEventService.serviceType)) {
await runtime.registerService(AgentEventService);
await runtime.getServiceLoadPromise(AgentEventService.serviceType);
}
queue = createApprovalQueue(runtime, { agentId: runtime.agentId });
}, 180_000);
afterAll(async () => {
await cleanup();
restoreEnv();
rmSync(isolatedStateDir, { recursive: true, force: true });
});
describe("ApprovalQueue integration (real PGlite)", () => {
it("enqueue → approve → markExecuting → markDone happy path", async () => {
const enqueued = await queue.enqueue(messageInput());
expect(enqueued.state).toBe("pending");
expect(enqueued.resolvedAt).toBeNull();
expect(enqueued.resolvedBy).toBeNull();
const fetched = await queue.byId(enqueued.id);
expect(fetched).not.toBeNull();
expect(fetched?.action).toBe("send_message");
const approved = await queue.approve(enqueued.id, {
resolvedBy: "owner-123",
resolutionReason: "looks good",
});
expect(approved.state).toBe("approved");
expect(approved.resolvedBy).toBe("owner-123");
expect(approved.resolvedAt).toBeInstanceOf(Date);
const executing = await queue.markExecuting(enqueued.id);
expect(executing.state).toBe("executing");
const done = await queue.markDone(enqueued.id);
expect(done.state).toBe("done");
const pendingList = await queue.list({
subjectUserId: "owner-123",
state: "pending",
action: null,
limit: 10,
});
expect(pendingList.every((r) => r.id !== enqueued.id)).toBe(true);
}, 60_000);
it("enqueue posts the question into chat as an assistant event with approve/reject chips (#14733)", async () => {
const events: Array<{ stream: string; data: Record<string, unknown> }> = [];
const eventService = runtime.getService(
AgentEventService.serviceType,
) as AgentEventService;
const unsubscribe = eventService.subscribe((event) => {
events.push({ stream: event.stream, data: event.data });
});
try {
const enqueued = await queue.enqueue(
messageInput({ subjectUserId: "owner-chips" }),
);
const assistant = events.filter(
(event) =>
event.stream === "assistant" &&
event.data.source === "lifeops-approval",
);
expect(assistant).toHaveLength(1);
const data = assistant[0]?.data ?? {};
expect(data.requestId).toBe(enqueued.id);
expect(data.action).toBe("send_message");
const text = String(data.text ?? "");
expect(text).toContain("agent wants to confirm before sending");
const { blocks } = parseInteractionBlocks(text);
expect(blocks).toHaveLength(1);
const block = blocks[0];
if (block?.kind !== "choice") throw new Error("expected choice block");
expect(block.scope).toBe(`approval-${enqueued.id}`);
expect(block.id).toBe(enqueued.id);
// The tapped value is the owner's next message; it must carry the id
// RESOLVE_REQUEST resolves verbatim.
expect(block.options.map((o) => o.value)).toEqual([
`approve ${enqueued.id}`,
`reject ${enqueued.id}`,
]);
// Round-trip: drive the queue with the tapped approve value's id.
const tapped = block.options[0]?.value ?? "";
const requestId = tapped.replace(/^approve /, "");
const approved = await queue.approve(requestId, {
resolvedBy: "owner-chips",
resolutionReason: "tapped Approve",
});
expect(approved.id).toBe(enqueued.id);
expect(approved.state).toBe("approved");
} finally {
unsubscribe();
}
}, 60_000);
it("enqueue creates an owner-visible approval ScheduledTask for connector escalation (#14722)", async () => {
const enqueued = await queue.enqueue(
messageInput({ subjectUserId: "owner-scheduled-task" }),
);
const repo = new LifeOpsRepository(runtime);
const task = await repo.getScheduledTaskByIdempotencyKey(
runtime.agentId,
`approval:${enqueued.id}`,
);
expect(task).not.toBeNull();
expect(task?.kind).toBe("approval");
expect(task?.priority).toBe("high");
expect(task?.ownerVisible).toBe(true);
expect(task?.respectsGlobalPause).toBe(false);
expect(task?.subject).toEqual({
kind: "self",
id: "owner-scheduled-task",
});
expect(task?.metadata?.approvalRequestId).toBe(enqueued.id);
expect(task?.metadata?.approvalAction).toBe("send_message");
expect(task?.metadata?.pendingPromptRoomId).toBe(`approval:${enqueued.id}`);
expect(task?.completionCheck?.kind).toBe("user_acknowledged");
expect(task?.completionCheck?.params).toEqual({ requestId: enqueued.id });
expect(task?.escalation?.steps?.map((step) => step.channelKey)).toEqual(
expect.arrayContaining(["sms", "telegram", "discord", "imessage"]),
);
expect(task?.escalation?.steps?.at(-1)?.channelKey).toBe("in_app");
expect(task?.promptInstructions).toContain(`approve ${enqueued.id}`);
expect(task?.promptInstructions).toContain(`reject ${enqueued.id}`);
}, 60_000);
it("rolls back the approval row when the ScheduledTask cannot be created", async () => {
const failedStateDir = mkdtempSync(
join(tmpdir(), "approval-queue-schedule-fail-"),
);
const failedConfigPath = join(failedStateDir, "eliza.json");
writeFileSync(
failedConfigPath,
JSON.stringify({ logging: { level: "error" } }),
"utf8",
);
const previousStateDir = process.env.ELIZA_STATE_DIR;
const previousConfigPath = process.env.ELIZA_CONFIG_PATH;
const previousPersistConfigPath = process.env.ELIZA_PERSIST_CONFIG_PATH;
process.env.ELIZA_STATE_DIR = failedStateDir;
process.env.ELIZA_CONFIG_PATH = failedConfigPath;
process.env.ELIZA_PERSIST_CONFIG_PATH = failedConfigPath;
let failedCleanup: (() => Promise<void>) | null = null;
try {
const result = await createRealTestRuntime({
plugins: [personalAssistantPlugin],
});
failedCleanup = result.cleanup;
const failedQueue = createApprovalQueue(result.runtime, {
agentId: result.runtime.agentId,
});
await expect(
failedQueue.enqueue(
messageInput({ subjectUserId: "owner-schedule-fail" }),
),
).rejects.toThrow("failed to schedule approval task");
await expect(
failedQueue.list({
subjectUserId: "owner-schedule-fail",
state: null,
action: null,
limit: 10,
}),
).resolves.toEqual([]);
} finally {
await failedCleanup?.();
if (previousStateDir === undefined) {
delete process.env.ELIZA_STATE_DIR;
} else {
process.env.ELIZA_STATE_DIR = previousStateDir;
}
if (previousConfigPath === undefined) {
delete process.env.ELIZA_CONFIG_PATH;
} else {
process.env.ELIZA_CONFIG_PATH = previousConfigPath;
}
if (previousPersistConfigPath === undefined) {
delete process.env.ELIZA_PERSIST_CONFIG_PATH;
} else {
process.env.ELIZA_PERSIST_CONFIG_PATH = previousPersistConfigPath;
}
rmSync(failedStateDir, { recursive: true, force: true });
}
}, 180_000);
it("enqueue → reject records resolver", async () => {
const enqueued = await queue.enqueue(
messageInput({ subjectUserId: "owner-reject" }),
);
const rejected = await queue.reject(enqueued.id, {
resolvedBy: "owner-reject",
resolutionReason: "not now",
});
expect(rejected.state).toBe("rejected");
expect(rejected.resolutionReason).toBe("not now");
}, 60_000);
it("purgeExpired moves past-due pending rows to expired", async () => {
const pastExpiry = new Date(Date.now() - 5 * 60 * 1000);
const enqueued = await queue.enqueue(
messageInput({
subjectUserId: "owner-expire",
expiresAt: pastExpiry,
}),
);
const purgedIds = await queue.purgeExpired(new Date());
expect(purgedIds).toContain(enqueued.id);
const after = await queue.byId(enqueued.id);
expect(after?.state).toBe("expired");
}, 60_000);
it("rejects invalid state transitions hard", async () => {
const enqueued = await queue.enqueue(
messageInput({ subjectUserId: "owner-invalid" }),
);
// pending -> executing is not allowed; must go through approved first
await expect(queue.markExecuting(enqueued.id)).rejects.toBeInstanceOf(
ApprovalStateTransitionError,
);
// pending -> done is not allowed
await expect(queue.markDone(enqueued.id)).rejects.toBeInstanceOf(
ApprovalStateTransitionError,
);
}, 60_000);
it("throws ApprovalNotFoundError on unknown id", async () => {
await expect(
queue.approve("00000000-0000-0000-0000-000000000000", {
resolvedBy: "owner-123",
resolutionReason: "x",
}),
).rejects.toBeInstanceOf(ApprovalNotFoundError);
}, 60_000);
});