From 403e33c4f0ef50bdf423ad70bf44d53308581ab7 Mon Sep 17 00:00:00 2001 From: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com> Date: Wed, 6 May 2026 09:47:35 +1000 Subject: [PATCH] fix: remove out-of-scope client block UI changes --- .../ai/openclaw/app/chat/ChatController.kt | 15 ++--- .../Sources/OpenClaw/GatewayConnection.swift | 4 +- .../OpenClawChatUI/ChatMessageViews.swift | 8 +-- .../Sources/OpenClawChatUI/ChatModels.swift | 2 +- .../OpenClawChatUI/ChatViewModel.swift | 18 +----- .../OpenClawKitTests/ChatViewModelTests.swift | 55 ------------------- ui/src/i18n/.i18n/raw-copy-baseline.json | 7 --- ui/src/styles/chat/grouped.css | 10 ---- ui/src/ui/app-gateway.node.test.ts | 34 ------------ ui/src/ui/app-gateway.ts | 19 +------ ui/src/ui/chat/message-normalizer.ts | 12 ++-- 11 files changed, 18 insertions(+), 166 deletions(-) diff --git a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt index f710a7fae65..17f4d82d55e 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt @@ -300,7 +300,7 @@ class ChatController( session.sendNodeEvent("chat.subscribe", """{"sessionKey":"$key"}""") } - val historyJson = requestChatHistoryJson(key) + val historyJson = session.request("chat.history", """{"sessionKey":"$key"}""") val history = parseHistory(historyJson, sessionKey = key, previousMessages = _messages.value) _messages.value = history.messages _sessionId.value = history.sessionId @@ -333,15 +333,6 @@ class ChatController( } } - private suspend fun requestChatHistoryJson(sessionKey: String): String { - return session.request( - "chat.history", - buildJsonObject { - put("sessionKey", JsonPrimitive(sessionKey)) - }.toString(), - ) - } - private suspend fun pollHealthIfNeeded(force: Boolean) { val now = System.currentTimeMillis() val last = lastHealthPollAtMs @@ -384,7 +375,8 @@ class ChatController( _streamingAssistantText.value = null scope.launch { try { - val historyJson = requestChatHistoryJson(_sessionKey.value) + val historyJson = + session.request("chat.history", """{"sessionKey":"${_sessionKey.value}"}""") val history = parseHistory(historyJson, sessionKey = _sessionKey.value, previousMessages = _messages.value) _messages.value = history.messages _sessionId.value = history.sessionId @@ -649,6 +641,7 @@ internal fun messageIdentityKey(message: ChatMessage): String? { .orEmpty(), ).joinToString(separator = "\u001F") } + if (timestamp.isEmpty() && contentFingerprint.isEmpty()) return null return listOf(role, timestamp, contentFingerprint).joinToString(separator = "|") } diff --git a/apps/macos/Sources/OpenClaw/GatewayConnection.swift b/apps/macos/Sources/OpenClaw/GatewayConnection.swift index 73b9297002d..261b94b02c4 100644 --- a/apps/macos/Sources/OpenClaw/GatewayConnection.swift +++ b/apps/macos/Sources/OpenClaw/GatewayConnection.swift @@ -628,9 +628,7 @@ extension GatewayConnection { timeoutMs: Int? = nil) async throws -> OpenClawChatHistoryPayload { let resolvedKey = self.canonicalizeSessionKey(sessionKey) - var params: [String: AnyCodable] = [ - "sessionKey": AnyCodable(resolvedKey), - ] + var params: [String: AnyCodable] = ["sessionKey": AnyCodable(resolvedKey)] if let limit { params["limit"] = AnyCodable(limit) } if let maxChars { params["maxChars"] = AnyCodable(maxChars) } let timeout = timeoutMs.map { Double($0) } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift index 75163998474..d24f92bd42c 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift @@ -248,7 +248,7 @@ private struct ChatMessageBody: View { } private var primaryText: String { - let parts = self.displayContent.compactMap { content -> String? in + 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 @@ -257,7 +257,7 @@ private struct ChatMessageBody: View { } private var inlineAttachments: [OpenClawChatMessageContent] { - self.displayContent.filter { content in + self.message.content.filter { content in switch content.type ?? "text" { case "file", "attachment": true @@ -267,10 +267,6 @@ private struct ChatMessageBody: View { } } - private var displayContent: [OpenClawChatMessageContent] { - self.message.content - } - private var toolCalls: [OpenClawChatMessageContent] { self.message.content.filter { content in let kind = (content.type ?? "").lowercased() diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatModels.swift index 1b6a9e08568..bf77fe037de 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatModels.swift @@ -189,6 +189,7 @@ public struct OpenClawChatMessage: Codable, Identifiable, Sendable { container.decodeIfPresent(String.self, forKey: .tool_name) self.usage = try container.decodeIfPresent(OpenClawChatUsage.self, forKey: .usage) self.stopReason = try container.decodeIfPresent(String.self, forKey: .stopReason) + if let decoded = try? container.decode([OpenClawChatMessageContent].self, forKey: .content) { self.content = decoded return @@ -224,7 +225,6 @@ public struct OpenClawChatMessage: Codable, Identifiable, Sendable { try container.encodeIfPresent(self.usage, forKey: .usage) try container.encodeIfPresent(self.stopReason, forKey: .stopReason) try container.encode(self.content, forKey: .content) - } } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift index 8219bbf067e..e647435008f 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift @@ -282,7 +282,6 @@ public final class OpenClawChatViewModel { arguments: content.arguments) } - return OpenClawChatMessage( id: message.id, role: message.role, @@ -295,20 +294,7 @@ public final class OpenClawChatViewModel { } private static func messageContentFingerprint(for message: OpenClawChatMessage) -> String { - let contentFingerprint = message.content.map { item in - let type = (item.type ?? "text").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - let text = (item.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - let id = (item.id ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - let name = (item.name ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - let fileName = (item.fileName ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - return [type, text, id, name, fileName].joined(separator: "\\u{001F}") - }.joined(separator: "\\u{001E}") - return contentFingerprint - } - - private static func userVisibleContentFingerprint(for message: OpenClawChatMessage) -> String { - let content = message.content - return content.map { item in + message.content.map { item in let type = (item.type ?? "text").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() let text = (item.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines) let id = (item.id ?? "").trimmingCharacters(in: .whitespacesAndNewlines) @@ -340,7 +326,7 @@ public final class OpenClawChatViewModel { let role = message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() guard role == "user" else { return nil } - let contentFingerprint = Self.userVisibleContentFingerprint(for: message) + let contentFingerprint = Self.messageContentFingerprint(for: message) let toolCallId = (message.toolCallId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) let toolName = (message.toolName ?? "").trimmingCharacters(in: .whitespacesAndNewlines) if contentFingerprint.isEmpty, toolCallId.isEmpty, toolName.isEmpty { diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift index a32bb87248c..e33c2890c39 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift @@ -11,21 +11,6 @@ private func chatTextMessage(role: String, text: String, timestamp: Double) -> A ]) } -private func chatBlockedUserMessage(redactedText: String, originalText _: String, timestamp: Double) -> AnyCodable { - AnyCodable([ - "role": "user", - "content": [["type": "text", "text": redactedText]], - "__openclaw": [ - "beforeAgentRunBlocked": [ - "blockedBy": "hitl-test", - "reason": "blocked", - "blockedAt": timestamp, - ], - ], - "timestamp": timestamp, - ]) -} - private func historyPayload( sessionKey: String = "main", sessionId: String? = "sess-main", @@ -602,46 +587,6 @@ extension TestChatTransportState { } } - @Test func doesNotDuplicateUserMessageWhenRefreshReturnsBlockedCanonicalMessage() async throws { - let sessionId = "sess-main" - let now = Date().timeIntervalSince1970 * 1000 - let history1 = historyPayload(sessionId: sessionId) - let history2 = historyPayload( - sessionId: sessionId, - messages: [ - chatBlockedUserMessage( - redactedText: "The agent cannot read this message.", - originalText: "hello from mac webchat", - timestamp: now + 5_000), - chatTextMessage( - role: "assistant", - text: "final answer", - timestamp: now + 6_000), - ]) - - let (transport, vm) = await makeViewModel(historyResponses: [history1, history2]) - try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId) - try await sendMessageAndEmitFinal( - transport: transport, - vm: vm, - text: "hello from mac webchat") - - try await waitUntil("blocked canonical refresh keeps one visible user message") { - await MainActor.run { - let userMessages = vm.messages.filter { message in - message.role == "user" && - message.content.compactMap(\.text).joined(separator: "\n") == - "The agent cannot read this message." - } - let hasAssistant = vm.messages.contains { message in - message.role == "assistant" && - message.content.compactMap(\.text).joined(separator: "\n") == "final answer" - } - return hasAssistant && userMessages.count == 1 - } - } - } - @Test func preservesRepeatedOptimisticUserMessagesWithIdenticalContentDuringRefresh() async throws { let sessionId = "sess-main" let now = Date().timeIntervalSince1970 * 1000 diff --git a/ui/src/i18n/.i18n/raw-copy-baseline.json b/ui/src/i18n/.i18n/raw-copy-baseline.json index f3480f7dd44..234927e5656 100644 --- a/ui/src/i18n/.i18n/raw-copy-baseline.json +++ b/ui/src/i18n/.i18n/raw-copy-baseline.json @@ -225,13 +225,6 @@ "path": "ui/src/ui/chat/grouped-render.ts", "text": "JSON" }, - { - "count": 1, - "kind": "html-text", - "name": "text", - "path": "ui/src/ui/chat/grouped-render.ts", - "text": "The agent cannot read this message." - }, { "count": 1, "kind": "html-text", diff --git a/ui/src/styles/chat/grouped.css b/ui/src/styles/chat/grouped.css index 664a9b4bb01..439bf143a0a 100644 --- a/ui/src/styles/chat/grouped.css +++ b/ui/src/styles/chat/grouped.css @@ -388,16 +388,6 @@ img.chat-avatar { border-color: color-mix(in srgb, var(--accent) 32%, transparent); } -.chat-blocked-user-note { - margin-top: 8px; - padding-top: 8px; - border-top: 1px solid color-mix(in srgb, var(--chat-text) 18%, transparent); - color: color-mix(in srgb, var(--chat-text) 68%, transparent); - font-size: 12px; - font-weight: 500; - line-height: 1.35; -} - /* Streaming animation */ .chat-bubble.streaming { animation: pulsing-border 1.5s ease-out infinite; diff --git a/ui/src/ui/app-gateway.node.test.ts b/ui/src/ui/app-gateway.node.test.ts index b5271c6f5be..21b43965fa2 100644 --- a/ui/src/ui/app-gateway.node.test.ts +++ b/ui/src/ui/app-gateway.node.test.ts @@ -925,40 +925,6 @@ describe("connectGateway", () => { expect(loadChatHistoryMock).not.toHaveBeenCalled(); }); - it("replays deferred blocked user reloads after renderable final assistant payloads", () => { - const { host, client } = connectHostGateway(); - host.chatRunId = "main-run-blocked"; - loadChatHistoryMock.mockClear(); - - client.emitEvent({ - event: "session.message", - payload: { - sessionKey: "main", - messageId: "blocked-1", - message: { - role: "user", - content: [{ type: "text", text: "The agent cannot read this message." }], - }, - }, - }); - client.emitEvent({ - event: "chat", - payload: { - runId: "main-run-blocked", - sessionKey: "main", - state: "final", - message: { - role: "assistant", - content: [{ type: "text", text: "The agent cannot read this message." }], - }, - }, - }); - - expect(host.chatRunId).toBeNull(); - expect(loadChatHistoryMock).toHaveBeenCalledTimes(1); - expect(loadChatHistoryMock).toHaveBeenCalledWith(host); - }); - it("replays deferred session.message reloads after legacy silent final payload", () => { const { host, client } = connectHostGateway(); host.chatRunId = "main-run-silent"; diff --git a/ui/src/ui/app-gateway.ts b/ui/src/ui/app-gateway.ts index a27a935d4ea..1bb37041935 100644 --- a/ui/src/ui/app-gateway.ts +++ b/ui/src/ui/app-gateway.ts @@ -112,7 +112,6 @@ type GatewayHost = { type GatewayHostWithDeferredSessionMessageReload = GatewayHost & { pendingSessionMessageReloadSessionKey?: string | null; - pendingSessionMessageReloadNeedsHistory?: boolean; }; type SessionDefaultsSnapshot = { @@ -654,12 +653,9 @@ function handleChatGatewayEvent(host: GatewayHost, payload: ChatEventPayload | u ); const shouldReplayDeferredSessionMessageReload = shouldResolveDeferredSessionMessageReload && - (state !== "final" || - finalEventNeedsHistoryReload || - deferredReloadHost.pendingSessionMessageReloadNeedsHistory === true); + (state !== "final" || finalEventNeedsHistoryReload); if (shouldResolveDeferredSessionMessageReload) { deferredReloadHost.pendingSessionMessageReloadSessionKey = null; - deferredReloadHost.pendingSessionMessageReloadNeedsHistory = false; } if (finalEventNeedsHistoryReload && !historyReloaded && !terminalEventIsForDifferentActiveRun) { void loadChatHistory(host as unknown as ChatState); @@ -672,7 +668,7 @@ function handleChatGatewayEvent(host: GatewayHost, payload: ChatEventPayload | u function handleSessionMessageGatewayEvent( host: GatewayHost, - payload: { sessionKey?: string; message?: unknown; messageId?: string } | undefined, + payload: { sessionKey?: string } | undefined, ) { applySessionsChangedEvent(host as unknown as SessionsState, payload); const deferredReloadHost = host as GatewayHostWithDeferredSessionMessageReload; @@ -687,20 +683,9 @@ function handleSessionMessageGatewayEvent( // first LLM delta arrives. if (host.chatRunId) { deferredReloadHost.pendingSessionMessageReloadSessionKey = sessionKey; - const messageRecord = - payload?.message && typeof payload.message === "object" - ? (payload.message as { role?: unknown }) - : undefined; - if ( - messageRecord?.role === "user" || - (typeof payload?.messageId === "string" && payload.messageId.startsWith("blocked-")) - ) { - deferredReloadHost.pendingSessionMessageReloadNeedsHistory = true; - } return; } deferredReloadHost.pendingSessionMessageReloadSessionKey = null; - deferredReloadHost.pendingSessionMessageReloadNeedsHistory = false; void loadChatHistory(host as unknown as ChatState); } diff --git a/ui/src/ui/chat/message-normalizer.ts b/ui/src/ui/chat/message-normalizer.ts index 618dc16e15d..dc090e97daa 100644 --- a/ui/src/ui/chat/message-normalizer.ts +++ b/ui/src/ui/chat/message-normalizer.ts @@ -240,12 +240,12 @@ function expandTextContent(text: string): { export function normalizeMessage(message: unknown): NormalizedMessage { const m = message as Record; let role = typeof m.role === "string" ? m.role : "unknown"; - const contentRaw = m.content; // Detect tool messages by common gateway shapes. // Some tool events come through as assistant role with tool_* items in the content array. const hasToolId = typeof m.toolCallId === "string" || typeof m.tool_call_id === "string"; + const contentRaw = m.content; const contentItems = Array.isArray(contentRaw) ? contentRaw : null; const hasToolContent = Array.isArray(contentItems) && @@ -266,17 +266,17 @@ export function normalizeMessage(message: unknown): NormalizedMessage { let audioAsVoice = false; let replyTarget: NormalizedMessage["replyTarget"] = null; - if (typeof contentRaw === "string") { + if (typeof m.content === "string") { if (isAssistantMessage) { - const expanded = expandTextContent(contentRaw); + const expanded = expandTextContent(m.content); content = expanded.content; audioAsVoice = expanded.audioAsVoice; replyTarget = expanded.replyTarget; } else { - content = [{ type: "text", text: contentRaw }]; + content = [{ type: "text", text: m.content }]; } - } else if (Array.isArray(contentRaw)) { - content = contentRaw.flatMap((item: Record) => { + } else if (Array.isArray(m.content)) { + content = m.content.flatMap((item: Record) => { if ( item.type === "attachment" && item.attachment &&