fix(msteams): stream progress tool lines

This commit is contained in:
Vincent Koc
2026-05-03 16:14:40 -07:00
parent b5affa64b3
commit 12dbfab678
5 changed files with 195 additions and 2 deletions

View File

@@ -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.

View File

@@ -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", () => {

View File

@@ -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

View File

@@ -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({

View File

@@ -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<void> | undefined;
const pushProgressLine = async (line?: string): Promise<void> => {
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<void> {
await pushProgressLine(line);
},
shouldSuppressDefaultToolProgressMessages(): boolean {
return shouldSuppressDefaultToolProgressMessages;
},
shouldStreamPreviewToolProgress(): boolean {
return shouldStreamPreviewToolProgress;
},
async preparePayload(payload: ReplyPayload): Promise<Maybe<ReplyPayload>> {
const hasMedia = Boolean(payload.mediaUrl || payload.mediaUrls?.length);