diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift index d21ab2fe074..8dd10da80a3 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift @@ -856,9 +856,7 @@ public final class OpenClawChatViewModel { if let message = self.decodedAssistantMessage(from: chat.message) { self.messages.append(message) } - if shouldResetExternalLiveState { - Task { await self.refreshHistoryAfterRun() } - } + Task { await self.refreshHistoryAfterRun() } case "error": if shouldResetExternalLiveState { self.streamingAssistantText = nil @@ -882,10 +880,10 @@ public final class OpenClawChatViewModel { self.clearPendingRuns(reason: nil) } - let appendedFinalMessage = self.appendFinalAssistantMessage(from: chat) + _ = self.appendFinalAssistantMessage(from: chat) self.pendingToolCallsById = [:] self.streamingAssistantText = nil - if !appendedFinalMessage { + if chat.state != "error" { Task { await self.refreshHistoryAfterRun() } } default: diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift index abdcaaabb3a..adbe764784c 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift @@ -586,9 +586,14 @@ extension TestChatTransportState { } } - @Test func appendsFinalAssistantMessageImmediatelyWithoutHistoryRefresh() async throws { - let history = historyPayload(messages: []) - let (transport, vm) = await makeViewModel(historyResponses: [history]) + @Test func appendsFinalAssistantMessageImmediatelyAndRefreshesHistory() async throws { + let now = Date().timeIntervalSince1970 * 1000 + let history1 = historyPayload(messages: []) + let history2 = historyPayload( + messages: [ + chatTextMessage(role: "assistant", text: "final from event", timestamp: now), + ]) + let (transport, vm) = await makeViewModel(historyResponses: [history1, history2]) try await loadAndWaitBootstrap(vm: vm) await sendUserMessage(vm) try await waitUntil("pending run starts") { await MainActor.run { vm.pendingRunCount == 1 } } @@ -602,7 +607,7 @@ extension TestChatTransportState { let finalMessage = AnyCodable([ "role": "assistant", "content": [["type": "text", "text": "final from event"]], - "timestamp": Date().timeIntervalSince1970 * 1000, + "timestamp": now, ]) transport.emit( .chat( @@ -620,6 +625,9 @@ extension TestChatTransportState { }) } } + try await waitUntil("history refreshed after local final message") { + await transport.historyRequestCount() == 2 + } #expect(await MainActor.run { vm.streamingAssistantText } == nil) } @@ -670,6 +678,47 @@ extension TestChatTransportState { } } + @Test func externalFinalMessageRefreshesHistoryEvenDuringLocalPendingRun() async throws { + let now = Date().timeIntervalSince1970 * 1000 + let history1 = historyPayload(messages: []) + let history2 = historyPayload( + messages: [ + chatTextMessage(role: "user", text: "prompt from another client", timestamp: now), + chatTextMessage(role: "assistant", text: "external final while local run pending", timestamp: now + 1), + ]) + let (transport, vm) = await makeViewModel(historyResponses: [history1, history2]) + + try await loadAndWaitBootstrap(vm: vm) + await sendUserMessage(vm, text: "local pending run") + try await waitUntil("local pending run starts") { await MainActor.run { vm.pendingRunCount == 1 } } + #expect(await transport.historyRequestCount() == 1) + + let finalMessage = AnyCodable([ + "role": "assistant", + "content": [["type": "text", "text": "external final while local run pending"]], + "timestamp": now + 1, + ]) + transport.emit( + .chat( + OpenClawChatEventPayload( + runId: "external-run", + sessionKey: "agent:main:main", + state: "final", + message: finalMessage, + errorMessage: nil))) + + try await waitUntil("history refreshed after external final with local pending run") { + await transport.historyRequestCount() == 2 + } + try await waitUntil("external prompt synced from history during local pending run") { + await MainActor.run { + vm.messages.contains(where: { message in + message.role == "user" && message.content.contains(where: { $0.text == "prompt from another client" }) + }) + } + } + } + @Test func preservesMessageIDsAcrossHistoryRefreshes() async throws { let now = Date().timeIntervalSince1970 * 1000 let history1 = historyPayload(messages: [chatTextMessage(role: "user", text: "hello", timestamp: now)])