diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d0b9be450e..9e8a13336e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -104,6 +104,7 @@ Docs: https://docs.openclaw.ai - Slack: preserve Socket Mode SDK error context and structured Slack API fields in reconnect logs, so startup failures no longer collapse to a bare `unknown error`. - iOS pairing: allow setup-code and manual `ws://` connects for private LAN and `.local` gateways while keeping Tailscale/public routes on `wss://`, and prefer explicit gateway passwords over stale bootstrap tokens in mixed-auth reconnects. Fixes #47887; carries forward #65185. Thanks @draix and @BunsDev. - Plugins/diagnostics: make source-only TypeScript package warnings actionable by explaining that missing compiled runtime output is a publisher packaging issue and pointing users to update/reinstall or disable/uninstall the plugin. Fixes #77835. Thanks @googlerest. +- Control UI/chat: keep persisted assistant progress text visible when the same transcript turn also contains tool-use metadata, so chat.history reloads no longer make those replies vanish after the next user message. Fixes #77374. Thanks @BunsDev. - TUI: skip the generic CLI respawn wrapper for interactive launches, exit cleanly on terminal loss, and refuse to restore heartbeat sessions as the remembered chat session, preventing stale heartbeat history and orphaned `openclaw-tui` processes on first boot. Thanks @vincentkoc. - Doctor/sessions: move heartbeat-poisoned default main session store entries to recovery keys and clear stale TUI restore pointers, so `doctor --fix` can repair instances already stuck on `agent:main:main` heartbeat history. Thanks @vincentkoc. - Agents/context engines: keep hidden OpenClaw runtime-context custom messages out of context-engine assemble, afterTurn, and ingest hooks so transcript reconstruction plugins only see conversation messages. Thanks @vincentkoc. diff --git a/src/gateway/chat-display-projection.ts b/src/gateway/chat-display-projection.ts index 1cf03f4503c..9b710d3bb79 100644 --- a/src/gateway/chat-display-projection.ts +++ b/src/gateway/chat-display-projection.ts @@ -163,6 +163,39 @@ function sanitizeAssistantPhasedContentBlocks(content: unknown[]): { }; } +function projectAssistantTextFromMixedToolContent( + content: unknown[], + maxChars: number, +): { content: unknown[]; changed: boolean } | null { + const hasToolHistoryBlock = content.some((block) => { + if (!block || typeof block !== "object") { + return false; + } + return isToolHistoryBlockType((block as { type?: unknown }).type); + }); + if (!hasToolHistoryBlock) { + return null; + } + + const textBlocks: 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()) { + continue; + } + const stripped = stripInlineDirectiveTagsForDisplay(entry.text); + const truncated = truncateChatHistoryText(stripped.text, maxChars); + if (truncated.text.trim()) { + textBlocks.push({ type: "text", text: truncated.text }); + } + } + + return textBlocks.length > 0 ? { content: textBlocks, changed: true } : null; +} + function toFiniteNumber(x: unknown): number | undefined { return typeof x === "number" && Number.isFinite(x) ? x : undefined; } @@ -285,10 +318,19 @@ function sanitizeChatHistoryMessage( changed = true; } if (entry.role === "assistant" && Array.isArray(entry.content)) { - const sanitizedPhases = sanitizeAssistantPhasedContentBlocks(entry.content); - if (sanitizedPhases.changed) { - entry.content = sanitizedPhases.content; + const mixedToolText = projectAssistantTextFromMixedToolContent(entry.content, maxChars); + if (mixedToolText) { + entry.content = mixedToolText.content; + if (entry.phase === "commentary") { + delete entry.phase; + } changed = true; + } else { + const sanitizedPhases = sanitizeAssistantPhasedContentBlocks(entry.content); + if (sanitizedPhases.changed) { + entry.content = sanitizedPhases.content; + changed = true; + } } } } @@ -353,6 +395,31 @@ function hasAssistantNonTextContent(message: unknown): boolean { ); } +function hasAssistantMixedToolVisibleText(message: unknown): boolean { + if (!message || typeof message !== "object") { + return false; + } + const content = (message as { content?: unknown }).content; + if (!Array.isArray(content)) { + return false; + } + let hasToolHistoryBlock = false; + let hasText = false; + for (const block of content) { + if (!block || typeof block !== "object") { + continue; + } + const entry = block as { type?: unknown; text?: unknown }; + if (isToolHistoryBlockType(entry.type)) { + hasToolHistoryBlock = true; + } + if (entry.type === "text" && typeof entry.text === "string" && entry.text.trim()) { + hasText = true; + } + } + return hasToolHistoryBlock && hasText; +} + function shouldDropAssistantHistoryMessage(message: unknown): boolean { if (!message || typeof message !== "object") { return false; @@ -362,7 +429,7 @@ function shouldDropAssistantHistoryMessage(message: unknown): boolean { return false; } if (resolveAssistantMessagePhase(message) === "commentary") { - return true; + return !hasAssistantMixedToolVisibleText(message); } const text = extractAssistantTextForSilentCheck(message); if (text === undefined || !isSuppressedControlReplyText(text)) { diff --git a/src/gateway/server-methods/server-methods.test.ts b/src/gateway/server-methods/server-methods.test.ts index 3cd08d2a012..29435f94769 100644 --- a/src/gateway/server-methods/server-methods.test.ts +++ b/src/gateway/server-methods/server-methods.test.ts @@ -458,6 +458,86 @@ describe("sanitizeChatHistoryMessages", () => { }); describe("projectRecentChatDisplayMessages", () => { + it("keeps visible assistant progress text from mixed tool-use messages", () => { + const result = projectRecentChatDisplayMessages([ + { + role: "user", + content: [{ type: "text", text: "fix it" }], + timestamp: 1, + }, + { + role: "assistant", + content: [ + { type: "thinking", thinking: "private reasoning" }, + { + type: "text", + text: "I will clean that up now.", + textSignature: JSON.stringify({ + v: 1, + id: "msg-progress", + phase: "commentary", + }), + }, + { + type: "toolCall", + id: "call-read", + name: "read", + arguments: { path: "AGENTS.md" }, + }, + ], + timestamp: 2, + __openclaw: { seq: 2 }, + }, + { + role: "toolResult", + toolCallId: "call-read", + toolName: "read", + content: [{ type: "text", text: "file contents" }], + timestamp: 3, + }, + ]); + + expect(result[1]).toEqual({ + role: "assistant", + content: [{ type: "text", text: "I will clean that up now." }], + timestamp: 2, + __openclaw: { seq: 2 }, + }); + }); + + it("keeps pure commentary assistant messages hidden", () => { + const result = projectRecentChatDisplayMessages([ + { + role: "user", + content: [{ type: "text", text: "status" }], + timestamp: 1, + }, + { + role: "assistant", + content: [ + { + type: "text", + text: "Working...", + textSignature: JSON.stringify({ + v: 1, + id: "msg-commentary", + phase: "commentary", + }), + }, + ], + timestamp: 2, + }, + ]); + + expect(result).toEqual([ + { + role: "user", + content: [{ type: "text", text: "status" }], + timestamp: 1, + }, + ]); + }); + it("applies history limits after dropping display-hidden messages", () => { const result = projectRecentChatDisplayMessages( [ 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 35af73e73c0..1f8118e7b7c 100644 --- a/src/gateway/server.chat.gateway-server-chat-b.test.ts +++ b/src/gateway/server.chat.gateway-server-chat-b.test.ts @@ -900,6 +900,61 @@ describe("gateway server chat", () => { }); }); + test("chat.history keeps visible assistant progress text from mixed tool-use transcript messages", async () => { + await withGatewayChatHarness(async ({ ws, createSessionDir }) => { + const sessionDir = await prepareMainHistoryHarness({ ws, createSessionDir }); + await writeMainSessionTranscript(sessionDir, [ + JSON.stringify({ + message: { + role: "user", + content: [{ type: "text", text: "fix it" }], + timestamp: 1, + }, + }), + JSON.stringify({ + message: { + role: "assistant", + content: [ + { type: "thinking", thinking: "private reasoning" }, + { + type: "text", + text: "I will clean that up now.", + textSignature: JSON.stringify({ + v: 1, + id: "msg-progress", + phase: "commentary", + }), + }, + { + type: "toolCall", + id: "call-read", + name: "read", + arguments: { path: "AGENTS.md" }, + }, + ], + timestamp: 2, + }, + }), + JSON.stringify({ + message: { + role: "toolResult", + toolCallId: "call-read", + toolName: "read", + content: [{ type: "text", text: "file contents" }], + timestamp: 3, + }, + }), + ]); + + const messages = await fetchHistoryMessages(ws); + expect(messages[1]).toMatchObject({ + role: "assistant", + content: [{ type: "text", text: "I will clean that up now." }], + timestamp: 2, + }); + }); + }); + test("chat.history applies gateway.webchat.chatHistoryMaxChars from config", async () => { await withGatewayChatHarness(async ({ ws, createSessionDir }) => { await writeGatewayConfig({