From 7f5ae9249fdc7c2979e32869ed64201dab0186da Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 21 Aug 2026 23:04:35 +0100 Subject: [PATCH] fix: preserve steering stream boundaries --- .../chat-flow.active-run-follow-ups.e2e.test.ts | 16 ++++++++++++++++ ui/src/pages/chat/chat-state.test.ts | 13 ++++++++++++- ui/src/pages/chat/session-message-apply.ts | 14 +++++++++++--- 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/ui/src/e2e/chat-flow.active-run-follow-ups.e2e.test.ts b/ui/src/e2e/chat-flow.active-run-follow-ups.e2e.test.ts index efa84dd9ce39..06485957954a 100644 --- a/ui/src/e2e/chat-flow.active-run-follow-ups.e2e.test.ts +++ b/ui/src/e2e/chat-flow.active-run-follow-ups.e2e.test.ts @@ -294,6 +294,22 @@ suite.define(() => { sessionKey: "main", state: "delta", }); + const streamingBubble = page.locator(".chat-bubble.streaming", { + hasText: "Terminal response paragraph 1.", + }); + await streamingBubble.waitFor(); + const streamingRow = streamingBubble.locator( + "xpath=ancestor::div[contains(@class, 'chat-virtual-row')]", + ); + await streamingRow.waitFor(); + const steerBubble = page.locator(".chat-group.user", { hasText: steerText }).last(); + const [steerBounds, streamingBounds] = await Promise.all([ + steerBubble.boundingBox(), + streamingBubble.boundingBox(), + ]); + expect(steerBounds).not.toBeNull(); + expect(streamingBounds).not.toBeNull(); + expect(streamingBounds!.y).toBeGreaterThanOrEqual(steerBounds!.y + steerBounds!.height - 1); await gateway.emitGatewayEvent("session.message", { activeRunIds: [], clientRunId: runId, diff --git a/ui/src/pages/chat/chat-state.test.ts b/ui/src/pages/chat/chat-state.test.ts index b7deb7a43269..f2b42a94b00c 100644 --- a/ui/src/pages/chat/chat-state.test.ts +++ b/ui/src/pages/chat/chat-state.test.ts @@ -24,7 +24,7 @@ import { } from "./chat-state-refresh.ts"; import { resolveChatAvatarUrl, selectedChatSessionRow } from "./chat-state-route.ts"; import { buildChatItems } from "./chat-thread-build.ts"; -import { getChatSessionProjection } from "./history-merge.ts"; +import { getChatSessionProjection, reduceChatSessionProjection } from "./history-merge.ts"; import { scheduleControlUiAfterPaint } from "./performance.ts"; import { applySessionMessagePayload } from "./session-message-apply.ts"; @@ -269,6 +269,16 @@ describe("canonical session message recovery", () => { ]); expect(state.chatRunId).toBe(activeRunId); expect(state.chatQueue).toEqual([]); + reduceChatSessionProjection(state, { + type: "sendPending", + runId: steerRunId, + message: { + role: "user", + content: [{ type: "text", text: "Steer prompt" }], + timestamp: 50, + __openclaw: { idempotencyKey: `${steerRunId}:user` }, + }, + }); state.chatRunId = steerRunId; const steerEvent = { @@ -296,6 +306,7 @@ describe("canonical session message recovery", () => { handlePageGatewayEvent(state, steerEvent); expect(state.chatRunId).toBe(activeRunId); const segmentsAfterRequestBoundary = state.chatStreamSegments; + expect(segmentsAfterRequestBoundary.at(-1)?.boundaryRunId).toBe(steerRunId); expect(state.chatStreamSegments).toBe(segmentsAfterRequestBoundary); expect( state.chatMessages.filter((message) => extractText(message) === "Steer prompt"), diff --git a/ui/src/pages/chat/session-message-apply.ts b/ui/src/pages/chat/session-message-apply.ts index 7035388dc446..d11fe5407412 100644 --- a/ui/src/pages/chat/session-message-apply.ts +++ b/ui/src/pages/chat/session-message-apply.ts @@ -12,7 +12,12 @@ import { readChatSessionProjectionScope, reduceChatSessionProjection, } from "./history-merge.ts"; -import { persistedSteerTargetRunId, rolloverChatStream } from "./stream-causal-boundary.ts"; +import { + latestPersistedSteerBoundary, + latestStreamBoundaryRunId, + persistedSteerTargetRunId, + rolloverChatStream, +} from "./stream-causal-boundary.ts"; import { maybeResetToolStream } from "./stream-reconciliation.ts"; import { prunePersistedAssistantStreamSegments } from "./stream-segment-pruning.ts"; @@ -121,7 +126,6 @@ export function applySessionMessagePayload( ...(incoming.sequence !== null ? { seq: incoming.sequence } : {}), }, }; - const previousMessageCount = state.chatMessages.length; const projection = reduceChatSessionProjection( state, { @@ -141,13 +145,17 @@ export function applySessionMessagePayload( } const steerTargetRunId = persistedSteerTargetRunId(message); const currentRunId = state.chatRunId; + const persistedSteerBoundary = steerTargetRunId + ? latestPersistedSteerBoundary(projection.messages, steerTargetRunId) + : null; if ( incoming.role === "user" && runActive === true && incoming.runId && steerTargetRunId && (!currentRunId || currentRunId === steerTargetRunId || currentRunId === incoming.runId) && - projection.messages.length > previousMessageCount + persistedSteerBoundary?.runId === incoming.runId && + latestStreamBoundaryRunId(state) !== incoming.runId ) { state.chatRunId = steerTargetRunId; rolloverChatStream(state, {