mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(chat): preserve trace across history reloads (#118332)
This commit is contained in:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user