From b01273cfc64f9c7205bbbfae052a33b20e81890a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 20:49:23 +0100 Subject: [PATCH] fix: narrow finalize boundary-drop guard (#27711) (thanks @scz2011) --- CHANGELOG.md | 1 + src/tui/tui-stream-assembler.test.ts | 29 ++++++++++++++++++++++++++++ src/tui/tui-stream-assembler.ts | 14 +++++++++++--- 3 files changed, 41 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fefa2f808..29ac74287 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- TUI/stream assembly: preserve streamed text across real tool-boundary drops without keeping stale streamed text when non-text blocks appear only in the final payload. Landed from contributor PR #27711 by @scz2011. (#27674) - Models/MiniMax auth header defaults: set `authHeader: true` for both onboarding-generated MiniMax API providers and implicit built-in MiniMax (`minimax`, `minimax-portal`) provider templates so first requests no longer fail with MiniMax `401 authentication_error` due to missing `Authorization` header. Landed from contributor PRs #27622 by @riccoyuanft and #27631 by @kevinWangSheng. (#27600, #15303) - Pi image-token usage: stop re-injecting history image blocks each turn, process image references from the current prompt only, and prune already-answered user-image blocks in stored history to prevent runaway token growth. (#27602) - BlueBubbles/SSRF: auto-allowlist the configured `serverUrl` hostname for attachment fetches so localhost/private-IP BlueBubbles setups are no longer false-blocked by default SSRF checks. Landed from contributor PR #27648 by @lailoo. (#27599) Thanks @taylorhou for reporting. diff --git a/src/tui/tui-stream-assembler.test.ts b/src/tui/tui-stream-assembler.test.ts index c7dc3d8fa..434f4e72d 100644 --- a/src/tui/tui-stream-assembler.test.ts +++ b/src/tui/tui-stream-assembler.test.ts @@ -152,6 +152,35 @@ describe("TuiStreamAssembler", () => { expect(finalText).toBe("Draft line 1"); }); + it("prefers final text when non-text blocks appear only in final payload", () => { + const assembler = new TuiStreamAssembler(); + assembler.ingestDelta( + "run-5c", + { + role: "assistant", + content: [ + { type: "text", text: "Draft line 1" }, + { type: "text", text: "Draft line 2" }, + ], + }, + false, + ); + + const finalText = assembler.finalize( + "run-5c", + { + role: "assistant", + content: [ + { type: "tool_use", name: "search" }, + { type: "text", text: "Draft line 2" }, + ], + }, + false, + ); + + expect(finalText).toBe("Draft line 2"); + }); + it("accepts richer final payload when it extends streamed text", () => { const assembler = new TuiStreamAssembler(); assembler.ingestDelta( diff --git a/src/tui/tui-stream-assembler.ts b/src/tui/tui-stream-assembler.ts index 4c2fa5a72..651951804 100644 --- a/src/tui/tui-stream-assembler.ts +++ b/src/tui/tui-stream-assembler.ts @@ -97,7 +97,10 @@ export class TuiStreamAssembler { state: RunStreamState, message: unknown, showThinking: boolean, - opts?: { protectBoundaryDrops?: boolean }, + opts?: { + protectBoundaryDrops?: boolean; + useIncomingNonTextForBoundaryDrops?: boolean; + }, ) { const thinkingText = extractThinkingFromMessage(message); const contentText = extractContentFromMessage(message); @@ -108,9 +111,11 @@ export class TuiStreamAssembler { } if (contentText) { const nextContentBlocks = textBlocks.length > 0 ? textBlocks : [contentText]; + const useIncomingNonTextForBoundaryDrops = opts?.useIncomingNonTextForBoundaryDrops !== false; const shouldPreserveBoundaryDroppedText = opts?.protectBoundaryDrops === true && - (state.sawNonTextContentBlocks || sawNonTextContentBlocks) && + (state.sawNonTextContentBlocks || + (useIncomingNonTextForBoundaryDrops && sawNonTextContentBlocks)) && isDroppedBoundaryTextBlockSubset({ streamedTextBlocks: state.contentBlocks, finalTextBlocks: nextContentBlocks, @@ -151,7 +156,10 @@ export class TuiStreamAssembler { const streamedDisplayText = state.displayText; const streamedTextBlocks = [...state.contentBlocks]; const streamedSawNonTextContentBlocks = state.sawNonTextContentBlocks; - this.updateRunState(state, message, showThinking, { protectBoundaryDrops: true }); + this.updateRunState(state, message, showThinking, { + protectBoundaryDrops: true, + useIncomingNonTextForBoundaryDrops: false, + }); const finalComposed = state.displayText; const shouldKeepStreamedText = streamedSawNonTextContentBlocks &&