mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-21 16:41:56 +00:00
fix(chatui): stabilize ios chat updates after gateway replies
This commit is contained in:
committed by
Mariano Belinky
parent
f1351fc545
commit
ae53543799
@@ -170,7 +170,9 @@ public final class OpenClawChatViewModel {
|
||||
}
|
||||
|
||||
let payload = try await self.transport.requestHistory(sessionKey: self.sessionKey)
|
||||
self.messages = Self.decodeMessages(payload.messages ?? [])
|
||||
self.messages = Self.reconcileMessageIDs(
|
||||
previous: self.messages,
|
||||
incoming: Self.decodeMessages(payload.messages ?? []))
|
||||
self.sessionId = payload.sessionId
|
||||
if let level = payload.thinkingLevel, !level.isEmpty {
|
||||
self.thinkingLevel = level
|
||||
@@ -191,6 +193,70 @@ public final class OpenClawChatViewModel {
|
||||
return Self.dedupeMessages(decoded)
|
||||
}
|
||||
|
||||
private static func messageIdentityKey(for message: OpenClawChatMessage) -> String? {
|
||||
let role = message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
guard !role.isEmpty else { return nil }
|
||||
|
||||
let timestamp: String = {
|
||||
guard let value = message.timestamp, value.isFinite else { return "" }
|
||||
return String(format: "%.3f", value)
|
||||
}()
|
||||
|
||||
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}")
|
||||
|
||||
let toolCallId = (message.toolCallId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let toolName = (message.toolName ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if timestamp.isEmpty, contentFingerprint.isEmpty, toolCallId.isEmpty, toolName.isEmpty {
|
||||
return nil
|
||||
}
|
||||
return [role, timestamp, toolCallId, toolName, contentFingerprint].joined(separator: "|")
|
||||
}
|
||||
|
||||
private static func reconcileMessageIDs(
|
||||
previous: [OpenClawChatMessage],
|
||||
incoming: [OpenClawChatMessage]) -> [OpenClawChatMessage]
|
||||
{
|
||||
guard !previous.isEmpty, !incoming.isEmpty else { return incoming }
|
||||
|
||||
var idsByKey: [String: [UUID]] = [:]
|
||||
for message in previous {
|
||||
guard let key = Self.messageIdentityKey(for: message) else { continue }
|
||||
idsByKey[key, default: []].append(message.id)
|
||||
}
|
||||
|
||||
return incoming.map { message in
|
||||
guard let key = Self.messageIdentityKey(for: message),
|
||||
var ids = idsByKey[key],
|
||||
let reusedId = ids.first
|
||||
else {
|
||||
return message
|
||||
}
|
||||
ids.removeFirst()
|
||||
if ids.isEmpty {
|
||||
idsByKey.removeValue(forKey: key)
|
||||
} else {
|
||||
idsByKey[key] = ids
|
||||
}
|
||||
guard reusedId != message.id else { return message }
|
||||
return OpenClawChatMessage(
|
||||
id: reusedId,
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
timestamp: message.timestamp,
|
||||
toolCallId: message.toolCallId,
|
||||
toolName: message.toolName,
|
||||
usage: message.usage,
|
||||
stopReason: message.stopReason)
|
||||
}
|
||||
}
|
||||
|
||||
private static func dedupeMessages(_ messages: [OpenClawChatMessage]) -> [OpenClawChatMessage] {
|
||||
var result: [OpenClawChatMessage] = []
|
||||
result.reserveCapacity(messages.count)
|
||||
@@ -375,11 +441,15 @@ public final class OpenClawChatViewModel {
|
||||
}
|
||||
|
||||
private func handleChatEvent(_ chat: OpenClawChatEventPayload) {
|
||||
if let sessionKey = chat.sessionKey, sessionKey != self.sessionKey {
|
||||
let isOurRun = chat.runId.flatMap { self.pendingRuns.contains($0) } ?? false
|
||||
|
||||
// Gateway may publish canonical session keys (for example "agent:main:main")
|
||||
// even when this view currently uses an alias key (for example "main").
|
||||
// Never drop events for our own pending run on key mismatch, or the UI can stay
|
||||
// stuck at "thinking" until the user reopens and forces a history reload.
|
||||
if let sessionKey = chat.sessionKey, sessionKey != self.sessionKey, !isOurRun {
|
||||
return
|
||||
}
|
||||
|
||||
let isOurRun = chat.runId.flatMap { self.pendingRuns.contains($0) } ?? false
|
||||
if !isOurRun {
|
||||
// Keep multiple clients in sync: if another client finishes a run for our session, refresh history.
|
||||
switch chat.state {
|
||||
@@ -444,7 +514,9 @@ public final class OpenClawChatViewModel {
|
||||
private func refreshHistoryAfterRun() async {
|
||||
do {
|
||||
let payload = try await self.transport.requestHistory(sessionKey: self.sessionKey)
|
||||
self.messages = Self.decodeMessages(payload.messages ?? [])
|
||||
self.messages = Self.reconcileMessageIDs(
|
||||
previous: self.messages,
|
||||
incoming: Self.decodeMessages(payload.messages ?? []))
|
||||
self.sessionId = payload.sessionId
|
||||
if let level = payload.thinkingLevel, !level.isEmpty {
|
||||
self.thinkingLevel = level
|
||||
|
||||
@@ -215,6 +215,103 @@ extension TestChatTransportState {
|
||||
#expect(await MainActor.run { vm.pendingToolCalls.isEmpty })
|
||||
}
|
||||
|
||||
@Test func acceptsCanonicalSessionKeyEventsForOwnPendingRun() async throws {
|
||||
let history1 = OpenClawChatHistoryPayload(
|
||||
sessionKey: "main",
|
||||
sessionId: "sess-main",
|
||||
messages: [],
|
||||
thinkingLevel: "off")
|
||||
let history2 = OpenClawChatHistoryPayload(
|
||||
sessionKey: "main",
|
||||
sessionId: "sess-main",
|
||||
messages: [
|
||||
AnyCodable([
|
||||
"role": "assistant",
|
||||
"content": [["type": "text", "text": "from history"]],
|
||||
"timestamp": Date().timeIntervalSince1970 * 1000,
|
||||
]),
|
||||
],
|
||||
thinkingLevel: "off")
|
||||
|
||||
let transport = TestChatTransport(historyResponses: [history1, history2])
|
||||
let vm = await MainActor.run { OpenClawChatViewModel(sessionKey: "main", transport: transport) }
|
||||
|
||||
await MainActor.run { vm.load() }
|
||||
try await waitUntil("bootstrap") { await MainActor.run { vm.healthOK } }
|
||||
|
||||
await MainActor.run {
|
||||
vm.input = "hi"
|
||||
vm.send()
|
||||
}
|
||||
try await waitUntil("pending run starts") { await MainActor.run { vm.pendingRunCount == 1 } }
|
||||
|
||||
let runId = try #require(await transport.lastSentRunId())
|
||||
transport.emit(
|
||||
.chat(
|
||||
OpenClawChatEventPayload(
|
||||
runId: runId,
|
||||
sessionKey: "agent:main:main",
|
||||
state: "final",
|
||||
message: nil,
|
||||
errorMessage: nil)))
|
||||
|
||||
try await waitUntil("pending run clears") { await MainActor.run { vm.pendingRunCount == 0 } }
|
||||
try await waitUntil("history refresh") {
|
||||
await MainActor.run { vm.messages.contains(where: { $0.role == "assistant" }) }
|
||||
}
|
||||
}
|
||||
|
||||
@Test func preservesMessageIDsAcrossHistoryRefreshes() async throws {
|
||||
let now = Date().timeIntervalSince1970 * 1000
|
||||
let history1 = OpenClawChatHistoryPayload(
|
||||
sessionKey: "main",
|
||||
sessionId: "sess-main",
|
||||
messages: [
|
||||
AnyCodable([
|
||||
"role": "user",
|
||||
"content": [["type": "text", "text": "hello"]],
|
||||
"timestamp": now,
|
||||
]),
|
||||
],
|
||||
thinkingLevel: "off")
|
||||
let history2 = OpenClawChatHistoryPayload(
|
||||
sessionKey: "main",
|
||||
sessionId: "sess-main",
|
||||
messages: [
|
||||
AnyCodable([
|
||||
"role": "user",
|
||||
"content": [["type": "text", "text": "hello"]],
|
||||
"timestamp": now,
|
||||
]),
|
||||
AnyCodable([
|
||||
"role": "assistant",
|
||||
"content": [["type": "text", "text": "world"]],
|
||||
"timestamp": now + 1,
|
||||
]),
|
||||
],
|
||||
thinkingLevel: "off")
|
||||
|
||||
let transport = TestChatTransport(historyResponses: [history1, history2])
|
||||
let vm = await MainActor.run { OpenClawChatViewModel(sessionKey: "main", transport: transport) }
|
||||
|
||||
await MainActor.run { vm.load() }
|
||||
try await waitUntil("bootstrap") { await MainActor.run { vm.messages.count == 1 } }
|
||||
let firstIdBefore = try #require(await MainActor.run { vm.messages.first?.id })
|
||||
|
||||
transport.emit(
|
||||
.chat(
|
||||
OpenClawChatEventPayload(
|
||||
runId: "other-run",
|
||||
sessionKey: "main",
|
||||
state: "final",
|
||||
message: nil,
|
||||
errorMessage: nil)))
|
||||
|
||||
try await waitUntil("history refresh") { await MainActor.run { vm.messages.count == 2 } }
|
||||
let firstIdAfter = try #require(await MainActor.run { vm.messages.first?.id })
|
||||
#expect(firstIdAfter == firstIdBefore)
|
||||
}
|
||||
|
||||
@Test func clearsStreamingOnExternalFinalEvent() async throws {
|
||||
let sessionId = "sess-main"
|
||||
let history = OpenClawChatHistoryPayload(
|
||||
|
||||
Reference in New Issue
Block a user