mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-12 01:31:08 +00:00
* fix(imessage): prevent self-chat dedupe false positives (#47830) Move echo cache remember() to post-send only, add early return when inbound message ID doesn't match cached IDs (prevents text-based false positives in self-chat), and reduce text TTL from 5s to 3s. Three targeted changes to fix silent user message loss in self-chat: 1. deliver.ts: Remove pre-send remember() call — cache only reflects successfully-delivered content, not pre-send full text. 2. echo-cache.ts: Skip text fallback when inbound has a valid message ID that doesn't match any cached outbound ID. In self-chat, sender == target so scopes collide; a user message with a fresh ID but matching text was incorrectly dropped as an echo. 3. echo-cache.ts: Reduce text TTL from 5000ms to 3000ms — agent echoes arrive within 1-2s, 5s was too wide. Adds self-chat-dedupe.test.ts (7 tests) + updates deliver.test.ts. BlueBubbles uses a different cache pattern — no changes needed there. Closes #47830 * review(imessage): strip debug logs, bump echo TTL to 4s (#47830) Bruce Phase 4 review changes: - Remove all [IMSG-DEBUG] console.error calls from inbound-processing.ts and monitor-provider.ts (23 lines, left over from Phase 2 debug deploy) - Bump SENT_MESSAGE_TEXT_TTL_MS from 3s to 4s in echo-cache.ts to give ~2s margin above the observed 2.2s echo arrival time under load - Update TTL tests to reflect 4s TTL (expired at 5s, live at 3s) * fix(imessage): add dedupe comments and canary/compat/TTL tests * fix(imessage): address review feedback on echo cache, shadowing, and test IDs * refactor(imessage): hoist inboundMessageId to eliminate duplicate computation (#47830) * fix(imessage): unify self-chat echo matching * fix: use inbound guid for self-chat echo matching (#55359) (thanks @rmarr) --------- Co-authored-by: Rohan Marr <rmarr@users.noreply.github.com> Co-authored-by: Ayaan Zaidi <hi@obviy.us>
117 lines
4.0 KiB
TypeScript
117 lines
4.0 KiB
TypeScript
export type SentMessageLookup = {
|
|
text?: string;
|
|
messageId?: string;
|
|
};
|
|
|
|
export type SentMessageCache = {
|
|
remember: (scope: string, lookup: SentMessageLookup) => void;
|
|
/**
|
|
* Check whether an inbound message matches a recently-sent outbound message.
|
|
*
|
|
* @param skipIdShortCircuit - When true, skip the early return on message-ID
|
|
* mismatch and fall through to text-based matching. Use this for self-chat
|
|
* `is_from_me=true` messages where the inbound ID is a numeric SQLite row ID
|
|
* that will never match the GUID outbound IDs, but text matching is still
|
|
* the right way to identify agent reply echoes.
|
|
*/
|
|
has: (scope: string, lookup: SentMessageLookup, skipIdShortCircuit?: boolean) => boolean;
|
|
};
|
|
|
|
// Echo arrival observed at ~2.2s on M4 Mac Mini (SQLite poll interval is the bottleneck).
|
|
// 4s provides ~80% margin. If echoes arrive after TTL expiry, the system degrades to
|
|
// duplicate delivery (noisy but not lossy) — never message loss.
|
|
const SENT_MESSAGE_TEXT_TTL_MS = 4_000;
|
|
const SENT_MESSAGE_ID_TTL_MS = 60_000;
|
|
|
|
function normalizeEchoTextKey(text: string | undefined): string | null {
|
|
if (!text) {
|
|
return null;
|
|
}
|
|
const normalized = text.replace(/\r\n?/g, "\n").trim();
|
|
return normalized ? normalized : null;
|
|
}
|
|
|
|
function normalizeEchoMessageIdKey(messageId: string | undefined): string | null {
|
|
if (!messageId) {
|
|
return null;
|
|
}
|
|
const normalized = messageId.trim();
|
|
if (!normalized || normalized === "ok" || normalized === "unknown") {
|
|
return null;
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
class DefaultSentMessageCache implements SentMessageCache {
|
|
private textCache = new Map<string, number>();
|
|
private textBackedByIdCache = new Map<string, number>();
|
|
private messageIdCache = new Map<string, number>();
|
|
|
|
remember(scope: string, lookup: SentMessageLookup): void {
|
|
const textKey = normalizeEchoTextKey(lookup.text);
|
|
if (textKey) {
|
|
this.textCache.set(`${scope}:${textKey}`, Date.now());
|
|
}
|
|
const messageIdKey = normalizeEchoMessageIdKey(lookup.messageId);
|
|
if (messageIdKey) {
|
|
this.messageIdCache.set(`${scope}:${messageIdKey}`, Date.now());
|
|
if (textKey) {
|
|
this.textBackedByIdCache.set(`${scope}:${textKey}`, Date.now());
|
|
}
|
|
}
|
|
this.cleanup();
|
|
}
|
|
|
|
has(scope: string, lookup: SentMessageLookup, skipIdShortCircuit = false): boolean {
|
|
this.cleanup();
|
|
const textKey = normalizeEchoTextKey(lookup.text);
|
|
const messageIdKey = normalizeEchoMessageIdKey(lookup.messageId);
|
|
if (messageIdKey) {
|
|
const idTimestamp = this.messageIdCache.get(`${scope}:${messageIdKey}`);
|
|
if (idTimestamp && Date.now() - idTimestamp <= SENT_MESSAGE_ID_TTL_MS) {
|
|
return true;
|
|
}
|
|
const textTimestamp = textKey ? this.textCache.get(`${scope}:${textKey}`) : undefined;
|
|
const textBackedByIdTimestamp = textKey
|
|
? this.textBackedByIdCache.get(`${scope}:${textKey}`)
|
|
: undefined;
|
|
const hasTextOnlyMatch =
|
|
typeof textTimestamp === "number" &&
|
|
(!textBackedByIdTimestamp || textTimestamp > textBackedByIdTimestamp);
|
|
if (!skipIdShortCircuit && !hasTextOnlyMatch) {
|
|
return false;
|
|
}
|
|
}
|
|
if (textKey) {
|
|
const textTimestamp = this.textCache.get(`${scope}:${textKey}`);
|
|
if (textTimestamp && Date.now() - textTimestamp <= SENT_MESSAGE_TEXT_TTL_MS) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private cleanup(): void {
|
|
const now = Date.now();
|
|
for (const [key, timestamp] of this.textCache.entries()) {
|
|
if (now - timestamp > SENT_MESSAGE_TEXT_TTL_MS) {
|
|
this.textCache.delete(key);
|
|
}
|
|
}
|
|
for (const [key, timestamp] of this.textBackedByIdCache.entries()) {
|
|
if (now - timestamp > SENT_MESSAGE_TEXT_TTL_MS) {
|
|
this.textBackedByIdCache.delete(key);
|
|
}
|
|
}
|
|
for (const [key, timestamp] of this.messageIdCache.entries()) {
|
|
if (now - timestamp > SENT_MESSAGE_ID_TTL_MS) {
|
|
this.messageIdCache.delete(key);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export function createSentMessageCache(): SentMessageCache {
|
|
return new DefaultSentMessageCache();
|
|
}
|