openclaw/src/tui/tui-stream-assembler.ts
AI Assistant d6cbaea434 fix(tui): preserve streamed text during tool call transitions
Fixes #27674

The TUI was erasing already-streamed assistant text when tool calls
were triggered. This happened because the finalize() method in
TuiStreamAssembler was not using the protectBoundaryDrops option
when updating run state.

Now finalize() applies the same boundary drop protection as
ingestDelta(), ensuring that streamed text before tool calls is
preserved when the final payload drops earlier content blocks.
2026-02-26 19:50:06 +00:00

175 lines
5.1 KiB
TypeScript

import {
composeThinkingAndContent,
extractContentFromMessage,
extractThinkingFromMessage,
resolveFinalAssistantText,
} from "./tui-formatters.js";
type RunStreamState = {
thinkingText: string;
contentText: string;
contentBlocks: string[];
sawNonTextContentBlocks: boolean;
displayText: string;
};
function extractTextBlocksAndSignals(message: unknown): {
textBlocks: string[];
sawNonTextContentBlocks: boolean;
} {
if (!message || typeof message !== "object") {
return { textBlocks: [], sawNonTextContentBlocks: false };
}
const record = message as Record<string, unknown>;
const content = record.content;
if (typeof content === "string") {
const text = content.trim();
return {
textBlocks: text ? [text] : [],
sawNonTextContentBlocks: false,
};
}
if (!Array.isArray(content)) {
return { textBlocks: [], sawNonTextContentBlocks: false };
}
const textBlocks: string[] = [];
let sawNonTextContentBlocks = false;
for (const block of content) {
if (!block || typeof block !== "object") {
continue;
}
const rec = block as Record<string, unknown>;
if (rec.type === "text" && typeof rec.text === "string") {
const text = rec.text.trim();
if (text) {
textBlocks.push(text);
}
continue;
}
if (typeof rec.type === "string" && rec.type !== "thinking") {
sawNonTextContentBlocks = true;
}
}
return { textBlocks, sawNonTextContentBlocks };
}
function isDroppedBoundaryTextBlockSubset(params: {
streamedTextBlocks: string[];
finalTextBlocks: string[];
}): boolean {
const { streamedTextBlocks, finalTextBlocks } = params;
if (finalTextBlocks.length === 0 || finalTextBlocks.length >= streamedTextBlocks.length) {
return false;
}
const prefixMatches = finalTextBlocks.every(
(block, index) => streamedTextBlocks[index] === block,
);
if (prefixMatches) {
return true;
}
const suffixStart = streamedTextBlocks.length - finalTextBlocks.length;
return finalTextBlocks.every((block, index) => streamedTextBlocks[suffixStart + index] === block);
}
export class TuiStreamAssembler {
private runs = new Map<string, RunStreamState>();
private getOrCreateRun(runId: string): RunStreamState {
let state = this.runs.get(runId);
if (!state) {
state = {
thinkingText: "",
contentText: "",
contentBlocks: [],
sawNonTextContentBlocks: false,
displayText: "",
};
this.runs.set(runId, state);
}
return state;
}
private updateRunState(
state: RunStreamState,
message: unknown,
showThinking: boolean,
opts?: { protectBoundaryDrops?: boolean },
) {
const thinkingText = extractThinkingFromMessage(message);
const contentText = extractContentFromMessage(message);
const { textBlocks, sawNonTextContentBlocks } = extractTextBlocksAndSignals(message);
if (thinkingText) {
state.thinkingText = thinkingText;
}
if (contentText) {
const nextContentBlocks = textBlocks.length > 0 ? textBlocks : [contentText];
const shouldPreserveBoundaryDroppedText =
opts?.protectBoundaryDrops === true &&
(state.sawNonTextContentBlocks || sawNonTextContentBlocks) &&
isDroppedBoundaryTextBlockSubset({
streamedTextBlocks: state.contentBlocks,
finalTextBlocks: nextContentBlocks,
});
if (!shouldPreserveBoundaryDroppedText) {
state.contentText = contentText;
state.contentBlocks = nextContentBlocks;
}
}
if (sawNonTextContentBlocks) {
state.sawNonTextContentBlocks = true;
}
const displayText = composeThinkingAndContent({
thinkingText: state.thinkingText,
contentText: state.contentText,
showThinking,
});
state.displayText = displayText;
}
ingestDelta(runId: string, message: unknown, showThinking: boolean): string | null {
const state = this.getOrCreateRun(runId);
const previousDisplayText = state.displayText;
this.updateRunState(state, message, showThinking, { protectBoundaryDrops: true });
if (!state.displayText || state.displayText === previousDisplayText) {
return null;
}
return state.displayText;
}
finalize(runId: string, message: unknown, showThinking: boolean): string {
const state = this.getOrCreateRun(runId);
const streamedDisplayText = state.displayText;
const streamedTextBlocks = [...state.contentBlocks];
const streamedSawNonTextContentBlocks = state.sawNonTextContentBlocks;
this.updateRunState(state, message, showThinking, { protectBoundaryDrops: true });
const finalComposed = state.displayText;
const shouldKeepStreamedText =
streamedSawNonTextContentBlocks &&
isDroppedBoundaryTextBlockSubset({
streamedTextBlocks,
finalTextBlocks: state.contentBlocks,
});
const finalText = resolveFinalAssistantText({
finalText: shouldKeepStreamedText ? streamedDisplayText : finalComposed,
streamedText: streamedDisplayText,
});
this.runs.delete(runId);
return finalText;
}
drop(runId: string) {
this.runs.delete(runId);
}
}