From 5cd9e210face6fa0da4d10b5051e1ba97740860f Mon Sep 17 00:00:00 2001 From: Tseka Luk <79151285+TsekaLuk@users.noreply.github.com> Date: Sat, 14 Feb 2026 02:12:59 +0800 Subject: [PATCH] fix(tui): preserve streamed text when final payload regresses (#15452) (#15573) Merged via /review-pr -> /prepare-pr -> /merge-pr. Prepared head SHA: e4a5e3c8a6744249d794e0f553dda3296501a1d1 Co-authored-by: TsekaLuk <79151285+TsekaLuk@users.noreply.github.com> Co-authored-by: steipete <58493+steipete@users.noreply.github.com> Reviewed-by: @steipete --- CHANGELOG.md | 1 + src/tui/tui-stream-assembler.test.ts | 105 +++++++++++++++++++++++++++ src/tui/tui-stream-assembler.ts | 84 ++++++++++++++++++++- 3 files changed, 188 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aedf20ac4d..49ca6117ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Agents/Image tool: cap image-analysis completion `maxTokens` by model capability (`min(4096, model.maxTokens)`) to avoid over-limit provider failures while still preventing truncation. (#11770) Thanks @detecti1. +- TUI/Streaming: preserve richer streamed assistant text when final payload drops pre-tool-call text blocks, while keeping non-empty final payload authoritative for plain-text updates. (#15452) Thanks @TsekaLuk. - Inbound/Web UI: preserve literal `\n` sequences when normalizing inbound text so Windows paths like `C:\\Work\\nxxx\\README.md` are not corrupted. (#11547) Thanks @mcaxtr. - Security/Canvas: serve A2UI assets via the shared safe-open path (`openFileWithinRoot`) to close traversal/TOCTOU gaps, with traversal and symlink regression coverage. (#10525) Thanks @abdelsfane. - Security/Gateway: breaking default-behavior change - canvas IP-based auth fallback now only accepts machine-scoped addresses (RFC1918, link-local, ULA IPv6, CGNAT); public-source IP matches now require bearer token auth. (#14661) Thanks @sumleo. diff --git a/src/tui/tui-stream-assembler.test.ts b/src/tui/tui-stream-assembler.test.ts index e56eb5699e..ed3aab3478 100644 --- a/src/tui/tui-stream-assembler.test.ts +++ b/src/tui/tui-stream-assembler.test.ts @@ -89,4 +89,109 @@ describe("TuiStreamAssembler", () => { expect(second).toBeNull(); }); + + it("keeps richer streamed text when final payload drops earlier blocks", () => { + const assembler = new TuiStreamAssembler(); + assembler.ingestDelta( + "run-5", + { + role: "assistant", + content: [ + { type: "text", text: "Before tool call" }, + { type: "tool_use", name: "search" }, + { type: "text", text: "After tool call" }, + ], + }, + false, + ); + + const finalText = assembler.finalize( + "run-5", + { + role: "assistant", + content: [ + { type: "tool_use", name: "search" }, + { type: "text", text: "After tool call" }, + ], + }, + false, + ); + + expect(finalText).toBe("Before tool call\nAfter tool call"); + }); + + it("keeps non-empty final text for plain text prefix/suffix updates", () => { + const assembler = new TuiStreamAssembler(); + assembler.ingestDelta( + "run-5b", + { + role: "assistant", + content: [ + { type: "text", text: "Draft line 1" }, + { type: "text", text: "Draft line 2" }, + ], + }, + false, + ); + + const finalText = assembler.finalize( + "run-5b", + { + role: "assistant", + content: [{ type: "text", text: "Draft line 1" }], + }, + false, + ); + + expect(finalText).toBe("Draft line 1"); + }); + + it("accepts richer final payload when it extends streamed text", () => { + const assembler = new TuiStreamAssembler(); + assembler.ingestDelta( + "run-6", + { + role: "assistant", + content: [{ type: "text", text: "Before tool call" }], + }, + false, + ); + + const finalText = assembler.finalize( + "run-6", + { + role: "assistant", + content: [ + { type: "text", text: "Before tool call" }, + { type: "text", text: "After tool call" }, + ], + }, + false, + ); + + expect(finalText).toBe("Before tool call\nAfter tool call"); + }); + + it("prefers non-empty final payload when it is not a dropped block regression", () => { + const assembler = new TuiStreamAssembler(); + assembler.ingestDelta( + "run-7", + { + role: "assistant", + content: [{ type: "text", text: "NOT OK" }], + }, + false, + ); + + const finalText = assembler.finalize( + "run-7", + { + role: "assistant", + content: [{ type: "text", text: "OK" }], + }, + false, + ); + + expect(finalText).toBe("OK"); + }); }); diff --git a/src/tui/tui-stream-assembler.ts b/src/tui/tui-stream-assembler.ts index f944834616..86d3dacd17 100644 --- a/src/tui/tui-stream-assembler.ts +++ b/src/tui/tui-stream-assembler.ts @@ -8,9 +8,73 @@ import { 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; + 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; + 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(); @@ -20,6 +84,8 @@ export class TuiStreamAssembler { state = { thinkingText: "", contentText: "", + contentBlocks: [], + sawNonTextContentBlocks: false, displayText: "", }; this.runs.set(runId, state); @@ -30,12 +96,17 @@ export class TuiStreamAssembler { private updateRunState(state: RunStreamState, message: unknown, showThinking: boolean) { const thinkingText = extractThinkingFromMessage(message); const contentText = extractContentFromMessage(message); + const { textBlocks, sawNonTextContentBlocks } = extractTextBlocksAndSignals(message); if (thinkingText) { state.thinkingText = thinkingText; } if (contentText) { state.contentText = contentText; + state.contentBlocks = textBlocks.length > 0 ? textBlocks : [contentText]; + } + if (sawNonTextContentBlocks) { + state.sawNonTextContentBlocks = true; } const displayText = composeThinkingAndContent({ @@ -61,11 +132,20 @@ export class TuiStreamAssembler { 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); const finalComposed = state.displayText; + const shouldKeepStreamedText = + streamedSawNonTextContentBlocks && + isDroppedBoundaryTextBlockSubset({ + streamedTextBlocks, + finalTextBlocks: state.contentBlocks, + }); const finalText = resolveFinalAssistantText({ - finalText: finalComposed, - streamedText: state.displayText, + finalText: shouldKeepStreamedText ? streamedDisplayText : finalComposed, + streamedText: streamedDisplayText, }); this.runs.delete(runId);