Skip to content

Commit 0cb28b4

Browse files
NIOcursoragent
andcommitted
fix(thread-ownership): cap mention cache and bound forwarder JSON reads
Co-authored-by: Cursor <[email protected]>
1 parent 94da0a9 commit 0cb28b4

2 files changed

Lines changed: 61 additions & 4 deletions

File tree

extensions/thread-ownership/index.test.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Thread Ownership tests cover index plugin behavior.
22
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
33
import type { OpenClawPluginApi } from "./api.js";
4-
import register from "./index.js";
4+
import register, { resetMentionedThreadsForTests } from "./index.js";
55

66
describe("thread-ownership plugin", () => {
77
const hooks: Record<string, Function> = {};
@@ -50,6 +50,7 @@ describe("thread-ownership plugin", () => {
5050

5151
beforeEach(() => {
5252
vi.clearAllMocks();
53+
resetMentionedThreadsForTests();
5354
for (const key of Object.keys(hooks)) {
5455
delete hooks[key];
5556
}
@@ -502,5 +503,43 @@ describe("thread-ownership plugin", () => {
502503

503504
expect(globalThis.fetch).toHaveBeenCalled();
504505
});
506+
507+
it("evicts oldest mention cache entries once the cache exceeds its cap", async () => {
508+
await hooks.message_received(
509+
{
510+
content: "hey @TestBot help",
511+
threadId: "1111.0001",
512+
metadata: { channelId: "C000" },
513+
},
514+
{ channelId: "slack", conversationId: "C000" },
515+
);
516+
517+
for (let i = 1; i <= 1000; i += 1) {
518+
await hooks.message_received(
519+
{
520+
content: "hey @TestBot help",
521+
threadId: `2222.${i.toString().padStart(4, "0")}`,
522+
metadata: { channelId: `C${i.toString().padStart(3, "0")}` },
523+
},
524+
{ channelId: "slack", conversationId: `C${i.toString().padStart(3, "0")}` },
525+
);
526+
}
527+
528+
vi.mocked(globalThis.fetch).mockResolvedValue(
529+
new Response(JSON.stringify({ owner: "test-agent" }), { status: 200 }),
530+
);
531+
532+
await hooks.message_sending(
533+
{
534+
content: "On it!",
535+
replyToId: "1111.0001",
536+
metadata: { channelId: "C000" },
537+
to: "C000",
538+
},
539+
{ channelId: "slack", conversationId: "C000" },
540+
);
541+
542+
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
543+
});
505544
});
506545
});

extensions/thread-ownership/index.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
// Thread Ownership plugin entrypoint registers its OpenClaw integration.
2+
import { pruneMapToMaxSize } from "openclaw/plugin-sdk/collection-runtime";
23
import { resolveLivePluginConfigObject } from "openclaw/plugin-sdk/plugin-config-runtime";
4+
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
35
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
46
import { escapeRegExp } from "openclaw/plugin-sdk/text-utility-runtime";
57
import {
@@ -22,6 +24,7 @@ type ThreadOwnershipMessageSendingResult = { cancel: true } | undefined;
2224
// Entries expire after 5 minutes.
2325
const mentionedThreads = new Map<string, number>();
2426
const MENTION_TTL_MS = 5 * 60 * 1000;
27+
const MENTIONED_THREADS_MAX_ENTRIES = 1000;
2528

2629
function isThreadOwnershipConfig(value: unknown): value is ThreadOwnershipConfig {
2730
return value !== null && typeof value === "object";
@@ -51,6 +54,15 @@ function cleanExpiredMentions(): void {
5154
}
5255
}
5356

57+
function trackMentionedThread(key: string): void {
58+
cleanExpiredMentions();
59+
if (mentionedThreads.has(key)) {
60+
mentionedThreads.delete(key);
61+
}
62+
mentionedThreads.set(key, Date.now());
63+
pruneMapToMaxSize(mentionedThreads, MENTIONED_THREADS_MAX_ENTRIES);
64+
}
65+
5466
function containsAgentNameMention(text: string, agentName: string): boolean {
5567
const trimmedName = agentName.trim();
5668
if (!trimmedName) {
@@ -136,8 +148,7 @@ export default definePluginEntry({
136148
containsAgentNameMention(text, agent.name) ||
137149
(botUserId && text.includes(`<@${botUserId}>`));
138150
if (mentioned) {
139-
cleanExpiredMentions();
140-
mentionedThreads.set(`${channelId}:${threadTs}`, Date.now());
151+
trackMentionedThread(`${channelId}:${threadTs}`);
141152
}
142153
});
143154

@@ -189,7 +200,10 @@ export default definePluginEntry({
189200
return undefined;
190201
}
191202
if (resp.status === 409) {
192-
const body = (await resp.json()) as { owner?: string };
203+
const body = await readProviderJsonResponse<{ owner?: string }>(
204+
resp,
205+
"thread-ownership.ownership-conflict",
206+
);
193207
api.logger.info?.(
194208
`thread-ownership: cancelled send to ${channelId}:${threadTs} — owned by ${body.owner}`,
195209
);
@@ -208,3 +222,7 @@ export default definePluginEntry({
208222
});
209223
},
210224
});
225+
226+
export function resetMentionedThreadsForTests(): void {
227+
mentionedThreads.clear();
228+
}

0 commit comments

Comments
 (0)