From 09618f91df5f60e6993e2d9e7bd2cf8018eb5cbc Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Sat, 21 Mar 2026 00:43:44 -0500 Subject: [PATCH] fix(ios): preserve repeated optimistic user messages on refresh --- .../OpenClawChatUI/ChatViewModel.swift | 20 +++++++-- .../OpenClawKitTests/ChatViewModelTests.swift | 43 +++++++++++++++++++ extensions/device-pair/index.test.ts | 2 +- 3 files changed, 61 insertions(+), 4 deletions(-) diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift index 983f140fe88..df987c3b910 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift @@ -376,9 +376,16 @@ public final class OpenClawChatViewModel { guard !previous.isEmpty else { return incoming } guard !incoming.isEmpty else { return previous } + func countKeys(_ keys: [String]) -> [String: Int] { + keys.reduce(into: [:]) { counts, key in + counts[key, default: 0] += 1 + } + } + var reconciled = Self.reconcileMessageIDs(previous: previous, incoming: incoming) let incomingIdentityKeys = Set(reconciled.compactMap(Self.messageIdentityKey(for:))) - let incomingUserRefreshKeys = Set(reconciled.compactMap(Self.userRefreshIdentityKey(for:))) + var remainingIncomingUserRefreshCounts = countKeys( + reconciled.compactMap(Self.userRefreshIdentityKey(for:))) var lastMatchedPreviousIndex: Int? for (index, message) in previous.enumerated() { @@ -389,8 +396,10 @@ public final class OpenClawChatViewModel { continue } if let userKey = Self.userRefreshIdentityKey(for: message), - incomingUserRefreshKeys.contains(userKey) + let remaining = remainingIncomingUserRefreshCounts[userKey], + remaining > 0 { + remainingIncomingUserRefreshCounts[userKey] = remaining - 1 lastMatchedPreviousIndex = index } } @@ -401,7 +410,12 @@ public final class OpenClawChatViewModel { .filter { message in guard message.role.lowercased() == "user" else { return false } guard let key = Self.userRefreshIdentityKey(for: message) else { return false } - return !incomingUserRefreshKeys.contains(key) + let remaining = remainingIncomingUserRefreshCounts[key] ?? 0 + if remaining > 0 { + remainingIncomingUserRefreshCounts[key] = remaining - 1 + return false + } + return true } guard !trailingUserMessages.isEmpty else { diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift index 43ad630cf34..d918c90155d 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift @@ -553,6 +553,49 @@ extension TestChatTransportState { } } + @Test func preservesRepeatedOptimisticUserMessagesWithIdenticalContentDuringRefresh() async throws { + let sessionId = "sess-main" + let now = Date().timeIntervalSince1970 * 1000 + let history1 = historyPayload(sessionId: sessionId) + let history2 = historyPayload( + sessionId: sessionId, + messages: [ + chatTextMessage( + role: "user", + text: "retry", + timestamp: now + 5_000), + chatTextMessage( + role: "assistant", + text: "first answer", + timestamp: now + 6_000), + ]) + + let (transport, vm) = await makeViewModel(historyResponses: [history1, history2, history2]) + try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId) + try await sendMessageAndEmitFinal( + transport: transport, + vm: vm, + text: "retry") + try await sendMessageAndEmitFinal( + transport: transport, + vm: vm, + text: "retry") + + try await waitUntil("repeated optimistic user message is preserved") { + await MainActor.run { + let retryMessages = vm.messages.filter { message in + message.role == "user" && + message.content.compactMap(\.text).joined(separator: "\n") == "retry" + } + let hasAssistant = vm.messages.contains { message in + message.role == "assistant" && + message.content.compactMap(\.text).joined(separator: "\n") == "first answer" + } + return hasAssistant && retryMessages.count == 2 + } + } + } + @Test func acceptsCanonicalSessionKeyEventsForOwnPendingRun() async throws { let history1 = historyPayload() let history2 = historyPayload( diff --git a/extensions/device-pair/index.test.ts b/extensions/device-pair/index.test.ts index 5172bd7ad4b..2888e64a30f 100644 --- a/extensions/device-pair/index.test.ts +++ b/extensions/device-pair/index.test.ts @@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawPluginCommandDefinition, PluginCommandContext, -} from "../../src/plugins/types.js"; +} from "openclaw/plugin-sdk/core"; import { createTestPluginApi } from "../../test/helpers/extensions/plugin-api.js"; import type { OpenClawPluginApi } from "./api.js";