Skip to content

Commit 81e1ec4

Browse files
ly-wang19claude
andauthored
fix(imessage): strip leading echo corruption markers in the persisted echo cache (#94442)
The persisted iMessage echo-dedupe cache normalized text with CRLF->LF + trim only, not the leading attributedBody corruption-marker stripping the in-memory echo cache applies (#93511). The persisted 12h cache is the only matcher once the 4s in-memory text TTL expires, so a delayed reflected own-message echo whose text decoded with a leading NUL/replacement/BOM marker did not match the clean stored send -- the agent's own message was re-ingested as fresh inbound, causing a self-reply loop. Extract the marker-stripping into a leaf module shared by both echo caches (the in-memory cache already imports the persisted one, so importing back would be a cycle) and apply it in the persisted normalizeText, so both caches strip identically. Co-authored-by: ly-wang19 <[email protected]> Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
1 parent 10113b2 commit 81e1ec4

4 files changed

Lines changed: 44 additions & 15 deletions

File tree

extensions/imessage/src/monitor/echo-cache.ts

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
// Imessage plugin module implements echo cache behavior.
2+
import { stripLeadingEchoTextCorruptionMarkers } from "./echo-text-corruption.js";
23
import { hasPersistedIMessageEcho } from "./persisted-echo-cache.js";
34

45
type SentMessageLookup = {
@@ -35,20 +36,6 @@ export type SentMessageCache = {
3536
const SENT_MESSAGE_TEXT_TTL_MS = 4_000;
3637
const SENT_MESSAGE_ID_TTL_MS = 60_000;
3738

38-
function isLeadingEchoTextCorruptionMarker(code: number): boolean {
39-
return (
40-
code === 0x0000 || code === 0xfeff || code === 0xfffd || code === 0xfffe || code === 0xffff
41-
);
42-
}
43-
44-
function stripLeadingEchoTextCorruptionMarkers(text: string): string {
45-
let offset = 0;
46-
while (offset < text.length && isLeadingEchoTextCorruptionMarker(text.charCodeAt(offset))) {
47-
offset += 1;
48-
}
49-
return offset === 0 ? text : text.slice(offset);
50-
}
51-
5239
function normalizeEchoTextKey(text: string | undefined): string | null {
5340
if (!text) {
5441
return null;
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
// Imessage plugin shared helper: strip leading attributedBody corruption markers from echo text.
2+
// The in-memory (echo-cache) and persisted (persisted-echo-cache) echo-dedupe paths must normalize
3+
// identically, so a reflected own-message echo whose attributedBody decoded with a leading
4+
// NUL/replacement marker still matches the clean stored send. Kept here (a leaf module with no
5+
// imports) so the persisted path can reuse it without an echo-cache <-> persisted-echo-cache cycle.
6+
7+
function isLeadingEchoTextCorruptionMarker(code: number): boolean {
8+
return (
9+
code === 0x0000 || code === 0xfeff || code === 0xfffd || code === 0xfffe || code === 0xffff
10+
);
11+
}
12+
13+
export function stripLeadingEchoTextCorruptionMarkers(text: string): string {
14+
let offset = 0;
15+
while (offset < text.length && isLeadingEchoTextCorruptionMarker(text.charCodeAt(offset))) {
16+
offset += 1;
17+
}
18+
return offset === 0 ? text : text.slice(offset);
19+
}

extensions/imessage/src/monitor/monitor-provider.echo-cache.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,21 @@ describe("iMessage sent-message echo cache", () => {
9191
expect(cache.has("acct:imessage:+1555", { text: "Delayed\u0000echo reply" })).toBe(false);
9292
});
9393

94+
it("matches a delayed reflected echo with leading corruption markers via the persisted cache", () => {
95+
// The persisted 12h cache is the only matcher once the 4s in-memory text TTL expires, so it
96+
// must strip leading attributedBody corruption markers exactly like the in-memory key (#93511).
97+
const scope = "acct:imessage:+1555";
98+
const markers = String.fromCharCode(0x0000, 0xfffd, 0xfffe, 0xffff, 0xfeff);
99+
rememberPersistedIMessageEcho({ scope, text: "Delayed echo reply" });
100+
101+
expect(hasPersistedIMessageEcho({ scope, text: "Delayed echo reply" })).toBe(true);
102+
expect(hasPersistedIMessageEcho({ scope, text: `${markers}Delayed echo reply` })).toBe(true);
103+
// Leading-only: a mid-string marker stays distinct.
104+
expect(
105+
hasPersistedIMessageEcho({ scope, text: `Delayed${String.fromCharCode(0x0000)}echo reply` }),
106+
).toBe(false);
107+
});
108+
94109
it("matches by outbound message id and ignores placeholder ids", () => {
95110
vi.useFakeTimers();
96111
vi.setSystemTime(new Date("2026-02-25T00:00:00Z"));

extensions/imessage/src/monitor/persisted-echo-cache.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { createHash } from "node:crypto";
33
import type { PluginStateSyncKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime";
44
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
55
import { getIMessageRuntime } from "../runtime.js";
6+
import { stripLeadingEchoTextCorruptionMarkers } from "./echo-text-corruption.js";
67

78
type PersistedEchoEntry = {
89
scope: string;
@@ -26,7 +27,14 @@ export const IMESSAGE_SENT_ECHOES_MAX_ENTRIES = 256;
2627
type PersistedEchoStore = PluginStateSyncKeyedStore<PersistedEchoEntry>;
2728

2829
function normalizeText(text: string | undefined): string | undefined {
29-
const normalized = text?.replace(/\r\n?/g, "\n").trim();
30+
if (!text) {
31+
return undefined;
32+
}
33+
// Match the in-memory echo-cache key so a reflected echo with a leading attributedBody
34+
// corruption marker still matches the clean stored send (the persisted sibling of #93511).
35+
const normalized = stripLeadingEchoTextCorruptionMarkers(
36+
text.replace(/\r\n?/g, "\n").trim(),
37+
).trim();
3038
return normalized || undefined;
3139
}
3240

0 commit comments

Comments
 (0)