// Imessage tests cover monitor reply cache plugin behavior. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { loadFreshIMessageReplyCacheForTest } from "./test-support/runtime.js"; type ReplyCacheModule = typeof import("./monitor-reply-cache.js"); let findLatestIMessageEntryForChat: ReplyCacheModule["findLatestIMessageEntryForChat"]; let isIMessageCurrentMessageInChat: ReplyCacheModule["isIMessageCurrentMessageInChat"]; let isKnownFromMeIMessageMessageId: ReplyCacheModule["isKnownFromMeIMessageMessageId"]; let rememberIMessageReplyCache: ReplyCacheModule["rememberIMessageReplyCache"]; let resolveIMessageCachedResourceBinding: ReplyCacheModule["resolveIMessageCachedResourceBinding"]; let resolveIMessageMessageId: ReplyCacheModule["resolveIMessageMessageId"]; async function loadReplyCache(options?: { preservePersistentState?: boolean }): Promise { ({ findLatestIMessageEntryForChat, isIMessageCurrentMessageInChat, isKnownFromMeIMessageMessageId, rememberIMessageReplyCache, resolveIMessageCachedResourceBinding, resolveIMessageMessageId, } = await loadFreshIMessageReplyCacheForTest(options)); } beforeEach(async () => { await loadReplyCache(); }); afterEach(() => { vi.useRealTimers(); }); describe("imessage short message id resolution", () => { it("resolves a short id to a cached message guid", () => { const entry = rememberIMessageReplyCache({ accountId: "default", messageId: "full-guid", chatGuid: "iMessage;+;chat0000", timestamp: Date.now(), }); expect(entry.shortId).toBe("1"); expect( resolveIMessageMessageId("1", { requireKnownShortId: true, chatContext: { chatGuid: "iMessage;+;chat0000" }, }), ).toBe("full-guid"); }); it("resolves a known short id even without caller-supplied chat scope", () => { rememberIMessageReplyCache({ accountId: "default", messageId: "full-guid", chatGuid: "iMessage;+;chat0000", timestamp: Date.now(), }); // The cached entry already carries chat info; cross-chat checks only // matter when the caller separately provides a (potentially conflicting) // chat scope. A plain known short id from the cache must resolve. expect(resolveIMessageMessageId("1", { requireKnownShortId: true })).toBe("full-guid"); }); it("requires chat scope when a privileged short id is unknown", () => { expect(() => resolveIMessageMessageId("9999", { requireKnownShortId: true })).toThrow( "requires a chat scope", ); }); it("rejects short ids from another chat", () => { rememberIMessageReplyCache({ accountId: "default", messageId: "full-guid", chatGuid: "iMessage;+;chat0000", timestamp: Date.now(), }); expect(() => resolveIMessageMessageId("1", { requireKnownShortId: true, chatContext: { chatGuid: "iMessage;+;other" }, }), ).toThrow("MessageSidFull from another chat is rejected"); }); it("recommends the full id when a short id has expired in the current chat", () => { expect(() => resolveIMessageMessageId("9999", { requireKnownShortId: true, chatContext: { chatGuid: "iMessage;+;chat0000" }, }), ).toThrow("is no longer available. Use MessageSidFull"); }); it("guards full guid reuse across chats when cached", () => { rememberIMessageReplyCache({ accountId: "default", messageId: "full-guid", chatId: 42, timestamp: Date.now(), }); expect(() => resolveIMessageMessageId("full-guid", { chatContext: { chatId: 99 } })).toThrow( "belongs to a different chat", ); }); it("recognizes only cached outbound message ids as own messages", () => { rememberIMessageReplyCache({ accountId: "default", messageId: "outbound-guid", chatGuid: "any;-;+12069106512", chatIdentifier: "+12069106512", chatId: 3, timestamp: Date.now(), isFromMe: true, }); rememberIMessageReplyCache({ accountId: "default", messageId: "inbound-guid", chatGuid: "any;-;+12069106512", chatIdentifier: "+12069106512", chatId: 3, timestamp: Date.now(), isFromMe: false, }); expect( isKnownFromMeIMessageMessageId("outbound-guid", { accountId: "default", chatGuid: "any;-;+12069106512", chatIdentifier: "+12069106512", chatId: 3, }), ).toBe(true); expect( isKnownFromMeIMessageMessageId("inbound-guid", { accountId: "default", chatGuid: "any;-;+12069106512", chatIdentifier: "+12069106512", chatId: 3, }), ).toBe(false); expect( isKnownFromMeIMessageMessageId("outbound-guid", { accountId: "default", chatGuid: "any;-;+12069106514", chatIdentifier: "+12069106514", chatId: 4, }), ).toBe(false); }); }); describe("requireFromMe (edit / unsend authorization)", () => { it("rejects a short id resolution when the cached entry came from inbound", () => { // The default inbound recorder sets isFromMe:false (or omits it), so // resolving with requireFromMe must reject — agents cannot edit/unsend // messages that other participants sent. const entry = rememberIMessageReplyCache({ accountId: "default", messageId: "inbound-guid", chatGuid: "iMessage;+;chatA", timestamp: Date.now(), isFromMe: false, }); expect(() => resolveIMessageMessageId(entry.shortId, { requireKnownShortId: true, chatContext: { chatGuid: "iMessage;+;chatA" }, requireFromMe: true, }), ).toThrow("not one this agent sent"); }); it("allows a short id resolution when the cached entry was sent by the gateway", () => { const entry = rememberIMessageReplyCache({ accountId: "default", messageId: "outbound-guid", chatGuid: "iMessage;+;chatA", timestamp: Date.now(), isFromMe: true, }); expect( resolveIMessageMessageId(entry.shortId, { requireKnownShortId: true, chatContext: { chatGuid: "iMessage;+;chatA" }, requireFromMe: true, }), ).toBe("outbound-guid"); }); it("rejects an uncached full guid under requireFromMe (agent cannot edit/unsend unknown messages)", () => { expect(() => resolveIMessageMessageId("never-seen-guid", { chatContext: { chatGuid: "iMessage;+;chatA" }, requireFromMe: true, }), ).toThrow("not one this agent sent"); }); it("rejects when the cached entry has no isFromMe field (older persisted entry, treated as not-from-me)", () => { // Persisted entries written before this option existed do not carry // isFromMe. Treat undefined as the safe default (false) — that pre- // existing-on-disk caller is the inbound recorder, the only writer that // existed before. rememberIMessageReplyCache({ accountId: "default", messageId: "legacy-guid", chatGuid: "iMessage;+;chatA", timestamp: Date.now(), // isFromMe deliberately omitted }); expect(() => resolveIMessageMessageId("legacy-guid", { chatContext: { chatGuid: "iMessage;+;chatA" }, requireFromMe: true, }), ).toThrow("not one this agent sent"); }); }); describe("findLatestIMessageEntryForChat", () => { it("returns the latest entry for the matching chat scope", () => { rememberIMessageReplyCache({ accountId: "default", messageId: "older", chatGuid: "any;-;+12069106512", chatIdentifier: "+12069106512", timestamp: Date.now() - 1000, }); rememberIMessageReplyCache({ accountId: "default", messageId: "newest", chatGuid: "any;-;+12069106512", chatIdentifier: "+12069106512", timestamp: Date.now(), }); const result = findLatestIMessageEntryForChat({ accountId: "default", chatIdentifier: "iMessage;-;+12069106512", }); expect(result?.messageId).toBe("newest"); }); it("requires a positive identifier match — no overlap means no fallback", () => { // Cache entry has only chatGuid; caller has only chatId. With the old // isCrossChatMismatch-as-filter, this entry would have been returned // (no overlap → no mismatch → pass). The strict positive-match // semantics require both sides to share at least one identifier kind. rememberIMessageReplyCache({ accountId: "default", messageId: "different-chat", chatGuid: "iMessage;+;chat0000", timestamp: Date.now(), }); expect(findLatestIMessageEntryForChat({ accountId: "default", chatId: 99 })).toBeUndefined(); }); it("never crosses account boundaries", () => { rememberIMessageReplyCache({ accountId: "other-account", messageId: "foreign-account", chatIdentifier: "+12069106512", timestamp: Date.now(), }); expect( findLatestIMessageEntryForChat({ accountId: "default", chatIdentifier: "+12069106512", }), ).toBeUndefined(); }); it("ignores entries older than the recency window", () => { const TWELVE_MINUTES_AGO = Date.now() - 12 * 60 * 1000; rememberIMessageReplyCache({ accountId: "default", messageId: "stale", chatIdentifier: "+12069106512", timestamp: TWELVE_MINUTES_AGO, }); expect( findLatestIMessageEntryForChat({ accountId: "default", chatIdentifier: "+12069106512", }), ).toBeUndefined(); }); it("matches across chat-id-format flavors (iMessage;-;, any;-;, bare phone)", () => { rememberIMessageReplyCache({ accountId: "default", messageId: "phone-msg", chatGuid: "any;-;+12069106512", chatIdentifier: "+12069106512", timestamp: Date.now(), }); for (const ctx of [ { accountId: "default", chatIdentifier: "iMessage;-;+12069106512" }, { accountId: "default", chatIdentifier: "SMS;-;+12069106512" }, { accountId: "default", chatGuid: "any;-;+12069106512" }, { accountId: "default", chatIdentifier: "+12069106512" }, ]) { const found = findLatestIMessageEntryForChat(ctx); expect(found?.messageId).toBe("phone-msg"); } }); it("requires accountId — refuses to guess across all known chats", () => { rememberIMessageReplyCache({ accountId: "default", messageId: "anywhere", chatIdentifier: "+12069106512", timestamp: Date.now(), }); // accountId is optional in the signature; calling without it exercises the // runtime guard that returns undefined rather than a cross-account match. expect(findLatestIMessageEntryForChat({ chatIdentifier: "+12069106512" })).toBeUndefined(); }); }); describe("hydrate-on-resolve (post-restart short-id persistence)", () => { it("hydrates SQLite state before resolving a short id whose mapping predates this run", async () => { // Issue-then-restart contract: a shortId we issued before a gateway // restart must still resolve afterwards. The first resolve call after // process boot would otherwise miss the persisted mapping because the // in-memory maps haven't been hydrated yet — that's the bug codex // review flagged. resolveIMessageMessageId now hydrates on entry. const issued = rememberIMessageReplyCache({ accountId: "default", messageId: "outbound-guid-pre-restart", chatGuid: "iMessage;+;chatA", timestamp: Date.now(), isFromMe: true, }); expect(issued.shortId).not.toBe(""); // Simulate a restart: clear only the process-local maps and leave the // SQLite plugin-state rows intact. await loadReplyCache({ preservePersistentState: true }); // Now resolve the short id we issued before the "restart". Without the // hydrate-on-resolve fix this throws "no longer available" because the // in-memory maps are empty and rememberIMessageReplyCache hasn't been // called yet to trigger hydration. expect( resolveIMessageMessageId(issued.shortId, { requireKnownShortId: true, chatContext: { chatGuid: "iMessage;+;chatA" }, }), ).toBe("outbound-guid-pre-restart"); }); it("persists entries when optional chat fields are explicitly undefined", async () => { const issued = rememberIMessageReplyCache({ accountId: "default", messageId: "guid-with-undefined-optionals", chatGuid: undefined, chatIdentifier: undefined, chatId: undefined, timestamp: Date.now(), }); await loadReplyCache({ preservePersistentState: true }); expect( resolveIMessageMessageId(issued.shortId, { requireKnownShortId: true, chatContext: { chatIdentifier: "+15551234567" }, }), ).toBe("guid-with-undefined-optionals"); }); it("does not reuse short ids after cached rows expire", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-05-08T00:00:00Z")); const first = rememberIMessageReplyCache({ accountId: "default", messageId: "old-guid", timestamp: Date.now(), }); expect(first.shortId).toBe("1"); vi.setSystemTime(new Date("2026-05-08T07:00:00Z")); await loadReplyCache({ preservePersistentState: true }); const second = rememberIMessageReplyCache({ accountId: "default", messageId: "new-guid", timestamp: Date.now(), }); expect(second.shortId).toBe("2"); }); }); describe("current-message chat binding", () => { it("preserves concrete service identity while allowing trusted any aliases", () => { rememberIMessageReplyCache({ accountId: "work", messageId: "sms-guid", chatGuid: "SMS;-;+12069106512", chatIdentifier: "+12069106512", timestamp: Date.now(), }); rememberIMessageReplyCache({ accountId: "work", messageId: "any-guid", chatGuid: "any;-;+12069106512", chatIdentifier: "+12069106512", timestamp: Date.now(), }); expect( resolveIMessageCachedResourceBinding("sms-guid", { accountId: "work", chatIdentifier: "iMessage;-;+12069106512", }), ).toBe("mismatch"); expect( resolveIMessageCachedResourceBinding("any-guid", { accountId: "work", chatIdentifier: "iMessage;-;+12069106512", }), ).toBe("match"); }); it("treats expired entries as unknown before account or chat mismatches", () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-05-08T00:00:00Z")); rememberIMessageReplyCache({ accountId: "work", messageId: "expired-guid", chatId: 42, timestamp: Date.now(), }); vi.setSystemTime(new Date("2026-05-08T07:00:00Z")); expect( resolveIMessageCachedResourceBinding("expired-guid", { accountId: "other", chatId: 99, }), ).toBe("unknown"); }); it.each([{ chatGuid: "any;-;+12069106512" }, { chatIdentifier: "+12069106512" }, { chatId: 42 }])( "matches a trusted current message through $chatGuid$chatIdentifier$chatId", (chatContext) => { const entry = rememberIMessageReplyCache({ accountId: "work", messageId: "current-guid", chatGuid: "any;-;+12069106512", chatIdentifier: "+12069106512", chatId: 42, timestamp: Date.now(), }); expect( isIMessageCurrentMessageInChat({ accountId: "work", currentMessageId: entry.shortId, chatContext, }), ).toBe(true); expect( isIMessageCurrentMessageInChat({ accountId: "work", currentMessageId: "current-guid", chatContext, }), ).toBe(true); }, ); it("fails closed for wrong accounts, chats, and unknown current messages", () => { rememberIMessageReplyCache({ accountId: "work", messageId: "current-guid", chatGuid: "any;-;+12069106512", chatIdentifier: "+12069106512", chatId: 42, timestamp: Date.now(), }); expect( isIMessageCurrentMessageInChat({ accountId: "other", currentMessageId: "current-guid", chatContext: { chatId: 42 }, }), ).toBe(false); expect( isIMessageCurrentMessageInChat({ accountId: "work", currentMessageId: "current-guid", chatContext: { chatId: 99 }, }), ).toBe(false); expect( isIMessageCurrentMessageInChat({ accountId: "work", currentMessageId: "unknown-guid", chatContext: { chatId: 42 }, }), ).toBe(false); }); }); describe("hydrate counter advancement (rowid-collision protection)", () => { it("advances the short-id counter past a corrupt persisted line so new allocations don't collide", () => { // Direct hydrate isn't easy to invoke without disk fixtures; instead // verify the public contract: after rememberIMessageReplyCache fires, // the next allocation never re-uses an existing live shortId. const a = rememberIMessageReplyCache({ accountId: "default", messageId: "msg-a", chatIdentifier: "+12069106512", timestamp: Date.now(), }); const b = rememberIMessageReplyCache({ accountId: "default", messageId: "msg-b", chatIdentifier: "+12069106512", timestamp: Date.now(), }); expect(a.shortId).not.toBe(b.shortId); expect(Number.parseInt(b.shortId, 10)).toBeGreaterThan(Number.parseInt(a.shortId, 10)); }); });