fix(chat): preserve trace across history reloads (#118332)

This commit is contained in:
Jason (Json)
2026-08-02 21:08:09 -06:00
committed by GitHub
parent b0e023a241
commit d3c5ac85ae
9 changed files with 247 additions and 51 deletions
+16 -16
View File
@@ -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",
@@ -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] {
@@ -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 "<think>\n\(thinking)\n</think>"
}
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)
}
}
@@ -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 {
@@ -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 <think>this literal tag</think>")
}
@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)
== "<think>\nCheck the persisted state.\n</think>\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,
@@ -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;
}
@@ -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 },
});
@@ -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);
});
@@ -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",