Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ Docs: https://docs.openclaw.ai
- Plugins/memory-core: respect configured memory-search embedding concurrency during non-batch indexing so local Ollama embedding backends can serialize indexing instead of flooding the server. Fixes #66822. (#66931) Thanks @oliviareid-svg and @LyraInTheFlesh.
- Docker/update smoke: keep the package-derived update-channel fixture on package-shipped files and make its UI build stub create the asset the updater verifies. Thanks @vincentkoc.
- Gateway/models: repair legacy `models.providers.*.api = "openai"` config values to `openai-completions`, and skip providers with future stale API enum values during startup instead of bricking the gateway. Fixes #72477. (#72542) Thanks @JooyoungChoi14 and @obviyus.
- Mattermost/thread replies: use the Mattermost thread root for delivery when replying to an existing thread without turning `replyToMode: "off"` conversations into thread-scoped sessions, preventing invalid non-root reply delivery. (#55151, #55186, #57565) Thanks @dlindegaard, @hnykda, and @dave.

## 2026.4.26

Expand Down
75 changes: 75 additions & 0 deletions extensions/mattermost/src/mattermost/monitor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
evaluateMattermostMentionGate,
MattermostRetryableInboundError,
processMattermostReplayGuardedPost,
resolveMattermostDeliveryReplyToId,
resolveMattermostReactionChannelId,
resolveMattermostEffectiveReplyToId,
resolveMattermostReplyRootId,
Expand Down Expand Up @@ -162,6 +163,7 @@ describe("resolveMattermostReplyRootId with block streaming payloads", () => {
// mode, the deliver callback should still use the existing threadRootId.
expect(
resolveMattermostReplyRootId({
kind: "channel",
threadRootId: "thread-root-1",
replyToId: "streamed-reply-id",
}),
Expand All @@ -173,16 +175,27 @@ describe("resolveMattermostReplyRootId with block streaming payloads", () => {
// inbound post id as replyToId from the "all" threading mode.
expect(
resolveMattermostReplyRootId({
kind: "channel",
replyToId: "inbound-post-for-threading",
}),
).toBe("inbound-post-for-threading");
});

it("uses the existing thread root only when a delivery reply target exists", () => {
expect(
resolveMattermostReplyRootId({
kind: "channel",
threadRootId: "thread-root-1",
}),
).toBeUndefined();
});
});

describe("resolveMattermostReplyRootId", () => {
it("uses replyToId for top-level replies", () => {
expect(
resolveMattermostReplyRootId({
kind: "channel",
replyToId: "inbound-post-123",
}),
).toBe("inbound-post-123");
Expand All @@ -191,17 +204,79 @@ describe("resolveMattermostReplyRootId", () => {
it("keeps the thread root when replying inside an existing thread", () => {
expect(
resolveMattermostReplyRootId({
kind: "group",
threadRootId: "thread-root-456",
replyToId: "child-post-789",
}),
).toBe("thread-root-456");
});

it("drops payload replyToId for direct messages", () => {
expect(
resolveMattermostReplyRootId({
kind: "direct",
replyToId: "dm-post-123",
}),
).toBeUndefined();
});

it("keeps direct messages non-threaded even when a thread root is present", () => {
expect(
resolveMattermostReplyRootId({
kind: "direct",
threadRootId: "thread-root-456",
replyToId: "child-post-789",
}),
).toBeUndefined();
});

it("falls back to undefined when neither reply target is available", () => {
expect(resolveMattermostReplyRootId({})).toBeUndefined();
});
});

describe("resolveMattermostDeliveryReplyToId", () => {
it("uses the session reply id when replyToMode routes to a thread", () => {
expect(
resolveMattermostDeliveryReplyToId({
kind: "channel",
effectiveReplyToId: "thread-root-456",
postId: "child-post-789",
threadRootId: "thread-root-456",
}),
).toBe("thread-root-456");
});

it("keeps replyToMode off sessions top-level while preserving threaded delivery targets", () => {
expect(
resolveMattermostDeliveryReplyToId({
kind: "channel",
postId: "child-post-789",
threadRootId: "thread-root-456",
}),
).toBe("child-post-789");
});

it("does not create a delivery reply target for top-level posts without session threading", () => {
expect(
resolveMattermostDeliveryReplyToId({
kind: "channel",
postId: "top-level-post-123",
}),
).toBeUndefined();
});

it("does not create delivery reply targets for direct messages", () => {
expect(
resolveMattermostDeliveryReplyToId({
kind: "direct",
postId: "dm-post-123",
threadRootId: "thread-root-456",
}),
).toBeUndefined();
});
});

describe("canFinalizeMattermostPreviewInPlace", () => {
it("allows in-place finalization when the final reply target matches the preview thread", () => {
expect(
Expand Down
90 changes: 78 additions & 12 deletions extensions/mattermost/src/mattermost/monitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,14 +231,22 @@ function channelChatType(kind: ChatType): "direct" | "group" | "channel" {
}

export function resolveMattermostReplyRootId(params: {
kind?: ChatType;
threadRootId?: string;
replyToId?: string;
}): string | undefined {
if (params.kind === "direct") {
return undefined;
}
const replyToId = normalizeOptionalString(params.replyToId);
if (!replyToId) {
return undefined;
}
const threadRootId = normalizeOptionalString(params.threadRootId);
if (threadRootId) {
return threadRootId;
}
return normalizeOptionalString(params.replyToId);
return replyToId;
}

export function canFinalizeMattermostPreviewInPlace(params: {
Expand Down Expand Up @@ -363,6 +371,24 @@ export function resolveMattermostEffectiveReplyToId(params: {
: undefined;
}

export function resolveMattermostDeliveryReplyToId(params: {
kind?: ChatType;
effectiveReplyToId?: string;
postId?: string | null;
threadRootId?: string | null;
}): string | undefined {
if (params.kind === "direct") {
return undefined;
}
const effectiveReplyToId = normalizeOptionalString(params.effectiveReplyToId);
if (effectiveReplyToId) {
return effectiveReplyToId;
}
return normalizeOptionalString(params.threadRootId)
? normalizeOptionalString(params.postId)
: undefined;
}

export function resolveMattermostThreadSessionContext(params: {
baseSessionKey: string;
kind: ChatType;
Expand Down Expand Up @@ -631,6 +657,17 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
replyToMode,
threadRootId: opts.post.root_id,
});
const deliveryReplyToId = resolveMattermostDeliveryReplyToId({
kind,
effectiveReplyToId: threadContext.effectiveReplyToId,
postId: opts.post.id || opts.postId,
threadRootId: opts.post.root_id,
});
const deliveryReplyRootId = resolveMattermostReplyRootId({
kind,
threadRootId: opts.post.root_id ?? undefined,
replyToId: deliveryReplyToId,
});
const to = kind === "direct" ? `user:${opts.userId}` : `channel:${opts.channelId}`;
const bodyText = `[Button click: user @${opts.userName} selected "${opts.actionName}"]`;
const ctxPayload = core.channel.reply.finalizeInboundContext({
Expand Down Expand Up @@ -658,7 +695,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
Provider: "mattermost" as const,
Surface: "mattermost" as const,
MessageSid: `interaction:${opts.postId}:${opts.actionId}`,
ReplyToId: threadContext.effectiveReplyToId,
ReplyToId: deliveryReplyToId,
MessageThreadId: threadContext.effectiveReplyToId,
WasMentioned: true,
CommandAuthorized: false,
Expand All @@ -683,7 +720,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
channel: "mattermost",
accountId: account.accountId,
typing: {
start: () => sendTypingIndicator(opts.channelId, threadContext.effectiveReplyToId),
start: () => sendTypingIndicator(opts.channelId, deliveryReplyRootId),
onStartError: (err) => {
logTypingFailure({
log: (message) => logger.debug?.(message),
Expand All @@ -707,7 +744,8 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
accountId: account.accountId,
agentId: route.agentId,
replyToId: resolveMattermostReplyRootId({
threadRootId: threadContext.effectiveReplyToId,
kind,
threadRootId: opts.post.root_id ?? undefined,
replyToId: payload.replyToId,
}),
textLimit,
Expand Down Expand Up @@ -813,9 +851,16 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
postId: string;
messageSid?: string;
effectiveReplyToId?: string;
deliveryReplyToId?: string;
threadRootId?: string;
deliverReplies?: boolean;
}): Promise<string> => {
const to = params.kind === "direct" ? `user:${params.senderId}` : `channel:${params.channelId}`;
const deliveryReplyRootId = resolveMattermostReplyRootId({
kind: params.kind,
threadRootId: params.threadRootId,
replyToId: params.deliveryReplyToId ?? params.effectiveReplyToId,
});
const fromLabel =
params.kind === "direct"
? `Mattermost DM from ${params.senderName}`
Expand Down Expand Up @@ -846,7 +891,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
Provider: "mattermost" as const,
Surface: "mattermost" as const,
MessageSid: params.messageSid ?? `interaction:${params.postId}:${Date.now()}`,
ReplyToId: params.effectiveReplyToId,
ReplyToId: params.deliveryReplyToId ?? params.effectiveReplyToId,
MessageThreadId: params.effectiveReplyToId,
Timestamp: Date.now(),
WasMentioned: true,
Expand Down Expand Up @@ -877,7 +922,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
accountId: account.accountId,
typing: shouldDeliverReplies
? {
start: () => sendTypingIndicator(params.channelId, params.effectiveReplyToId),
start: () => sendTypingIndicator(params.channelId, deliveryReplyRootId),
onStartError: (err) => {
logTypingFailure({
log: (message) => logger.debug?.(message),
Expand Down Expand Up @@ -915,7 +960,8 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
accountId: account.accountId,
agentId: params.route.agentId,
replyToId: resolveMattermostReplyRootId({
threadRootId: params.effectiveReplyToId,
kind: params.kind,
threadRootId: params.threadRootId,
replyToId: trimmedPayload.replyToId,
}),
textLimit,
Expand Down Expand Up @@ -1058,6 +1104,12 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
replyToMode,
threadRootId: params.post.root_id,
});
const deliveryReplyToId = resolveMattermostDeliveryReplyToId({
kind,
effectiveReplyToId: threadContext.effectiveReplyToId,
postId: params.post.id || params.payload.post_id,
threadRootId: params.post.root_id,
});
const modelSessionRoute = {
agentId: route.agentId,
sessionKey: threadContext.sessionKey,
Expand Down Expand Up @@ -1143,6 +1195,8 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
model: pickerState.model,
}),
effectiveReplyToId: threadContext.effectiveReplyToId,
deliveryReplyToId,
threadRootId: params.post.root_id ?? undefined,
deliverReplies: true,
});
const updatedModel = resolveMattermostModelPickerCurrentModel({
Expand Down Expand Up @@ -1374,6 +1428,17 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
threadRootId,
});
const { effectiveReplyToId, sessionKey, parentSessionKey } = threadContext;
const deliveryReplyToId = resolveMattermostDeliveryReplyToId({
kind,
effectiveReplyToId,
postId: post.id,
threadRootId,
});
const deliveryReplyRootId = resolveMattermostReplyRootId({
kind,
threadRootId,
replyToId: deliveryReplyToId,
});
const historyKey = kind === "direct" ? null : sessionKey;

const mentionRegexes = core.channel.mentions.buildMentionRegexes(cfg, route.agentId);
Expand Down Expand Up @@ -1557,7 +1622,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
MessageSidFirst: allMessageIds.length > 1 ? allMessageIds[0] : undefined,
MessageSidLast:
allMessageIds.length > 1 ? allMessageIds[allMessageIds.length - 1] : undefined,
ReplyToId: effectiveReplyToId,
ReplyToId: deliveryReplyToId,
MessageThreadId: effectiveReplyToId,
Timestamp: typeof post.create_at === "number" ? post.create_at : undefined,
WasMentioned: kind !== "direct" ? mentionDecision.effectiveWasMentioned : undefined,
Expand Down Expand Up @@ -1608,7 +1673,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
channel: "mattermost",
accountId: account.accountId,
typing: {
start: () => sendTypingIndicator(channelId, effectiveReplyToId),
start: () => sendTypingIndicator(channelId, deliveryReplyRootId),
onStartError: (err) => {
logTypingFailure({
log: (message) => logger.debug?.(message),
Expand All @@ -1622,7 +1687,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
const draftStream = createMattermostDraftStream({
client,
channelId,
rootId: effectiveReplyToId,
rootId: deliveryReplyRootId,
throttleMs: 1200,
log: logVerboseMessage,
warn: logVerboseMessage,
Expand Down Expand Up @@ -1697,7 +1762,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
info,
client,
draftStream,
effectiveReplyToId,
effectiveReplyToId: deliveryReplyRootId,
resolvePreviewFinalText,
previewState,
logVerboseMessage,
Expand All @@ -1710,7 +1775,8 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
accountId: account.accountId,
agentId: route.agentId,
replyToId: resolveMattermostReplyRootId({
threadRootId: effectiveReplyToId,
kind,
threadRootId,
replyToId: payload.replyToId,
}),
textLimit,
Expand Down
Loading