From 12dbfab678b8723e478743e3fc8cdadeff16ed9e Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 3 May 2026 16:14:40 -0700 Subject: [PATCH] fix(msteams): stream progress tool lines --- CHANGELOG.md | 1 + .../msteams/src/reply-dispatcher.test.ts | 37 +++++++++++- extensions/msteams/src/reply-dispatcher.ts | 60 +++++++++++++++++++ .../src/reply-stream-controller.test.ts | 52 ++++++++++++++++ .../msteams/src/reply-stream-controller.ts | 47 +++++++++++++++ 5 files changed, 195 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f4b2d70bcd8..77a05dc4d23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,7 @@ Docs: https://docs.openclaw.ai - Mattermost: accept the documented `channels.mattermost.streaming` config and honor `streaming: "off"` by disabling draft preview posts. Thanks @vincentkoc. - Mattermost: expose streaming progress config labels and help text in generated channel config metadata so Control UI/docs can explain the new `channels.mattermost.streaming.progress.*` fields. Thanks @vincentkoc. - Mattermost: honor `channels.mattermost.streaming.progress.toolProgress=false` in progress draft mode so compact tool status lines stay hidden until final delivery. Thanks @vincentkoc. +- Microsoft Teams: honor progress draft tool lines in native Teams progress streams and suppress standalone tool messages when `channels.msteams.streaming.progress.toolProgress=false`. Thanks @vincentkoc. - Discord: keep progress draft boundary callbacks bound during streaming replies, so extension lint stays green while progress previews transition between assistant and reasoning blocks. Thanks @vincentkoc. - Discord: resolve SecretRef-backed bot tokens from the active runtime snapshot for named accounts and keep unresolved configured tokens from crashing status or health checks. (#76987) Thanks @joshavant. - Channels/streaming: expose `streaming.progress.label`, `labels`, `maxLines`, and `toolProgress` in bundled channel config metadata so progress draft settings appear in config, docs, and control surfaces. Thanks @vincentkoc. diff --git a/extensions/msteams/src/reply-dispatcher.test.ts b/extensions/msteams/src/reply-dispatcher.test.ts index 2228bcb1180..90f6257122a 100644 --- a/extensions/msteams/src/reply-dispatcher.test.ts +++ b/extensions/msteams/src/reply-dispatcher.test.ts @@ -332,6 +332,39 @@ describe("createMSTeamsReplyDispatcher", () => { expect(streamInstances[0]?.update).toHaveBeenCalledWith("partial response"); }); + it("surfaces Teams progress tool lines through native stream updates", async () => { + const dispatcher = createDispatcher("personal", { + streaming: { + mode: "progress", + progress: { + label: "Working", + }, + }, + }); + + expect(dispatcher.replyOptions.suppressDefaultToolProgressMessages).toBe(true); + await dispatcher.replyOptions.onToolStart?.({ name: "web_search" }); + + expect(streamInstances[0]?.sendInformativeUpdate).toHaveBeenCalledWith( + "Working\n- tool: web_search", + ); + }); + + it("suppresses standalone Teams progress messages when progress tool lines are disabled", async () => { + const dispatcher = createDispatcher("personal", { + streaming: { + mode: "progress", + progress: { + toolProgress: false, + }, + }, + }); + + expect(dispatcher.replyOptions.suppressDefaultToolProgressMessages).toBe(true); + expect(dispatcher.replyOptions.onToolStart).toBeUndefined(); + expect(streamInstances[0]?.sendInformativeUpdate).not.toHaveBeenCalled(); + }); + it("does not create a stream for channel conversations", async () => { createDispatcher("channel"); @@ -446,8 +479,8 @@ describe("createMSTeamsReplyDispatcher", () => { describe("pickInformativeStatusText", () => { it("selects a deterministic status line for a fixed random source", () => { - expect(pickInformativeStatusText(() => 0)).toBe("Thinking"); - expect(pickInformativeStatusText(() => 0.99)).toBe("Surfacing"); + expect(pickInformativeStatusText(() => 0)).toBe("Thinking..."); + expect(pickInformativeStatusText(() => 0.99)).toBe("Surfacing..."); }); it("honors disabled progress labels", () => { diff --git a/extensions/msteams/src/reply-dispatcher.ts b/extensions/msteams/src/reply-dispatcher.ts index 1566e6f2751..f66f17ec9d7 100644 --- a/extensions/msteams/src/reply-dispatcher.ts +++ b/extensions/msteams/src/reply-dispatcher.ts @@ -345,6 +345,66 @@ export function createMSTeamsReplyDispatcher(params: { streamController.onPartialReply(payload), } : {}), + ...(streamController.shouldSuppressDefaultToolProgressMessages() + ? { suppressDefaultToolProgressMessages: true } + : {}), + ...(streamController.shouldStreamPreviewToolProgress() + ? { + onToolStart: async (payload: { name?: string; phase?: string }) => { + await streamController.pushProgressLine( + payload.name ? `tool: ${payload.name}` : (payload.phase ?? "tool running"), + ); + }, + onItemEvent: async (payload: { + progressText?: string; + summary?: string; + title?: string; + name?: string; + }) => { + await streamController.pushProgressLine( + payload.progressText ?? payload.summary ?? payload.title ?? payload.name, + ); + }, + onPlanUpdate: async (payload: { + phase?: string; + explanation?: string; + steps?: string[]; + }) => { + if (payload.phase !== "update") { + return; + } + await streamController.pushProgressLine( + payload.explanation ?? payload.steps?.[0] ?? "planning", + ); + }, + onApprovalEvent: async (payload: { phase?: string; command?: string }) => { + if (payload.phase !== "requested") { + return; + } + await streamController.pushProgressLine( + payload.command ? `approval: ${payload.command}` : "approval requested", + ); + }, + onCommandOutput: async (payload: { phase?: string; summary?: string }) => { + if (payload.phase !== "end") { + return; + } + await streamController.pushProgressLine(payload.summary ?? "command output ready"); + }, + onPatchSummary: async (payload: { + phase?: string; + summary?: string; + title?: string; + }) => { + if (payload.phase !== "end") { + return; + } + await streamController.pushProgressLine( + payload.summary ?? payload.title ?? "patch applied", + ); + }, + } + : {}), disableBlockStreaming: typeof resolvedBlockStreamingEnabled === "boolean" ? !resolvedBlockStreamingEnabled diff --git a/extensions/msteams/src/reply-stream-controller.test.ts b/extensions/msteams/src/reply-stream-controller.test.ts index 97358efe960..e5632ab562b 100644 --- a/extensions/msteams/src/reply-stream-controller.test.ts +++ b/extensions/msteams/src/reply-stream-controller.test.ts @@ -268,6 +268,58 @@ describe("createTeamsReplyStreamController", () => { expect(streamInstances[0]?.sendInformativeUpdate).not.toHaveBeenCalled(); }); + it("streams compact Teams progress lines when tool progress is enabled", async () => { + streamInstances.length = 0; + const ctrl = createTeamsReplyStreamController({ + conversationType: "personal", + context: { sendActivity: vi.fn(async () => ({ id: "a" })) } as never, + feedbackLoopEnabled: false, + log: { debug: vi.fn() } as never, + msteamsConfig: { + streaming: { + mode: "progress", + progress: { + label: "Working", + maxLines: 1, + }, + }, + } as never, + }); + + await ctrl.pushProgressLine("tool: search"); + await ctrl.pushProgressLine("tool: exec"); + + expect(ctrl.shouldSuppressDefaultToolProgressMessages()).toBe(true); + expect(ctrl.shouldStreamPreviewToolProgress()).toBe(true); + expect(streamInstances[0]?.sendInformativeUpdate).toHaveBeenLastCalledWith( + "Working\n- tool: exec", + ); + }); + + it("suppresses Teams default progress messages without stream lines when tool progress is disabled", async () => { + streamInstances.length = 0; + const ctrl = createTeamsReplyStreamController({ + conversationType: "personal", + context: { sendActivity: vi.fn(async () => ({ id: "a" })) } as never, + feedbackLoopEnabled: false, + log: { debug: vi.fn() } as never, + msteamsConfig: { + streaming: { + mode: "progress", + progress: { + toolProgress: false, + }, + }, + } as never, + }); + + await ctrl.pushProgressLine("tool: search"); + + expect(ctrl.shouldSuppressDefaultToolProgressMessages()).toBe(true); + expect(ctrl.shouldStreamPreviewToolProgress()).toBe(false); + expect(streamInstances[0]?.sendInformativeUpdate).not.toHaveBeenCalled(); + }); + it("does not start native streaming for Teams block mode", async () => { streamInstances.length = 0; const ctrl = createTeamsReplyStreamController({ diff --git a/extensions/msteams/src/reply-stream-controller.ts b/extensions/msteams/src/reply-stream-controller.ts index 6d683d396cf..c8cb8834c8c 100644 --- a/extensions/msteams/src/reply-stream-controller.ts +++ b/extensions/msteams/src/reply-stream-controller.ts @@ -1,6 +1,9 @@ import { + formatChannelProgressDraftText, resolveChannelPreviewStreamMode, + resolveChannelProgressDraftMaxLines, resolveChannelProgressDraftLabel, + resolveChannelStreamingPreviewToolProgress, } from "openclaw/plugin-sdk/channel-streaming"; import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime"; import type { MSTeamsConfig, ReplyPayload } from "../runtime-api.js"; @@ -40,6 +43,11 @@ export function createTeamsReplyStreamController(params: { const streamMode = resolveChannelPreviewStreamMode(params.msteamsConfig, "partial"); const shouldUseNativeStream = isPersonal && (streamMode === "partial" || streamMode === "progress"); + const shouldSuppressDefaultToolProgressMessages = + shouldUseNativeStream && streamMode === "progress"; + const shouldStreamPreviewToolProgress = + shouldSuppressDefaultToolProgressMessages && + resolveChannelStreamingPreviewToolProgress(params.msteamsConfig); const stream = shouldUseNativeStream ? new TeamsHttpStream({ sendActivity: (activity) => params.context.sendActivity(activity), @@ -52,8 +60,35 @@ export function createTeamsReplyStreamController(params: { let streamReceivedTokens = false; let informativeUpdateSent = false; + let progressLines: string[] = []; let pendingFinalize: Promise | undefined; + const pushProgressLine = async (line?: string): Promise => { + if (!stream || !shouldStreamPreviewToolProgress) { + return; + } + const normalized = line?.replace(/\s+/g, " ").trim(); + if (!normalized) { + return; + } + const previous = progressLines.at(-1); + if (previous === normalized) { + return; + } + progressLines = [...progressLines, normalized].slice( + -resolveChannelProgressDraftMaxLines(params.msteamsConfig), + ); + informativeUpdateSent = true; + await stream.sendInformativeUpdate( + formatChannelProgressDraftText({ + entry: params.msteamsConfig, + lines: progressLines, + seed: params.progressSeed, + bullet: "-", + }), + ); + }; + const fallbackAfterStreamFailure = ( payload: ReplyPayload, hasMedia: boolean, @@ -100,6 +135,18 @@ export function createTeamsReplyStreamController(params: { stream.update(payload.text); }, + async pushProgressLine(line?: string): Promise { + await pushProgressLine(line); + }, + + shouldSuppressDefaultToolProgressMessages(): boolean { + return shouldSuppressDefaultToolProgressMessages; + }, + + shouldStreamPreviewToolProgress(): boolean { + return shouldStreamPreviewToolProgress; + }, + async preparePayload(payload: ReplyPayload): Promise> { const hasMedia = Boolean(payload.mediaUrl || payload.mediaUrls?.length);