Files
elizaos--eliza/plugins/plugin-inbox/test/inbox-aggregate.real-runtime.test.ts
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

748 lines
24 KiB
TypeScript

/**
* Tests for the cross-channel inbox aggregation domain that moved from
* plugin-personal-assistant (`lifeops/domains/inbox-service.ts`) into
* `src/inbox/aggregate.ts` (#8652 port).
*
* The subject — builders, request resolver, LLM-score orchestration, and the
* cached read-through `InboxDomain` — is REAL, running against a REAL
* PGLite-backed AgentRuntime. The injected pieces are the domain's typed host
* seams, exercised with contract-true implementations:
* - `InboxMessageCache` — an in-memory store honoring the channel /
* maxResults / markRead contract (the production impl is PA's
* LifeOpsRepository over `life_inbox_messages`).
* - `GmailInboxSource` / `XDmInboxSource` — connector projections feeding a
* realistic Gmail triage feed.
* - `PriorityScoringSettingsLoader` — the owner policy seam.
* Only TEXT_SMALL is a deterministic handler (the LLM boundary).
*/
import {
type AgentRuntime,
ModelType,
type ModelTypeName,
} from "@elizaos/core";
import type {
GetLifeOpsGmailTriageRequest,
LifeOpsGmailMessageSummary,
LifeOpsGmailTriageFeed,
LifeOpsGoogleConnectorStatus,
LifeOpsInboxChannel,
LifeOpsInboxMessage,
LifeOpsXConnectorStatus,
LifeOpsXDm,
} from "@elizaos/shared";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
createRealTestRuntime,
type RealTestRuntimeResult,
} from "../../../packages/test/helpers/real-runtime.ts";
import {
buildInbox,
type CachedInboxMessage,
InboxDomain,
type InboxMessageCache,
normalizeInboxChannel,
type PriorityScoringSettings,
resolveInboxRequest,
toInboxMessages,
} from "../src/inbox/aggregate.ts";
import type { InboundMessage } from "../src/inbox/types.ts";
// ---------------------------------------------------------------------------
// Host-seam implementations (contract-true, in-memory)
// ---------------------------------------------------------------------------
/** In-memory implementation of the `InboxMessageCache` host seam. */
class MemoryInboxCache implements InboxMessageCache {
rows = new Map<string, CachedInboxMessage>();
listCalls = 0;
upsertCalls = 0;
seed(message: LifeOpsInboxMessage, cachedAt: string): void {
this.rows.set(message.id, { ...message, cachedAt });
}
async listCachedInboxMessages(
_agentId: string,
options?: {
channels?: readonly LifeOpsInboxChannel[];
maxResults?: number;
gmailAccountId?: string;
},
): Promise<CachedInboxMessage[]> {
this.listCalls += 1;
const channels = options?.channels ? new Set(options.channels) : null;
const out = [...this.rows.values()]
.filter((row) => !channels || channels.has(row.channel))
.filter(
(row) =>
!options?.gmailAccountId ||
row.gmailAccountId === options.gmailAccountId,
)
.sort((a, b) => b.timestamp - a.timestamp);
return typeof options?.maxResults === "number"
? out.slice(0, options.maxResults)
: out;
}
async upsertCachedInboxMessages(
_agentId: string,
messages: readonly LifeOpsInboxMessage[],
): Promise<void> {
this.upsertCalls += 1;
const now = new Date().toISOString();
for (const message of messages) {
this.rows.set(message.id, { ...message, cachedAt: now });
}
}
async markCachedInboxMessageRead(
_agentId: string,
inboxEntryId: string,
): Promise<LifeOpsInboxMessage | null> {
const row = this.rows.get(inboxEntryId);
if (!row) return null;
const updated = {
...row,
unread: false,
lastSeenAt: new Date().toISOString(),
};
this.rows.set(inboxEntryId, updated);
return updated;
}
}
function gmailSummary(
overrides: Partial<LifeOpsGmailMessageSummary> & {
id: string;
subject: string;
snippet: string;
},
): LifeOpsGmailMessageSummary {
const now = new Date().toISOString();
return {
externalId: `ext-${overrides.id}`,
agentId: "agent-aggregate-tests",
provider: "google",
side: "owner",
threadId: `thread-${overrides.id}`,
from: "Ada Lovelace",
fromEmail: "ada@example.com",
replyTo: null,
to: ["owner@example.com"],
cc: [],
receivedAt: now,
isUnread: true,
isImportant: false,
likelyReplyNeeded: false,
triageScore: 0,
triageReason: "",
labels: [],
htmlLink: null,
metadata: {},
syncedAt: now,
updatedAt: now,
...overrides,
};
}
interface FakeConnectorOptions {
/** Override the Google connector status (merged over the connected base). */
google?: Partial<LifeOpsGoogleConnectorStatus>;
/** When set, getGmailTriage rejects with this error after counting. */
gmailTriageError?: Error;
/** Override the X connector status (merged over the disconnected base). */
x?: Partial<LifeOpsXConnectorStatus>;
/** Inbound X DMs to serve when the X side is connected with dmRead. */
xDms?: LifeOpsXDm[];
}
/**
* Connector-source seam. Defaults to a connected Gmail feed + a disconnected
* X side; tests override statuses/errors to exercise degradation paths.
*/
class FakeConnectorSources {
gmailTriageCalls = 0;
xDmSyncCalls = 0;
constructor(
private readonly feedMessages: LifeOpsGmailMessageSummary[],
private readonly options: FakeConnectorOptions = {},
) {}
async getGoogleConnectorStatus(
_requestUrl: URL,
): Promise<LifeOpsGoogleConnectorStatus> {
return {
provider: "google",
side: "owner",
mode: "local",
defaultMode: "local",
availableModes: ["local"],
executionTarget: "local",
sourceOfTruth: "local_storage",
configured: true,
connected: true,
reason: "connected" as LifeOpsGoogleConnectorStatus["reason"],
preferredByAgent: false,
cloudConnectionId: null,
identity: null,
grantedCapabilities: ["google.gmail.triage"],
grantedScopes: [],
expiresAt: null,
hasRefreshToken: true,
grant: null,
...this.options.google,
};
}
async getGmailTriage(
_requestUrl: URL,
_request?: GetLifeOpsGmailTriageRequest,
): Promise<LifeOpsGmailTriageFeed> {
this.gmailTriageCalls += 1;
if (this.options.gmailTriageError) {
throw this.options.gmailTriageError;
}
return {
messages: this.feedMessages,
source: "synced",
syncedAt: new Date().toISOString(),
summary: {
unreadCount: this.feedMessages.length,
importantNewCount: 0,
likelyReplyNeededCount: 0,
},
};
}
async getXConnectorStatus(): Promise<LifeOpsXConnectorStatus> {
return {
provider: "x",
mode: "local",
connected: false,
grantedCapabilities: [],
grantedScopes: [],
identity: null,
hasCredentials: false,
feedRead: false,
feedWrite: false,
dmRead: false,
dmWrite: false,
dmInbound: false,
grant: null,
...this.options.x,
};
}
async syncXDms(): Promise<{ synced: number }> {
this.xDmSyncCalls += 1;
return { synced: this.options.xDms?.length ?? 0 };
}
async getXDms(): Promise<LifeOpsXDm[]> {
return this.options.xDms ?? [];
}
}
function xDm(
overrides: Partial<LifeOpsXDm> & { id: string; text: string },
): LifeOpsXDm {
const now = new Date().toISOString();
return {
agentId: "agent-aggregate-tests",
externalDmId: `ext-${overrides.id}`,
conversationId: `conv-${overrides.id}`,
senderHandle: "adalovelace",
senderId: `x-user-${overrides.id}`,
isInbound: true,
receivedAt: now,
readAt: null,
repliedAt: null,
metadata: {},
syncedAt: now,
updatedAt: now,
...overrides,
};
}
function inboundChat(
overrides: Partial<InboundMessage> & { id: string; text: string },
): InboundMessage {
return {
source: "discord",
roomId: `room-${overrides.id}`,
entityId: `sender-${overrides.id}`,
senderName: `Sender ${overrides.id}`,
channelName: `channel-${overrides.id}`,
channelType: "dm",
snippet: overrides.text.slice(0, 80),
timestamp: Date.now(),
chatType: "dm",
...overrides,
};
}
// ---------------------------------------------------------------------------
// Pure builders (moved verbatim from PA)
// ---------------------------------------------------------------------------
describe("aggregate builders", () => {
it("normalizeInboxChannel accepts known channels case-insensitively and rejects the rest", () => {
expect(normalizeInboxChannel("gmail")).toBe("gmail");
expect(normalizeInboxChannel(" Discord ")).toBe("discord");
expect(normalizeInboxChannel("x_dm")).toBe("x_dm");
expect(normalizeInboxChannel("sms")).toBe("sms");
expect(normalizeInboxChannel("slack")).toBeNull();
expect(normalizeInboxChannel("carrier-pigeon")).toBeNull();
expect(normalizeInboxChannel("")).toBeNull();
expect(normalizeInboxChannel(null)).toBeNull();
expect(normalizeInboxChannel(undefined)).toBeNull();
});
it("resolveInboxRequest clamps limits, filters bogus channels, and defaults cache mode", () => {
const defaults = resolveInboxRequest({});
expect(defaults.limit).toBeGreaterThan(0);
expect(defaults.cacheMode).toBe("read-through");
expect(defaults.allowed.size).toBeGreaterThan(1);
const resolved = resolveInboxRequest({
limit: 100000,
channels: ["gmail", "not-a-channel" as LifeOpsInboxChannel],
cacheMode: "refresh",
});
expect(resolved.limit).toBe(500);
expect([...resolved.allowed]).toEqual(["gmail"]);
expect(resolved.cacheMode).toBe("refresh");
const bogusMode = resolveInboxRequest({
cacheMode: "banana" as "refresh",
});
expect(bogusMode.cacheMode).toBe("read-through");
});
it("buildInbox groups threads, honors the channel allow-list, and counts channels", () => {
const now = Date.now();
const inbox = buildInbox(
[
inboundChat({
id: "a1",
text: "first in thread",
threadId: "thr-1",
timestamp: now - 2000,
}),
inboundChat({
id: "a2",
text: "second in thread",
threadId: "thr-1",
timestamp: now - 1000,
}),
inboundChat({ id: "b1", text: "telegram msg", source: "telegram" }),
inboundChat({ id: "c1", text: "signal msg", source: "signal" }),
],
{
limit: 10,
allowed: new Set<LifeOpsInboxChannel>(["discord", "telegram"]),
sources: [{ source: "chat", state: "ok", degradations: [] }],
groupByThread: true,
ownerName: null,
},
);
// The source-health surface is carried through verbatim.
expect(inbox.sources).toEqual([
{ source: "chat", state: "ok", degradations: [] },
]);
// Signal was filtered by the allow-list.
expect(inbox.channelCounts.signal.total).toBe(0);
expect(inbox.channelCounts.discord.total).toBe(2);
expect(inbox.channelCounts.telegram.total).toBe(1);
const discordGroup = inbox.threadGroups?.find(
(group) => group.threadId === "thr-1",
);
expect(discordGroup).toBeDefined();
expect(discordGroup?.messages).toHaveLength(2);
// Latest message wins the group headline.
expect(discordGroup?.latestMessage.snippet).toBe("second in thread");
});
it("does not fabricate small-group priority scores when LLM scores are missing", () => {
const now = Date.now();
const inbox = buildInbox(
[
inboundChat({
id: "group-1",
text: "Ada, can you review this tomorrow at 3pm?",
threadId: "group-thread",
timestamp: now - 1000,
chatType: "group",
participantCount: 4,
}),
],
{
limit: 10,
allowed: new Set<LifeOpsInboxChannel>(["discord"]),
sources: [{ source: "chat", state: "ok", degradations: [] }],
groupByThread: true,
ownerName: "Ada",
sortByPriority: true,
},
);
const group = inbox.threadGroups?.[0];
expect(group?.maxPriorityScore).toBeUndefined();
expect(group?.priorityCategory).toBeUndefined();
expect(group?.latestMessage.priorityScore).toBeUndefined();
expect(inbox.messages[0]?.priorityScore).toBeUndefined();
});
it("missedOnly requires a real priority score instead of keyword fallback", () => {
const old = Date.now() - 25 * 60 * 60 * 1000;
const inbox = buildInbox(
[
inboundChat({
id: "missed-group-1",
text: "Ada, are you free tomorrow at 3pm?",
threadId: "missed-group-thread",
timestamp: old,
chatType: "group",
participantCount: 5,
}),
],
{
limit: 10,
allowed: new Set<LifeOpsInboxChannel>(["discord"]),
sources: [{ source: "chat", state: "ok", degradations: [] }],
groupByThread: true,
ownerName: "Ada",
missedOnly: true,
sortByPriority: true,
},
);
expect(inbox.messages).toEqual([]);
expect(inbox.threadGroups).toEqual([]);
});
});
// ---------------------------------------------------------------------------
// InboxDomain — cached read-through spine on a real runtime
// ---------------------------------------------------------------------------
describe("InboxDomain on a real runtime", () => {
let runtime: AgentRuntime;
let testResult: RealTestRuntimeResult;
const scoringCalls: string[] = [];
beforeAll(async () => {
testResult = await createRealTestRuntime({
characterName: "inbox-aggregate-tests",
});
runtime = testResult.runtime;
// Deterministic priority scorer (the LLM boundary): scores every listed
// message; "invoice" snippets rank important/90, the rest casual/10.
runtime.registerModel(
ModelType.TEXT_SMALL as ModelTypeName,
async (_rt, params) => {
const prompt = String((params as { prompt?: string }).prompt);
scoringCalls.push(prompt);
const count = (prompt.match(/^messages\[\d+\]:/gm) ?? []).length;
const snippets = prompt
.split("\n")
.filter((line) => line.trim().startsWith("snippet:"));
const scores = Array.from({ length: count }, (_unused, i) => {
const snippet = snippets[i]?.toLowerCase() ?? "";
return snippet.includes("invoice")
? { score: 90, category: "important", flags: ["deadline"] }
: { score: 10, category: "casual", flags: [] };
});
return JSON.stringify({ scores });
},
"inbox-aggregate-tests",
100,
);
}, 120_000);
afterAll(async () => {
await testResult?.cleanup();
});
function makeDomain(opts: {
cache: MemoryInboxCache;
sources: FakeConnectorSources;
settings?: PriorityScoringSettings;
}): InboxDomain {
return new InboxDomain({
runtime,
cache: opts.cache,
sources: opts.sources,
...(opts.settings
? { loadPriorityScoringSettings: async () => opts.settings }
: {}),
});
}
it("cache-only mode serves from the cache seam without touching connector sources", async () => {
const cache = new MemoryInboxCache();
const sources = new FakeConnectorSources([]);
const domain = makeDomain({ cache, sources });
const seeded = toInboxMessages([
inboundChat({ id: "cached-1", text: "hello from cache" }),
]);
const first = seeded[0];
expect(first).toBeDefined();
if (!first) throw new Error("unreachable");
cache.seed(first, new Date().toISOString());
const inbox = await domain.getInbox({
channels: ["discord"],
cacheMode: "cache-only",
});
expect(inbox.messages.map((message) => message.id)).toEqual([first.id]);
expect(sources.gmailTriageCalls).toBe(0);
expect(cache.upsertCalls).toBe(0);
});
it("read-through returns fresh cache without refetch, then refresh mode forces the source fetch", async () => {
const cache = new MemoryInboxCache();
const sources = new FakeConnectorSources([
gmailSummary({
id: "gm-1",
subject: "Invoice due Friday",
snippet: "Your invoice is due Friday",
}),
]);
const domain = makeDomain({ cache, sources });
const seeded = toInboxMessages([
inboundChat({ id: "warm-1", text: "warm cache row" }),
]);
const warm = seeded[0];
expect(warm).toBeDefined();
if (!warm) throw new Error("unreachable");
cache.seed(warm, new Date().toISOString());
// Fresh cache -> no connector fetch; chat health still reported.
const cachedRead = await domain.getInbox({ channels: ["discord"] });
expect(cachedRead.messages.map((message) => message.id)).toEqual([warm.id]);
expect(sources.gmailTriageCalls).toBe(0);
expect(cachedRead.sources).toEqual([
{ source: "chat", state: "ok", degradations: [] },
]);
// refresh -> hits the Gmail source, upserts pre- and post-LLM, and the
// returned inbox carries the LLM priority score.
const refreshed = await domain.getInbox({
channels: ["gmail"],
cacheMode: "refresh",
});
expect(sources.gmailTriageCalls).toBe(1);
expect(cache.upsertCalls).toBe(2);
// Happy path: the pulled source reports healthy.
expect(refreshed.sources).toEqual([
{ source: "gmail", state: "ok", degradations: [] },
]);
expect(refreshed.messages).toHaveLength(1);
const scored = refreshed.messages[0];
expect(scored?.channel).toBe("gmail");
expect(scored?.priorityScore).toBe(90);
expect(scored?.priorityCategory).toBe("important");
// The scored message landed back in the cache seam.
const recached = await cache.listCachedInboxMessages(runtime.agentId, {
channels: ["gmail"],
});
expect(recached.some((row) => row.priorityScore === 90)).toBe(true);
});
it("owner policy seam: scoring disabled means the model is never consulted", async () => {
const before = scoringCalls.length;
const cache = new MemoryInboxCache();
const sources = new FakeConnectorSources([
gmailSummary({
id: "gm-2",
subject: "Weekly digest",
snippet: "Casual weekly digest",
}),
]);
const domain = makeDomain({
cache,
sources,
settings: { enabled: false, model: null },
});
const inbox = await domain.getInbox({
channels: ["gmail"],
cacheMode: "refresh",
});
expect(scoringCalls.length).toBe(before);
expect(inbox.messages).toHaveLength(1);
expect(inbox.messages[0]?.priorityScore ?? null).toBeNull();
});
it("a degraded source with zero messages returns an explicit degraded status, not a healthy empty inbox", async () => {
const cache = new MemoryInboxCache();
const sources = new FakeConnectorSources([], {
google: { connected: false, reason: "needs_reauth" },
});
const domain = makeDomain({ cache, sources });
const inbox = await domain.getInbox({
channels: ["gmail"],
cacheMode: "refresh",
});
expect(inbox.messages).toEqual([]);
expect(inbox.sources).toHaveLength(1);
const gmail = inbox.sources[0];
expect(gmail?.source).toBe("gmail");
expect(gmail?.state).toBe("degraded");
expect(gmail?.degradations.map((entry) => entry.code)).toContain(
"gmail_needs_reauth",
);
expect(gmail?.degradations[0]?.axis).toBe("auth-expired");
// Degraded means no pull was attempted against the dead grant.
expect(sources.gmailTriageCalls).toBe(0);
});
it("a partial failure returns the healthy channels' messages plus a per-source warning", async () => {
const cache = new MemoryInboxCache();
const sources = new FakeConnectorSources([], {
gmailTriageError: new Error("gmail 401: invalid_grant"),
x: {
connected: true,
hasCredentials: true,
dmRead: true,
dmInbound: true,
grantedCapabilities: ["x.dm.read"],
},
xDms: [xDm({ id: "dm-1", text: "hey — got a minute?" })],
});
const domain = makeDomain({ cache, sources });
const inbox = await domain.getInbox({
channels: ["gmail", "x_dm"],
cacheMode: "refresh",
});
// X DMs still flow even though the Gmail pull blew up.
expect(inbox.messages).toHaveLength(1);
expect(inbox.messages[0]?.channel).toBe("x_dm");
const bySource = new Map(
inbox.sources.map((status) => [status.source, status]),
);
expect(bySource.get("x_dm")?.state).toBe("ok");
const gmail = bySource.get("gmail");
expect(gmail?.state).toBe("degraded");
expect(gmail?.degradations[0]?.axis).toBe("transport-offline");
// The real error is preserved, not swallowed.
expect(gmail?.degradations[0]?.message).toContain("invalid_grant");
expect(sources.gmailTriageCalls).toBe(1);
});
it("all sources degraded returns empty messages with every source explicitly degraded", async () => {
const cache = new MemoryInboxCache();
const sources = new FakeConnectorSources([], {
google: { connected: false, reason: "needs_reauth" },
x: { connected: false, reason: "needs_reauth" },
});
const domain = makeDomain({ cache, sources });
const inbox = await domain.getInbox({
channels: ["gmail", "x_dm"],
cacheMode: "refresh",
});
expect(inbox.messages).toEqual([]);
expect(inbox.sources).toHaveLength(2);
for (const status of inbox.sources) {
expect(status.state).toBe("degraded");
expect(status.degradations.length).toBeGreaterThan(0);
}
});
it("gmail connected without the triage capability reports a missing-scope degradation", async () => {
const cache = new MemoryInboxCache();
const sources = new FakeConnectorSources([], {
google: { grantedCapabilities: [] },
});
const domain = makeDomain({ cache, sources });
const inbox = await domain.getInbox({
channels: ["gmail"],
cacheMode: "refresh",
});
expect(inbox.messages).toEqual([]);
expect(inbox.sources[0]?.state).toBe("degraded");
expect(inbox.sources[0]?.degradations[0]?.axis).toBe("missing-scope");
expect(sources.gmailTriageCalls).toBe(0);
});
it("a fresh-cache read still reports connector degradation via the status probe", async () => {
const cache = new MemoryInboxCache();
const sources = new FakeConnectorSources([], {
google: { connected: false, reason: "needs_reauth" },
});
const domain = makeDomain({ cache, sources });
const seeded = toInboxMessages([
inboundChat({ id: "cached-degraded-1", text: "warm row" }),
]);
const warm = seeded[0];
expect(warm).toBeDefined();
if (!warm) throw new Error("unreachable");
cache.seed(warm, new Date().toISOString());
// read-through with a fresh cache: no message pull, but the response
// still says Gmail is degraded — the whole point of the health surface.
const inbox = await domain.getInbox({ channels: ["discord", "gmail"] });
expect(inbox.messages.map((message) => message.id)).toEqual([warm.id]);
expect(sources.gmailTriageCalls).toBe(0);
const bySource = new Map(
inbox.sources.map((status) => [status.source, status]),
);
expect(bySource.get("chat")?.state).toBe("ok");
expect(bySource.get("gmail")?.state).toBe("degraded");
expect(
bySource.get("gmail")?.degradations.map((entry) => entry.code),
).toContain("gmail_needs_reauth");
});
it("cache-only mode probes connector health without pulling messages", async () => {
const cache = new MemoryInboxCache();
const sources = new FakeConnectorSources([], {
google: { connected: false, reason: "token_missing" },
});
const domain = makeDomain({ cache, sources });
const inbox = await domain.getInbox({
channels: ["gmail"],
cacheMode: "cache-only",
});
expect(inbox.messages).toEqual([]);
expect(sources.gmailTriageCalls).toBe(0);
expect(inbox.sources[0]?.source).toBe("gmail");
expect(inbox.sources[0]?.state).toBe("degraded");
expect(inbox.sources[0]?.degradations[0]?.axis).toBe("auth-expired");
});
it("markInboxEntryRead round-trips through the cache seam and returns null on miss", async () => {
const cache = new MemoryInboxCache();
const sources = new FakeConnectorSources([]);
const domain = makeDomain({ cache, sources });
const seeded = toInboxMessages([
inboundChat({ id: "read-1", text: "unread row" }),
]);
const row = seeded[0];
expect(row).toBeDefined();
if (!row) throw new Error("unreachable");
cache.seed({ ...row, unread: true }, new Date().toISOString());
const marked = await domain.markInboxEntryRead(row.id);
expect(marked?.unread).toBe(false);
expect(marked?.lastSeenAt).toBeTruthy();
// Miss -> null; the HOST owns the transport mapping (PA raises 404).
const missing = await domain.markInboxEntryRead("no-such-entry");
expect(missing).toBeNull();
});
});