refactor(channels): share Matrix and Slack progress draft composition (#117097)

This commit is contained in:
Peter Steinberger
2026-07-31 17:17:07 -07:00
committed by GitHub
parent e857d8e5c6
commit a1b41d5ddb
6 changed files with 92 additions and 172 deletions

View File

@@ -1,14 +1,9 @@
import {
type AgentPlanStep,
buildChannelProgressDraftLineForEntry,
type ChannelProgressDraftLine,
createChannelProgressDraftGate,
createChannelProgressDraftCompositor,
formatChannelProgressDraftLine,
formatChannelProgressDraftText,
isChannelProgressDraftWorkToolName,
mergeChannelProgressDraftLine,
normalizeChannelProgressDraftLineIdentity,
resolveChannelProgressDraftMaxLines,
} from "openclaw/plugin-sdk/channel-outbound";
import type { GetReplyOptions } from "openclaw/plugin-sdk/reply-runtime";
import type { CoreConfig, MatrixConfig, MatrixStreamingMode, ReplyToMode } from "../../types.js";
@@ -80,124 +75,41 @@ export async function createMatrixDraftController(params: {
const pendingDraftBoundaries: PendingDraftBoundary[] = [];
const latestQueuedDraftBoundaryOffsets = new Map<number, number>();
let currentDraftReplyToId = draftReplyToId;
let previewToolProgressSuppressed = false;
let previewToolProgressLines: Array<string | ChannelProgressDraftLine> = [];
let latestPlan: AgentPlanStep[] | undefined;
let latestPlanExplanation: string | undefined;
let previewPlan: AgentPlanStep[] | undefined;
let previewPlanExplanation: string | undefined;
let previewPlanSuppressed = false;
const progressConfigEntry = accountConfig ?? cfg.channels?.matrix;
const progressSeed = `${accountId}:${roomId}`;
// Set after the first final payload consumes or discards the draft event
// so subsequent finals go through normal delivery.
const renderProgressDraft = () => {
if (!draftStream) {
return;
}
const previewText = formatChannelProgressDraftText({
const renderPreviewPlan = (): string =>
formatChannelProgressDraftText({
entry: progressConfigEntry,
lines: previewToolProgressLines,
lines: [...progressDraft.getSnapshot().lines],
seed: progressSeed,
formatLine: formatMatrixToolProgressMarkdownCode,
bullet: "-",
narration: latestPlanExplanation,
plan: latestPlan,
narration: previewPlanExplanation,
plan: previewPlan,
});
if (!previewText) {
return;
}
draftStream.update(previewText);
};
const progressDraftGate = createChannelProgressDraftGate({
onStart: renderProgressDraft,
const progressDraft = createChannelProgressDraftCompositor({
entry: progressConfigEntry,
mode: streaming === "quiet" ? "partial" : streaming,
active: Boolean(draftStream),
seed: progressSeed,
formatLine: formatMatrixToolProgressMarkdownCode,
update: (text) => {
const previewText =
!progressDraftStreaming && (previewPlan || previewPlanExplanation)
? renderPreviewPlan()
: text.replace(/^ /gmu, "- ");
draftStream?.update(previewText);
},
});
const pushPreviewToolProgress = async (
line?: string | ChannelProgressDraftLine,
options?: { toolName?: string },
) => {
if (!draftStream) {
return;
}
if (options?.toolName !== undefined && !isChannelProgressDraftWorkToolName(options.toolName)) {
return;
}
const normalized = normalizeChannelProgressDraftLineIdentity(line);
const progressLine = typeof line === "object" && line !== undefined ? line : normalized;
if (!progressDraftStreaming) {
if (!shouldStreamPreviewToolProgress || previewToolProgressSuppressed || !normalized) {
return;
}
const nextLines = mergeChannelProgressDraftLine(previewToolProgressLines, progressLine, {
maxLines: resolveChannelProgressDraftMaxLines(progressConfigEntry),
});
if (nextLines === previewToolProgressLines) {
return;
}
previewToolProgressLines = nextLines;
draftStream.update(
formatChannelProgressDraftText({
entry: progressConfigEntry,
lines: previewToolProgressLines,
seed: progressSeed,
formatLine: formatMatrixToolProgressMarkdownCode,
bullet: "-",
narration: latestPlanExplanation,
plan: latestPlan,
}),
);
return;
}
if (shouldStreamPreviewToolProgress && !previewToolProgressSuppressed && normalized) {
previewToolProgressLines = mergeChannelProgressDraftLine(
previewToolProgressLines,
progressLine,
{
maxLines: resolveChannelProgressDraftMaxLines(progressConfigEntry),
},
);
}
const alreadyStarted = progressDraftGate.hasStarted;
const progressActive = await progressDraftGate.noteWork();
if ((alreadyStarted || progressActive) && progressDraftGate.hasStarted) {
renderProgressDraft();
}
};
const pushPlanProgress = async (steps?: AgentPlanStep[], explanation?: string) => {
latestPlan = steps?.length ? steps.map((entry) => ({ ...entry })) : undefined;
latestPlanExplanation = explanation?.replace(/\s+/g, " ").trim() || undefined;
if (!draftStream || previewToolProgressSuppressed) {
return;
}
if (!progressDraftStreaming) {
renderProgressDraft();
return;
}
const alreadyStarted = progressDraftGate.hasStarted;
await progressDraftGate.startNow();
if (alreadyStarted && progressDraftGate.hasStarted) {
// An empty-render clear keeps the prior draft visible on purpose:
// deleting mid-turn drops the edit anchor, and zero-step snapshots
// only arrive from label:false configs with retracting producers.
renderProgressDraft();
}
};
const suppressPreviewToolProgressForAnswerText = (text: string | undefined) => {
if (!text?.trim()) {
return;
}
previewToolProgressSuppressed = true;
previewToolProgressLines = [];
latestPlan = undefined;
latestPlanExplanation = undefined;
};
const resetPreviewToolProgress = () => {
previewToolProgressSuppressed = false;
previewToolProgressLines = [];
latestPlan = undefined;
latestPlanExplanation = undefined;
previewPlan = undefined;
previewPlanExplanation = undefined;
previewPlanSuppressed = false;
progressDraft.reset();
};
const buildPreviewToolProgressReplyOptions = (): Partial<GetReplyOptions> => {
@@ -214,7 +126,7 @@ export async function createMatrixDraftController(params: {
...options,
onToolStart: async (payload) => {
const toolName = payload.name?.trim();
await pushPreviewToolProgress(
await progressDraft.pushToolProgress(
buildChannelProgressDraftLineForEntry(
progressConfigEntry,
{
@@ -231,7 +143,7 @@ export async function createMatrixDraftController(params: {
);
},
onItemEvent: async (payload) => {
await pushPreviewToolProgress(
await progressDraft.pushToolProgress(
buildChannelProgressDraftLineForEntry(progressConfigEntry, {
event: "item",
itemId: payload.itemId,
@@ -251,13 +163,27 @@ export async function createMatrixDraftController(params: {
if (payload.phase !== "update") {
return;
}
await pushPlanProgress(payload.steps, payload.explanation);
if (progressDraftStreaming) {
await progressDraft.pushPlanProgress(payload.steps, { explanation: payload.explanation });
return;
}
if (!draftStream || previewPlanSuppressed) {
return;
}
previewPlan = payload.steps?.length
? payload.steps.map((step) => ({ ...step }))
: undefined;
previewPlanExplanation = payload.explanation?.replace(/\s+/g, " ").trim() || undefined;
const text = renderPreviewPlan();
if (text) {
draftStream.update(text);
}
},
onApprovalEvent: async (payload) => {
if (payload.phase !== "requested") {
return;
}
await pushPreviewToolProgress(
await progressDraft.pushToolProgress(
formatChannelProgressDraftLine({
event: "approval",
phase: payload.phase,
@@ -272,7 +198,7 @@ export async function createMatrixDraftController(params: {
if (payload.phase !== "end") {
return;
}
await pushPreviewToolProgress(
await progressDraft.pushToolProgress(
buildChannelProgressDraftLineForEntry(progressConfigEntry, {
event: "command-output",
itemId: payload.itemId,
@@ -289,7 +215,7 @@ export async function createMatrixDraftController(params: {
if (payload.phase !== "end") {
return;
}
await pushPreviewToolProgress(
await progressDraft.pushToolProgress(
buildChannelProgressDraftLineForEntry(progressConfigEntry, {
event: "patch",
itemId: payload.itemId,
@@ -376,13 +302,13 @@ export async function createMatrixDraftController(params: {
pendingDraftBoundaries.length = 0;
latestQueuedDraftBoundaryOffsets.clear();
currentDraftReplyToId = draftReplyToId;
progressDraftGate.reset();
progressDraft.beginNewTurn({ force: true });
resetPreviewToolProgress();
};
return {
draftStream,
progressDraftGate,
cancelProgressDraft: () => progressDraft.cancel(),
buildPreviewToolProgressReplyOptions,
queueDraftBlockBoundary,
advanceDraftBlockBoundary,
@@ -409,7 +335,12 @@ export async function createMatrixDraftController(params: {
return;
}
latestDraftFullText = text;
suppressPreviewToolProgressForAnswerText(text);
if (text.trim()) {
previewPlanSuppressed = true;
previewPlan = undefined;
previewPlanExplanation = undefined;
progressDraft.suppress();
}
updateDraftFromLatestFullText();
},
};

View File

@@ -526,7 +526,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
},
dispatcherOptions: {
...turnDispatcherOptions,
onSettled: () => draftController.progressDraftGate.cancel(),
onSettled: () => draftController.cancelProgressDraft(),
},
replyOptions: {
skillFilter: roomConfig?.skills,

View File

@@ -4,8 +4,6 @@ import {
createChannelProgressReceiptTracker,
formatChannelProgressDraftText,
isChannelProgressDraftWorkToolName,
mergeChannelProgressDraftLine,
resolveChannelProgressDraftMaxLines,
resolveChannelProgressDraftMaxLineChars,
resolveChannelProgressDraftRender,
resolveChannelStreamingPreviewToolProgress,
@@ -110,7 +108,6 @@ export function createSlackProgressRuntime(runtimeParams: {
previewStreamingEnabled,
});
let previewToolProgressSuppressed = false;
let legacyPreviewToolProgressLines: ChannelProgressDraftLine[] = [];
// Last task rows emitted to the native stream; reconciliation terminalizes
// ids that drop out (plan shrinks, tool-line <-> plan source switches).
let nativeTaskState: SlackNativeTaskSnapshot = new Map();
@@ -321,7 +318,7 @@ export function createSlackProgressRuntime(runtimeParams: {
const progressDraft = createChannelProgressDraftCompositor({
entry: account.config,
mode: slackStreaming.mode,
active: progressDraftActive && streamMode === "status_final",
active: progressDraftActive,
seed: progressSeed,
formatLine: escapeSlackMrkdwn,
reasoningLinePrefix: "🧠 ",
@@ -437,7 +434,7 @@ export function createSlackProgressRuntime(runtimeParams: {
}
const text = formatChannelProgressDraftText({
entry: account.config,
lines: legacyPreviewToolProgressLines,
lines: [...progressDraft.getSnapshot().lines],
seed: progressSeed,
formatLine: escapeSlackMrkdwn,
narration: explanation,
@@ -468,31 +465,10 @@ export function createSlackProgressRuntime(runtimeParams: {
await progressDraft.pushToolProgress(line, options);
return;
}
if (
!line ||
!normalized ||
!draftStream ||
!previewToolProgressEnabled ||
previewToolProgressSuppressed
) {
if (!line || !normalized || !draftStream || !previewToolProgressEnabled) {
return;
}
const nextLines = mergeChannelProgressDraftLine(legacyPreviewToolProgressLines, line, {
maxLines: resolveChannelProgressDraftMaxLines(account.config),
});
if (nextLines === legacyPreviewToolProgressLines) {
return;
}
legacyPreviewToolProgressLines = nextLines;
draftStream.update(
formatChannelProgressDraftText({
entry: account.config,
lines: legacyPreviewToolProgressLines,
seed: progressSeed,
formatLine: escapeSlackMrkdwn,
}),
);
hasStreamedMessage = true;
await progressDraft.pushToolProgress(line, options);
};
const updateDraftFromPartial = (text?: string) => {
@@ -503,7 +479,7 @@ export function createSlackProgressRuntime(runtimeParams: {
if (streamMode === "append") {
previewToolProgressSuppressed = true;
legacyPreviewToolProgressLines = [];
progressDraft.suppress();
const next = applyAppendOnlyStreamUpdate({
incoming: trimmed,
rendered: appendRenderedText,
@@ -524,7 +500,7 @@ export function createSlackProgressRuntime(runtimeParams: {
}
previewToolProgressSuppressed = true;
legacyPreviewToolProgressLines = [];
progressDraft.suppress();
draftStream?.update(trimmed);
hasStreamedMessage = true;
};
@@ -551,6 +527,8 @@ export function createSlackProgressRuntime(runtimeParams: {
text: normalized,
label: "Reasoning",
});
// Tool admission closes reasoning bursts; restore this still-open preview lane.
progressDraft.mergeReasoningProgress(normalized, { snapshot: true });
return;
}
progressReceipt.noteReasoning();
@@ -564,9 +542,8 @@ export function createSlackProgressRuntime(runtimeParams: {
appendSourceText = "";
};
const resetDraftProgressState = () => {
progressDraft.resetReasoningProgress();
previewToolProgressSuppressed = false;
legacyPreviewToolProgressLines = [];
progressDraft.reset();
};
const beginNewProgressTurn = async (options?: { force?: boolean }) => {
const completionChunks =

View File

@@ -1942,7 +1942,7 @@ describe("dispatchPreparedSlackMessage preview fallback", () => {
);
expect(draftStream.update).toHaveBeenCalledWith(
"Shelling\n• ran &lt;!here&gt; &lt;@U123&gt; \\*bold\\* \\`code\\` &amp; done",
"Shelling\n\n• ran &lt;!here&gt; &lt;@U123&gt; \\*bold\\* \\`code\\` &amp; done",
);
});

View File

@@ -59,6 +59,24 @@ describe("createChannelProgressDraftCompositor", () => {
expect(update).toHaveBeenLastCalledWith("🛠️ Next", expect.anything());
});
it("publishes partial-preview tool lines without enabling progress-only plans", async () => {
const update = vi.fn();
const progress = createChannelProgressDraftCompositor({
entry: { streaming: { mode: "partial", progress: { label: false } } },
mode: "partial",
active: true,
seed: "preview",
update,
});
await progress.pushToolProgress("Inspecting files");
expect(update).toHaveBeenLastCalledWith("• Inspecting files", {
lines: ["Inspecting files"],
});
expect(await progress.pushPlanProgress([{ step: "Patch", status: "in_progress" }])).toBe(false);
expect(update).toHaveBeenCalledTimes(1);
});
it("returns detached structured state for channel-native renderers", async () => {
const progress = createChannelProgressDraftCompositor({
entry: { streaming: { mode: "progress", progress: { label: false } } },

View File

@@ -264,10 +264,7 @@ export function createChannelProgressDraftCompositor(params: {
planExplanation = "";
};
const render = async (options?: { flush?: boolean }): Promise<boolean> => {
if (!params.active || params.mode !== "progress" || finalReplyStarted || finalReplyDelivered) {
return false;
}
const publish = async (options?: { flush?: boolean }): Promise<boolean> => {
const text = formatDraftText();
const linesChanged = params.updateOnLineChange === true && lines !== lastRenderedLines;
if (!text || (text === lastRenderedText && !linesChanged)) {
@@ -279,6 +276,13 @@ export function createChannelProgressDraftCompositor(params: {
return true;
};
const render = async (options?: { flush?: boolean }): Promise<boolean> => {
if (!params.active || params.mode !== "progress" || finalReplyStarted || finalReplyDelivered) {
return false;
}
return await publish(options);
};
const schedulePreambleExpiryRefresh = () => {
clearPreambleExpiryTimer();
if (
@@ -409,17 +413,7 @@ export function createChannelProgressDraftCompositor(params: {
}
lines = nextLines;
if (params.mode !== "progress") {
if (!shouldStoreLine) {
return false;
}
const text = formatDraftText();
if (!text || text === lastRenderedText) {
return false;
}
lastRenderedText = text;
lastRenderedLines = lines;
await params.update(text, { lines: [...lines] });
return true;
return shouldStoreLine ? await publish() : false;
}
if (options?.startImmediately || params.shouldStartNow?.(line)) {
const alreadyStarted = gate.hasStarted;