fix(matrix): resolve reply context body and sender for quoted messages (#55056)

Merged via squash.

Prepared head SHA: 6fd580bb03
Co-authored-by: alberthild <3729342+alberthild@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
alberthild 2026-03-28 00:03:21 +01:00 committed by GitHub
parent 65ad45a37f
commit c7fbd51890
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 582 additions and 32 deletions

View File

@ -68,6 +68,7 @@ Docs: https://docs.openclaw.ai
- Daemon/Linux: stop flagging non-gateway systemd services as duplicate gateways just because their unit files mention OpenClaw, reducing false-positive doctor/log noise. (#45328) Thanks @gregretkowski.
- Feishu: close WebSocket connections on monitor stop/abort so ghost connections no longer persist, preventing duplicate event processing and resource leaks across restart cycles. (#52844) Thanks @schumilin.
- Feishu: use the original message `create_time` instead of `Date.now()` for inbound timestamps so offline-retried messages carry the correct authoring time, preventing mis-targeted agent actions on stale instructions. (#52809) Thanks @schumilin.
- Matrix/replies: include quoted poll question/options in inbound reply context so the agent sees the original poll content when users reply to Matrix poll messages. (#55056) Thanks @alberthild.
- Agents/sandbox: honor `tools.sandbox.tools.alsoAllow`, let explicit sandbox re-allows remove matching built-in default-deny tools, and keep sandbox explain/error guidance aligned with the effective sandbox tool policy. (#54492) Thanks @ngutman.
- Agents/sandbox: make blocked-tool guidance glob-aware again, redact/sanitize session-specific explain hints for safer copy-paste, and avoid leaking control-character session keys in those hints. (#54684) Thanks @ngutman.
- Agents/compaction: trigger timeout recovery compaction before retrying high-context LLM timeouts so embedded runs stop repeating oversized requests. (#46417) thanks @joeykrug.

View File

@ -0,0 +1,43 @@
import {
formatMatrixMessageText,
resolveMatrixMessageAttachment,
resolveMatrixMessageBody,
} from "../media-text.js";
import {
formatPollAsText,
isPollStartType,
parsePollStartContent,
type PollStartContent,
} from "../poll-types.js";
import type { MatrixRawEvent } from "./types.js";
export function trimMatrixMaybeString(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed || undefined;
}
export function summarizeMatrixMessageContextEvent(event: MatrixRawEvent): string | undefined {
if (isPollStartType(event.type)) {
const pollSummary = parsePollStartContent(event.content as PollStartContent);
if (pollSummary) {
return formatPollAsText(pollSummary);
}
}
const content = event.content as { body?: unknown; filename?: unknown; msgtype?: unknown };
return formatMatrixMessageText({
body: resolveMatrixMessageBody({
body: trimMatrixMaybeString(content.body),
filename: trimMatrixMaybeString(content.filename),
msgtype: trimMatrixMaybeString(content.msgtype),
}),
attachment: resolveMatrixMessageAttachment({
body: trimMatrixMaybeString(content.body),
filename: trimMatrixMaybeString(content.filename),
msgtype: trimMatrixMaybeString(content.msgtype),
}),
});
}

View File

@ -123,4 +123,118 @@ describe("createMatrixRoomMessageHandler inbound body formatting", () => {
}),
);
});
it("records reply context for quoted poll start events inside always-threaded replies", async () => {
const { handler, finalizeInboundContext } = createMatrixHandlerTestHarness({
client: {
getEvent: async (_roomId: string, eventId: string) => {
if (eventId === "$thread-root") {
return createMatrixTextMessageEvent({
eventId: "$thread-root",
sender: "@bob:example.org",
body: "Root topic",
});
}
return {
event_id: "$poll",
sender: "@alice:example.org",
type: "m.poll.start",
origin_server_ts: 1,
content: {
"m.poll.start": {
question: { "m.text": "Lunch?" },
kind: "m.poll.disclosed",
max_selections: 1,
answers: [
{ id: "a1", "m.text": "Pizza" },
{ id: "a2", "m.text": "Sushi" },
],
},
},
} satisfies MatrixRawEvent;
},
} as unknown as Partial<MatrixClient>,
isDirectMessage: false,
threadReplies: "always",
getMemberDisplayName: async (_roomId, userId) => {
if (userId === "@alice:example.org") {
return "Alice";
}
if (userId === "@bob:example.org") {
return "Bob";
}
return "sender";
},
});
await handler(
"!room:example.org",
createMatrixTextMessageEvent({
eventId: "$reply1",
body: "@room follow up",
relatesTo: {
rel_type: "m.thread",
event_id: "$thread-root",
"m.in_reply_to": { event_id: "$poll" },
},
mentions: { room: true },
}),
);
expect(finalizeInboundContext).toHaveBeenCalledWith(
expect.objectContaining({
MessageThreadId: "$thread-root",
ReplyToId: undefined,
ReplyToSender: "Alice",
ReplyToBody: "[Poll]\nLunch?\n\n1. Pizza\n2. Sushi",
ThreadStarterBody: "Matrix thread root $thread-root from Bob:\nRoot topic",
}),
);
});
it("reuses the fetched thread root when reply context points at the same event", async () => {
const getEvent = vi.fn(async () =>
createMatrixTextMessageEvent({
eventId: "$thread-root",
sender: "@alice:example.org",
body: "Root topic",
}),
);
const getMemberDisplayName = vi.fn(async (_roomId: string, userId: string) =>
userId === "@alice:example.org" ? "Alice" : "sender",
);
const { handler, finalizeInboundContext } = createMatrixHandlerTestHarness({
client: { getEvent },
isDirectMessage: false,
threadReplies: "always",
getMemberDisplayName,
});
await handler(
"!room:example.org",
createMatrixTextMessageEvent({
eventId: "$reply1",
body: "@room follow up",
relatesTo: {
rel_type: "m.thread",
event_id: "$thread-root",
"m.in_reply_to": { event_id: "$thread-root" },
},
mentions: { room: true },
}),
);
expect(finalizeInboundContext).toHaveBeenCalledWith(
expect.objectContaining({
MessageThreadId: "$thread-root",
ReplyToId: undefined,
ReplyToSender: "Alice",
ReplyToBody: "Root topic",
ThreadStarterBody: "Matrix thread root $thread-root from Alice:\nRoot topic",
}),
);
expect(getEvent).toHaveBeenCalledTimes(1);
expect(getMemberDisplayName).toHaveBeenCalledTimes(2);
});
});

View File

@ -37,6 +37,7 @@ import { downloadMatrixMedia } from "./media.js";
import { resolveMentions } from "./mentions.js";
import { handleInboundMatrixReaction } from "./reaction-events.js";
import { deliverMatrixReplies } from "./replies.js";
import { createMatrixReplyContextResolver } from "./reply-context.js";
import { resolveMatrixRoomConfig } from "./rooms.js";
import { resolveMatrixInboundRoute } from "./route.js";
import { createMatrixThreadContextResolver } from "./thread-context.js";
@ -182,6 +183,11 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
getMemberDisplayName,
logVerboseMessage,
});
const resolveReplyContext = createMatrixReplyContextResolver({
client,
getMemberDisplayName,
logVerboseMessage,
});
const readStoreAllowFrom = async (): Promise<string[]> => {
const now = Date.now();
@ -707,6 +713,20 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
? await resolveThreadContext({ roomId, threadRootId: _threadRootId })
: undefined;
// Resolve the body and sender of the replied-to message so the agent
// can see what is being replied to, not just the event ID.
// Note: resolve even when threadTarget is set (e.g. threadReplies: "always")
// because the user may still be quoting a specific message within the thread.
const replyContext =
replyToEventId && replyToEventId === _threadRootId && threadContext?.summary
? {
replyToBody: threadContext.summary,
replyToSender: threadContext.senderLabel,
}
: replyToEventId
? await resolveReplyContext({ roomId, eventId: replyToEventId })
: undefined;
if (_configuredBinding) {
const ensured = await ensureConfiguredAcpBindingReady({
cfg,
@ -766,6 +786,8 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
WasMentioned: isRoom ? wasMentioned : undefined,
MessageSid: _messageId,
ReplyToId: threadTarget ? undefined : (replyToEventId ?? undefined),
ReplyToBody: replyContext?.replyToBody,
ReplyToSender: replyContext?.replyToSender,
MessageThreadId: threadTarget,
ThreadStarterBody: threadContext?.threadStarterBody,
Timestamp: eventTs ?? undefined,

View File

@ -0,0 +1,273 @@
import { describe, expect, it, vi } from "vitest";
import { createMatrixReplyContextResolver, summarizeMatrixReplyEvent } from "./reply-context.js";
import type { MatrixRawEvent } from "./types.js";
describe("matrix reply context", () => {
it("summarizes reply events from body text", () => {
expect(
summarizeMatrixReplyEvent({
event_id: "$original",
sender: "@alice:example.org",
type: "m.room.message",
origin_server_ts: Date.now(),
content: {
msgtype: "m.text",
body: " Some quoted message ",
},
} as MatrixRawEvent),
).toBe("Some quoted message");
});
it("truncates long reply bodies", () => {
const longBody = "x".repeat(600);
const result = summarizeMatrixReplyEvent({
event_id: "$original",
sender: "@alice:example.org",
type: "m.room.message",
origin_server_ts: Date.now(),
content: {
msgtype: "m.text",
body: longBody,
},
} as MatrixRawEvent);
expect(result).toBeDefined();
expect(result!.length).toBeLessThanOrEqual(500);
expect(result!.endsWith("...")).toBe(true);
});
it("handles media-only reply events", () => {
expect(
summarizeMatrixReplyEvent({
event_id: "$original",
sender: "@alice:example.org",
type: "m.room.message",
origin_server_ts: Date.now(),
content: {
msgtype: "m.image",
body: "photo.jpg",
},
} as MatrixRawEvent),
).toBe("[matrix image attachment]");
});
it("summarizes poll start events from poll content", () => {
expect(
summarizeMatrixReplyEvent({
event_id: "$poll",
sender: "@alice:example.org",
type: "m.poll.start",
origin_server_ts: Date.now(),
content: {
"m.poll.start": {
question: { "m.text": "Lunch?" },
kind: "m.poll.disclosed",
max_selections: 1,
answers: [
{ id: "a1", "m.text": "Pizza" },
{ id: "a2", "m.text": "Sushi" },
],
},
},
} as MatrixRawEvent),
).toBe("[Poll]\nLunch?\n\n1. Pizza\n2. Sushi");
});
it("resolves and caches reply context", async () => {
const getEvent = vi.fn(async () => ({
event_id: "$original",
sender: "@alice:example.org",
type: "m.room.message",
origin_server_ts: Date.now(),
content: {
msgtype: "m.text",
body: "This is the original message",
},
}));
const getMemberDisplayName = vi.fn(async () => "Alice");
const resolveReplyContext = createMatrixReplyContextResolver({
client: {
getEvent,
} as never,
getMemberDisplayName,
logVerboseMessage: () => {},
});
const result = await resolveReplyContext({
roomId: "!room:example.org",
eventId: "$original",
});
expect(result).toEqual({
replyToBody: "This is the original message",
replyToSender: "Alice",
});
// Second call should use cache
await resolveReplyContext({
roomId: "!room:example.org",
eventId: "$original",
});
expect(getEvent).toHaveBeenCalledTimes(1);
expect(getMemberDisplayName).toHaveBeenCalledTimes(1);
});
it("returns empty context when event fetch fails", async () => {
const getEvent = vi.fn().mockRejectedValueOnce(new Error("not found"));
const getMemberDisplayName = vi.fn(async () => "Alice");
const resolveReplyContext = createMatrixReplyContextResolver({
client: {
getEvent,
} as never,
getMemberDisplayName,
logVerboseMessage: () => {},
});
const result = await resolveReplyContext({
roomId: "!room:example.org",
eventId: "$missing",
});
expect(result).toEqual({});
});
it("returns empty context for redacted events", async () => {
const getEvent = vi.fn(async () => ({
event_id: "$redacted",
sender: "@alice:example.org",
type: "m.room.message",
origin_server_ts: Date.now(),
unsigned: {
redacted_because: { type: "m.room.redaction" },
},
content: {},
}));
const getMemberDisplayName = vi.fn(async () => "Alice");
const resolveReplyContext = createMatrixReplyContextResolver({
client: {
getEvent,
} as never,
getMemberDisplayName,
logVerboseMessage: () => {},
});
const result = await resolveReplyContext({
roomId: "!room:example.org",
eventId: "$redacted",
});
expect(result).toEqual({});
expect(getMemberDisplayName).not.toHaveBeenCalled();
});
it("does not cache fetch failures so retries can succeed", async () => {
const getEvent = vi
.fn()
.mockRejectedValueOnce(new Error("temporary failure"))
.mockResolvedValueOnce({
event_id: "$original",
sender: "@bob:example.org",
type: "m.room.message",
origin_server_ts: Date.now(),
content: {
msgtype: "m.text",
body: "Recovered message",
},
});
const getMemberDisplayName = vi.fn(async () => "Bob");
const resolveReplyContext = createMatrixReplyContextResolver({
client: {
getEvent,
} as never,
getMemberDisplayName,
logVerboseMessage: () => {},
});
// First call fails
const first = await resolveReplyContext({
roomId: "!room:example.org",
eventId: "$original",
});
expect(first).toEqual({});
// Second call succeeds (should retry, not use cached failure)
const second = await resolveReplyContext({
roomId: "!room:example.org",
eventId: "$original",
});
expect(second).toEqual({
replyToBody: "Recovered message",
replyToSender: "Bob",
});
expect(getEvent).toHaveBeenCalledTimes(2);
});
it("falls back to senderId when display name resolution fails", async () => {
const getEvent = vi.fn(async () => ({
event_id: "$original",
sender: "@charlie:example.org",
type: "m.room.message",
origin_server_ts: Date.now(),
content: {
msgtype: "m.text",
body: "Hello",
},
}));
const getMemberDisplayName = vi.fn().mockRejectedValueOnce(new Error("unknown member"));
const resolveReplyContext = createMatrixReplyContextResolver({
client: {
getEvent,
} as never,
getMemberDisplayName,
logVerboseMessage: () => {},
});
const result = await resolveReplyContext({
roomId: "!room:example.org",
eventId: "$original",
});
expect(result).toEqual({
replyToBody: "Hello",
replyToSender: "@charlie:example.org",
});
});
it("uses LRU eviction — recently accessed entries survive over older ones", async () => {
let callCount = 0;
const getEvent = vi.fn().mockImplementation((_roomId: string, eventId: string) => {
callCount++;
return Promise.resolve({
event_id: eventId,
sender: `@user${callCount}:example.org`,
type: "m.room.message",
origin_server_ts: Date.now(),
content: { msgtype: "m.text", body: `msg-${eventId}` },
});
});
const getMemberDisplayName = vi
.fn()
.mockImplementation((_r: string, userId: string) => Promise.resolve(userId));
// Use a small cache by testing the eviction pattern:
// The actual MAX_CACHED_REPLY_CONTEXTS is 256. We cannot override it easily,
// but we can verify that a cache hit reorders entries (delete + re-insert).
const resolveReplyContext = createMatrixReplyContextResolver({
client: { getEvent } as never,
getMemberDisplayName,
logVerboseMessage: () => {},
});
// Populate cache with two entries
await resolveReplyContext({ roomId: "!r:e", eventId: "$A" });
await resolveReplyContext({ roomId: "!r:e", eventId: "$B" });
expect(getEvent).toHaveBeenCalledTimes(2);
// Access $A again — should be a cache hit (no new getEvent call)
// and should move $A to the end of the Map for LRU.
const hitResult = await resolveReplyContext({ roomId: "!r:e", eventId: "$A" });
expect(getEvent).toHaveBeenCalledTimes(2); // Still 2 — cache hit
expect(hitResult.replyToBody).toBe("msg-$A");
});
});

View File

@ -0,0 +1,90 @@
import type { MatrixClient } from "../sdk.js";
import { summarizeMatrixMessageContextEvent, trimMatrixMaybeString } from "./context-summary.js";
import type { MatrixRawEvent } from "./types.js";
const MAX_CACHED_REPLY_CONTEXTS = 256;
const MAX_REPLY_BODY_LENGTH = 500;
export type MatrixReplyContext = {
replyToBody?: string;
replyToSender?: string;
};
function truncateReplyBody(value: string): string {
if (value.length <= MAX_REPLY_BODY_LENGTH) {
return value;
}
return `${value.slice(0, MAX_REPLY_BODY_LENGTH - 3)}...`;
}
export function summarizeMatrixReplyEvent(event: MatrixRawEvent): string | undefined {
const body = summarizeMatrixMessageContextEvent(event);
return body ? truncateReplyBody(body) : undefined;
}
/**
* Creates a cached resolver that fetches the body and sender of a replied-to
* Matrix event. This allows the agent to see the content of the message being
* replied to, not just its event ID.
*/
export function createMatrixReplyContextResolver(params: {
client: MatrixClient;
getMemberDisplayName: (roomId: string, userId: string) => Promise<string>;
logVerboseMessage: (message: string) => void;
}) {
const cache = new Map<string, MatrixReplyContext>();
const remember = (key: string, value: MatrixReplyContext): MatrixReplyContext => {
cache.set(key, value);
if (cache.size > MAX_CACHED_REPLY_CONTEXTS) {
const oldest = cache.keys().next().value;
if (typeof oldest === "string") {
cache.delete(oldest);
}
}
return value;
};
return async (input: { roomId: string; eventId: string }): Promise<MatrixReplyContext> => {
const cacheKey = `${input.roomId}:${input.eventId}`;
const cached = cache.get(cacheKey);
if (cached) {
// Move to end for LRU semantics so frequently accessed entries survive eviction.
cache.delete(cacheKey);
cache.set(cacheKey, cached);
return cached;
}
const event = await params.client.getEvent(input.roomId, input.eventId).catch((err) => {
params.logVerboseMessage(
`matrix: failed resolving reply context room=${input.roomId} id=${input.eventId}: ${String(err)}`,
);
return null;
});
if (!event) {
// Do not cache failures so transient errors can be retried on the next
// message that references the same event.
return {};
}
const rawEvent = event as MatrixRawEvent;
if (rawEvent.unsigned?.redacted_because) {
return remember(cacheKey, {});
}
const replyToBody = summarizeMatrixReplyEvent(rawEvent);
if (!replyToBody) {
return remember(cacheKey, {});
}
const senderId = trimMatrixMaybeString(rawEvent.sender);
const senderName =
senderId &&
(await params.getMemberDisplayName(input.roomId, senderId).catch(() => undefined));
return remember(cacheKey, {
replyToBody,
replyToSender: senderName ?? senderId,
});
};
}

View File

@ -63,6 +63,8 @@ describe("matrix thread context", () => {
}),
).resolves.toEqual({
threadStarterBody: "Matrix thread root $root from Alice:\nRoot topic",
senderLabel: "Alice",
summary: "Root topic",
});
await resolveThreadContext({
@ -113,9 +115,33 @@ describe("matrix thread context", () => {
}),
).resolves.toEqual({
threadStarterBody: "Matrix thread root $root from Alice:\nRecovered topic",
senderLabel: "Alice",
summary: "Recovered topic",
});
expect(getEvent).toHaveBeenCalledTimes(2);
expect(getMemberDisplayName).toHaveBeenCalledTimes(1);
});
it("summarizes poll start thread roots from poll content", () => {
expect(
summarizeMatrixThreadStarterEvent({
event_id: "$root",
sender: "@alice:example.org",
type: "m.poll.start",
origin_server_ts: Date.now(),
content: {
"m.poll.start": {
question: { "m.text": "Lunch?" },
kind: "m.poll.disclosed",
max_selections: 1,
answers: [
{ id: "a1", "m.text": "Pizza" },
{ id: "a2", "m.text": "Sushi" },
],
},
},
} as MatrixRawEvent),
).toBe("[Poll]\nLunch?\n\n1. Pizza\n2. Sushi");
});
});

View File

@ -1,9 +1,5 @@
import {
formatMatrixMessageText,
resolveMatrixMessageAttachment,
resolveMatrixMessageBody,
} from "../media-text.js";
import type { MatrixClient } from "../sdk.js";
import { summarizeMatrixMessageContextEvent, trimMatrixMaybeString } from "./context-summary.js";
import type { MatrixRawEvent } from "./types.js";
const MAX_TRACKED_THREAD_STARTERS = 256;
@ -11,16 +7,10 @@ const MAX_THREAD_STARTER_BODY_LENGTH = 500;
type MatrixThreadContext = {
threadStarterBody?: string;
senderLabel?: string;
summary?: string;
};
function trimMaybeString(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed || undefined;
}
function truncateThreadStarterBody(value: string): string {
if (value.length <= MAX_THREAD_STARTER_BODY_LENGTH) {
return value;
@ -29,27 +19,16 @@ function truncateThreadStarterBody(value: string): string {
}
export function summarizeMatrixThreadStarterEvent(event: MatrixRawEvent): string | undefined {
const content = event.content as { body?: unknown; filename?: unknown; msgtype?: unknown };
const body = formatMatrixMessageText({
body: resolveMatrixMessageBody({
body: trimMaybeString(content.body),
filename: trimMaybeString(content.filename),
msgtype: trimMaybeString(content.msgtype),
}),
attachment: resolveMatrixMessageAttachment({
body: trimMaybeString(content.body),
filename: trimMaybeString(content.filename),
msgtype: trimMaybeString(content.msgtype),
}),
});
const body = summarizeMatrixMessageContextEvent(event);
if (body) {
return truncateThreadStarterBody(body);
}
const msgtype = trimMaybeString(content.msgtype);
const content = event.content as { msgtype?: unknown };
const msgtype = trimMatrixMaybeString(content.msgtype);
if (msgtype) {
return `Matrix ${msgtype} message`;
}
const eventType = trimMaybeString(event.type);
const eventType = trimMatrixMaybeString(event.type);
return eventType ? `Matrix ${eventType} event` : undefined;
}
@ -107,17 +86,21 @@ export function createMatrixThreadContextResolver(params: {
}
const rawEvent = rootEvent as MatrixRawEvent;
const senderId = trimMaybeString(rawEvent.sender);
const senderId = trimMatrixMaybeString(rawEvent.sender);
const senderName =
senderId &&
(await params.getMemberDisplayName(input.roomId, senderId).catch(() => undefined));
const senderLabel = senderName ?? senderId;
const summary = summarizeMatrixThreadStarterEvent(rawEvent);
return remember(cacheKey, {
threadStarterBody: formatMatrixThreadStarterBody({
threadRootId: input.threadRootId,
senderId,
senderName,
summary: summarizeMatrixThreadStarterEvent(rawEvent),
summary,
}),
senderLabel,
summary,
});
};
}

View File

@ -480,8 +480,6 @@ importers:
specifier: ^1.2.10
version: 1.2.10
extensions/microsoft-foundry: {}
extensions/minimax: {}
extensions/mistral: {}