项目文件夹

文件
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

1004 行
34 KiB
TypeScript

/**
* `INBOX` umbrella action — unit tests.
*
* Asserts the cross-channel inbox surface exists, fans out across the
* configured platforms via the injectable fetcher hook, dedupes by id + thread
* topic, and respects the search / summarize subactions. Ported from the
* LifeOps INBOX action tests; behavior is byte-identical.
*/
import type {
Content,
HandlerOptions,
IAgentRuntime,
Memory,
MessageAdapter,
UUID,
} from "@elizaos/core";
import { getDefaultTriageService, parseInteractionBlocks } from "@elizaos/core";
import { beforeEach, describe, expect, it, vi } from "vitest";
const TEST_AGENT_ID = "11111111-1111-1111-1111-111111111111" as UUID;
import {
__resetInboxFetchersForTests,
type InboxItem,
inboxAction,
setInboxFetchers,
} from "../src/actions/inbox.ts";
function makeRuntime(): IAgentRuntime {
return {
agentId: TEST_AGENT_ID,
logger: {
info: () => undefined,
warn: () => undefined,
error: () => undefined,
debug: () => undefined,
},
} as unknown as IAgentRuntime;
}
function makeDbRuntime(rowsFor: (sql: string) => unknown): {
runtime: IAgentRuntime;
calls: Array<{ sql: string }>;
} {
const calls: Array<{ sql: string }> = [];
const runtime = {
...makeRuntime(),
adapter: {
db: {
execute: async (query: { queryChunks: Array<{ value?: unknown }> }) => {
const chunk = query.queryChunks[0]?.value;
const sql = Array.isArray(chunk) ? String(chunk[0]) : String(chunk);
calls.push({ sql });
return rowsFor(sql);
},
},
},
} as unknown as IAgentRuntime;
return { runtime, calls };
}
function makeMessage(text = "show my inbox"): Memory {
return {
id: "msg-inbox-1" as UUID,
entityId: TEST_AGENT_ID,
roomId: "room-inbox-1" as UUID,
content: { text, source: "test" },
} as Memory;
}
async function callInbox(
runtime: IAgentRuntime,
message: Memory,
parameters: Record<string, unknown>,
) {
return inboxAction.handler(
runtime,
message,
undefined,
{ parameters } as unknown as HandlerOptions,
async () => undefined,
);
}
function makeItem(
overrides: Partial<InboxItem> & {
platform: InboxItem["platform"];
id: string;
},
): InboxItem {
return {
channel: "default",
senderName: "Alice",
snippet: "hello",
receivedAt: "2026-05-11T10:00:00.000Z",
...overrides,
};
}
function makeTriageRow(
overrides: Record<string, unknown> = {},
): Record<string, unknown> {
return {
id: "entry-1",
agent_id: "11111111-1111-1111-1111-111111111111",
source: "gmail",
source_room_id: null,
source_entity_id: "alice@example.com",
source_message_id: "gmail-msg-1",
channel_name: "Email from Alice",
channel_type: "email",
deep_link: null,
classification: "needs_reply",
urgency: "high",
confidence: 0.9,
snippet: "Can you confirm the launch date?",
sender_name: "Alice",
thread_context: null,
triage_reasoning: "asks a direct question",
suggested_response: "Yes, Friday.",
draft_response: null,
auto_replied: false,
snoozed_until: null,
resolved: false,
resolved_at: null,
created_at: "2026-06-17T10:00:00.000Z",
updated_at: "2026-06-17T10:00:00.000Z",
...overrides,
};
}
describe("INBOX umbrella action — cross-channel inbox", () => {
beforeEach(() => {
__resetInboxFetchersForTests();
});
describe("metadata", () => {
it("exposes the canonical name and similes", () => {
expect(inboxAction.name).toBe("INBOX");
const similes = inboxAction.similes ?? [];
for (const required of [
"INBOX",
"CROSS_CHANNEL_INBOX",
"ALL_MESSAGES",
"INBOX_TRIAGE_PRIORITY",
]) {
expect(similes).toContain(required);
}
});
it("exposes triage queue filter parameters to routed tool calls", () => {
const parameterNames = new Set(
(inboxAction.parameters ?? []).map((parameter) => parameter.name),
);
expect(parameterNames).toContain("classification");
expect(parameterNames).toContain("includeSnoozed");
});
it("rejects calls with no subaction selector", async () => {
const result = await callInbox(makeRuntime(), makeMessage(), {});
expect(result.success).toBe(false);
expect(result.text).toContain("triage");
expect(result.text).toContain("approve");
expect(result.data).toMatchObject({ error: "MISSING_SUBACTION" });
});
it("rejects callers that fail the owner-access check", async () => {
const result = await callInbox(
makeRuntime(),
{
...makeMessage(),
entityId: "not-owner" as UUID,
content: { text: "show my inbox", source: "discord" },
} as Memory,
{
subaction: "list",
},
);
expect(result.success).toBe(false);
expect(result.data).toMatchObject({ error: "PERMISSION_DENIED" });
});
it("rejects when the message has no entity id (access guard)", async () => {
const message = { id: "m" as UUID, content: { text: "x" } } as Memory;
const result = await callInbox(makeRuntime(), message, {
subaction: "list",
});
expect(result.success).toBe(false);
expect(result.data).toMatchObject({ error: "PERMISSION_DENIED" });
});
});
describe("list", () => {
it("fans out to all configured platforms and orders by recency", async () => {
setInboxFetchers({
gmail: async () => [
makeItem({
id: "g-1",
platform: "gmail",
receivedAt: "2026-05-11T08:00:00.000Z",
}),
],
slack: async () => [
makeItem({
id: "s-1",
platform: "slack",
receivedAt: "2026-05-11T12:00:00.000Z",
}),
],
});
const result = await callInbox(makeRuntime(), makeMessage(), {
subaction: "list",
platforms: ["gmail", "slack"],
});
expect(result.success).toBe(true);
const data = result.data as {
items: { id: string; platform: string }[];
platforms: string[];
};
expect(data.platforms).toEqual(["gmail", "slack"]);
expect(data.items.map((item) => item.id)).toEqual(["s-1", "g-1"]);
});
it("dedupes items that share thread topic + channel + platform", async () => {
setInboxFetchers({
gmail: async () => [
makeItem({
id: "g-1",
platform: "gmail",
channel: "inbox",
threadTopic: "Launch",
receivedAt: "2026-05-11T09:00:00.000Z",
}),
makeItem({
id: "g-2",
platform: "gmail",
channel: "inbox",
threadTopic: "Launch",
receivedAt: "2026-05-11T11:00:00.000Z",
}),
],
});
const result = await callInbox(makeRuntime(), makeMessage(), {
subaction: "list",
platforms: ["gmail"],
});
const data = result.data as {
items: { id: string }[];
totalBeforeDedupe: number;
};
expect(data.totalBeforeDedupe).toBe(2);
expect(data.items).toHaveLength(1);
expect(data.items[0]?.id).toBe("g-2");
});
});
describe("search", () => {
it("requires a non-empty query", async () => {
const result = await callInbox(makeRuntime(), makeMessage(), {
subaction: "search",
});
expect(result.success).toBe(false);
expect(result.data).toMatchObject({ error: "MISSING_QUERY" });
});
it("forwards the query to each platform fetcher", async () => {
const gmailFetcher = vi.fn(async () => []);
setInboxFetchers({
gmail: gmailFetcher,
});
const result = await callInbox(makeRuntime(), makeMessage(), {
subaction: "search",
platforms: ["gmail"],
query: "launch plan",
});
expect(result.success).toBe(true);
expect(gmailFetcher).toHaveBeenCalledTimes(1);
const fetcherArgs = gmailFetcher.mock.calls[0]?.[0];
expect(fetcherArgs?.query).toBe("launch plan");
});
});
describe("summarize", () => {
it("returns per-platform counts without items", async () => {
setInboxFetchers({
gmail: async () => [
makeItem({
id: "g-1",
platform: "gmail",
receivedAt: "2026-05-11T09:00:00.000Z",
}),
makeItem({
id: "g-2",
platform: "gmail",
receivedAt: "2026-05-11T10:00:00.000Z",
}),
],
slack: async () => [],
});
const result = await callInbox(makeRuntime(), makeMessage(), {
subaction: "summarize",
platforms: ["gmail", "slack"],
});
expect(result.success).toBe(true);
const data = result.data as {
items: unknown[];
summary: { platform: string; count: number; latestAt: string | null }[];
};
expect(data.items).toHaveLength(0);
const summary = data.summary;
const gmail = summary.find((s) => s.platform === "gmail");
const slack = summary.find((s) => s.platform === "slack");
expect(gmail?.count).toBe(2);
expect(gmail?.latestAt).toBe("2026-05-11T10:00:00.000Z");
expect(slack?.count).toBe(0);
expect(slack?.latestAt).toBeNull();
});
});
describe("platforms arg parsing", () => {
it("falls back to all PLATFORMS when omitted", async () => {
const result = await callInbox(makeRuntime(), makeMessage(), {
subaction: "list",
});
expect(result.success).toBe(true);
const data = result.data as { platforms: string[] };
expect(data.platforms).toEqual([
"gmail",
"slack",
"discord",
"telegram",
"signal",
"imessage",
"whatsapp",
]);
});
it("errors when the platforms list is provided but no entries match", async () => {
const result = await callInbox(makeRuntime(), makeMessage(), {
subaction: "list",
platforms: ["myspace", "aim"],
});
expect(result.success).toBe(false);
expect(result.data).toMatchObject({ error: "NO_PLATFORMS" });
});
});
describe("empty result", () => {
it("returns an empty list with a friendly text when no items match", async () => {
const result = await callInbox(makeRuntime(), makeMessage(), {
subaction: "list",
platforms: ["gmail"],
});
expect(result.success).toBe(true);
const data = result.data as { items: unknown[] };
expect(data.items).toHaveLength(0);
expect(result.text).toContain("empty");
});
});
describe("degraded platforms", () => {
it("a failing platform is reported as degraded while healthy platforms still return items", async () => {
setInboxFetchers({
gmail: async () => {
throw new Error("gmail token expired");
},
discord: async () => [
makeItem({ platform: "discord", id: "d-1", snippet: "gm" }),
],
});
const result = await callInbox(makeRuntime(), makeMessage(), {
subaction: "list",
platforms: ["gmail", "discord"],
});
expect(result.success).toBe(true);
const data = result.data as {
items: InboxItem[];
degraded: Array<{ platform: string; error: string }>;
};
// Partial failure: discord's message survives the gmail blow-up.
expect(data.items.map((item) => item.id)).toEqual(["d-1"]);
expect(data.degraded).toEqual([
{ platform: "gmail", error: "gmail token expired" },
]);
// The planner-visible text names the broken platform and the reason.
expect(result.text).toContain("could not check gmail");
expect(result.text).toContain("gmail token expired");
});
it("an empty result with a degraded platform is never presented as a clean empty inbox", async () => {
setInboxFetchers({
gmail: async () => {
throw new Error("gmail token expired");
},
});
const result = await callInbox(makeRuntime(), makeMessage(), {
subaction: "list",
platforms: ["gmail"],
});
expect(result.success).toBe(true);
expect(result.text).not.toContain("Your inbox is empty");
expect(result.text).toContain("No messages from reachable platforms");
expect(result.text).toContain("could not check gmail");
const data = result.data as {
degraded: Array<{ platform: string; error: string }>;
};
expect(data.degraded).toHaveLength(1);
});
it("a healthy fan-out reports an empty degraded list", async () => {
setInboxFetchers({
gmail: async () => [makeItem({ platform: "gmail", id: "g-1" })],
});
const result = await callInbox(makeRuntime(), makeMessage(), {
subaction: "list",
platforms: ["gmail"],
});
expect(result.success).toBe(true);
const data = result.data as { degraded: unknown[] };
expect(data.degraded).toEqual([]);
expect(result.text).not.toContain("could not check");
});
it("summarize appends the degradation warning to its rollup text", async () => {
setInboxFetchers({
gmail: async () => {
throw new Error("HTTP 503 from Gmail");
},
discord: async () => [
makeItem({ platform: "discord", id: "d-2", snippet: "hey" }),
],
});
const result = await callInbox(makeRuntime(), makeMessage(), {
subaction: "summarize",
platforms: ["gmail", "discord"],
});
expect(result.success).toBe(true);
expect(result.text).toContain("Summarized 2 platforms");
expect(result.text).toContain("could not check gmail");
expect(result.text).toContain("HTTP 503 from Gmail");
});
});
describe("triage queue operations", () => {
it("lists persisted unresolved triage entries and hides snoozed rows by default", async () => {
const { runtime, calls } = makeDbRuntime((sql) =>
sql.includes("life_inbox_triage_entries")
? [makeTriageRow({ id: "entry-1" })]
: [],
);
const result = await callInbox(runtime, makeMessage(), {
subaction: "triage",
limit: 5,
});
expect(result.success).toBe(true);
expect(result.text).toContain("Loaded 1");
const data = result.data as { entries: Array<{ id: string }> };
expect(data.entries[0]?.id).toBe("entry-1");
const select = calls[0]?.sql ?? "";
expect(select).toContain("resolved = FALSE");
expect(select).toContain("snoozed_until IS NULL");
expect(select).toContain("LIMIT 5");
});
it("runs the triage classifier over fresh messages and persists one entry per message", async () => {
setInboxFetchers({
gmail: async () => [
makeItem({
platform: "gmail",
id: "gmail-fresh-1",
senderName: "Priya",
snippet: "PROD IS DOWN — approve the rollback now",
receivedAt: "2026-05-11T10:05:00.000Z",
}),
makeItem({
platform: "gmail",
id: "gmail-fresh-2",
senderName: "ShoeDeals Weekly",
snippet: "50% OFF SNEAKERS this weekend only",
receivedAt: "2026-05-11T10:00:00.000Z",
}),
],
});
// Full canonical ref in the core store for the first item — the
// classifier input must carry the full body, not just the snippet.
getDefaultTriageService()
.getStore()
.saveMessage({
id: "gmail-fresh-1",
source: "gmail",
externalId: "gmail-fresh-1",
from: { identifier: "priya@example.com", displayName: "Priya" },
to: [{ identifier: "owner@example.com" }],
snippet: "PROD IS DOWN — approve the rollback now",
body: "PROD IS DOWN — checkout is returning 500s for every customer. Approve the emergency rollback right now.",
receivedAtMs: Date.parse("2026-05-11T10:05:00.000Z"),
hasAttachments: false,
isRead: false,
});
const { runtime, calls } = makeDbRuntime((sql) => {
if (sql.includes("SELECT source_message_id")) return [];
if (sql.includes("WHERE source_message_id =")) return [];
if (sql.includes("life_inbox_triage_examples")) return [];
if (sql.startsWith("INSERT")) return [];
return [makeTriageRow({ id: "entry-fresh-1" })];
});
const useModel = vi.fn(async (_type: string, _params: unknown) =>
JSON.stringify({
results: [
{
classification: "urgent",
urgency: "high",
confidence: 0.95,
reasoning: "production outage",
suggestedResponse: null,
},
{
classification: "ignore",
urgency: "low",
confidence: 0.9,
reasoning: "automated promotion",
suggestedResponse: null,
},
],
}),
);
(runtime as { useModel?: unknown }).useModel = useModel;
(runtime as { getService?: unknown }).getService = () => null;
const result = await callInbox(runtime, makeMessage("triage my inbox"), {
subaction: "triage",
platforms: ["gmail"],
});
expect(result.success).toBe(true);
expect(result.text).toContain("Triaged 2 new messages");
expect(result.data).toMatchObject({ subaction: "triage", classified: 2 });
// Exactly one classifier call, carrying the full stored body (not just
// the fetched snippet) for the store-backed message.
expect(useModel).toHaveBeenCalledTimes(1);
const prompt = (useModel.mock.calls[0]?.[1] as { prompt: string }).prompt;
expect(prompt).toContain("Classify each message");
expect(prompt).toContain("checkout is returning 500s for every customer");
expect(prompt).toContain("50% OFF SNEAKERS");
const inserts = calls.filter((call) => call.sql.startsWith("INSERT"));
expect(inserts).toHaveLength(2);
expect(inserts[0]?.sql).toContain("'urgent'");
expect(inserts[1]?.sql).toContain("'ignore'");
});
it("classifies only messages without a persisted triage entry", async () => {
setInboxFetchers({
gmail: async () => [
makeItem({
platform: "gmail",
id: "gmail-old-1",
snippet: "already triaged",
receivedAt: "2026-05-11T10:05:00.000Z",
}),
makeItem({
platform: "gmail",
id: "gmail-new-1",
snippet: "brand new question about the launch",
receivedAt: "2026-05-11T10:00:00.000Z",
}),
],
});
const { runtime, calls } = makeDbRuntime((sql) => {
if (sql.includes("SELECT source_message_id"))
return [{ source_message_id: "gmail-old-1" }];
if (sql.includes("WHERE source_message_id =")) return [];
if (sql.includes("life_inbox_triage_examples")) return [];
if (sql.startsWith("INSERT")) return [];
return [makeTriageRow({ id: "entry-new-1" })];
});
const useModel = vi.fn(async () =>
JSON.stringify({
results: [
{
classification: "needs_reply",
urgency: "medium",
confidence: 0.8,
reasoning: "direct question",
suggestedResponse: null,
},
],
}),
);
(runtime as { useModel?: unknown }).useModel = useModel;
(runtime as { getService?: unknown }).getService = () => null;
const result = await callInbox(runtime, makeMessage("triage my inbox"), {
subaction: "triage",
platforms: ["gmail"],
});
expect(result.success).toBe(true);
expect(result.data).toMatchObject({ classified: 1 });
expect(useModel).toHaveBeenCalledTimes(1);
const prompt = (useModel.mock.calls[0]?.[1] as { prompt: string }).prompt;
expect(prompt).toContain("brand new question about the launch");
expect(prompt).not.toContain("already triaged");
expect(
calls.filter((call) => call.sql.startsWith("INSERT")),
).toHaveLength(1);
});
it("classifies fresh messages before applying a classification-filtered queue read", async () => {
const gmailFetcher = vi.fn(async () => [
makeItem({
platform: "gmail",
id: "gmail-should-classify",
snippet: "production is down and needs urgent review",
receivedAt: "2026-05-11T10:00:00.000Z",
}),
]);
setInboxFetchers({ gmail: gmailFetcher });
const { runtime, calls } = makeDbRuntime((sql) => {
if (sql.includes("SELECT source_message_id")) return [];
if (sql.includes("WHERE source_message_id =")) return [];
if (sql.includes("life_inbox_triage_examples")) return [];
if (sql.startsWith("INSERT")) return [];
if (sql.includes("classification = ")) {
return [
makeTriageRow({ id: "entry-urgent-1", classification: "urgent" }),
];
}
return [];
});
const useModel = vi.fn(async () =>
JSON.stringify({
results: [
{
classification: "urgent",
urgency: "high",
confidence: 0.92,
reasoning: "production outage",
suggestedResponse: null,
},
],
}),
);
(runtime as { useModel?: unknown }).useModel = useModel;
(runtime as { getService?: unknown }).getService = () => null;
const result = await callInbox(
runtime,
makeMessage("show my urgent inbox items"),
{
subaction: "triage",
platforms: ["gmail"],
classification: "urgent",
},
);
expect(result.success).toBe(true);
expect(result.data).toMatchObject({ subaction: "triage", classified: 1 });
expect(gmailFetcher).toHaveBeenCalledTimes(1);
expect(useModel).toHaveBeenCalledTimes(1);
const prompt = (useModel.mock.calls[0]?.[1] as { prompt: string }).prompt;
expect(prompt).toContain("production is down and needs urgent review");
const select = calls.find((call) =>
call.sql.includes("classification = "),
)?.sql;
expect(select).toContain("classification = ");
});
it("surfaces a classifier failure as an action failure instead of silently degrading", async () => {
setInboxFetchers({
gmail: async () => [
makeItem({
platform: "gmail",
id: "gmail-fail-1",
snippet: "will not classify",
receivedAt: "2026-05-11T10:00:00.000Z",
}),
],
});
const { runtime, calls } = makeDbRuntime((sql) => {
if (sql.includes("SELECT source_message_id")) return [];
if (sql.includes("life_inbox_triage_examples")) return [];
return [];
});
(runtime as { useModel?: unknown }).useModel = vi.fn(async () => {
throw new Error("model unavailable");
});
(runtime as { getService?: unknown }).getService = () => null;
const result = await callInbox(runtime, makeMessage("triage my inbox"), {
subaction: "triage",
platforms: ["gmail"],
});
expect(result.success).toBe(false);
expect(result.data).toMatchObject({ error: "INBOX_OPERATION_FAILED" });
expect(
calls.filter((call) => call.sql.startsWith("INSERT")),
).toHaveLength(0);
});
it("snoozes a persisted triage entry until the provided timestamp", async () => {
const { runtime, calls } = makeDbRuntime((sql) => {
if (sql.startsWith("SELECT")) return [makeTriageRow({ id: "entry-1" })];
return [];
});
const result = await callInbox(runtime, makeMessage(), {
subaction: "snooze",
entryId: "entry-1",
until: "2026-07-02T00:00:00-04:00",
});
expect(result.success).toBe(true);
expect(result.data).toMatchObject({
subaction: "snooze",
entryId: "entry-1",
snoozedUntil: "2026-07-02T04:00:00.000Z",
});
const update = calls.find((call) => call.sql.startsWith("UPDATE"));
expect(update?.sql).toContain(
"snoozed_until = '2026-07-02T04:00:00.000Z'",
);
expect(update?.sql).toContain("resolved = FALSE");
});
it("rejects an explicit malformed snooze timestamp instead of applying the default window", async () => {
const { runtime, calls } = makeDbRuntime((sql) => {
if (sql.startsWith("SELECT")) return [makeTriageRow({ id: "entry-1" })];
return [];
});
const result = await callInbox(runtime, makeMessage(), {
subaction: "snooze",
entryId: "entry-1",
snoozedUntil: 24 * 60 * 60 * 1000,
} as unknown as InboxActionParameters);
expect(result.success).toBe(false);
expect(result.data).toMatchObject({ error: "INBOX_OPERATION_FAILED" });
expect(
calls.filter((call) => call.sql.startsWith("UPDATE")),
).toHaveLength(0);
});
});
// The marker builders themselves are pinned by inbox-choice-markers.test.ts;
// these tests drive the REAL action handler end-to-end (repository + triage
// adapter + callback) and assert the chips reach the chat reply with values
// the next turn's handler accepts.
describe("one-tap [CHOICE] chips through the handler (#14733)", () => {
/**
* Minimal real adapter registered on the default triage service so the
* draft/send rail runs for real (create + send recorded); only the
* connector transport is faked.
*/
function registerFakeGmailAdapter(): {
drafts: Array<{ body: string }>;
sent: string[];
archived: string[];
} {
const drafts: Array<{ body: string }> = [];
const sent: string[] = [];
const archived: string[] = [];
let seq = 0;
const adapter: MessageAdapter = {
source: "gmail",
isAvailable: () => true,
capabilities: () => ({
list: true,
search: false,
manage: { archive: true },
send: { reply: true },
worlds: "single",
channels: "none",
}),
listMessages: async () => [],
getMessage: async () => null,
manageMessage: async (_runtime, messageId) => {
archived.push(messageId);
return { ok: true };
},
createDraft: async (_runtime, draft) => {
drafts.push({ body: draft.body });
seq += 1;
return { draftId: `draft-${seq}`, preview: draft.body.slice(0, 40) };
},
sendDraft: async (_runtime, draftId) => {
sent.push(draftId);
return { externalId: `ext-${draftId}` };
},
};
getDefaultTriageService().register(adapter);
return { drafts, sent, archived };
}
function collectCallback(): {
texts: string[];
callback: (content: Content) => Promise<[]>;
} {
const texts: string[] = [];
return {
texts,
callback: async (content: Content) => {
if (typeof content.text === "string") texts.push(content.text);
return [];
},
};
}
it("appends send/discard chips to a drafted-reply confirmation and keeps the values the approve turn accepts", async () => {
registerFakeGmailAdapter();
const { runtime } = makeDbRuntime((sql) =>
sql.includes("WHERE id =") ? [makeTriageRow({ id: "entry-42" })] : [],
);
const { texts, callback } = collectCallback();
const result = await inboxAction.handler(
runtime,
makeMessage("reply to alice that friday works"),
undefined,
{
parameters: {
subaction: "reply",
entryId: "entry-42",
body: "Yes, Friday works.",
},
} as unknown as HandlerOptions,
callback as unknown as Parameters<typeof inboxAction.handler>[4],
);
expect(result.success).toBe(true);
expect(result.data).toMatchObject({
subaction: "reply",
requiresConfirmation: true,
entryId: "entry-42",
});
// The chat reply (callback) and the planner-facing result text agree,
// and both carry the structured block appended after the prose.
expect(texts).toHaveLength(1);
expect(result.text).toBe(texts[0]);
expect(texts[0]).toContain("Confirm before sending");
const { blocks } = parseInteractionBlocks(texts[0] ?? "");
expect(blocks).toHaveLength(1);
const block = blocks[0];
expect(block).toMatchObject({
kind: "choice",
scope: "inbox-draft-entry-42",
id: "entry-42",
});
if (block?.kind !== "choice") throw new Error("expected choice block");
// Values carry the subaction token + entry id the next turn's handler
// resolves ("inbox approve entry-42" → approve, entryId=entry-42);
// every chip maps to an op the INBOX action actually supports.
expect(block.options.map((o) => o.value)).toEqual([
"inbox approve entry-42",
"inbox archive entry-42",
]);
expect(block.options.map((o) => o.label)).toEqual(["Send", "Discard"]);
});
it("a confirmed approve dispatches the stored draft and emits NO chips", async () => {
const { sent } = registerFakeGmailAdapter();
const { runtime } = makeDbRuntime((sql) =>
sql.includes("WHERE id =")
? [makeTriageRow({ id: "entry-42", draft_response: "Yes, Friday." })]
: [],
);
const { texts, callback } = collectCallback();
const result = await inboxAction.handler(
runtime,
makeMessage("send"),
undefined,
{
parameters: { subaction: "approve", entryId: "entry-42" },
} as unknown as HandlerOptions,
callback as unknown as Parameters<typeof inboxAction.handler>[4],
);
expect(result.success).toBe(true);
expect(sent).toHaveLength(1);
expect(texts[0]).toContain("Sent reply");
expect(parseInteractionBlocks(texts[0] ?? "").blocks).toHaveLength(0);
});
it("appends per-thread reply/snooze/archive chips to the triage queue, capped at five threads, with entry ids in the values", async () => {
const rows = ["e1", "e2", "e3", "e4", "e5", "e6"].map((id, index) =>
makeTriageRow({
id,
sender_name: `Sender ${index + 1}`,
snippet: `message ${index + 1}`,
}),
);
const { runtime } = makeDbRuntime((sql) =>
sql.includes("life_inbox_triage_entries") ? rows : [],
);
const { texts, callback } = collectCallback();
const result = await inboxAction.handler(
runtime,
makeMessage("triage my inbox"),
undefined,
{
parameters: { subaction: "triage", limit: 10 },
} as unknown as HandlerOptions,
callback as unknown as Parameters<typeof inboxAction.handler>[4],
);
expect(result.success).toBe(true);
const text = texts[0] ?? "";
const { blocks } = parseInteractionBlocks(text);
expect(blocks).toHaveLength(5);
blocks.forEach((block, index) => {
const id = `e${index + 1}`;
expect(block).toMatchObject({
kind: "choice",
scope: `inbox-thread-${id}`,
id,
});
if (block.kind !== "choice") throw new Error("expected choice block");
// Values embed the entry id: a tap round-trips only the value, and
// with several blocks in one reply a bare "reply" is unattributable.
expect(block.options.map((o) => o.value)).toEqual([
`inbox reply ${id}`,
`inbox snooze ${id}`,
`inbox archive ${id}`,
]);
// The reply chip names the sender so each block reads attributably.
expect(block.options[0]?.label).toBe(`Reply to Sender ${index + 1}`);
});
expect(text).not.toContain("e6");
});
it("an archive chip value round-trips into a successful archive of that entry", async () => {
const { archived } = registerFakeGmailAdapter();
const { runtime } = makeDbRuntime((sql) =>
sql.includes("WHERE id =") ? [makeTriageRow({ id: "e2" })] : [],
);
// The tapped value is `inbox archive e2`; the planner maps the tokens
// to subaction + entryId. Drive the handler with exactly that mapping.
const value = "inbox archive e2";
const [, subaction, entryId] = value.split(" ");
const result = await callInbox(runtime, makeMessage(value), {
subaction,
entryId,
});
expect(result.success).toBe(true);
expect(archived).toEqual(["gmail-msg-1"]);
expect(result.data).toMatchObject({
subaction: "archive",
entryId: "e2",
});
});
it("a snooze chip value round-trips into a successful default snooze of that entry (#14735)", async () => {
// The Snooze chip emitted by the triage grammar carries only
// `inbox snooze <id>` (no timestamp — a tap has no free text to add and
// the value must stay under the 64-byte connector cap). Before this fix
// the snooze op threw "valid snooze timestamp is required", so the one
// shipped Snooze affordance was a guaranteed error. A bare snooze tap
// must now succeed with a sane default window instead of failing.
const before = Date.now();
const { runtime, calls } = makeDbRuntime((sql) =>
sql.startsWith("SELECT") ? [makeTriageRow({ id: "e3" })] : [],
);
// The tapped value is exactly `inbox snooze e3`; the planner maps the
// tokens to subaction + entryId with NO `until`/`snoozedUntil` param.
const value = "inbox snooze e3";
const [, subaction, entryId] = value.split(" ");
const result = await callInbox(runtime, makeMessage(value), {
subaction,
entryId,
});
expect(result.success).toBe(true);
expect(result.data).toMatchObject({ subaction: "snooze", entryId: "e3" });
const snoozedUntil = (result.data as { snoozedUntil?: string })
.snoozedUntil;
expect(typeof snoozedUntil).toBe("string");
const untilMs = Date.parse(snoozedUntil ?? "");
// Default window is ~24h out: safely in the future, and under two days.
expect(untilMs).toBeGreaterThan(before + 20 * 60 * 60 * 1000);
expect(untilMs).toBeLessThan(before + 48 * 60 * 60 * 1000);
const update = calls.find((call) => call.sql.startsWith("UPDATE"));
expect(update?.sql).toContain("snoozed_until = ");
expect(update?.sql).toContain("resolved = FALSE");
});
});
});