fix: remove out-of-scope client block UI changes

This commit is contained in:
jesse-merhi
2026-05-06 09:47:35 +10:00
committed by clawsweeper
parent ff0f097885
commit 403e33c4f0
11 changed files with 18 additions and 166 deletions

View File

@@ -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 = "|")
}

View File

@@ -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) }

View File

@@ -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()

View File

@@ -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)
}
}

View File

@@ -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 {

View File

@@ -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

View File

@@ -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",

View File

@@ -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;

View File

@@ -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";

View File

@@ -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);
}

View File

@@ -240,12 +240,12 @@ function expandTextContent(text: string): {
export function normalizeMessage(message: unknown): NormalizedMessage {
const m = message as Record<string, unknown>;
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<string, unknown>) => {
} else if (Array.isArray(m.content)) {
content = m.content.flatMap((item: Record<string, unknown>) => {
if (
item.type === "attachment" &&
item.attachment &&