diff --git a/ui/src/pages/chat/chat-gateway.test.ts b/ui/src/pages/chat/chat-gateway.test.ts index 602c22f23c9b..f19e5f3c17b0 100644 --- a/ui/src/pages/chat/chat-gateway.test.ts +++ b/ui/src/pages/chat/chat-gateway.test.ts @@ -824,6 +824,58 @@ describe("handleChatGatewayEvent", () => { expect(state.chatStream).toBe(expected); }); + it("reuses persisted text across live deltas and refreshes replaced messages", () => { + const persistedMessage = (id: string, text: string) => { + const readContent = vi.fn(() => [{ type: "text", text }]); + return { + readContent, + message: { + role: "assistant", + get content() { + return readContent(); + }, + __openclaw: { id, runId: "run-1" }, + }, + }; + }; + const first = persistedMessage("part-a", "A"); + const second = persistedMessage("part-b", "B"); + const state = createState({ + chatRunId: "run-1", + chatMessages: [first.message, second.message], + }); + const receiveDelta = (text: string) => { + expect( + handleChatGatewayEvent(state, { + runId: "run-1", + sessionKey: "main", + state: "delta", + message: createTextChatMessage("assistant", text), + }), + ).toBe("delta"); + return visibleCurrentAssistantStreamTail(state, () => false); + }; + + expect(receiveDelta("ABC")).toBe("C"); + expect(first.readContent).toHaveBeenCalled(); + expect(second.readContent).toHaveBeenCalled(); + first.readContent.mockClear(); + second.readContent.mockClear(); + + for (const text of ["ABCD", "ABCDE", "ABCDEF"]) { + expect(receiveDelta(text)).toBe(text.slice(2)); + } + expect(first.readContent).not.toHaveBeenCalled(); + expect(second.readContent).not.toHaveBeenCalled(); + + const replacement = persistedMessage("part-b", "BC"); + state.chatMessages = [first.message, replacement.message]; + expect(receiveDelta("ABCDEFG")).toBe("DEFG"); + expect(replacement.readContent).toHaveBeenCalled(); + expect(first.readContent).not.toHaveBeenCalled(); + expect(second.readContent).not.toHaveBeenCalled(); + }); + it("adopts the run id for selected-session live deltas observed from another channel", () => { const state = createState({ sessionKey: "agent:main:feishu:direct:peer-1", diff --git a/ui/src/pages/chat/stream-causal-boundary.ts b/ui/src/pages/chat/stream-causal-boundary.ts index 57c2a91d07b3..a99609149091 100644 --- a/ui/src/pages/chat/stream-causal-boundary.ts +++ b/ui/src/pages/chat/stream-causal-boundary.ts @@ -9,7 +9,7 @@ import { streamSegmentUsesAccumulatedText, type ChatStreamSegment, } from "../../lib/chat/chat-types.ts"; -import { extractText } from "../../lib/chat/message-extract.ts"; +import { extractText, extractTextCached } from "../../lib/chat/message-extract.ts"; import { userTurnRunId } from "./chat-thread-items.ts"; import { isKeyedAssistantStreamFallbackMessage } from "./chat-thread-run-identity.ts"; @@ -236,7 +236,7 @@ export function resolveCumulativeAssistantTail( const identity = readSessionMessageIdentity(message); const persistedText = identity?.runId === runId && !isKeyedAssistantStreamFallbackMessage(message) - ? extractText(message) + ? extractTextCached(message) : null; if ( identity?.role === "assistant" && @@ -255,7 +255,7 @@ export function resolveCumulativeAssistantTail( return identity?.role === "assistant" && (!identity.runId || identity.runId === runId) && !isKeyedAssistantStreamFallbackMessage(message) - ? extractText(message) + ? extractTextCached(message) : null; }); return resolveAssistantTextTail(persistedTexts, cumulativeText);