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

879 lines
27 KiB
TypeScript

/**
* Approved-request execution (issues #10723 Bug 2 and #10721).
*
* The old `executeApprovedRequest` flipped execute_workflow approvals through
* markExecuting -> markDone WITHOUT invoking the workflow runner, and returned
* `success: true, "Approved."` for executor-less actions while executing
* nothing. These tests pin the fixes: approved execute_workflow /
* schedule_event / make_call / sign_document requests drive their real rails
* (`LifeOpsService.runWorkflow`, `LifeOpsService.createCalendarEvent`, Twilio
* voice dispatch, the DocumentRequest lifecycle), rail failures surface as
* typed failures instead of fake success, and actions with no rail at all
* (spend_money) return an explicit NO_EXECUTOR failure.
*
* Run: bunx vitest run test/resolve-request-executor.test.ts
*/
import { randomUUID } from "node:crypto";
import type {
HandlerCallback,
HandlerOptions,
IAgentRuntime,
Memory,
UUID,
} from "@elizaos/core";
import { parseInteractionBlocks } from "@elizaos/core";
import type { LifeOpsCalendarEvent } from "@elizaos/shared";
import { afterEach, describe, expect, it, vi } from "vitest";
const twilioMocks = vi.hoisted(() => ({
readTwilioCredentialsFromEnv: vi.fn<
() => {
accountSid: string;
authToken: string;
fromPhoneNumber: string;
} | null
>(() => null),
sendTwilioVoiceCall: vi.fn(
async (): Promise<{
ok: boolean;
status: number | null;
sid?: string;
error?: string;
}> => ({ ok: true, status: 201, sid: "CA-approved-1" }),
),
}));
vi.mock("@elizaos/plugin-phone/twilio", () => twilioMocks);
// The sign_document tests seed a real DocumentRequest through the real
// OWNER_DOCUMENTS action; only its collaborators (owner gate, approval-queue
// persistence, scheduled-task runner) are mocked, mirroring
// test/document-action.test.ts.
const docMocks = vi.hoisted(() => ({
hasOwnerAccess: vi.fn(async () => true),
enqueue: vi.fn(async (input: { payload?: unknown }) => ({
id: `approval-${Math.random().toString(36).slice(2, 8)}`,
createdAt: new Date(),
updatedAt: new Date(),
state: "pending" as const,
requestedBy: "OWNER_DOCUMENTS",
subjectUserId: "owner-1",
action: "sign_document",
payload: input.payload ?? {},
channel: "internal",
reason: "",
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
resolvedAt: null,
resolvedBy: null,
resolutionReason: null,
})),
list: vi.fn(async (): Promise<unknown[]> => []),
approve: vi.fn(),
reject: vi.fn(),
markExecuting: vi.fn(),
markDone: vi.fn(),
schedule: vi.fn(async (task: { kind: string; trigger: unknown }) => ({
taskId: `task-${Math.random().toString(36).slice(2, 8)}`,
kind: task.kind,
trigger: task.trigger,
state: { status: "scheduled", followupCount: 0 },
})),
}));
vi.mock("@elizaos/agent", () => ({
hasOwnerAccess: docMocks.hasOwnerAccess,
}));
vi.mock("../src/lifeops/approval-queue.js", () => ({
createApprovalQueue: () => ({
enqueue: docMocks.enqueue,
list: docMocks.list,
approve: docMocks.approve,
reject: docMocks.reject,
markExecuting: docMocks.markExecuting,
markDone: docMocks.markDone,
}),
}));
vi.mock("../src/lifeops/scheduled-task/service.js", () => ({
getScheduledTaskRunner: () => ({
schedule: docMocks.schedule,
apply: vi.fn(),
list: vi.fn(),
pipeline: vi.fn(),
evaluateCompletion: vi.fn(),
fire: vi.fn(),
fireWithResult: vi.fn(),
}),
}));
import {
__resetDocumentStoreForTests,
ownerDocumentsAction,
} from "../src/actions/document.js";
import {
buildResolveRequestChoice,
executeApprovedRequest,
resolveRequestAction,
} from "../src/actions/resolve-request.js";
import type {
ApprovalEnqueueInput,
ApprovalListFilter,
ApprovalQueue,
ApprovalRequest,
ApprovalRequestState,
ApprovalResolution,
} from "../src/lifeops/approval-queue.types.js";
import { LifeOpsRepository } from "../src/lifeops/repository.js";
import { LifeOpsService } from "../src/lifeops/service.js";
function makeRuntime(): IAgentRuntime {
return { agentId: randomUUID() as UUID } as unknown as IAgentRuntime;
}
/** In-memory ApprovalQueue that records the transitions executors drive. */
class RecordingQueue implements ApprovalQueue {
public readonly transitions: ApprovalRequestState[] = [];
constructor(private request: ApprovalRequest) {}
private setState(state: ApprovalRequestState): ApprovalRequest {
this.request = { ...this.request, state };
this.transitions.push(state);
return this.request;
}
async enqueue(_input: ApprovalEnqueueInput): Promise<ApprovalRequest> {
throw new Error("not under test");
}
async list(
_filter: ApprovalListFilter,
): Promise<ReadonlyArray<ApprovalRequest>> {
return [this.request];
}
async byId(id: string): Promise<ApprovalRequest | null> {
return this.request.id === id ? this.request : null;
}
async approve(
_id: string,
_resolution: ApprovalResolution,
): Promise<ApprovalRequest> {
return this.setState("approved");
}
async reject(
_id: string,
_resolution: ApprovalResolution,
): Promise<ApprovalRequest> {
return this.setState("rejected");
}
async markExecuting(_id: string): Promise<ApprovalRequest> {
return this.setState("executing");
}
async markDone(_id: string): Promise<ApprovalRequest> {
return this.setState("done");
}
async markExpired(_id: string): Promise<ApprovalRequest> {
return this.setState("expired");
}
async purgeExpired(_now: Date): Promise<ReadonlyArray<string>> {
return [];
}
}
function approvedRequest(
overrides: Pick<ApprovalRequest, "action" | "payload"> &
Partial<ApprovalRequest>,
): ApprovalRequest {
const now = new Date();
return {
id: randomUUID(),
createdAt: now,
updatedAt: now,
state: "approved",
requestedBy: "agent:lifeops",
subjectUserId: "owner-1",
channel: "browser",
reason: "needs owner approval",
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
resolvedAt: now,
resolvedBy: "owner-1",
resolutionReason: "user approved",
...overrides,
};
}
function fakeCalendarEvent(
overrides: Partial<LifeOpsCalendarEvent> = {},
): LifeOpsCalendarEvent {
return {
id: "cal-evt-1",
externalId: "ext-cal-evt-1",
agentId: "agent-1",
provider: "google",
side: "owner",
calendarId: "primary",
title: "Board sync",
description: "",
location: "",
status: "confirmed",
startAt: "2026-07-02T17:00:00.000Z",
endAt: "2026-07-02T18:00:00.000Z",
isAllDay: false,
timezone: null,
htmlLink: null,
conferenceLink: null,
organizer: null,
attendees: [],
metadata: {},
syncedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
};
}
afterEach(() => {
vi.restoreAllMocks();
__resetDocumentStoreForTests();
twilioMocks.readTwilioCredentialsFromEnv.mockReset();
twilioMocks.readTwilioCredentialsFromEnv.mockReturnValue(null);
twilioMocks.sendTwilioVoiceCall.mockReset();
twilioMocks.sendTwilioVoiceCall.mockResolvedValue({
ok: true,
status: 201,
sid: "CA-approved-1",
});
docMocks.enqueue.mockClear();
docMocks.schedule.mockClear();
docMocks.list.mockReset();
docMocks.list.mockResolvedValue([]);
docMocks.approve.mockReset();
docMocks.reject.mockReset();
docMocks.markExecuting.mockReset();
docMocks.markDone.mockReset();
});
function collectTexts(): { texts: string[]; callback: HandlerCallback } {
const texts: string[] = [];
const callback: HandlerCallback = async (content) => {
if (typeof content.text === "string") texts.push(content.text);
return [];
};
return { texts, callback };
}
describe("executeApprovedRequest", () => {
it("execute_workflow approval actually runs the workflow", async () => {
const runtime = makeRuntime();
const request = approvedRequest({
action: "execute_workflow",
payload: {
action: "execute_workflow",
workflowId: "doc.upload_asset",
input: { documentId: "doc-1" },
},
});
const queue = new RecordingQueue(request);
const runSpy = vi
.spyOn(LifeOpsService.prototype, "runWorkflow")
.mockResolvedValue({
id: "run-77",
agentId: String(runtime.agentId),
workflowId: "doc.upload_asset",
startedAt: new Date().toISOString(),
finishedAt: new Date().toISOString(),
status: "success",
result: {},
auditRef: null,
});
const { texts, callback } = collectTexts();
const result = await executeApprovedRequest({
runtime,
queue,
request,
callback,
});
expect(runSpy).toHaveBeenCalledTimes(1);
expect(runSpy).toHaveBeenCalledWith("doc.upload_asset", {
confirmBrowserActions: true,
});
expect(queue.transitions).toEqual(["executing", "done"]);
expect(result.success).toBe(true);
expect(result.data).toMatchObject({
workflowId: "doc.upload_asset",
workflowRunId: "run-77",
workflowRunStatus: "success",
state: "done",
});
expect(texts.join(" ")).toContain("run-77");
});
it("a failing workflow surfaces the error and never reaches done", async () => {
const runtime = makeRuntime();
const request = approvedRequest({
action: "execute_workflow",
payload: {
action: "execute_workflow",
workflowId: "wf-broken",
input: {},
},
});
const queue = new RecordingQueue(request);
vi.spyOn(LifeOpsService.prototype, "runWorkflow").mockRejectedValue(
new Error("workflow step exploded"),
);
await expect(
executeApprovedRequest({ runtime, queue, request }),
).rejects.toThrow("workflow step exploded");
expect(queue.transitions).toEqual(["executing"]);
});
it("schedule_event approval creates the calendar event through LifeOpsService", async () => {
const runtime = makeRuntime();
const request = approvedRequest({
action: "schedule_event",
payload: {
action: "schedule_event",
calendarId: "primary",
title: "Board sync",
startsAtMs: Date.parse("2026-07-02T17:00:00.000Z"),
endsAtMs: Date.parse("2026-07-02T18:00:00.000Z"),
attendees: ["ada@example.com", "grace@example.com"],
location: "Zoom",
description: "Q3 planning",
},
});
const queue = new RecordingQueue(request);
const createSpy = vi
.spyOn(LifeOpsService.prototype, "createCalendarEvent")
.mockResolvedValue(fakeCalendarEvent());
const { texts, callback } = collectTexts();
const result = await executeApprovedRequest({
runtime,
queue,
request,
callback,
});
expect(createSpy).toHaveBeenCalledTimes(1);
expect(createSpy.mock.calls[0]?.[1]).toEqual({
calendarId: "primary",
title: "Board sync",
startAt: "2026-07-02T17:00:00.000Z",
endAt: "2026-07-02T18:00:00.000Z",
location: "Zoom",
description: "Q3 planning",
attendees: [{ email: "ada@example.com" }, { email: "grace@example.com" }],
});
expect(queue.transitions).toEqual(["executing", "done"]);
expect(result.success).toBe(true);
expect(result.data).toMatchObject({
calendarEventId: "cal-evt-1",
calendarId: "primary",
state: "done",
});
expect(texts.join(" ")).toContain("Board sync");
});
it("a failing calendar rail (e.g. calendar not connected) propagates and never reaches done", async () => {
const runtime = makeRuntime();
const request = approvedRequest({
action: "schedule_event",
payload: {
action: "schedule_event",
calendarId: "",
title: "Board sync",
startsAtMs: Date.parse("2026-07-02T17:00:00.000Z"),
endsAtMs: Date.parse("2026-07-02T18:00:00.000Z"),
attendees: [],
location: null,
description: null,
},
});
const queue = new RecordingQueue(request);
vi.spyOn(LifeOpsService.prototype, "createCalendarEvent").mockRejectedValue(
new Error("Google Calendar is not connected"),
);
await expect(
executeApprovedRequest({ runtime, queue, request }),
).rejects.toThrow("Google Calendar is not connected");
expect(queue.transitions).toEqual(["executing"]);
});
it("send_email approval projects sent-mail commitments into the ledger after delivery", async () => {
const runtime = {
...makeRuntime(),
adapter: { db: {} },
reportError: vi.fn(),
} as unknown as IAgentRuntime;
const request = approvedRequest({
action: "send_email",
payload: {
action: "send_email",
to: ["mira@example.com"],
cc: [],
bcc: [],
subject: "Launch deck",
body: "I'll send the deck by 2026-07-10 and include the pricing appendix.",
replyToMessageId: null,
},
});
const queue = new RecordingQueue(request);
const sendSpy = vi
.spyOn(LifeOpsService.prototype, "sendGmailMessage")
.mockResolvedValue({ ok: true });
const upsertSpy = vi
.spyOn(LifeOpsRepository.prototype, "upsertCommitmentLedgerRecord")
.mockResolvedValue();
const { texts, callback } = collectTexts();
const result = await executeApprovedRequest({
runtime,
queue,
request,
callback,
});
expect(sendSpy).toHaveBeenCalledTimes(1);
expect(queue.transitions).toEqual(["executing", "done"]);
expect(upsertSpy).toHaveBeenCalledTimes(1);
expect(upsertSpy.mock.calls[0]?.[0]).toMatchObject({
agentId: runtime.agentId,
source: "sent_mail",
sourceKey: `approval:${request.id}`,
kind: "commitment",
counterparty: "mira@example.com",
dueAt: "2026-07-10T17:00:00.000Z",
status: "open",
scheduledTaskId: null,
metadata: {
approvalRequestId: request.id,
subject: "Launch deck",
to: ["mira@example.com"],
},
});
expect(upsertSpy.mock.calls[0]?.[0].summary).toContain("send the deck");
expect(runtime.reportError).not.toHaveBeenCalled();
expect(result.success).toBe(true);
expect(texts.join(" ")).toContain("mira@example.com");
});
it("make_call approval without Twilio credentials fails honestly and stays retriable", async () => {
const runtime = makeRuntime();
const request = approvedRequest({
action: "make_call",
payload: {
action: "make_call",
to: "+15550100",
script: "Confirming tomorrow's appointment.",
maxDurationSeconds: 120,
},
});
const queue = new RecordingQueue(request);
const { texts, callback } = collectTexts();
const result = await executeApprovedRequest({
runtime,
queue,
request,
callback,
});
expect(result.success).toBe(false);
expect(result.data).toMatchObject({
error: "TWILIO_NOT_CONFIGURED",
action: "make_call",
requestId: request.id,
});
expect(twilioMocks.sendTwilioVoiceCall).not.toHaveBeenCalled();
// The request never left `approved`: the owner can retry after
// configuring Twilio.
expect(queue.transitions).toEqual([]);
expect(texts.join(" ")).toContain("not placed");
});
it("a failed Twilio dispatch surfaces a typed failure and never reaches done", async () => {
const runtime = makeRuntime();
twilioMocks.readTwilioCredentialsFromEnv.mockReturnValue({
accountSid: "AC-test",
authToken: "token",
fromPhoneNumber: "+15550999",
});
twilioMocks.sendTwilioVoiceCall.mockResolvedValue({
ok: false,
status: 401,
error: "Authenticate",
});
const request = approvedRequest({
action: "make_call",
payload: {
action: "make_call",
to: "+15550100",
script: "Confirming tomorrow's appointment.",
maxDurationSeconds: 120,
},
});
const queue = new RecordingQueue(request);
const { texts, callback } = collectTexts();
const result = await executeApprovedRequest({
runtime,
queue,
request,
callback,
});
expect(result.success).toBe(false);
expect(result.data).toMatchObject({
error: "TWILIO_DELIVERY_FAILED",
detail: "Authenticate",
status: 401,
});
expect(queue.transitions).toEqual(["executing"]);
expect(texts.join(" ")).toContain("not placed");
});
it("make_call approval places the call through the Twilio rail", async () => {
const runtime = makeRuntime();
const credentials = {
accountSid: "AC-test",
authToken: "token",
fromPhoneNumber: "+15550999",
};
twilioMocks.readTwilioCredentialsFromEnv.mockReturnValue(credentials);
const request = approvedRequest({
action: "make_call",
payload: {
action: "make_call",
to: "+15550100",
script: "Confirming tomorrow's appointment.",
maxDurationSeconds: 120,
},
});
const queue = new RecordingQueue(request);
const { texts, callback } = collectTexts();
const result = await executeApprovedRequest({
runtime,
queue,
request,
callback,
});
expect(twilioMocks.sendTwilioVoiceCall).toHaveBeenCalledTimes(1);
expect(twilioMocks.sendTwilioVoiceCall).toHaveBeenCalledWith({
credentials,
to: "+15550100",
message: "Confirming tomorrow's appointment.",
});
expect(queue.transitions).toEqual(["executing", "done"]);
expect(result.success).toBe(true);
expect(result.data).toMatchObject({
callSid: "CA-approved-1",
state: "done",
});
expect(texts.join(" ")).toContain("+15550100");
});
it("sign_document approval dispatches the real DocumentRequest seeded through OWNER_DOCUMENTS", async () => {
const runtime = makeRuntime();
const deadline = new Date(Date.now() + 7 * 86_400_000).toISOString();
const seeded = await ownerDocumentsAction.handler(
runtime,
{
id: randomUUID() as UUID,
entityId: "owner-1" as UUID,
roomId: randomUUID() as UUID,
content: { text: "get the NDA signed" },
} as Memory,
undefined,
{
parameters: {
subaction: "request_signature",
requesteeEntityId: "entity-alice-001",
documentTitle: "Partnership NDA",
deadline,
signatureUrl: "https://sign.example.com/nda",
},
} as unknown as HandlerOptions,
async () => [],
);
expect(seeded).toMatchObject({ success: true });
// The payload the document action actually enqueued for owner approval.
const enqueued = docMocks.enqueue.mock.calls[0]?.[0];
if (!enqueued) throw new Error("request_signature enqueued no approval");
const payload = enqueued.payload as ApprovalRequest["payload"];
expect(payload).toMatchObject({
action: "sign_document",
documentName: "Partnership NDA",
});
const request = approvedRequest({ action: "sign_document", payload });
const queue = new RecordingQueue(request);
const { texts, callback } = collectTexts();
const result = await executeApprovedRequest({
runtime,
queue,
request,
callback,
});
expect(result.success).toBe(true);
expect(queue.transitions).toEqual(["executing", "done"]);
expect(result.data).toMatchObject({
documentStatus: "in_progress",
state: "done",
});
expect(texts.join(" ")).toContain("Partnership NDA");
});
it("sign_document approval for a vanished DocumentRequest fails honestly and dispatches nothing", async () => {
const runtime = makeRuntime();
const request = approvedRequest({
action: "sign_document",
payload: {
action: "sign_document",
documentId: "doc-gone",
documentName: "NDA.pdf",
signatureUrl: "https://sign.example.com/doc-gone",
deadline: new Date(Date.now() + 86_400_000).toISOString(),
},
});
const queue = new RecordingQueue(request);
const { texts, callback } = collectTexts();
const result = await executeApprovedRequest({
runtime,
queue,
request,
callback,
});
expect(result.success).toBe(false);
expect(result.data).toMatchObject({
error: "DOCUMENT_REQUEST_NOT_FOUND",
action: "sign_document",
documentId: "doc-gone",
});
expect(queue.transitions).toEqual(["executing"]);
expect(texts.join(" ")).toContain("re-issue");
});
it("executor-less action (spend_money) fails loudly with NO_EXECUTOR", async () => {
const runtime = makeRuntime();
const request = approvedRequest({
action: "spend_money",
payload: {
action: "spend_money",
vendor: "AWS",
amountCents: 12_000,
currency: "USD",
memo: "renew reserved instances",
},
});
const queue = new RecordingQueue(request);
const runSpy = vi.spyOn(LifeOpsService.prototype, "runWorkflow");
const calendarSpy = vi.spyOn(
LifeOpsService.prototype,
"createCalendarEvent",
);
const { texts, callback } = collectTexts();
const result = await executeApprovedRequest({
runtime,
queue,
request,
callback,
});
expect(result.success).toBe(false);
expect(result.data).toMatchObject({
error: "NO_EXECUTOR",
action: "spend_money",
requestId: request.id,
});
// Nothing executed and nothing marked done: the queue rows stay honest.
expect(runSpy).not.toHaveBeenCalled();
expect(calendarSpy).not.toHaveBeenCalled();
expect(twilioMocks.sendTwilioVoiceCall).not.toHaveBeenCalled();
expect(queue.transitions).toEqual([]);
expect(texts.join(" ")).toContain("nothing was executed");
});
});
describe("RESOLVE_REQUEST reject path", () => {
it("rejecting a make_call request executes no rail and flips the row to rejected", async () => {
const runtime = makeRuntime();
const pending = approvedRequest({
action: "make_call",
state: "pending",
resolvedAt: null,
resolvedBy: null,
resolutionReason: null,
payload: {
action: "make_call",
to: "+15550100",
script: "Confirming tomorrow's appointment.",
maxDurationSeconds: 120,
},
});
twilioMocks.readTwilioCredentialsFromEnv.mockReturnValue({
accountSid: "AC-test",
authToken: "token",
fromPhoneNumber: "+15550999",
});
docMocks.list.mockResolvedValue([pending]);
docMocks.reject.mockResolvedValue({ ...pending, state: "rejected" });
const { texts, callback } = collectTexts();
const result = await resolveRequestAction.handler(
runtime,
{
id: randomUUID() as UUID,
entityId: "owner-1" as UUID,
roomId: randomUUID() as UUID,
content: { text: "no, don't place that call" },
} as Memory,
undefined,
{
parameters: {
action: "reject",
requestId: pending.id,
reason: "not now",
},
} as unknown as HandlerOptions,
callback,
);
expect(docMocks.reject).toHaveBeenCalledTimes(1);
expect(docMocks.reject).toHaveBeenCalledWith(pending.id, {
resolvedBy: "owner-1",
resolutionReason: "not now",
});
// Rejection touches no rail and no executing/done transition.
expect(twilioMocks.sendTwilioVoiceCall).not.toHaveBeenCalled();
expect(docMocks.markExecuting).not.toHaveBeenCalled();
expect(docMocks.markDone).not.toHaveBeenCalled();
expect(result).toMatchObject({ success: true });
expect(texts.join(" ")).toContain("Rejected");
});
});
describe("RESOLVE_REQUEST ambiguous-target chips (#14733)", () => {
function pendingPair(): [ApprovalRequest, ApprovalRequest] {
const base = {
state: "pending" as const,
resolvedAt: null,
resolvedBy: null,
resolutionReason: null,
};
return [
approvedRequest({
...base,
action: "send_email",
reason: "Send the quarterly report to Dana",
payload: {
action: "send_email",
to: ["dana@example.com"],
cc: [],
bcc: [],
subject: "Q2 report",
body: "Attached.",
replyToMessageId: null,
},
}),
approvedRequest({
...base,
action: "send_message",
reason: "Text JJ that the demo moved to 3pm",
payload: {
action: "send_message",
recipient: "+15550100",
body: "Demo moved to 3pm",
replyToMessageId: null,
},
}),
];
}
it("asks 'Which request?' with one chip per pending row, values carrying `<intent> <requestId>`", async () => {
const runtime = makeRuntime();
const [first, second] = pendingPair();
docMocks.list.mockResolvedValue([first, second]);
const { texts, callback } = collectTexts();
// No requestId and no useModel on the runtime: extraction cannot pick a
// row, which is exactly the ambiguous branch under test.
const result = await resolveRequestAction.handler(
runtime,
{
id: randomUUID() as UUID,
entityId: "owner-1" as UUID,
roomId: randomUUID() as UUID,
content: { text: "approve it" },
} as Memory,
undefined,
{
parameters: { action: "approve" },
} as unknown as HandlerOptions,
callback,
);
expect(result).toMatchObject({ success: false });
expect(texts).toHaveLength(1);
const reply = texts[0] ?? "";
expect(reply).toContain("Which request?");
const { blocks } = parseInteractionBlocks(reply);
expect(blocks).toHaveLength(1);
const block = blocks[0];
if (block?.kind !== "choice") throw new Error("expected choice block");
expect(block.scope).toBe("approval-resolve");
expect(block.options.map((o) => o.value)).toEqual([
`approve ${first.id}`,
`approve ${second.id}`,
]);
// Labels are the human-authored reasons, so the chips read as decisions.
expect(block.options[0]?.label).toContain("quarterly report");
expect(block.options[1]?.label).toContain("demo moved to 3pm");
});
it("a tapped chip value round-trips: `reject <id>` resolves exactly that row", async () => {
const runtime = makeRuntime();
const [first, second] = pendingPair();
docMocks.list.mockResolvedValue([first, second]);
docMocks.reject.mockResolvedValue({ ...second, state: "rejected" });
// Build the chips for a reject intent and simulate the tap: the value is
// the user's next message and the extraction reads the id verbatim.
const chips = buildResolveRequestChoice("reject", [first, second]);
const tapped = chips.options[1]?.value ?? "";
expect(tapped).toBe(`reject ${second.id}`);
const { texts, callback } = collectTexts();
const result = await resolveRequestAction.handler(
runtime,
{
id: randomUUID() as UUID,
entityId: "owner-1" as UUID,
roomId: randomUUID() as UUID,
content: { text: tapped },
} as Memory,
undefined,
{
parameters: { action: "reject", requestId: second.id },
} as unknown as HandlerOptions,
callback,
);
expect(result).toMatchObject({ success: true });
expect(docMocks.reject).toHaveBeenCalledTimes(1);
expect(docMocks.reject.mock.calls[0]?.[0]).toBe(second.id);
expect(texts.join(" ")).toContain("Rejected");
});
});