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:
parent
c8137f5dc7
commit
ad77e1340e
@ -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:
|
||||
|
||||
@ -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)])
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user