From d3c5ac85aedee7fcdea22418fb8ac533f55fb405 Mon Sep 17 00:00:00 2001 From: "Jason (Json)" <263060202+fuller-stack-dev@users.noreply.github.com> Date: Sun, 2 Aug 2026 21:08:09 -0600 Subject: [PATCH] fix(chat): preserve trace across history reloads (#118332) --- apps/.i18n/native-source.json | 32 ++--- .../OpenClawChatUI/ChatMessageViews.swift | 13 +- .../ChatMessageVisibleText.swift | 23 ++- .../Sources/OpenClawChatUI/ChatView.swift | 13 +- .../ChatMessageVisibleTextTests.swift | 27 ++++ .../chat-display-projection.sanitize.ts | 24 ++-- .../server-methods/server-methods.test.ts | 23 ++- .../server.chat.gateway-server-chat-b.test.ts | 9 +- .../chat-flow.history-recovery.e2e.test.ts | 134 ++++++++++++++++++ 9 files changed, 247 insertions(+), 51 deletions(-) diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index 3ae96b2dd158..5ed009329557 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -40707,7 +40707,7 @@ }, { "kind": "conditional-branch", - "line": 708, + "line": 701, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift", "source": "Voice note", "surface": "apple", @@ -40715,7 +40715,7 @@ }, { "kind": "ui-localized-call", - "line": 749, + "line": 742, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift", "source": "Attachment", "surface": "apple", @@ -40723,7 +40723,7 @@ }, { "kind": "ui-call", - "line": 789, + "line": 782, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift", "source": "Writing", "surface": "apple", @@ -40731,7 +40731,7 @@ }, { "kind": "ui-call", - "line": 828, + "line": 821, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift", "source": "Preparing audio…", "surface": "apple", @@ -40739,7 +40739,7 @@ }, { "kind": "ui-call", - "line": 831, + "line": 824, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift", "source": "Speaking…", "surface": "apple", @@ -40747,7 +40747,7 @@ }, { "kind": "conditional-branch", - "line": 839, + "line": 832, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift", "source": "Preparing audio, tap to cancel", "surface": "apple", @@ -40755,7 +40755,7 @@ }, { "kind": "conditional-branch", - "line": 840, + "line": 833, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift", "source": "Speaking, tap to stop", "surface": "apple", @@ -41987,7 +41987,7 @@ }, { "kind": "ui-call", - "line": 1172, + "line": 1165, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift", "source": "Copy Message", "surface": "apple", @@ -41995,7 +41995,7 @@ }, { "kind": "ui-call", - "line": 1195, + "line": 1188, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift", "source": "Open Full Message", "surface": "apple", @@ -42003,7 +42003,7 @@ }, { "kind": "ui-call", - "line": 1214, + "line": 1207, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift", "source": "Rewind to Here", "surface": "apple", @@ -42011,7 +42011,7 @@ }, { "kind": "ui-call", - "line": 1234, + "line": 1227, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift", "source": "Fork from Here", "surface": "apple", @@ -42019,7 +42019,7 @@ }, { "kind": "ui-localized-call", - "line": 1260, + "line": 1253, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift", "source": "Reply", "surface": "apple", @@ -42027,7 +42027,7 @@ }, { "kind": "ui-localized-call", - "line": 1270, + "line": 1263, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift", "source": "You", "surface": "apple", @@ -42035,7 +42035,7 @@ }, { "kind": "ui-localized-call", - "line": 1272, + "line": 1265, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift", "source": "Assistant", "surface": "apple", @@ -42043,7 +42043,7 @@ }, { "kind": "ui-call", - "line": 1338, + "line": 1331, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift", "source": "Loading chat", "surface": "apple", @@ -42051,7 +42051,7 @@ }, { "kind": "ui-modifier", - "line": 1418, + "line": 1411, "path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift", "source": "Dismiss", "surface": "apple", diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift index d040f84a9549..37cdade0e353 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift @@ -515,16 +515,9 @@ private struct ChatMessageBody: View { } private var primaryText: String { - let parts = self.message.content.compactMap { content -> String? in - let kind = (content.type ?? "text").lowercased() - guard kind == "text" || kind.isEmpty else { return nil } - return content.text - } - return OpenClawChatMessage.displayText( - contentText: parts.joined(separator: "\n"), - role: self.message.role, - stopReason: self.message.stopReason, - errorMessage: self.message.errorMessage) + ChatMessageVisibleText.displayText( + in: self.message, + includeThinking: self.displayOptions.contains(.reasoning)) } private var inlineAttachments: [OpenClawChatMessageContent] { diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageVisibleText.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageVisibleText.swift index 7e0c30f09264..7d825f85bf5b 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageVisibleText.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageVisibleText.swift @@ -25,11 +25,24 @@ public enum ChatMessageVisibleText { .isEmpty } - private static func primaryText(in message: OpenClawChatMessage) -> String { + static func displayText(in message: OpenClawChatMessage, includeThinking: Bool) -> String { + let isAssistant = message.role.trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() == "assistant" let parts = message.content.compactMap { content -> String? in let kind = (content.type ?? "text").lowercased() - guard kind == "text" || kind.isEmpty else { return nil } - return content.text + if kind == "text" || kind.isEmpty { + return content.text + } + guard + includeThinking, + isAssistant, + kind == "thinking", + let thinking = content.thinking, + !thinking.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { + return nil + } + return "\n\(thinking)\n" } return OpenClawChatMessage.displayText( contentText: parts.joined(separator: "\n"), @@ -37,4 +50,8 @@ public enum ChatMessageVisibleText { stopReason: message.stopReason, errorMessage: message.errorMessage) } + + private static func primaryText(in message: OpenClawChatMessage) -> String { + self.displayText(in: message, includeThinking: false) + } } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift index f04fb5e69462..6e955441003f 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift @@ -1111,16 +1111,9 @@ extension OpenClawChatView { } private func primaryText(in message: OpenClawChatMessage) -> String { - let parts = message.content.compactMap { content -> String? in - let kind = (content.type ?? "text").lowercased() - guard kind == "text" || kind.isEmpty else { return nil } - return content.text - } - return OpenClawChatMessage.displayText( - contentText: parts.joined(separator: "\n"), - role: message.role, - stopReason: message.stopReason, - errorMessage: message.errorMessage) + ChatMessageVisibleText.displayText( + in: message, + includeThinking: self.displayOptions.contains(.reasoning)) } private func hasInlineAttachments(in message: OpenClawChatMessage) -> Bool { diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatMessageVisibleTextTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatMessageVisibleTextTests.swift index 4d613064734d..208a84c0e4ef 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatMessageVisibleTextTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatMessageVisibleTextTests.swift @@ -17,6 +17,16 @@ private func toolCallContent(name: String) -> OpenClawChatMessageContent { name: name) } +private func thinkingContent(_ thinking: String) -> OpenClawChatMessageContent { + OpenClawChatMessageContent( + type: "thinking", + text: nil, + thinking: thinking, + mimeType: nil, + fileName: nil, + content: nil) +} + @Suite("ChatMessageVisibleText") struct ChatMessageVisibleTextTests { @Test func `assistant visible text skips non text blocks`() { @@ -56,6 +66,23 @@ struct ChatMessageVisibleTextTests { #expect(ChatMessageVisibleText.copyText(in: user) == "Keep this literal tag") } + @Test func `assistant display includes structured thinking only when enabled`() { + let message = OpenClawChatMessage( + role: "assistant", + content: [ + thinkingContent("Check the persisted state."), + textContent("Here is the answer."), + toolCallContent(name: "read"), + ], + timestamp: 1) + + #expect(ChatMessageVisibleText.displayText(in: message, includeThinking: false) + == "Here is the answer.") + #expect(ChatMessageVisibleText.displayText(in: message, includeThinking: true) + == "\nCheck the persisted state.\n\nHere is the answer.") + #expect(ChatMessageVisibleText.copyText(in: message) == "Here is the answer.") + } + @Test func `history decode retains transcript identity and truncation signals`() throws { let metadata = try JSONDecoder().decode( OpenClawChatMessage.self, diff --git a/src/gateway/chat-display-projection.sanitize.ts b/src/gateway/chat-display-projection.sanitize.ts index adec0af4958a..6ac80d04a274 100644 --- a/src/gateway/chat-display-projection.sanitize.ts +++ b/src/gateway/chat-display-projection.sanitize.ts @@ -157,7 +157,7 @@ function sanitizeAssistantPhasedContentBlocks(content: unknown[]): { }; } -function projectAssistantTextFromMixedToolContent( +function projectAssistantMixedToolContent( content: unknown[], maxChars: number, ): { content: unknown[]; changed: boolean } | null { @@ -171,23 +171,31 @@ function projectAssistantTextFromMixedToolContent( return null; } - const textBlocks: unknown[] = []; + let hasVisibleText = false; + const projectedContent: unknown[] = []; for (const block of content) { if (!block || typeof block !== "object") { continue; } const entry = block as { type?: unknown; text?: unknown }; - if (entry.type !== "text" || typeof entry.text !== "string" || !entry.text.trim()) { + if (!isAssistantTextContentType(entry.type)) { + projectedContent.push(block); + continue; + } + if (typeof entry.text !== "string" || !entry.text.trim()) { continue; } const stripped = stripInlineDirectiveTagsForDisplay(entry.text); const truncated = truncateChatHistoryText(stripped.text, maxChars); if (truncated.text.trim()) { - textBlocks.push({ type: "text", text: truncated.text }); + projectedContent.push({ type: "text", text: truncated.text }); + hasVisibleText = true; } } - return textBlocks.length > 0 ? { content: textBlocks, changed: true } : null; + // Mixed messages supply both the visible bubble and its reasoning/tool trace. + // Keep structured siblings or a history reload loses activity shown while live. + return hasVisibleText ? { content: projectedContent, changed: true } : null; } function toFiniteNumber(x: unknown): number | undefined { @@ -394,9 +402,9 @@ export function sanitizeChatHistoryMessage( changed = true; } if (entry.role === "assistant" && Array.isArray(entry.content)) { - const mixedToolText = projectAssistantTextFromMixedToolContent(entry.content, maxChars); - if (mixedToolText) { - entry.content = mixedToolText.content; + const mixedToolContent = projectAssistantMixedToolContent(entry.content, maxChars); + if (mixedToolContent) { + entry.content = mixedToolContent.content; if (entry.phase === "commentary") { delete entry.phase; } diff --git a/src/gateway/server-methods/server-methods.test.ts b/src/gateway/server-methods/server-methods.test.ts index 408db0074785..cab007762603 100644 --- a/src/gateway/server-methods/server-methods.test.ts +++ b/src/gateway/server-methods/server-methods.test.ts @@ -1123,7 +1123,15 @@ describe("projectRecentChatDisplayMessages", () => { errorMessage: privateError, errorBody: "private response body", }, - content: [{ type: "text", text: "I read the requested file before the run failed." }], + content: [ + { type: "text", text: "I read the requested file before the run failed." }, + { + type: "toolCall", + id: "call-1", + name: "read", + arguments: { path: "README.md" }, + }, + ], }, ]; @@ -1927,7 +1935,7 @@ describe("projectRecentChatDisplayMessages", () => { ]); }); - it("keeps visible assistant progress text from mixed tool-use messages", () => { + it("preserves structured trace alongside visible assistant progress text", () => { const result = projectRecentChatDisplayMessages([ { role: "user", @@ -1968,7 +1976,16 @@ describe("projectRecentChatDisplayMessages", () => { expect(result[1]).toEqual({ role: "assistant", - content: [{ type: "text", text: "I will clean that up now." }], + content: [ + { type: "thinking", thinking: "private reasoning" }, + { type: "text", text: "I will clean that up now." }, + { + type: "toolCall", + id: "call-read", + name: "read", + arguments: { path: "AGENTS.md" }, + }, + ], timestamp: 2, __openclaw: { seq: 2 }, }); diff --git a/src/gateway/server.chat.gateway-server-chat-b.test.ts b/src/gateway/server.chat.gateway-server-chat-b.test.ts index bd78f857f3ec..fd4d27b4e606 100644 --- a/src/gateway/server.chat.gateway-server-chat-b.test.ts +++ b/src/gateway/server.chat.gateway-server-chat-b.test.ts @@ -5192,7 +5192,7 @@ describe("gateway server chat", () => { }); }); - test("chat.history keeps visible assistant progress text from mixed tool-use transcript messages", async () => { + test("chat.history preserves assistant trace from mixed tool-use transcript messages", async () => { await withGatewayChatHarness(async ({ ws, createSessionDir }) => { await prepareMainHistoryHarness({ ws, createSessionDir }); await writeMainSessionTranscript([ @@ -5240,7 +5240,14 @@ describe("gateway server chat", () => { }; expect(assistantMessage.role).toBe("assistant"); expect(assistantMessage.content).toEqual([ + { type: "thinking", thinking: "private reasoning" }, { type: "text", text: "I will clean that up now." }, + { + type: "toolCall", + id: "call-read", + name: "read", + arguments: { path: "AGENTS.md" }, + }, ]); expect(assistantMessage.timestamp).toBe(2); }); diff --git a/ui/src/e2e/chat-flow.history-recovery.e2e.test.ts b/ui/src/e2e/chat-flow.history-recovery.e2e.test.ts index a0606dbd8b5f..a1978a76b924 100644 --- a/ui/src/e2e/chat-flow.history-recovery.e2e.test.ts +++ b/ui/src/e2e/chat-flow.history-recovery.e2e.test.ts @@ -1,3 +1,5 @@ +import { mkdir } from "node:fs/promises"; +import path from "node:path"; import { expect, it } from "vitest"; import { chatSessionListResponse, @@ -17,6 +19,138 @@ import { const suite = createChatFlowE2eSuite(); suite.define(() => { + it("restores reasoning and tool activity after navigating away from a session", async () => { + const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim(); + if (artifactDir) { + await mkdir(artifactDir, { recursive: true }); + } + const context = await suite.newBrowserContext({ + locale: "en-US", + ...(artifactDir + ? { recordVideo: { dir: artifactDir, size: { height: 900, width: 1280 } } } + : {}), + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + const sessionA = "agent:main:session-a"; + const sessionB = "agent:main:session-b"; + const visibleAnswer = "Trace preserved after navigation."; + const reasoning = "Checked the persisted session trace."; + const currentMessages = [ + { + role: "assistant", + content: [{ type: "text", text: "Current session placeholder." }], + timestamp: 1, + }, + ]; + const traceMessages = [ + { + role: "assistant", + content: [ + { type: "thinking", thinking: reasoning }, + { type: "text", text: visibleAnswer }, + { + type: "toolCall", + id: "call-read", + name: "read", + arguments: { path: "AGENTS.md" }, + }, + ], + timestamp: 2, + }, + { + role: "toolResult", + toolCallId: "call-read", + toolName: "read", + content: [{ type: "text", text: "file contents" }], + timestamp: 3, + }, + ]; + const responseCases = { + cases: [ + { + match: { sessionKey: sessionB }, + response: { messages: traceMessages, sessionId: "trace-session", thinkingLevel: "high" }, + }, + { + match: { sessionKey: sessionA }, + response: { + messages: currentMessages, + sessionId: "current-session", + thinkingLevel: "high", + }, + }, + ], + }; + const gateway = await installMockGateway(page, { + methodResponses: { + "chat.history": responseCases, + "chat.startup": responseCases, + "sessions.list": chatSessionListResponse([ + { + key: sessionA, + kind: "direct", + label: "Session A", + reasoningLevel: "high", + updatedAt: 2, + }, + { + key: sessionB, + kind: "direct", + label: "Session B", + reasoningLevel: "high", + updatedAt: 1, + }, + ]), + }, + sessionKey: sessionA, + }); + + try { + await page.goto(`${suite.server.baseUrl}chat`); + await page.getByText("Current session placeholder.").waitFor({ timeout: 10_000 }); + + const sessionLink = (sessionKey: string) => + page.locator( + `.sidebar-recent-session[data-session-key="${sessionKey}"] a.sidebar-recent-session__link`, + ); + const expectTrace = async () => { + await page.getByText(visibleAnswer, { exact: true }).waitFor({ timeout: 10_000 }); + await expect.poll(() => page.locator(".chat-thinking").textContent()).toContain(reasoning); + await expect + .poll(() => page.locator(".chat-tool-msg-summary").textContent()) + .toContain("Read"); + }; + + await sessionLink(sessionB).click(); + await expectTrace(); + if (artifactDir) { + await page.screenshot({ + fullPage: true, + path: path.join(artifactDir, "trace-after-first-navigation.png"), + }); + } + + await sessionLink(sessionA).click(); + await page.getByText("Current session placeholder.").waitFor({ timeout: 10_000 }); + const historyRequestsBeforeReturn = (await gateway.getRequests("chat.history")).length; + await sessionLink(sessionB).click(); + await expect + .poll(async () => (await gateway.getRequests("chat.history")).length) + .toBeGreaterThan(historyRequestsBeforeReturn); + await expectTrace(); + if (artifactDir) { + await page.screenshot({ + fullPage: true, + path: path.join(artifactDir, "trace-after-return.png"), + }); + } + } finally { + await suite.closeBrowserContext(context); + } + }); + it("keeps valid assistant history visible after a malformed transcript block", async () => { const context = await suite.newBrowserContext({ locale: "en-US",