Files
openclaw/src/channels/progress-draft-compositor.ts
T
Peter Steinberger 17101e9e4a fix(channels): keep markup out of the status label and make the line opt-in
Reasoning and commentary lines arrive channel-formatted, so falling back to their
raw text leaked <i> markup into the status label; only structured work lines carry a
clean label/detail pair. Defaulting the line to off keeps existing progress drafts
byte-identical until a channel opts in.
2026-07-19 23:30:19 -07:00

728 lines
25 KiB
TypeScript

// Stateful progress-draft compositor for channel streaming previews.
// It merges status, tool, reasoning, and commentary updates until the final reply replaces them.
import type { StatusFooterMode } from "../config/types.base.js";
import { removeChannelProgressDraftLine } from "./progress-draft-lines.js";
import {
formatReasoningProgressDisplayLine,
mergeReasoningProgressText,
normalizeCommentaryProgressText,
normalizeReasoningProgressLine,
sanitizeProgressStatusText,
} from "./progress-draft-status-text.js";
import { renderStatusFooterLine } from "./status-footer.js";
import {
createChannelProgressDraftGate,
type AgentPlanStep,
type ChannelProgressDraftLine,
formatChannelProgressDraftText,
isChannelProgressDraftWorkToolName,
mergeChannelProgressDraftLine,
normalizeChannelProgressDraftLineIdentity,
resolveChannelProgressDraftMaxLineChars,
resolveChannelProgressDraftMaxLines,
resolveChannelStreamingProgressCommentary,
resolveChannelStreamingPreviewToolProgress,
resolveChannelStreamingSuppressDefaultToolProgressMessages,
type StreamingCompatEntry,
type StreamingMode,
} from "./streaming.js";
// A recent model preamble remains the primary status; utility narration fills
// the slot only after the model has been quiet for this interval. Exported for
// the narrator, deliberately not re-exported through the SDK barrels.
export const PROGRESS_STATUS_PREAMBLE_FRESH_MS = 20_000;
// Composes transient channel progress drafts from tool, reasoning, and
// commentary updates. It owns draft lifecycle state before the final reply wins.
type ChannelProgressDraftMode = StreamingMode;
export type ChannelProgressDraftCompositorLine = string | ChannelProgressDraftLine;
export type ChannelProgressDraftCompositorSnapshot = Readonly<{
lines: readonly ChannelProgressDraftCompositorLine[];
statusHeadline?: string;
plan?: readonly AgentPlanStep[];
planExplanation?: string;
}>;
/** Tracks per-turn activity for compact progress receipts. */
export function createChannelProgressReceiptTracker(params?: { now?: () => number }) {
const now = params?.now ?? Date.now;
let startedAt = now();
let reasoningSteps = 0;
let toolCalls = 0;
let commentaryNotes = 0;
let reasoningOpen = false;
const seenCommentaryIds = new Set<string>();
let lastCommentaryText = "";
const closeReasoning = () => {
if (!reasoningOpen) {
return;
}
reasoningOpen = false;
reasoningSteps += 1;
};
const reset = () => {
startedAt = now();
reasoningSteps = 0;
toolCalls = 0;
commentaryNotes = 0;
reasoningOpen = false;
seenCommentaryIds.clear();
lastCommentaryText = "";
};
return {
noteReasoning() {
reasoningOpen = true;
},
closeReasoning,
noteToolCall(toolName?: string) {
closeReasoning();
if (isChannelProgressDraftWorkToolName(toolName)) {
toolCalls += 1;
}
},
noteCommentary(itemId?: string, text?: string) {
const trimmed = text?.trim();
if (!trimmed) {
return;
}
if (itemId) {
if (!seenCommentaryIds.has(itemId)) {
seenCommentaryIds.add(itemId);
commentaryNotes += 1;
}
return;
}
if (trimmed !== lastCommentaryText) {
lastCommentaryText = trimmed;
commentaryNotes += 1;
}
},
reset,
buildSummaryLine() {
closeReasoning();
const seconds = Math.max(1, Math.round((now() - startedAt) / 1000));
return [
...(reasoningSteps > 0
? [`🧠 ${reasoningSteps} thought${reasoningSteps === 1 ? "" : "s"}`]
: []),
...(commentaryNotes > 0
? [`💬 ${commentaryNotes} note${commentaryNotes === 1 ? "" : "s"}`]
: []),
...(toolCalls > 0 ? [`🛠️ ${toolCalls} tool call${toolCalls === 1 ? "" : "s"}`] : []),
`⏱️ ${seconds}s`,
].join(" · ");
},
};
}
type ChannelProgressDraftUpdateOptions = {
flush?: boolean;
lines?: readonly ChannelProgressDraftCompositorLine[];
/**
* Trailing status line for the live draft. Kept out of both `text` and `lines`: text
* placement differs per renderer (Telegram derives its heading from the text/line
* count), and staying out of `lines` means the rolling `maxLines` window can never
* evict it and id-keyed merging never claims it. Renderers append it last themselves.
*/
statusLine?: string;
};
/** Creates a stateful compositor for one streaming channel reply. */
export function createChannelProgressDraftCompositor(params: {
entry: StreamingCompatEntry | null | undefined;
mode: ChannelProgressDraftMode;
active: boolean;
seed: string;
update: (text: string, options?: ChannelProgressDraftUpdateOptions) => Promise<void> | void;
/** Trailing status line mode for the live draft. Default: "off". */
statusMode?: StatusFooterMode;
deleteCurrent?: () => Promise<void> | void;
tryNativeUpdate?: (text: string) => Promise<boolean> | boolean;
/** Publish when structured lines change even if the rendered text does not. */
updateOnLineChange?: boolean;
formatLine?: (line: string) => string;
isEmptyLine?: (line: ChannelProgressDraftCompositorLine | undefined) => boolean;
shouldStartNow?: (line: ChannelProgressDraftCompositorLine | undefined) => boolean;
reasoningLinePrefix?: string;
commentaryLinePrefix?: string;
reasoningGate?: boolean;
commentaryItalics?: boolean;
now?: () => number;
setTimeoutFn?: typeof setTimeout;
clearTimeoutFn?: typeof clearTimeout;
}) {
const now = params.now ?? Date.now;
const setTimeoutFn = params.setTimeoutFn ?? setTimeout;
const clearTimeoutFn = params.clearTimeoutFn ?? clearTimeout;
const reasoningLinePrefix = params.reasoningLinePrefix ?? "";
const commentaryLinePrefix = params.commentaryLinePrefix ?? "";
const commentaryItalics = params.commentaryItalics ?? true;
const stripLaneItalics = (text: string): string =>
text
.split("\n")
.map((line) => line.replace(/^_(.*)_$/su, "$1"))
.join("\n");
const previewToolProgressEnabled =
params.active && resolveChannelStreamingPreviewToolProgress(params.entry);
const commentaryProgressEnabled =
params.active && resolveChannelStreamingProgressCommentary(params.entry);
const thinkingProgressEnabled =
params.active && (params.reasoningGate ?? previewToolProgressEnabled);
const suppressDefaultToolProgressMessages =
params.active &&
resolveChannelStreamingSuppressDefaultToolProgressMessages(params.entry, {
draftStreamActive: true,
previewToolProgressEnabled,
});
let progressSuppressed = false;
let lines: ChannelProgressDraftCompositorLine[] = [];
let lastRenderedText = "";
let lastRenderedLines = lines;
let reasoningRawText = "";
let lastReasoningLine: string | undefined;
// Model preambles and narration share the status slot while tool lines keep
// accumulating underneath for turns where neither source is available.
let preambleText = "";
let preambleItemId: string | undefined;
let preambleAt: number | undefined;
let narrationText = "";
let planSteps: AgentPlanStep[] | undefined;
let planExplanation = "";
// Turn-scoped clock for the status line elapsed field; reset when a new turn begins.
let turnStartedAt = now();
let finalReplyStarted = false;
let finalReplyDelivered = false;
let preambleExpiryTimer: ReturnType<typeof setTimeout> | undefined;
const mergeReasoningProgress = (text?: string, options?: { snapshot?: boolean }): string => {
if (!text) {
return "";
}
reasoningRawText = mergeReasoningProgressText(reasoningRawText, text, {
snapshot: options?.snapshot === true,
});
return normalizeReasoningProgressLine(reasoningRawText);
};
const clearPreambleExpiryTimer = () => {
if (preambleExpiryTimer !== undefined) {
clearTimeoutFn(preambleExpiryTimer);
preambleExpiryTimer = undefined;
}
};
const resolveStatusText = () => {
const preambleIsFresh =
preambleAt !== undefined && now() - preambleAt < PROGRESS_STATUS_PREAMBLE_FRESH_MS;
const effectiveNarration = narrationText || planExplanation;
return preambleText && (preambleIsFresh || !effectiveNarration)
? preambleText
: effectiveNarration;
};
const formatDraftText = (draftLines = lines, options?: { formatted?: boolean }) =>
formatChannelProgressDraftText({
entry: params.entry,
lines: draftLines,
seed: params.seed,
formatLine: options?.formatted === false ? undefined : params.formatLine,
narration: resolveStatusText() || undefined,
plan: planSteps,
});
const getSnapshot = (): ChannelProgressDraftCompositorSnapshot => {
const statusHeadline = resolveStatusText();
return {
lines: lines.map((line) => (typeof line === "string" ? line : { ...line })),
...(statusHeadline ? { statusHeadline } : {}),
...(planSteps ? { plan: planSteps.map((entry) => ({ ...entry })) } : {}),
...(planExplanation ? { planExplanation } : {}),
};
};
const resolveActivityLabel = (): string | undefined => {
// Only structured work lines carry a clean label/detail pair. String lines are
// already channel-formatted (reasoning and commentary arrive with markup), so using
// their text would leak `<i>` and friends into the status line.
const newest = lines.findLast((line) => typeof line !== "string");
if (!newest || typeof newest === "string" || !newest.label) {
return undefined;
}
return newest.detail ? `${newest.label}: ${newest.detail}` : newest.label;
};
const resolveStatusLine = (): string | undefined => {
const mode = params.statusMode ?? "off";
if (mode === "off") {
return undefined;
}
return renderStatusFooterLine({
mode,
...(mode === "activity" ? { activityLabel: resolveActivityLabel() } : {}),
elapsedMs: now() - turnStartedAt,
});
};
const clearProgressState = (suppressed: boolean) => {
clearPreambleExpiryTimer();
progressSuppressed = suppressed;
lines = [];
lastRenderedText = "";
lastRenderedLines = lines;
reasoningRawText = "";
lastReasoningLine = undefined;
preambleText = "";
preambleItemId = undefined;
preambleAt = undefined;
narrationText = "";
planSteps = undefined;
planExplanation = "";
};
const render = async (options?: { flush?: boolean }): Promise<boolean> => {
if (!params.active || params.mode !== "progress" || finalReplyStarted || finalReplyDelivered) {
return false;
}
const body = formatDraftText();
const linesChanged = params.updateOnLineChange === true && lines !== lastRenderedLines;
// Dedupe on the body only: the status line carries elapsed time, so folding it in
// here would defeat the no-op check and push an extra transport edit per event.
if (!body || (body === lastRenderedText && !linesChanged)) {
return false;
}
lastRenderedText = body;
lastRenderedLines = lines;
const statusLine = resolveStatusLine();
await params.update(body, {
...options,
lines: [...lines],
...(statusLine ? { statusLine } : {}),
});
return true;
};
const schedulePreambleExpiryRefresh = () => {
clearPreambleExpiryTimer();
if (
!preambleText ||
!narrationText ||
preambleAt === undefined ||
!gate.hasStarted ||
finalReplyStarted ||
finalReplyDelivered
) {
return;
}
const remaining = PROGRESS_STATUS_PREAMBLE_FRESH_MS - (now() - preambleAt);
if (remaining <= 0) {
return;
}
preambleExpiryTimer = setTimeoutFn(() => {
preambleExpiryTimer = undefined;
void render().catch((err: unknown) => {
console.warn(`[progress-draft] channel progress status refresh failed: ${String(err)}`);
});
}, remaining);
};
const gate = createChannelProgressDraftGate({
onStart: async () => {
await render({ flush: true });
schedulePreambleExpiryRefresh();
},
setTimeoutFn,
clearTimeoutFn,
});
const clearLine = async (lineId: string) => {
const nextLines = removeChannelProgressDraftLine(lines, lineId);
if (nextLines === lines) {
return;
}
lines = nextLines;
if (!gate.hasStarted) {
return;
}
const text = formatDraftText();
if (text) {
await render();
return;
}
lastRenderedText = "";
await params.deleteCurrent?.();
};
const noteProgress = async (
line?: ChannelProgressDraftCompositorLine,
options?: { toolName?: string; startImmediately?: boolean },
) => {
if (!params.active || finalReplyStarted || finalReplyDelivered) {
return false;
}
if (options?.toolName !== undefined && !isChannelProgressDraftWorkToolName(options.toolName)) {
return false;
}
if (params.isEmptyLine?.(line)) {
return false;
}
const normalized = normalizeChannelProgressDraftLineIdentity(line);
if (!normalized || progressSuppressed) {
return false;
}
if (params.mode !== "progress" && !previewToolProgressEnabled) {
return false;
}
const progressLine = typeof line === "object" && line !== undefined ? line : normalized;
const shouldStoreLine = previewToolProgressEnabled;
const nextLines = shouldStoreLine
? mergeChannelProgressDraftLine(lines, progressLine, {
maxLines: resolveChannelProgressDraftMaxLines(params.entry),
})
: lines;
if (shouldStoreLine && nextLines === lines) {
return false;
}
// A work line lands between reasoning bursts: commit the current thinking
// line so the next thought appends as its own line, interleaved with tools
// in arrival order, instead of replacing the prior thought.
if (shouldStoreLine) {
reasoningRawText = "";
lastReasoningLine = undefined;
}
if (shouldStoreLine && params.tryNativeUpdate) {
// Native draft updates get unformatted text; if the channel accepts it,
// keep local state aligned without sending a generic draft message.
const text = formatDraftText(nextLines, { formatted: false });
if (text && (await params.tryNativeUpdate(text))) {
lines = nextLines;
lastRenderedText = text;
lastRenderedLines = lines;
return true;
}
}
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;
}
if (options?.startImmediately || params.shouldStartNow?.(line)) {
const alreadyStarted = gate.hasStarted;
await gate.startNow();
if (!gate.hasStarted) {
return false;
}
return alreadyStarted ? await render() : true;
}
const alreadyStarted = gate.hasStarted;
const progressActive = await gate.noteWork();
if ((alreadyStarted || progressActive) && gate.hasStarted) {
return await render();
}
return false;
};
return {
get previewToolProgressEnabled() {
return previewToolProgressEnabled;
},
get commentaryProgressEnabled() {
return commentaryProgressEnabled;
},
get suppressDefaultToolProgressMessages() {
return suppressDefaultToolProgressMessages;
},
get hasStarted() {
return gate.hasStarted;
},
get isVisible() {
return gate.hasStarted && !finalReplyStarted && !finalReplyDelivered;
},
get hasStatusHeadline() {
return Boolean(resolveStatusText());
},
get hasPlanProgress() {
return Boolean(planSteps?.length);
},
getSnapshot,
markFinalReplyStarted() {
finalReplyStarted = true;
// Final delivery must disarm the delayed start before async delivery work.
// Queued turns reopen the gate through beginNewTurn().
gate.cancel();
clearPreambleExpiryTimer();
},
markFinalReplyDelivered() {
finalReplyDelivered = true;
clearPreambleExpiryTimer();
},
// Authoritative queued admission may force reset after a silent turn;
// ordinary assistant boundaries still require a settled final.
beginNewTurn(options?: { force?: boolean }) {
if (options?.force !== true && !finalReplyStarted && !finalReplyDelivered) {
return false;
}
finalReplyStarted = false;
finalReplyDelivered = false;
turnStartedAt = now();
gate.reset();
clearProgressState(false);
return true;
},
reset() {
clearProgressState(false);
},
resetReasoningProgress() {
reasoningRawText = "";
},
mergeReasoningProgress,
suppress() {
clearProgressState(true);
},
cancel() {
gate.cancel();
clearPreambleExpiryTimer();
},
start() {
return gate.startNow();
},
async noteActivity(options?: { startImmediately?: boolean }) {
if (
!params.active ||
params.mode !== "progress" ||
progressSuppressed ||
finalReplyStarted ||
finalReplyDelivered
) {
return false;
}
if (options?.startImmediately) {
await gate.startNow();
return gate.hasStarted ? await render({ flush: true }) : false;
}
const alreadyStarted = gate.hasStarted;
const progressActive = await gate.noteWork();
if ((alreadyStarted || progressActive) && gate.hasStarted) {
return await render();
}
return false;
},
pushToolProgress: noteProgress,
async pushPlanProgress(
steps?: AgentPlanStep[],
options?: { explanation?: string },
): Promise<boolean> {
if (
!params.active ||
params.mode !== "progress" ||
progressSuppressed ||
finalReplyStarted ||
finalReplyDelivered
) {
return false;
}
planSteps = steps && steps.length > 0 ? steps.map((entry) => ({ ...entry })) : undefined;
planExplanation = options?.explanation?.replace(/\s+/g, " ").trim() ?? "";
if (!planSteps && !planExplanation) {
if (!gate.hasStarted) {
return false;
}
const rendered = await render();
if (rendered || formatDraftText()) {
return rendered;
}
lastRenderedText = "";
await params.deleteCurrent?.();
return true;
}
const alreadyStarted = gate.hasStarted;
await gate.startNow();
if (!gate.hasStarted) {
return false;
}
if (alreadyStarted) {
await render();
}
return true;
},
async pushPreambleHeadline(text?: string, options?: { itemId?: string }) {
if (!params.active || params.mode !== "progress" || progressSuppressed) {
return false;
}
// The opt-in commentary lane already renders every preamble as an
// interleaved 💬 line; letting the headline also consume it would
// replace those documented lines with a duplicate status paragraph.
// Deliberate: the headline itself is default-on presentation of the
// typed preamble (owner decision, #105872); `commentary` only picks the
// interleaved-lane presentation, it is not a preamble kill switch.
if (commentaryProgressEnabled) {
return false;
}
if (finalReplyStarted || finalReplyDelivered) {
return false;
}
const itemId = options?.itemId?.trim() || undefined;
const normalized = sanitizeProgressStatusText(text ?? "")
.replace(/\s+/g, " ")
.trim();
if (!normalized) {
// Retractions must identify the currently displayed preamble. A late
// retraction for an older item must not clear a newer headline.
if (!itemId || itemId !== preambleItemId) {
return false;
}
preambleText = "";
preambleItemId = undefined;
preambleAt = undefined;
clearPreambleExpiryTimer();
if (!gate.hasStarted) {
return false;
}
const rendered = await render();
if (rendered || formatDraftText()) {
return rendered;
}
lastRenderedText = "";
await params.deleteCurrent?.();
return true;
}
const isNewPreambleItem = Boolean(itemId && itemId !== preambleItemId);
if (isNewPreambleItem) {
preambleItemId = itemId;
} else if (!itemId) {
preambleItemId = undefined;
}
if (normalized === preambleText && !isNewPreambleItem) {
return false;
}
preambleText = normalized;
preambleAt = now();
schedulePreambleExpiryRefresh();
// Work activity owns the delayed start gate. Retain preambles from fast
// turns without making their draft visible.
return gate.hasStarted ? await render() : false;
},
async pushNarrationProgress(text?: string) {
if (!params.active || params.mode !== "progress" || progressSuppressed) {
return false;
}
if (finalReplyStarted || finalReplyDelivered) {
return false;
}
const normalized = text?.replace(/\s+/g, " ").trim() ?? "";
if (normalized === narrationText) {
return false;
}
if (!normalized) {
// Release stopped narration without retracting the model's headline;
// raw tool lines return only when no preamble remains.
narrationText = "";
clearPreambleExpiryTimer();
return await render();
}
narrationText = normalized;
schedulePreambleExpiryRefresh();
// Tool activity owns the delayed start gate. Narration may arrive while
// that timer is pending; retain the newest text without flashing a draft
// for a turn that finishes inside the grace period.
return gate.hasStarted ? await render() : false;
},
async pushReasoningProgress(text?: string, options?: { snapshot?: boolean }) {
if (
!params.active ||
params.mode !== "progress" ||
!text ||
progressSuppressed ||
finalReplyDelivered ||
!thinkingProgressEnabled
) {
return false;
}
const normalized = mergeReasoningProgress(text, options);
if (!normalized) {
return false;
}
const compactLine = formatReasoningProgressDisplayLine(
normalized,
resolveChannelProgressDraftMaxLineChars(params.entry),
);
if (!compactLine) {
return false;
}
const displayLine = `${reasoningLinePrefix}${compactLine}`;
// Reasoning streams usually arrive as deltas. Replace the previous
// reasoning line so the draft stays compact instead of appending noise.
const priorIndex =
lastReasoningLine === undefined ? -1 : lines.lastIndexOf(lastReasoningLine);
if (priorIndex >= 0) {
lines = [...lines];
lines[priorIndex] = displayLine;
} else {
lines = [...lines, displayLine].slice(-resolveChannelProgressDraftMaxLines(params.entry));
}
lastReasoningLine = displayLine;
const progressActive = await gate.noteWork();
if (progressActive && gate.hasStarted) {
return await render();
}
return false;
},
async pushCommentaryProgress(text?: string, options?: { itemId?: string }) {
if (!params.active || params.mode !== "progress" || !commentaryProgressEnabled) {
return false;
}
if (finalReplyStarted || finalReplyDelivered) {
return false;
}
const itemId = options?.itemId?.trim();
if (!text && !itemId) {
return false;
}
const normalized = normalizeCommentaryProgressText(text ?? "");
const lineId = itemId ? `commentary:${itemId}` : normalized ? `commentary:${normalized}` : "";
if (!normalized) {
// Empty commentary with an item id means the producer retracted that
// item; remove its draft line if it was already rendered.
if (lineId) {
await clearLine(lineId);
}
return false;
}
const line: ChannelProgressDraftLine = {
id: lineId,
// The lane marker (💬, matching 🧠 thinking / 🛠️ tools) is a per-channel
// presentation choice supplied via commentaryLinePrefix; default none.
text: `${commentaryLinePrefix}${commentaryItalics ? normalized : stripLaneItalics(normalized)}`,
kind: "item",
label: "Commentary",
prefix: false,
};
lines = mergeChannelProgressDraftLine(lines, line, {
maxLines: resolveChannelProgressDraftMaxLines(params.entry),
});
const alreadyStarted = gate.hasStarted;
await gate.startNow();
if (!gate.hasStarted) {
return false;
}
if (alreadyStarted) {
await render();
}
// True means the sanitized commentary was accepted into the visible
// lane. A first item renders inside gate.onStart, not this call site.
return true;
},
};
}