fix(ios): reconcile external finals even during local pending runs

P1 review thread fixes for #50483:

1. History sync after local finals: appendFinalAssistantMessage now always calls refreshHistoryAfterRun() instead of only when the append failed. This ensures tool results from local runs are persisted to canonical history.

2. Multi-client reconciliation: When external client finals arrive while this client has a pending run, immediately append the message AND schedule history refresh instead of deferring refresh until pendingRuns.isEmpty. This prevents cross-client history gaps.

Added test cases:
- appendsFinalAssistantMessageImmediatelyAndRefreshesHistory: validates history sync after local finals
- externalFinalMessageRefreshesHistoryEvenDuringLocalPendingRun: validates multi-client reconciliation
This commit is contained in:
Eulices Lopez 2026-03-19 12:10:05 -04:00
parent c8137f5dc7
commit ad77e1340e
2 changed files with 56 additions and 9 deletions

View File

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

View File

@ -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)])