fix(imessage): try all inbound echo ids

This commit is contained in:
Ayaan Zaidi
2026-03-29 13:00:01 +05:30
parent bc9c074b2c
commit 4a5885df3a
3 changed files with 103 additions and 20 deletions

View File

@@ -75,13 +75,10 @@ describe("resolveIMessageInboundDecision echo detection", () => {
});
expect(decision).toEqual({ kind: "drop", reason: "echo" });
expect(echoHas).toHaveBeenCalledWith(
"default:imessage:+15555550123",
expect.objectContaining({
text: "Reasoning:\n_step_",
messageId: "42",
}),
);
expect(echoHas).toHaveBeenNthCalledWith(1, "default:imessage:+15555550123", {
messageId: "42",
});
expect(echoHas).toHaveBeenCalledTimes(1);
});
it("matches attachment-only echoes by bodyText placeholder", () => {
@@ -100,12 +97,17 @@ describe("resolveIMessageInboundDecision echo detection", () => {
});
expect(decision).toEqual({ kind: "drop", reason: "echo" });
expect(echoHas).toHaveBeenCalledWith(
expect(echoHas).toHaveBeenNthCalledWith(1, "default:imessage:+15555550123", {
messageId: "42",
});
expect(echoHas).toHaveBeenNthCalledWith(
2,
"default:imessage:+15555550123",
expect.objectContaining({
{
text: "<media:image>",
messageId: "42",
}),
},
undefined,
);
});

View File

@@ -64,6 +64,50 @@ function describeReplyContext(message: IMessagePayload): IMessageReplyContext |
return { body, id, sender };
}
function resolveInboundEchoMessageIds(message: IMessagePayload): string[] {
const values = [
message.id != null ? String(message.id) : undefined,
normalizeReplyField(message.guid),
];
const ids: string[] = [];
for (const value of values) {
if (!value || ids.includes(value)) {
continue;
}
ids.push(value);
}
return ids;
}
function hasIMessageEchoMatch(params: {
echoCache: {
has: (
scope: string,
lookup: { text?: string; messageId?: string },
skipIdShortCircuit?: boolean,
) => boolean;
};
scope: string;
text?: string;
messageIds: string[];
skipIdShortCircuit?: boolean;
}): boolean {
for (const messageId of params.messageIds) {
if (params.echoCache.has(params.scope, { messageId })) {
return true;
}
}
const fallbackMessageId = params.messageIds[0];
if (!params.text && !fallbackMessageId) {
return false;
}
return params.echoCache.has(
params.scope,
{ text: params.text, messageId: fallbackMessageId },
params.skipIdShortCircuit,
);
}
export type IMessageInboundDispatchDecision = {
kind: "dispatch";
isGroup: boolean;
@@ -168,9 +212,9 @@ export function resolveIMessageInboundDecision(params: {
// When true, the selfChatCache.has() check below must be skipped — we just
// called remember() and would immediately match our own entry.
let skipSelfChatHasCheck = false;
const inboundMessageId =
normalizeReplyField(params.message.guid) ??
(params.message.id != null ? String(params.message.id) : undefined);
const inboundMessageIds = resolveInboundEchoMessageIds(params.message);
const inboundMessageId = inboundMessageIds[0];
const hasInboundGuid = Boolean(normalizeReplyField(params.message.guid));
if (params.message.is_from_me) {
// Always cache in selfChatCache so the upcoming is_from_me=false reflection
@@ -190,11 +234,13 @@ export function resolveIMessageInboundDecision(params: {
if (
params.echoCache &&
(bodyText || inboundMessageId) &&
params.echoCache.has(
echoScope,
{ text: bodyText || undefined, messageId: inboundMessageId },
!normalizeReplyField(params.message.guid),
)
hasIMessageEchoMatch({
echoCache: params.echoCache,
scope: echoScope,
text: bodyText || undefined,
messageIds: inboundMessageIds,
skipIdShortCircuit: !hasInboundGuid,
})
) {
return { kind: "drop", reason: "agent echo in self-chat" };
}
@@ -305,9 +351,11 @@ export function resolveIMessageInboundDecision(params: {
sender,
});
if (
params.echoCache.has(echoScope, {
hasIMessageEchoMatch({
echoCache: params.echoCache,
scope: echoScope,
text: bodyText || undefined,
messageId: inboundMessageId,
messageIds: inboundMessageIds,
})
) {
params.logVerbose?.(

View File

@@ -439,6 +439,39 @@ describe("self-chat is_from_me=true handling (Bruce Phase 2 fix)", () => {
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"));