fix(ios): preserve repeated optimistic user messages on refresh

This commit is contained in:
Val Alexander 2026-03-21 00:43:44 -05:00
parent 9ee9006af5
commit 09618f91df
No known key found for this signature in database
3 changed files with 61 additions and 4 deletions

View File

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

View File

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

View File

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