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
345 lines
12 KiB
TypeScript
345 lines
12 KiB
TypeScript
/**
|
|
* Regression coverage for model-mediated scheduled-task dispatch: a task's
|
|
* instruction-voice `promptInstructions` must never reach a user-visible
|
|
* surface (assistant stream, notification body, connector channel send)
|
|
* verbatim — the dispatcher renders it through the model, and a render failure
|
|
* is a typed retryable dispatch failure, never a raw-instruction fallback.
|
|
* Deterministic: the model is stubbed at the runtime boundary (`useModel`).
|
|
*/
|
|
import type { IAgentRuntime } from "@elizaos/core";
|
|
import {
|
|
buildScheduledDispatchRenderPrompt,
|
|
buildScheduledDispatchTitlePrompt,
|
|
} from "@elizaos/plugin-scheduling";
|
|
import { beforeEach, describe, expect, it } from "vitest";
|
|
import type { ChannelContribution } from "../src/lifeops/channels/contract.js";
|
|
import {
|
|
createChannelRegistry,
|
|
registerChannelRegistry,
|
|
} from "../src/lifeops/channels/index.js";
|
|
import { createProductionScheduledTaskDispatcher } from "../src/lifeops/scheduled-task/runtime-wiring.js";
|
|
import {
|
|
enableAgentEventServiceStub,
|
|
getAgentEventServiceStubEvents,
|
|
resetAgentBackupStubState,
|
|
resetAgentEventServiceStub,
|
|
setAgentBackupStubState,
|
|
} from "./stubs/agent.ts";
|
|
|
|
const INSTRUCTION =
|
|
"Remind the owner to take their medication and ask how they slept.";
|
|
const RENDERED = "Time for your medication — and how did you sleep last night?";
|
|
const RENDERED_TITLE = "Medication and sleep check";
|
|
|
|
interface ReportedErrorCapture {
|
|
scope: string;
|
|
error: unknown;
|
|
context?: Record<string, unknown>;
|
|
}
|
|
|
|
function makeRuntime(opts: {
|
|
model?: (params: { prompt: string }) => string | Promise<string>;
|
|
notifier?: { notify: (input: Record<string, unknown>) => Promise<unknown> };
|
|
}) {
|
|
const modelPrompts: string[] = [];
|
|
const reported: ReportedErrorCapture[] = [];
|
|
const runtime = {
|
|
agentId: "00000000-0000-0000-0000-0000000000aa",
|
|
getService: (type: string) =>
|
|
type === "notification" ? (opts.notifier ?? null) : null,
|
|
getSetting: () => null,
|
|
...(opts.model
|
|
? {
|
|
useModel: async (_type: string, params: { prompt: string }) => {
|
|
modelPrompts.push(params.prompt);
|
|
return opts.model?.(params);
|
|
},
|
|
}
|
|
: {}),
|
|
reportError: (
|
|
scope: string,
|
|
error: unknown,
|
|
context?: Record<string, unknown>,
|
|
) => {
|
|
reported.push({ scope, error, ...(context ? { context } : {}) });
|
|
},
|
|
} as unknown as IAgentRuntime;
|
|
return { runtime, modelPrompts, reported };
|
|
}
|
|
|
|
function inAppRecord(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
taskId: "st_render_test",
|
|
firedAtIso: "2026-07-05T09:00:00.000Z",
|
|
channelKey: "in_app",
|
|
intensity: "normal" as const,
|
|
promptInstructions: INSTRUCTION,
|
|
contextRequest: undefined,
|
|
output: undefined,
|
|
metadata: undefined,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function sendCapableChannel(
|
|
send: ChannelContribution["send"],
|
|
): ChannelContribution {
|
|
return {
|
|
kind: "telegram",
|
|
describe: { label: "Telegram" },
|
|
capabilities: {
|
|
send: true,
|
|
read: true,
|
|
reminders: true,
|
|
voice: false,
|
|
attachments: true,
|
|
quietHoursAware: true,
|
|
},
|
|
send,
|
|
};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
resetAgentEventServiceStub();
|
|
resetAgentBackupStubState();
|
|
});
|
|
|
|
describe("scheduled dispatch renders promptInstructions through the model", () => {
|
|
it("delivers model output — never raw or generic copy — to the assistant stream and notification", async () => {
|
|
enableAgentEventServiceStub();
|
|
const notified: Record<string, unknown>[] = [];
|
|
const { runtime, modelPrompts } = makeRuntime({
|
|
model: ({ prompt }) =>
|
|
prompt.includes("notification title") ? RENDERED_TITLE : RENDERED,
|
|
notifier: {
|
|
notify: async (input) => {
|
|
notified.push(input);
|
|
return { id: "n1" };
|
|
},
|
|
},
|
|
});
|
|
const dispatcher = createProductionScheduledTaskDispatcher({ runtime });
|
|
|
|
const result = await dispatcher.dispatch(inAppRecord());
|
|
|
|
expect(result).toMatchObject({ ok: true });
|
|
// The instruction fed the model as prompt payload...
|
|
expect(modelPrompts).toHaveLength(2);
|
|
expect(modelPrompts[0]).toContain(INSTRUCTION);
|
|
expect(modelPrompts[1]).toContain(RENDERED);
|
|
expect(modelPrompts[1]).not.toContain(INSTRUCTION);
|
|
// ...and only the model's rendering reached the user-visible surfaces.
|
|
const events = getAgentEventServiceStubEvents();
|
|
expect(events).toHaveLength(1);
|
|
expect(events[0]?.data.text).toBe(RENDERED);
|
|
expect(String(events[0]?.data.text)).not.toContain(
|
|
"Remind the owner to take",
|
|
);
|
|
expect(notified).toHaveLength(1);
|
|
expect(notified[0]?.title).toBe(RENDERED_TITLE);
|
|
expect(notified[0]?.title).not.toBe("Reminder");
|
|
expect(notified[0]?.title).not.toBe("Approval needed");
|
|
expect(notified[0]?.body).toBe(RENDERED);
|
|
expect(String(notified[0]?.body)).not.toContain("Remind the owner to take");
|
|
});
|
|
|
|
it("delivers the model output — never the raw instruction — to a connector channel send", async () => {
|
|
const sent: Array<{ message?: unknown }> = [];
|
|
const { runtime, modelPrompts } = makeRuntime({ model: () => RENDERED });
|
|
const registry = createChannelRegistry();
|
|
registry.register(
|
|
sendCapableChannel(async (payload) => {
|
|
sent.push(payload as { message?: unknown });
|
|
return { ok: true, messageId: "m1" };
|
|
}),
|
|
);
|
|
registerChannelRegistry(runtime, registry);
|
|
const dispatcher = createProductionScheduledTaskDispatcher({ runtime });
|
|
|
|
const result = await dispatcher.dispatch(
|
|
inAppRecord({
|
|
channelKey: "telegram",
|
|
output: { destination: "channel", target: "telegram:owner-dm" },
|
|
}),
|
|
);
|
|
|
|
expect(result).toMatchObject({ ok: true, messageId: "m1" });
|
|
expect(modelPrompts).toHaveLength(1);
|
|
expect(sent).toHaveLength(1);
|
|
expect(sent[0]?.message).toBe(RENDERED);
|
|
expect(sent[0]?.message).not.toBe(INSTRUCTION);
|
|
});
|
|
|
|
it("a throwing model is a typed retryable failure with reportError — nothing is emitted, no raw fallback", async () => {
|
|
enableAgentEventServiceStub();
|
|
const notified: Record<string, unknown>[] = [];
|
|
const { runtime, reported } = makeRuntime({
|
|
model: () => {
|
|
throw new Error("model backend down");
|
|
},
|
|
notifier: {
|
|
notify: async (input) => {
|
|
notified.push(input);
|
|
return { id: "n1" };
|
|
},
|
|
},
|
|
});
|
|
const dispatcher = createProductionScheduledTaskDispatcher({ runtime });
|
|
|
|
const result = await dispatcher.dispatch(inAppRecord());
|
|
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
reason: "transport_error",
|
|
userActionable: false,
|
|
retryAfterMinutes: 5,
|
|
});
|
|
expect(getAgentEventServiceStubEvents()).toHaveLength(0);
|
|
expect(notified).toHaveLength(0);
|
|
expect(reported).toHaveLength(1);
|
|
expect(reported[0]?.scope).toBe("lifeops:scheduled-task:dispatch-render");
|
|
});
|
|
|
|
it("a runtime without a model surface fails closed instead of sending the raw instruction", async () => {
|
|
enableAgentEventServiceStub();
|
|
const { runtime, reported } = makeRuntime({});
|
|
const dispatcher = createProductionScheduledTaskDispatcher({ runtime });
|
|
|
|
const result = await dispatcher.dispatch(inAppRecord());
|
|
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
reason: "transport_error",
|
|
retryAfterMinutes: 5,
|
|
});
|
|
expect(getAgentEventServiceStubEvents()).toHaveLength(0);
|
|
expect(reported).toHaveLength(1);
|
|
});
|
|
|
|
it("blank model output fails closed instead of sending the raw instruction", async () => {
|
|
enableAgentEventServiceStub();
|
|
const { runtime, reported } = makeRuntime({ model: () => " \n" });
|
|
const dispatcher = createProductionScheduledTaskDispatcher({ runtime });
|
|
|
|
const result = await dispatcher.dispatch(inAppRecord());
|
|
|
|
expect(result).toMatchObject({ ok: false, reason: "transport_error" });
|
|
expect(getAgentEventServiceStubEvents()).toHaveLength(0);
|
|
expect(reported).toHaveLength(1);
|
|
});
|
|
|
|
it("a channel-send failure never leaks the raw instruction and preserves the typed result", async () => {
|
|
const { runtime } = makeRuntime({ model: () => RENDERED });
|
|
const registry = createChannelRegistry();
|
|
registry.register(
|
|
sendCapableChannel(async () => ({
|
|
ok: false as const,
|
|
reason: "rate_limited" as const,
|
|
retryAfterMinutes: 8,
|
|
userActionable: false,
|
|
})),
|
|
);
|
|
registerChannelRegistry(runtime, registry);
|
|
const dispatcher = createProductionScheduledTaskDispatcher({ runtime });
|
|
|
|
const result = await dispatcher.dispatch(
|
|
inAppRecord({
|
|
channelKey: "telegram",
|
|
output: { destination: "channel", target: "telegram:owner-dm" },
|
|
}),
|
|
);
|
|
|
|
expect(result).toMatchObject({
|
|
ok: false,
|
|
reason: "rate_limited",
|
|
retryAfterMinutes: 8,
|
|
});
|
|
});
|
|
|
|
it("structural system operations (local backup) never touch the model", async () => {
|
|
setAgentBackupStubState({
|
|
createdBackup: {
|
|
fileName: "2026-07-05T090000Z.agent-backup.json",
|
|
path: "/tmp/2026-07-05T090000Z.agent-backup.json",
|
|
createdAt: "2026-07-05T09:00:00.000Z",
|
|
agentId: "00000000-0000-0000-0000-0000000000aa",
|
|
stateSha256: "abc123",
|
|
sizeBytes: 4096,
|
|
},
|
|
});
|
|
const { runtime, modelPrompts } = makeRuntime({ model: () => RENDERED });
|
|
const dispatcher = createProductionScheduledTaskDispatcher({ runtime });
|
|
|
|
const result = await dispatcher.dispatch(
|
|
inAppRecord({
|
|
metadata: {
|
|
systemOperation: "agent.localBackup",
|
|
backupTarget: "local-file",
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(result).toMatchObject({ ok: true });
|
|
expect(modelPrompts).toHaveLength(0);
|
|
});
|
|
|
|
it("an unconnected channel fails before rendering — no wasted model call", async () => {
|
|
const { runtime, modelPrompts } = makeRuntime({ model: () => RENDERED });
|
|
registerChannelRegistry(runtime, createChannelRegistry());
|
|
const dispatcher = createProductionScheduledTaskDispatcher({ runtime });
|
|
|
|
const result = await dispatcher.dispatch(
|
|
inAppRecord({
|
|
channelKey: "missing",
|
|
output: { destination: "channel", target: "missing:owner" },
|
|
}),
|
|
);
|
|
|
|
expect(result).toMatchObject({ ok: false, reason: "disconnected" });
|
|
expect(modelPrompts).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("buildScheduledDispatchRenderPrompt", () => {
|
|
it("embeds the instruction as opaque payload with delivery framing", () => {
|
|
const prompt = buildScheduledDispatchRenderPrompt({
|
|
promptInstructions: INSTRUCTION,
|
|
intensity: "normal",
|
|
firedAtIso: "2026-07-05T09:00:00.000Z",
|
|
});
|
|
expect(prompt).toContain(INSTRUCTION);
|
|
expect(prompt).toContain("not the message itself");
|
|
expect(prompt).toContain("Fired at: 2026-07-05T09:00:00.000Z");
|
|
});
|
|
|
|
it("keys urgency framing on the structural intensity field", () => {
|
|
const urgent = buildScheduledDispatchRenderPrompt({
|
|
promptInstructions: INSTRUCTION,
|
|
intensity: "urgent",
|
|
firedAtIso: "2026-07-05T09:00:00.000Z",
|
|
});
|
|
expect(urgent).toContain("urgent");
|
|
const soft = buildScheduledDispatchRenderPrompt({
|
|
promptInstructions: INSTRUCTION,
|
|
intensity: "soft",
|
|
firedAtIso: "2026-07-05T09:00:00.000Z",
|
|
});
|
|
expect(soft).toContain("gentle");
|
|
});
|
|
});
|
|
|
|
describe("buildScheduledDispatchTitlePrompt", () => {
|
|
it("uses the rendered body, not the instruction payload, as notification title context", () => {
|
|
const prompt = buildScheduledDispatchTitlePrompt(
|
|
{
|
|
intensity: "normal",
|
|
firedAtIso: "2026-07-05T09:00:00.000Z",
|
|
},
|
|
RENDERED,
|
|
);
|
|
expect(prompt).toContain(RENDERED);
|
|
expect(prompt).toContain("under 8 words");
|
|
expect(prompt).not.toContain(INSTRUCTION);
|
|
});
|
|
});
|