From 77761f4a3e39e06acf48068eef3dfee6a2b68bec Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 30 May 2026 11:49:47 -0400 Subject: [PATCH] fix(msteams): bound parent thread cache expiry --- .../msteams/src/thread-parent-context.test.ts | 42 ++++++++++++------- .../msteams/src/thread-parent-context.ts | 24 +++++++++-- 2 files changed, 49 insertions(+), 17 deletions(-) diff --git a/extensions/msteams/src/thread-parent-context.test.ts b/extensions/msteams/src/thread-parent-context.test.ts index e8502b12933..b142db38ef1 100644 --- a/extensions/msteams/src/thread-parent-context.test.ts +++ b/extensions/msteams/src/thread-parent-context.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { GraphThreadMessage } from "./graph-thread.js"; import { resetThreadParentContextCachesForTest, @@ -95,6 +95,10 @@ describe("fetchParentMessageCached", () => { resetThreadParentContextCachesForTest(); }); + afterEach(() => { + vi.useRealTimers(); + }); + it("invokes the fetcher on first call", async () => { const mockMsg: GraphThreadMessage = { id: "p1", @@ -150,21 +154,31 @@ describe("fetchParentMessageCached", () => { it("re-fetches after TTL expires", async () => { vi.useFakeTimers(); - try { - const fetcher = vi.fn(async () => ({ - id: "p1", - body: { content: "hi", contentType: "text" }, - })); + const fetcher = vi.fn(async () => ({ + id: "p1", + body: { content: "hi", contentType: "text" }, + })); - await fetchParentMessageCached("tok", "g1", "c1", "p1", fetcher); - // 5 min TTL: advance just beyond. - vi.advanceTimersByTime(5 * 60 * 1000 + 1); - await fetchParentMessageCached("tok", "g1", "c1", "p1", fetcher); + await fetchParentMessageCached("tok", "g1", "c1", "p1", fetcher); + // 5 min TTL: advance just beyond. + vi.advanceTimersByTime(5 * 60 * 1000 + 1); + await fetchParentMessageCached("tok", "g1", "c1", "p1", fetcher); - expect(fetcher).toHaveBeenCalledTimes(2); - } finally { - vi.useRealTimers(); - } + expect(fetcher).toHaveBeenCalledTimes(2); + }); + + it("does not cache parent fetches when the expiry would exceed Date range", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(8_640_000_000_000_000)); + const fetcher = vi.fn(async () => ({ + id: "p1", + body: { content: "hi", contentType: "text" }, + })); + + await fetchParentMessageCached("tok", "g1", "c1", "p1", fetcher); + await fetchParentMessageCached("tok", "g1", "c1", "p1", fetcher); + + expect(fetcher).toHaveBeenCalledTimes(2); }); it("evicts oldest entries when exceeding the 100-entry cap", async () => { diff --git a/extensions/msteams/src/thread-parent-context.ts b/extensions/msteams/src/thread-parent-context.ts index 66bec3e7405..6fc658b57b2 100644 --- a/extensions/msteams/src/thread-parent-context.ts +++ b/extensions/msteams/src/thread-parent-context.ts @@ -13,6 +13,10 @@ // the same parent is not re-injected on every subsequent reply in the // thread. +import { + asDateTimestampMs, + resolveExpiresAtMsFromDurationMs, +} from "openclaw/plugin-sdk/number-runtime"; import { fetchChannelMessage, stripHtmlFromTeamsMessage } from "./graph-thread.js"; import type { GraphThreadMessage } from "./graph-thread.js"; @@ -61,6 +65,13 @@ function buildParentCacheKey(groupId: string, channelId: string, parentId: strin return `${groupId}\u0000${channelId}\u0000${parentId}`; } +function resolveParentCacheExpiresAt(nowRaw: number): number | undefined { + const nowMs = asDateTimestampMs(nowRaw); + return nowMs === undefined + ? undefined + : resolveExpiresAtMsFromDurationMs(PARENT_CACHE_TTL_MS, { nowMs }); +} + /** * Fetch a channel parent message with an LRU+TTL cache. * @@ -75,16 +86,23 @@ export async function fetchParentMessageCached( fetchParent: ThreadParentContextFetcher = fetchChannelMessage, ): Promise { const key = buildParentCacheKey(groupId, channelId, parentId); - const now = Date.now(); + const now = asDateTimestampMs(Date.now()); const cached = parentCache.get(key); - if (cached && cached.expiresAt > now) { + const cachedExpiresAt = cached ? asDateTimestampMs(cached.expiresAt) : undefined; + if (cached && now !== undefined && cachedExpiresAt !== undefined && cachedExpiresAt > now) { // Refresh LRU ordering on hit. parentCache.delete(key); parentCache.set(key, cached); return cached.message; } + if (cached) { + parentCache.delete(key); + } const message = await fetchParent(token, groupId, channelId, parentId); - touchLru(parentCache, key, { message, expiresAt: now + PARENT_CACHE_TTL_MS }, PARENT_CACHE_MAX); + const expiresAt = resolveParentCacheExpiresAt(Date.now()); + if (expiresAt !== undefined) { + touchLru(parentCache, key, { message, expiresAt }, PARENT_CACHE_MAX); + } return message; }