mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 05:01:15 +00:00
658 lines
22 KiB
TypeScript
658 lines
22 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import type { OpenClawConfig } from "../../../../src/config/config.js";
|
|
import { createSentMessageCache } from "./echo-cache.js";
|
|
import { resolveIMessageInboundDecision } from "./inbound-processing.js";
|
|
import { createSelfChatCache } from "./self-chat-cache.js";
|
|
|
|
/**
|
|
* Self-chat dedupe regression tests for #47830.
|
|
*
|
|
* PR #38440 introduced a SentMessageCache to suppress echo messages when the
|
|
* agent replies in iMessage. In self-chat (user messaging themselves), the
|
|
* sender == target so the echo scope collides, causing legitimate user
|
|
* messages to be silently dropped when text happens to match recent agent
|
|
* output.
|
|
*
|
|
* These tests verify:
|
|
* 1. User messages in self-chat are NOT dropped (even if text matches agent output)
|
|
* 2. Genuine agent echo reflections ARE still dropped
|
|
* 3. Different-text messages pass through unaffected
|
|
* 4. Chunked replies don't cause false drops of user messages matching a chunk
|
|
*/
|
|
|
|
type InboundDecisionParams = Parameters<typeof resolveIMessageInboundDecision>[0];
|
|
|
|
const cfg = {} as OpenClawConfig;
|
|
|
|
function createParams(
|
|
overrides: Omit<Partial<InboundDecisionParams>, "message"> & {
|
|
message?: Partial<InboundDecisionParams["message"]>;
|
|
} = {},
|
|
): InboundDecisionParams {
|
|
const { message: msgOverrides, ...restOverrides } = overrides;
|
|
const message = {
|
|
id: 100,
|
|
sender: "+15551234567",
|
|
text: "Hello",
|
|
is_from_me: false,
|
|
is_group: false,
|
|
...msgOverrides,
|
|
};
|
|
const messageText = restOverrides.messageText ?? message.text ?? "";
|
|
const bodyText = restOverrides.bodyText ?? messageText;
|
|
return {
|
|
cfg,
|
|
accountId: "default",
|
|
opts: undefined,
|
|
allowFrom: [],
|
|
groupAllowFrom: [],
|
|
groupPolicy: "open",
|
|
dmPolicy: "open",
|
|
storeAllowFrom: [],
|
|
historyLimit: 0,
|
|
groupHistories: new Map(),
|
|
echoCache: undefined,
|
|
selfChatCache: undefined,
|
|
logVerbose: undefined,
|
|
...restOverrides,
|
|
message,
|
|
messageText,
|
|
bodyText,
|
|
};
|
|
}
|
|
|
|
describe("echo cache — message ID type canary (#47830)", () => {
|
|
// Tests the implicit contract that outbound GUIDs (e.g. "p:0/abc-def-123")
|
|
// never match inbound SQLite row IDs (e.g. "200"). If iMessage ever changes
|
|
// ID schemes, this test should break loudly.
|
|
it("outbound GUID format and inbound SQLite row ID format never collide", () => {
|
|
const echoCache = createSentMessageCache();
|
|
const scope = "default:imessage:+15555550123";
|
|
|
|
// Outbound messageId is a GUID format string
|
|
echoCache.remember(scope, { text: "test", messageId: "p:0/abc-def-123" });
|
|
|
|
// An inbound SQLite row ID (numeric string) should NOT match the GUID
|
|
expect(echoCache.has(scope, { text: "different", messageId: "200" })).toBe(false);
|
|
|
|
// The original GUID should still match
|
|
expect(echoCache.has(scope, { text: "different", messageId: "p:0/abc-def-123" })).toBe(true);
|
|
});
|
|
|
|
it('falls back to text when outbound messageId was junk ("ok")', () => {
|
|
const echoCache = createSentMessageCache();
|
|
const scope = "default:imessage:+15555550123";
|
|
|
|
// "ok" is normalized out and should not populate the ID cache.
|
|
echoCache.remember(scope, { text: "text-only fallback", messageId: "ok" });
|
|
|
|
// Inbound has a numeric SQLite ID that does not exist in cache. Since this
|
|
// scope has no real cached IDs, has() must still fall through to text match.
|
|
expect(echoCache.has(scope, { text: "text-only fallback", messageId: "200" })).toBe(true);
|
|
});
|
|
|
|
it("keeps ID short-circuit when scope has real outbound GUID IDs", () => {
|
|
const echoCache = createSentMessageCache();
|
|
const scope = "default:imessage:+15555550123";
|
|
|
|
echoCache.remember(scope, { text: "guid-backed", messageId: "p:0/abc-def-123" });
|
|
|
|
// Different inbound numeric ID should still short-circuit to false.
|
|
expect(echoCache.has(scope, { text: "guid-backed", messageId: "200" })).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("echo cache — backward compat for channels without messageId", () => {
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
// Proves text-fallback echo detection still works when no messageId is present
|
|
// on either side. Critical for backward compat with channels that don't
|
|
// populate messageId.
|
|
it("text-only remember/has works within TTL", () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));
|
|
|
|
const echoCache = createSentMessageCache();
|
|
const scope = "default:imessage:+15555550123";
|
|
|
|
echoCache.remember(scope, { text: "no id message" });
|
|
|
|
vi.advanceTimersByTime(2000);
|
|
expect(echoCache.has(scope, { text: "no id message" })).toBe(true);
|
|
});
|
|
|
|
it("text-only has returns false after TTL expiry", () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));
|
|
|
|
const echoCache = createSentMessageCache();
|
|
const scope = "default:imessage:+15555550123";
|
|
|
|
echoCache.remember(scope, { text: "no id message" });
|
|
|
|
vi.advanceTimersByTime(5000);
|
|
expect(echoCache.has(scope, { text: "no id message" })).toBe(false);
|
|
});
|
|
|
|
it("text-only has returns false for different text", () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));
|
|
|
|
const echoCache = createSentMessageCache();
|
|
const scope = "default:imessage:+15555550123";
|
|
|
|
echoCache.remember(scope, { text: "no id message" });
|
|
|
|
vi.advanceTimersByTime(1000);
|
|
expect(echoCache.has(scope, { text: "totally different text" })).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("self-chat dedupe — #47830", () => {
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("does NOT drop a user message that matches recently-sent agent text (self-chat scope collision)", () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));
|
|
|
|
const echoCache = createSentMessageCache();
|
|
const selfChatCache = createSelfChatCache();
|
|
|
|
// Agent sends "Hello" to self-chat target +15551234567
|
|
const scope = "default:imessage:+15551234567";
|
|
echoCache.remember(scope, { text: "Hello", messageId: "agent-msg-1" });
|
|
|
|
// 2 seconds later, user sends "Hello" to themselves (different message id)
|
|
vi.advanceTimersByTime(2000);
|
|
|
|
const decision = resolveIMessageInboundDecision(
|
|
createParams({
|
|
message: {
|
|
id: 200,
|
|
sender: "+15551234567",
|
|
text: "Hello",
|
|
is_from_me: false,
|
|
},
|
|
messageText: "Hello",
|
|
bodyText: "Hello",
|
|
echoCache,
|
|
selfChatCache,
|
|
}),
|
|
);
|
|
|
|
// BUG: Before fix, this was "drop" reason "echo" — user message silently lost.
|
|
// After fix: message-id mismatch means this is NOT an echo.
|
|
// The echo cache should only match when message IDs match OR when text
|
|
// matches and no message ID is available on inbound.
|
|
expect(decision.kind).toBe("dispatch");
|
|
});
|
|
|
|
it("DOES drop genuine agent echo (same message id reflected back)", () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));
|
|
|
|
const echoCache = createSentMessageCache();
|
|
|
|
// Agent sends "Hello" to target
|
|
const scope = "default:imessage:+15551234567";
|
|
echoCache.remember(scope, { text: "Hello", messageId: "agent-msg-1" });
|
|
|
|
// 1 second later, iMessage reflects it back with same message id
|
|
vi.advanceTimersByTime(1000);
|
|
|
|
const decision = resolveIMessageInboundDecision(
|
|
createParams({
|
|
message: {
|
|
id: "agent-msg-1" as unknown as number,
|
|
sender: "+15551234567",
|
|
text: "Hello",
|
|
is_from_me: false,
|
|
},
|
|
messageText: "Hello",
|
|
bodyText: "Hello",
|
|
echoCache,
|
|
}),
|
|
);
|
|
|
|
expect(decision).toEqual({ kind: "drop", reason: "echo" });
|
|
});
|
|
|
|
it("does NOT drop different-text messages even within TTL", () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));
|
|
|
|
const echoCache = createSentMessageCache();
|
|
|
|
// Agent sends "Hello"
|
|
const scope = "default:imessage:+15551234567";
|
|
echoCache.remember(scope, { text: "Hello", messageId: "agent-msg-1" });
|
|
|
|
vi.advanceTimersByTime(1000);
|
|
|
|
const decision = resolveIMessageInboundDecision(
|
|
createParams({
|
|
message: {
|
|
id: 201,
|
|
sender: "+15551234567",
|
|
text: "Goodbye",
|
|
is_from_me: false,
|
|
},
|
|
messageText: "Goodbye",
|
|
bodyText: "Goodbye",
|
|
echoCache,
|
|
}),
|
|
);
|
|
|
|
expect(decision.kind).toBe("dispatch");
|
|
});
|
|
|
|
it("does NOT drop user messages that match a chunk of a multi-chunk agent reply", () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));
|
|
|
|
const echoCache = createSentMessageCache();
|
|
const scope = "default:imessage:+15551234567";
|
|
|
|
// Agent sends a multi-chunk reply: "Part one", "Part two", "Part three"
|
|
echoCache.remember(scope, { text: "Part one", messageId: "agent-chunk-1" });
|
|
echoCache.remember(scope, { text: "Part two", messageId: "agent-chunk-2" });
|
|
echoCache.remember(scope, { text: "Part three", messageId: "agent-chunk-3" });
|
|
|
|
vi.advanceTimersByTime(2000);
|
|
|
|
// User sends "Part two" (matches chunk 2 text, but different message id)
|
|
const decision = resolveIMessageInboundDecision(
|
|
createParams({
|
|
message: {
|
|
id: 300,
|
|
sender: "+15551234567",
|
|
text: "Part two",
|
|
is_from_me: false,
|
|
},
|
|
messageText: "Part two",
|
|
bodyText: "Part two",
|
|
echoCache,
|
|
}),
|
|
);
|
|
|
|
// Should NOT be dropped — different message id means not an echo
|
|
expect(decision.kind).toBe("dispatch");
|
|
});
|
|
|
|
it("drops echo after text TTL expiry (4s TTL: expired at 5s)", () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));
|
|
|
|
const echoCache = createSentMessageCache();
|
|
const scope = "default:imessage:+15555550123";
|
|
|
|
// Agent sends text (no message id available)
|
|
echoCache.remember(scope, { text: "Hello there" });
|
|
|
|
// After 5 seconds — beyond the 4s TTL, should NOT match
|
|
vi.advanceTimersByTime(5000);
|
|
|
|
const result = echoCache.has(scope, { text: "Hello there" });
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
// Safe failure mode: TTL expiry causes duplicate delivery (noisy), never message loss (lossy)
|
|
it("does NOT catch echo after TTL expiry — safe failure mode is duplicate delivery", () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));
|
|
|
|
const echoCache = createSentMessageCache();
|
|
const scope = "default:imessage:+15551234567";
|
|
|
|
// Agent sends "Delayed echo test"
|
|
echoCache.remember(scope, { text: "Delayed echo test", messageId: "agent-msg-delayed" });
|
|
|
|
// 4.5 seconds later — beyond 4s TTL
|
|
vi.advanceTimersByTime(4500);
|
|
|
|
// Echo arrives with no messageId (text-only fallback path)
|
|
const result = echoCache.has(scope, { text: "Delayed echo test" });
|
|
|
|
// TTL expired → not caught → duplicate delivery (noisy but safe, not lossy)
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
it("still drops text echo within 4s TTL window", () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));
|
|
|
|
const echoCache = createSentMessageCache();
|
|
const scope = "default:imessage:+15555550123";
|
|
|
|
echoCache.remember(scope, { text: "Hello there" });
|
|
|
|
// After 3 seconds — within the 4s TTL, should still match
|
|
vi.advanceTimersByTime(3000);
|
|
|
|
const result = echoCache.has(scope, { text: "Hello there" });
|
|
expect(result).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("self-chat is_from_me=true handling (Bruce Phase 2 fix)", () => {
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("processes real user self-chat message (is_from_me=true, no echo cache match)", () => {
|
|
// User sends "Hello" to themselves — is_from_me=true, sender==chat_identifier
|
|
const echoCache = createSentMessageCache();
|
|
const selfChatCache = createSelfChatCache();
|
|
|
|
const decision = resolveIMessageInboundDecision(
|
|
createParams({
|
|
message: {
|
|
id: 123703,
|
|
sender: "+15551234567",
|
|
chat_identifier: "+15551234567",
|
|
text: "Hello this is a test message",
|
|
is_from_me: true,
|
|
is_group: false,
|
|
},
|
|
messageText: "Hello this is a test message",
|
|
bodyText: "Hello this is a test message",
|
|
echoCache,
|
|
selfChatCache,
|
|
}),
|
|
);
|
|
|
|
// Real user message — should be dispatched, not dropped
|
|
expect(decision.kind).toBe("dispatch");
|
|
});
|
|
|
|
it("drops agent reply echo in self-chat (is_from_me=true, echo cache text match)", () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));
|
|
|
|
const echoCache = createSentMessageCache();
|
|
const selfChatCache = createSelfChatCache();
|
|
|
|
// Agent sends "Hi there!" to self-chat
|
|
const scope = "default:imessage:+15551234567";
|
|
echoCache.remember(scope, { text: "Hi there!", messageId: "p:0/GUID-abc-def" });
|
|
|
|
// 1 second later, iMessage delivers the agent reply as is_from_me=true
|
|
// with a SQLite row ID (never matches the GUID)
|
|
vi.advanceTimersByTime(1000);
|
|
|
|
const decision = resolveIMessageInboundDecision(
|
|
createParams({
|
|
message: {
|
|
id: 123706,
|
|
guid: "p:0/GUID-abc-def",
|
|
sender: "+15551234567",
|
|
chat_identifier: "+15551234567",
|
|
text: "Hi there!",
|
|
is_from_me: true,
|
|
is_group: false,
|
|
},
|
|
messageText: "Hi there!",
|
|
bodyText: "Hi there!",
|
|
echoCache,
|
|
selfChatCache,
|
|
}),
|
|
);
|
|
|
|
// Agent echo — should be dropped
|
|
expect(decision).toEqual({ kind: "drop", reason: "agent echo in self-chat" });
|
|
});
|
|
|
|
it("drops attachment-only agent echo in self-chat via bodyText placeholder", () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));
|
|
|
|
const echoCache = createSentMessageCache();
|
|
const selfChatCache = createSelfChatCache();
|
|
|
|
const scope = "default:imessage:+15551234567";
|
|
echoCache.remember(scope, { text: "<media:image>", messageId: "p:0/GUID-media" });
|
|
|
|
vi.advanceTimersByTime(1000);
|
|
|
|
const decision = resolveIMessageInboundDecision(
|
|
createParams({
|
|
message: {
|
|
id: 123707,
|
|
guid: "p:0/GUID-media",
|
|
sender: "+15551234567",
|
|
chat_identifier: "+15551234567",
|
|
text: "",
|
|
is_from_me: true,
|
|
is_group: false,
|
|
},
|
|
messageText: "",
|
|
bodyText: "<media:image>",
|
|
echoCache,
|
|
selfChatCache,
|
|
}),
|
|
);
|
|
|
|
expect(decision).toEqual({ kind: "drop", reason: "agent echo in self-chat" });
|
|
});
|
|
|
|
it("drops self-chat echo when outbound cache stored numeric id but inbound also carries a guid", () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));
|
|
|
|
const echoCache = createSentMessageCache();
|
|
const selfChatCache = createSelfChatCache();
|
|
|
|
const scope = "default:imessage:+15551234567";
|
|
echoCache.remember(scope, { text: "Numeric id echo", messageId: "123709" });
|
|
|
|
vi.advanceTimersByTime(1000);
|
|
|
|
const decision = resolveIMessageInboundDecision(
|
|
createParams({
|
|
message: {
|
|
id: 123709,
|
|
guid: "p:0/GUID-different-shape",
|
|
sender: "+15551234567",
|
|
chat_identifier: "+15551234567",
|
|
text: "Numeric id echo",
|
|
is_from_me: true,
|
|
is_group: false,
|
|
},
|
|
messageText: "Numeric id echo",
|
|
bodyText: "Numeric id echo",
|
|
echoCache,
|
|
selfChatCache,
|
|
}),
|
|
);
|
|
|
|
expect(decision).toEqual({ kind: "drop", reason: "agent echo in self-chat" });
|
|
});
|
|
|
|
it("does not drop a real self-chat image just because a recent agent image used the same placeholder", () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));
|
|
|
|
const echoCache = createSentMessageCache();
|
|
const selfChatCache = createSelfChatCache();
|
|
|
|
const scope = "default:imessage:+15551234567";
|
|
echoCache.remember(scope, { text: "<media:image>", messageId: "p:0/GUID-agent-image" });
|
|
|
|
vi.advanceTimersByTime(1000);
|
|
|
|
const decision = resolveIMessageInboundDecision(
|
|
createParams({
|
|
message: {
|
|
id: 123708,
|
|
guid: "p:0/GUID-user-image",
|
|
sender: "+15551234567",
|
|
chat_identifier: "+15551234567",
|
|
text: "",
|
|
is_from_me: true,
|
|
is_group: false,
|
|
},
|
|
messageText: "",
|
|
bodyText: "<media:image>",
|
|
echoCache,
|
|
selfChatCache,
|
|
}),
|
|
);
|
|
|
|
expect(decision.kind).toBe("dispatch");
|
|
});
|
|
|
|
it("drops is_from_me=false reflection via selfChatCache (existing behavior preserved)", () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));
|
|
|
|
const selfChatCache = createSelfChatCache();
|
|
const createdAt = "2026-03-24T12:00:00.000Z";
|
|
|
|
// Step 1: is_from_me=true copy arrives (real user message) → processed, selfChatCache populated
|
|
const first = resolveIMessageInboundDecision(
|
|
createParams({
|
|
message: {
|
|
id: 123703,
|
|
sender: "+15551234567",
|
|
chat_identifier: "+15551234567",
|
|
text: "Hello",
|
|
created_at: createdAt,
|
|
is_from_me: true,
|
|
is_group: false,
|
|
},
|
|
messageText: "Hello",
|
|
bodyText: "Hello",
|
|
selfChatCache,
|
|
}),
|
|
);
|
|
expect(first.kind).toBe("dispatch");
|
|
|
|
// Step 2: is_from_me=false reflection arrives 2s later with same text+createdAt
|
|
vi.advanceTimersByTime(2200);
|
|
const second = resolveIMessageInboundDecision(
|
|
createParams({
|
|
message: {
|
|
id: 123704,
|
|
sender: "+15551234567",
|
|
chat_identifier: "+15551234567",
|
|
text: "Hello",
|
|
created_at: createdAt,
|
|
is_from_me: false,
|
|
is_group: false,
|
|
},
|
|
messageText: "Hello",
|
|
bodyText: "Hello",
|
|
selfChatCache,
|
|
}),
|
|
);
|
|
// Reflection correctly dropped
|
|
expect(second).toEqual({ kind: "drop", reason: "self-chat echo" });
|
|
});
|
|
|
|
it("normal DM is_from_me=true is still dropped (regression test)", () => {
|
|
const selfChatCache = createSelfChatCache();
|
|
|
|
// Normal DM with is_from_me=true: in iMessage, sender is the local user's
|
|
// handle and chat_identifier is the OTHER person's handle. They differ,
|
|
// so this is NOT self-chat.
|
|
const decision = resolveIMessageInboundDecision(
|
|
createParams({
|
|
message: {
|
|
id: 9999,
|
|
sender: "+15551234567", // local user sent this
|
|
chat_identifier: "+15555550123", // sent TO this other person
|
|
text: "Hello",
|
|
is_from_me: true,
|
|
is_group: false,
|
|
},
|
|
messageText: "Hello",
|
|
bodyText: "Hello",
|
|
selfChatCache,
|
|
}),
|
|
);
|
|
|
|
// sender != chat_identifier → not self-chat → dropped as "from me"
|
|
expect(decision).toEqual({ kind: "drop", reason: "from me" });
|
|
});
|
|
|
|
it("echo cache text matching works with skipIdShortCircuit=true", () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));
|
|
|
|
const echoCache = createSentMessageCache();
|
|
const scope = "default:imessage:+15551234567";
|
|
echoCache.remember(scope, { text: "Cached reply", messageId: "p:0/some-guid" });
|
|
|
|
vi.advanceTimersByTime(1000);
|
|
|
|
// Text matches but ID is a SQLite row (format mismatch). With skipIdShortCircuit=true,
|
|
// text matching should still fire.
|
|
expect(echoCache.has(scope, { text: "Cached reply", messageId: "123799" }, true)).toBe(true);
|
|
|
|
// With skipIdShortCircuit=false (default), ID mismatch causes early return false.
|
|
expect(echoCache.has(scope, { text: "Cached reply", messageId: "123799" }, false)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("echo cache — text fallback for null-id inbound messages", () => {
|
|
it("still identifies echo via text when inbound message has id: null", () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));
|
|
|
|
const echoCache = createSentMessageCache();
|
|
const selfChatCache = createSelfChatCache();
|
|
|
|
// Agent sends "Sounds good" — no messageId available (edge case)
|
|
const scope = "default:imessage:+15551234567";
|
|
echoCache.remember(scope, { text: "Sounds good" });
|
|
|
|
// 1 second later, inbound reflection arrives with id: null
|
|
vi.advanceTimersByTime(1000);
|
|
|
|
const decision = resolveIMessageInboundDecision(
|
|
createParams({
|
|
message: {
|
|
id: null as unknown as number,
|
|
sender: "+15551234567",
|
|
text: "Sounds good",
|
|
is_from_me: false,
|
|
},
|
|
messageText: "Sounds good",
|
|
bodyText: "Sounds good",
|
|
echoCache,
|
|
selfChatCache,
|
|
}),
|
|
);
|
|
|
|
// With id: null, the text-based fallback path is still active and should
|
|
// correctly identify this as an echo.
|
|
expect(decision).toEqual({ kind: "drop", reason: "echo" });
|
|
});
|
|
});
|
|
|
|
describe("echo cache — mixed GUID and text-only scopes", () => {
|
|
it("still falls back to text for the latest text-only send in a scope with older GUID-backed sends", () => {
|
|
const echoCache = createSentMessageCache();
|
|
const scope = "default:imessage:+15555550123";
|
|
|
|
echoCache.remember(scope, { text: "older guid-backed", messageId: "p:0/GUID-older" });
|
|
echoCache.remember(scope, { text: "latest text-only", messageId: "unknown" });
|
|
|
|
expect(echoCache.has(scope, { text: "latest text-only", messageId: "200" })).toBe(true);
|
|
});
|
|
|
|
it("still short-circuits when the latest copy of a text was GUID-backed", () => {
|
|
const echoCache = createSentMessageCache();
|
|
const scope = "default:imessage:+15555550123";
|
|
|
|
echoCache.remember(scope, { text: "same text", messageId: "unknown" });
|
|
echoCache.remember(scope, { text: "same text", messageId: "p:0/GUID-newer" });
|
|
|
|
expect(echoCache.has(scope, { text: "same text", messageId: "200" })).toBe(false);
|
|
});
|
|
});
|