From 28e27ca5d16a9b4e5e42cf4e86492f4188ae4b4e Mon Sep 17 00:00:00 2001 From: Alex Knight Date: Wed, 6 May 2026 20:01:18 +1000 Subject: [PATCH] fix(msteams): preserve proactive thread replies (#78387) Co-authored-by: Alex Knight <15041791+amknight@users.noreply.github.com> --- CHANGELOG.md | 1 + extensions/msteams/src/messenger.test.ts | 88 +++++++++++++++++++ extensions/msteams/src/messenger.ts | 2 +- extensions/msteams/src/send-context.test.ts | 93 +++++++++++++++++++++ extensions/msteams/src/send-context.ts | 38 +++++++++ extensions/msteams/src/send.test.ts | 58 +++++++++++++ extensions/msteams/src/send.ts | 3 +- 7 files changed, 281 insertions(+), 2 deletions(-) create mode 100644 extensions/msteams/src/send-context.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6854a58d976..522cae479cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -117,6 +117,7 @@ Docs: https://docs.openclaw.ai - PDF/Codex: include extraction-fallback instructions for `openai-codex/*` PDF tool requests so Codex Responses receives its required system prompt. Fixes #77872. Thanks @anyech. - Onboard/channels: recover externalized channel plugins from stale `channels.` config by falling back to `ensureChannelSetupPluginInstalled` via the trusted catalog when the plugin is missing on disk, so leftover `appId`/token entries no longer dead-end onboard with " plugin not available." (#78328) Thanks @sliverp. - Codex/app-server: forward the OpenClaw workspace bootstrap block through Codex `developerInstructions` instead of `config.instructions`, so persona/style guidance reaches the behavior-shaping app-server lane. Fixes #77363. Thanks @lonexreb. +- MS Teams: route proactive channel sends with stored thread roots through the configured threaded reply path instead of forcing every CLI/message-tool send into a new top-level post. Fixes #78298. Thanks @amknight. - CLI/infer: pass minimal instructions to local `openai-codex/*` model probes and surface provider error details when `infer model run` returns no text. Fixes #76464. Thanks @lilesjtu. - Dependencies: override transitive `ip-address` to `10.2.0` so the runtime lockfile no longer includes the vulnerable `10.1.0` build flagged by Dependabot alert 109. Thanks @vincentkoc. - Plugins/install: apply OpenClaw's npm security overrides inside managed external plugin npm roots so hoisted plugin dependencies inherit the host package hardening. Thanks @vincentkoc. diff --git a/extensions/msteams/src/messenger.test.ts b/extensions/msteams/src/messenger.test.ts index a1cd85674a0..0a6e8f1367f 100644 --- a/extensions/msteams/src/messenger.test.ts +++ b/extensions/msteams/src/messenger.test.ts @@ -574,6 +574,94 @@ describe("msteams messenger", () => { expect(reference.conversation?.id).toBe("19:abc@thread.tacv2;messageid=legacy-activity-id"); }); + it("sends no-context thread replies proactively with the channel thread root", async () => { + let capturedReference: unknown; + const sent: string[] = []; + const channelRef: StoredConversationReference = { + activityId: "current-msg", + user: { id: "user123", name: "User" }, + agent: { id: "bot123", name: "Bot" }, + conversation: { + id: "19:abc@thread.tacv2", + conversationType: "channel", + }, + channelId: "msteams", + serviceUrl: "https://service.example.com", + threadId: "thread-root-msg-id", + }; + + const adapter: MSTeamsAdapter = { + continueConversation: async (_appId, reference, logic) => { + capturedReference = reference; + await logic({ + sendActivity: createRecordedSendActivity(sent), + updateActivity: noopUpdateActivity, + deleteActivity: noopDeleteActivity, + }); + }, + process: async () => {}, + updateActivity: noopUpdateActivity, + deleteActivity: noopDeleteActivity, + }; + + const ids = await sendMSTeamsMessages({ + replyStyle: "thread", + adapter, + appId: "app123", + conversationRef: channelRef, + messages: [{ text: "hello" }], + }); + + expect(sent).toEqual(["hello"]); + expect(ids).toEqual(["id:hello"]); + const ref = capturedReference as { conversation?: { id?: string }; activityId?: string }; + expect(ref.conversation?.id).toBe("19:abc@thread.tacv2;messageid=thread-root-msg-id"); + expect(ref.activityId).toBeUndefined(); + }); + + it("uses activityId for no-context thread replies when threadId is absent", async () => { + let capturedReference: unknown; + const sent: string[] = []; + const channelRef: StoredConversationReference = { + activityId: "legacy-activity-id", + user: { id: "user123", name: "User" }, + agent: { id: "bot123", name: "Bot" }, + conversation: { + id: "19:abc@thread.tacv2", + conversationType: "channel", + }, + channelId: "msteams", + serviceUrl: "https://service.example.com", + }; + + const adapter: MSTeamsAdapter = { + continueConversation: async (_appId, reference, logic) => { + capturedReference = reference; + await logic({ + sendActivity: createRecordedSendActivity(sent), + updateActivity: noopUpdateActivity, + deleteActivity: noopDeleteActivity, + }); + }, + process: async () => {}, + updateActivity: noopUpdateActivity, + deleteActivity: noopDeleteActivity, + }; + + await sendMSTeamsMessages({ + replyStyle: "thread", + adapter, + appId: "app123", + conversationRef: channelRef, + messages: [{ text: "hello" }], + }); + + const ref = capturedReference as { conversation?: { id?: string }; activityId?: string }; + expect(sent).toEqual(["hello"]); + expect(ref.conversation?.id).toBe("19:abc@thread.tacv2;messageid=legacy-activity-id"); + expect(ref.activityId).toBeUndefined(); + }); + it("does not add thread suffix for top-level replyStyle even with threadId set", async () => { let capturedReference: unknown; const sent: string[] = []; diff --git a/extensions/msteams/src/messenger.ts b/extensions/msteams/src/messenger.ts index 2e000df35a6..d77a48014ca 100644 --- a/extensions/msteams/src/messenger.ts +++ b/extensions/msteams/src/messenger.ts @@ -572,7 +572,7 @@ export async function sendMSTeamsMessages(params: { if (params.replyStyle === "thread") { const ctx = params.context; if (!ctx) { - throw new Error("Missing context for replyStyle=thread"); + return await sendProactively(messages, 0, resolvedThreadId); } const messageIds: string[] = []; for (const [idx, message] of messages.entries()) { diff --git a/extensions/msteams/src/send-context.test.ts b/extensions/msteams/src/send-context.test.ts new file mode 100644 index 00000000000..fdad5989894 --- /dev/null +++ b/extensions/msteams/src/send-context.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; +import type { MSTeamsConfig } from "../runtime-api.js"; +import type { StoredConversationReference } from "./conversation-store.js"; +import { resolveMSTeamsProactiveReplyStyle } from "./send-context.js"; + +function channelRef(params?: Partial): StoredConversationReference { + return { + user: { id: "user-1" }, + agent: { id: "agent-1" }, + conversation: { id: "19:channel@thread.tacv2", conversationType: "channel" }, + channelId: "msteams", + teamId: "team-1", + ...params, + }; +} + +describe("resolveMSTeamsProactiveReplyStyle", () => { + it("uses thread for channel conversations with a stored thread root", () => { + expect( + resolveMSTeamsProactiveReplyStyle({ + cfg: {}, + conversationId: "19:channel@thread.tacv2", + ref: channelRef({ threadId: "thread-root-1" }), + conversationType: "channel", + }), + ).toBe("thread"); + }); + + it("falls back to activityId for legacy channel references", () => { + expect( + resolveMSTeamsProactiveReplyStyle({ + cfg: {}, + conversationId: "19:channel@thread.tacv2", + ref: channelRef({ activityId: "legacy-root-1" }), + conversationType: "channel", + }), + ).toBe("thread"); + }); + + it("keeps configured top-level channel routing", () => { + const cfg: MSTeamsConfig = { + replyStyle: "thread", + teams: { + "team-1": { + channels: { + "19:channel@thread.tacv2": { replyStyle: "top-level" }, + }, + }, + }, + }; + + expect( + resolveMSTeamsProactiveReplyStyle({ + cfg, + conversationId: "19:channel@thread.tacv2", + ref: channelRef({ threadId: "thread-root-1" }), + conversationType: "channel", + }), + ).toBe("top-level"); + }); + + it("uses top-level when a channel has no stored thread root", () => { + expect( + resolveMSTeamsProactiveReplyStyle({ + cfg: { replyStyle: "thread" }, + conversationId: "19:channel@thread.tacv2", + ref: channelRef(), + conversationType: "channel", + }), + ).toBe("top-level"); + }); + + it("uses top-level for non-channel conversations", () => { + const ref = channelRef({ activityId: "activity-1" }); + + expect( + resolveMSTeamsProactiveReplyStyle({ + cfg: { replyStyle: "thread" }, + conversationId: "19:group@thread.v2", + ref, + conversationType: "groupChat", + }), + ).toBe("top-level"); + expect( + resolveMSTeamsProactiveReplyStyle({ + cfg: { replyStyle: "thread" }, + conversationId: "a:personal", + ref, + conversationType: "personal", + }), + ).toBe("top-level"); + }); +}); diff --git a/extensions/msteams/src/send-context.ts b/extensions/msteams/src/send-context.ts index ff6d41f3c73..515738f824b 100644 --- a/extensions/msteams/src/send-context.ts +++ b/extensions/msteams/src/send-context.ts @@ -1,6 +1,8 @@ import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; import { resolveChannelMediaMaxBytes, + type MSTeamsConfig, + type MSTeamsReplyStyle, type OpenClawConfig, type PluginRuntime, } from "../runtime-api.js"; @@ -13,6 +15,7 @@ import type { import { formatUnknownError } from "./errors.js"; import { resolveGraphChatId } from "./graph-upload.js"; import type { MSTeamsAdapter } from "./messenger.js"; +import { resolveMSTeamsReplyPolicy, resolveMSTeamsRouteConfig } from "./policy.js"; import { getMSTeamsRuntime } from "./runtime.js"; import { createMSTeamsAdapter, createMSTeamsTokenProvider, loadMSTeamsSdkWithAuth } from "./sdk.js"; import { resolveMSTeamsCredentials } from "./token.js"; @@ -27,6 +30,8 @@ export type MSTeamsProactiveContext = { log: ReturnType; /** The type of conversation: personal (1:1), groupChat, or channel */ conversationType: MSTeamsConversationType; + /** Reply style resolved for proactive text/media sends. */ + replyStyle: MSTeamsReplyStyle; /** Token provider for Graph API / OneDrive operations */ tokenProvider: MSTeamsAccessTokenProvider; /** SharePoint site ID for file uploads in group chats/channels */ @@ -42,6 +47,32 @@ export type MSTeamsProactiveContext = { graphChatId?: string | null; }; +export function resolveMSTeamsProactiveReplyStyle(params: { + cfg?: MSTeamsConfig; + conversationId: string; + ref: StoredConversationReference; + conversationType: MSTeamsConversationType; +}): MSTeamsReplyStyle { + const threadRootId = params.ref.threadId ?? params.ref.activityId; + if (params.conversationType !== "channel" || !threadRootId) { + return "top-level"; + } + + const routeConfig = resolveMSTeamsRouteConfig({ + cfg: params.cfg, + teamId: params.ref.teamId, + conversationId: params.conversationId, + allowNameMatching: false, + }); + const { replyStyle } = resolveMSTeamsReplyPolicy({ + isDirectMessage: false, + globalConfig: params.cfg, + teamConfig: routeConfig.teamConfig, + channelConfig: routeConfig.channelConfig, + }); + return replyStyle; +} + /** * Parse the target value into a conversation reference lookup key. * Supported formats: @@ -167,6 +198,12 @@ export async function resolveMSTeamsSendContext(params: { // groupChat, or unknown defaults to groupChat behavior conversationType = "groupChat"; } + const replyStyle = resolveMSTeamsProactiveReplyStyle({ + cfg: msteamsCfg, + conversationId, + ref, + conversationType, + }); // Get SharePoint site ID from config (required for file uploads in group chats/channels) const sharePointSiteId = msteamsCfg.sharePointSiteId; @@ -223,6 +260,7 @@ export async function resolveMSTeamsSendContext(params: { adapter: adapter as unknown as MSTeamsAdapter, log, conversationType, + replyStyle, tokenProvider, sharePointSiteId, mediaMaxBytes, diff --git a/extensions/msteams/src/send.test.ts b/extensions/msteams/src/send.test.ts index 396edf6857e..52b6c43d450 100644 --- a/extensions/msteams/src/send.test.ts +++ b/extensions/msteams/src/send.test.ts @@ -116,6 +116,7 @@ function createSharePointSendContext(params: { ref: {}, log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, conversationType: "groupChat" as const, + replyStyle: "top-level" as const, tokenProvider: { getAccessToken: vi.fn(async () => "token") }, mediaMaxBytes: 8 * 1024 * 1024, sharePointSiteId: params.siteId, @@ -184,6 +185,7 @@ describe("sendMessageMSTeams", () => { ref: {}, log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, conversationType: "personal", + replyStyle: "top-level", tokenProvider: { getAccessToken: vi.fn(async () => "token") }, mediaMaxBytes: 8 * 1024, sharePointSiteId: undefined, @@ -266,6 +268,62 @@ describe("sendMessageMSTeams", () => { expect(mockState.convertMarkdownTables).toHaveBeenCalledWith("hello", "off"); }); + it("passes the resolved proactive replyStyle to text sends", async () => { + mockState.resolveMSTeamsSendContext.mockResolvedValue({ + adapter: {}, + appId: "app-id", + conversationId: "19:channel@thread.tacv2", + ref: { + threadId: "thread-root-1", + conversation: { id: "19:channel@thread.tacv2", conversationType: "channel" }, + }, + log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + conversationType: "channel", + replyStyle: "thread", + tokenProvider: { getAccessToken: vi.fn(async () => "token") }, + mediaMaxBytes: 8 * 1024, + sharePointSiteId: undefined, + }); + + await sendMessageMSTeams({ + cfg: {} as OpenClawConfig, + to: "conversation:19:channel@thread.tacv2", + text: "threaded reply", + }); + + expect(mockState.sendMSTeamsMessages).toHaveBeenCalledWith( + expect.objectContaining({ replyStyle: "thread" }), + ); + }); + + it("keeps top-level proactive replyStyle when resolved for a channel", async () => { + mockState.resolveMSTeamsSendContext.mockResolvedValue({ + adapter: {}, + appId: "app-id", + conversationId: "19:channel@thread.tacv2", + ref: { + threadId: "thread-root-1", + conversation: { id: "19:channel@thread.tacv2", conversationType: "channel" }, + }, + log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + conversationType: "channel", + replyStyle: "top-level", + tokenProvider: { getAccessToken: vi.fn(async () => "token") }, + mediaMaxBytes: 8 * 1024, + sharePointSiteId: undefined, + }); + + await sendMessageMSTeams({ + cfg: {} as OpenClawConfig, + to: "conversation:19:channel@thread.tacv2", + text: "top-level reply", + }); + + expect(mockState.sendMSTeamsMessages).toHaveBeenCalledWith( + expect.objectContaining({ replyStyle: "top-level" }), + ); + }); + it("uses graphChatId instead of conversationId when uploading to SharePoint", async () => { // Simulates a group chat where Bot Framework conversationId is valid but we have // a resolved Graph chat ID cached from a prior send. diff --git a/extensions/msteams/src/send.ts b/extensions/msteams/src/send.ts index 9d173f2e3c3..a03b8dd0d31 100644 --- a/extensions/msteams/src/send.ts +++ b/extensions/msteams/src/send.ts @@ -391,12 +391,13 @@ async function sendTextWithMedia( tokenProvider, sharePointSiteId, mediaMaxBytes, + replyStyle, } = ctx; let platformMessageIds: string[]; try { platformMessageIds = await sendMSTeamsMessages({ - replyStyle: "top-level", + replyStyle, adapter, appId, conversationRef: ref,