Files
openclaw/extensions/msteams/src/thread-parent-context.ts
2026-05-01 17:06:26 +01:00

160 lines
5.6 KiB
TypeScript

// Parent-message context injection for Teams channel thread replies.
//
// When an inbound message arrives as a reply inside a Teams channel thread,
// the triggering message often makes no sense on its own (for example, a
// one-word "yes" or "go ahead"). Per-thread session isolation (PR #62713)
// gives each thread its own session, but the first message in a brand-new
// thread session still has no parent context.
//
// This module fetches the parent message via Graph and prepends a compact
// `Replying to @sender: …` system event to the next agent turn so the agent
// knows what is being responded to. Fetches are cached to avoid repeated
// Graph calls within the same active thread, and per-session dedupe ensures
// the same parent is not re-injected on every subsequent reply in the
// thread.
import { fetchChannelMessage, stripHtmlFromTeamsMessage } from "./graph-thread.js";
import type { GraphThreadMessage } from "./graph-thread.js";
// LRU cache for parent message fetches. Keyed by `teamId:channelId:parentId`.
// 5-minute TTL and 100-entry cap keep active-thread chatter fast without
// holding stale data when a thread goes quiet. Eviction uses Map insertion
// order for LRU semantics (get() re-inserts on hit).
const PARENT_CACHE_TTL_MS = 5 * 60 * 1000;
const PARENT_CACHE_MAX = 100;
type ParentCacheEntry = {
message: GraphThreadMessage | undefined;
expiresAt: number;
};
const parentCache = new Map<string, ParentCacheEntry>();
// Per-session dedupe: remembers the most recent parent id we injected for a
// given session key. When the same thread session sees another reply against
// the same parent, we skip re-enqueueing the identical system event. We keep
// a small LRU so idle sessions eventually drop out.
const INJECTED_MAX = 200;
const injectedParents = new Map<string, string>();
type ThreadParentContextFetcher = (
token: string,
groupId: string,
channelId: string,
messageId: string,
) => Promise<GraphThreadMessage | undefined>;
function touchLru<K, V>(map: Map<K, V>, key: K, value: V, max: number): void {
if (map.has(key)) {
map.delete(key);
} else if (map.size >= max) {
// Drop the oldest (first-inserted) entry.
const firstKey = map.keys().next().value;
if (firstKey !== undefined) {
map.delete(firstKey);
}
}
map.set(key, value);
}
function buildParentCacheKey(groupId: string, channelId: string, parentId: string): string {
return `${groupId}\u0000${channelId}\u0000${parentId}`;
}
/**
* Fetch a channel parent message with an LRU+TTL cache.
*
* Uses the injected `fetchParent` (defaults to `fetchChannelMessage`) so
* tests can swap in a stub without mocking the Graph transport.
*/
export async function fetchParentMessageCached(
token: string,
groupId: string,
channelId: string,
parentId: string,
fetchParent: ThreadParentContextFetcher = fetchChannelMessage,
): Promise<GraphThreadMessage | undefined> {
const key = buildParentCacheKey(groupId, channelId, parentId);
const now = Date.now();
const cached = parentCache.get(key);
if (cached && cached.expiresAt > now) {
// Refresh LRU ordering on hit.
parentCache.delete(key);
parentCache.set(key, cached);
return cached.message;
}
const message = await fetchParent(token, groupId, channelId, parentId);
touchLru(parentCache, key, { message, expiresAt: now + PARENT_CACHE_TTL_MS }, PARENT_CACHE_MAX);
return message;
}
type ParentContextSummary = {
/** Display name of the parent message author, or "unknown". */
sender: string;
/** Stripped, single-line parent body text (or empty if unresolved). */
text: string;
};
const PARENT_TEXT_MAX_CHARS = 400;
/**
* Extract a compact summary (sender + plain-text body) from a Graph parent
* message. Returns undefined when the parent cannot be summarized (missing
* or blank body).
*/
export function summarizeParentMessage(
message: GraphThreadMessage | undefined,
): ParentContextSummary | undefined {
if (!message) {
return undefined;
}
const sender =
message.from?.user?.displayName ?? message.from?.application?.displayName ?? "unknown";
const contentType = message.body?.contentType ?? "text";
const raw = message.body?.content ?? "";
const text =
contentType === "html" ? stripHtmlFromTeamsMessage(raw) : raw.replace(/\s+/g, " ").trim();
if (!text) {
return undefined;
}
return {
sender,
text:
text.length > PARENT_TEXT_MAX_CHARS ? `${text.slice(0, PARENT_TEXT_MAX_CHARS - 1)}` : text,
};
}
/**
* Build the single-line `Replying to @sender: body` system event text.
* Callers should pass this text to `enqueueSystemEvent` together with a
* stable contextKey derived from the parent id.
*/
export function formatParentContextEvent(summary: ParentContextSummary): string {
return `Replying to @${summary.sender}: ${summary.text}`;
}
/**
* Decide whether a parent context event should be enqueued for the current
* session. Returns `false` when we already injected the same parent for this
* session recently (prevents re-prepending identical context on every reply
* in the thread).
*/
export function shouldInjectParentContext(sessionKey: string, parentId: string): boolean {
const key = sessionKey;
return injectedParents.get(key) !== parentId;
}
/**
* Record that `parentId` was just injected for `sessionKey` so subsequent
* replies with the same parent can short-circuit via `shouldInjectParentContext`.
*/
export function markParentContextInjected(sessionKey: string, parentId: string): void {
touchLru(injectedParents, sessionKey, parentId, INJECTED_MAX);
}
// Exported for test isolation.
export function _resetThreadParentContextCachesForTest(): void {
parentCache.clear();
injectedParents.clear();
}