mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 18:40:44 +00:00
fix: remove out-of-scope client block UI changes
This commit is contained in:
@@ -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 = "|")
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
Reference in New Issue
Block a user