Skip to content

Commit 4a5885d

Browse files
committed
fix(imessage): try all inbound echo ids
1 parent bc9c074 commit 4a5885d

3 files changed

Lines changed: 103 additions & 20 deletions

File tree

extensions/imessage/src/monitor/inbound-processing.test.ts

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -75,13 +75,10 @@ describe("resolveIMessageInboundDecision echo detection", () => {
7575
});
7676

7777
expect(decision).toEqual({ kind: "drop", reason: "echo" });
78-
expect(echoHas).toHaveBeenCalledWith(
79-
"default:imessage:+15555550123",
80-
expect.objectContaining({
81-
text: "Reasoning:\n_step_",
82-
messageId: "42",
83-
}),
84-
);
78+
expect(echoHas).toHaveBeenNthCalledWith(1, "default:imessage:+15555550123", {
79+
messageId: "42",
80+
});
81+
expect(echoHas).toHaveBeenCalledTimes(1);
8582
});
8683

8784
it("matches attachment-only echoes by bodyText placeholder", () => {
@@ -100,12 +97,17 @@ describe("resolveIMessageInboundDecision echo detection", () => {
10097
});
10198

10299
expect(decision).toEqual({ kind: "drop", reason: "echo" });
103-
expect(echoHas).toHaveBeenCalledWith(
100+
expect(echoHas).toHaveBeenNthCalledWith(1, "default:imessage:+15555550123", {
101+
messageId: "42",
102+
});
103+
expect(echoHas).toHaveBeenNthCalledWith(
104+
2,
104105
"default:imessage:+15555550123",
105-
expect.objectContaining({
106+
{
106107
text: "<media:image>",
107108
messageId: "42",
108-
}),
109+
},
110+
undefined,
109111
);
110112
});
111113

extensions/imessage/src/monitor/inbound-processing.ts

Lines changed: 58 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,50 @@ function describeReplyContext(message: IMessagePayload): IMessageReplyContext |
6464
return { body, id, sender };
6565
}
6666

67+
function resolveInboundEchoMessageIds(message: IMessagePayload): string[] {
68+
const values = [
69+
message.id != null ? String(message.id) : undefined,
70+
normalizeReplyField(message.guid),
71+
];
72+
const ids: string[] = [];
73+
for (const value of values) {
74+
if (!value || ids.includes(value)) {
75+
continue;
76+
}
77+
ids.push(value);
78+
}
79+
return ids;
80+
}
81+
82+
function hasIMessageEchoMatch(params: {
83+
echoCache: {
84+
has: (
85+
scope: string,
86+
lookup: { text?: string; messageId?: string },
87+
skipIdShortCircuit?: boolean,
88+
) => boolean;
89+
};
90+
scope: string;
91+
text?: string;
92+
messageIds: string[];
93+
skipIdShortCircuit?: boolean;
94+
}): boolean {
95+
for (const messageId of params.messageIds) {
96+
if (params.echoCache.has(params.scope, { messageId })) {
97+
return true;
98+
}
99+
}
100+
const fallbackMessageId = params.messageIds[0];
101+
if (!params.text && !fallbackMessageId) {
102+
return false;
103+
}
104+
return params.echoCache.has(
105+
params.scope,
106+
{ text: params.text, messageId: fallbackMessageId },
107+
params.skipIdShortCircuit,
108+
);
109+
}
110+
67111
export type IMessageInboundDispatchDecision = {
68112
kind: "dispatch";
69113
isGroup: boolean;
@@ -168,9 +212,9 @@ export function resolveIMessageInboundDecision(params: {
168212
// When true, the selfChatCache.has() check below must be skipped — we just
169213
// called remember() and would immediately match our own entry.
170214
let skipSelfChatHasCheck = false;
171-
const inboundMessageId =
172-
normalizeReplyField(params.message.guid) ??
173-
(params.message.id != null ? String(params.message.id) : undefined);
215+
const inboundMessageIds = resolveInboundEchoMessageIds(params.message);
216+
const inboundMessageId = inboundMessageIds[0];
217+
const hasInboundGuid = Boolean(normalizeReplyField(params.message.guid));
174218

175219
if (params.message.is_from_me) {
176220
// Always cache in selfChatCache so the upcoming is_from_me=false reflection
@@ -190,11 +234,13 @@ export function resolveIMessageInboundDecision(params: {
190234
if (
191235
params.echoCache &&
192236
(bodyText || inboundMessageId) &&
193-
params.echoCache.has(
194-
echoScope,
195-
{ text: bodyText || undefined, messageId: inboundMessageId },
196-
!normalizeReplyField(params.message.guid),
197-
)
237+
hasIMessageEchoMatch({
238+
echoCache: params.echoCache,
239+
scope: echoScope,
240+
text: bodyText || undefined,
241+
messageIds: inboundMessageIds,
242+
skipIdShortCircuit: !hasInboundGuid,
243+
})
198244
) {
199245
return { kind: "drop", reason: "agent echo in self-chat" };
200246
}
@@ -305,9 +351,11 @@ export function resolveIMessageInboundDecision(params: {
305351
sender,
306352
});
307353
if (
308-
params.echoCache.has(echoScope, {
354+
hasIMessageEchoMatch({
355+
echoCache: params.echoCache,
356+
scope: echoScope,
309357
text: bodyText || undefined,
310-
messageId: inboundMessageId,
358+
messageIds: inboundMessageIds,
311359
})
312360
) {
313361
params.logVerbose?.(

extensions/imessage/src/monitor/self-chat-dedupe.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,39 @@ describe("self-chat is_from_me=true handling (Bruce Phase 2 fix)", () => {
439439
expect(decision).toEqual({ kind: "drop", reason: "agent echo in self-chat" });
440440
});
441441

442+
it("drops self-chat echo when outbound cache stored numeric id but inbound also carries a guid", () => {
443+
vi.useFakeTimers();
444+
vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));
445+
446+
const echoCache = createSentMessageCache();
447+
const selfChatCache = createSelfChatCache();
448+
449+
const scope = "default:imessage:+15551234567";
450+
echoCache.remember(scope, { text: "Numeric id echo", messageId: "123709" });
451+
452+
vi.advanceTimersByTime(1000);
453+
454+
const decision = resolveIMessageInboundDecision(
455+
createParams({
456+
message: {
457+
id: 123709,
458+
guid: "p:0/GUID-different-shape",
459+
sender: "+15551234567",
460+
chat_identifier: "+15551234567",
461+
text: "Numeric id echo",
462+
is_from_me: true,
463+
is_group: false,
464+
},
465+
messageText: "Numeric id echo",
466+
bodyText: "Numeric id echo",
467+
echoCache,
468+
selfChatCache,
469+
}),
470+
);
471+
472+
expect(decision).toEqual({ kind: "drop", reason: "agent echo in self-chat" });
473+
});
474+
442475
it("does not drop a real self-chat image just because a recent agent image used the same placeholder", () => {
443476
vi.useFakeTimers();
444477
vi.setSystemTime(new Date("2026-03-24T12:00:00Z"));

0 commit comments

Comments
 (0)