fix(ios): prevent duplicate native chat replies after tool runs (#115678)

This commit is contained in:
Peter Steinberger
2026-07-29 02:59:25 -04:00
committed by GitHub
parent 138675aeeb
commit 844329284e
3 changed files with 122 additions and 1 deletions
@@ -1,9 +1,9 @@
import Foundation
import OpenClawChatUI
import OpenClawKit
import OpenClawProtocol
import Testing
@testable import OpenClaw
@testable import OpenClawChatUI
struct IOSGatewayChatTransportTests {
private actor RequestRecorder {
@@ -354,11 +354,104 @@ struct IOSGatewayChatTransportTests {
#expect(message.messageSeq == 7)
#expect(message.message?.role == "assistant")
#expect(message.message?.content.first?.text == "agent reply")
#expect(message.message?.transcriptMessageID == "msg-1")
default:
Issue.record("expected .sessionMessage from session.message event, got \(String(describing: mapped))")
}
}
@Test @MainActor func `canonical transcript identity deduplicates replayed assistant messages`() {
let original = Self.canonicalAssistantMessage(timestamp: 1234.5)
let replay = Self.canonicalAssistantMessage(timestamp: 5678.5)
let messages = OpenClawChatViewModel.dedupeMessages([original, replay])
#expect(messages.count == 1)
#expect(messages.first?.transcriptMessageID == "canonical-assistant-1")
}
@Test @MainActor func `distinct transcript identities preserve identical assistant replies`() {
let first = Self.canonicalAssistantMessage(timestamp: 1234.5)
let second = Self.canonicalAssistantMessage(
timestamp: 1234.5,
transcriptMessageID: "canonical-assistant-2")
let messages = OpenClawChatViewModel.dedupeMessages([first, second])
#expect(messages.count == 2)
#expect(messages.map(\.transcriptMessageID) == ["canonical-assistant-1", "canonical-assistant-2"])
}
@Test @MainActor func `history reconciles a replay by its canonical transcript identity`() {
let original = Self.canonicalAssistantMessage(timestamp: 1234.5)
let replay = Self.canonicalAssistantMessage(timestamp: 5678.5)
let messages = OpenClawChatViewModel.reconcileMessageIDs(
previous: [original],
incoming: [replay])
#expect(messages.count == 1)
#expect(messages.first?.id == original.id)
#expect(messages.first?.timestamp == replay.timestamp)
#expect(messages.first?.transcriptMessageID == "canonical-assistant-1")
}
@Test @MainActor func `canonical adoption keeps the durable transcript identity`() {
let existing = OpenClawChatMessage(
role: "assistant",
content: [Self.assistantText],
timestamp: 1234.5)
let incoming = Self.canonicalAssistantMessage(timestamp: 5678.5)
let adopted = OpenClawChatViewModel.adoptingCanonicalMessage(incoming, over: existing)
#expect(adopted.id == existing.id)
#expect(adopted.timestamp == incoming.timestamp)
#expect(adopted.transcriptMessageID == "canonical-assistant-1")
}
@Test @MainActor func `user idempotency still reconciles an optimistic canonical echo`() {
let original = OpenClawChatMessage(
role: "user",
content: [Self.assistantText],
timestamp: 1234.5,
idempotencyKey: "run-1:user")
let echo = OpenClawChatMessage(
role: "user",
content: [Self.assistantText],
timestamp: 5678.5,
transcriptMessageID: "canonical-user-1",
idempotencyKey: "run-1:user")
let messages = OpenClawChatViewModel.reconcileMessageIDs(
previous: [original],
incoming: [echo])
#expect(messages.count == 1)
#expect(messages.first?.id == original.id)
#expect(messages.first?.transcriptMessageID == "canonical-user-1")
}
private static var assistantText: OpenClawChatMessageContent {
OpenClawChatMessageContent(
type: "text",
text: "agent reply",
mimeType: nil,
fileName: nil,
content: nil)
}
private static func canonicalAssistantMessage(
timestamp: Double,
transcriptMessageID: String = "canonical-assistant-1") -> OpenClawChatMessage
{
OpenClawChatMessage(
role: "assistant",
content: [self.assistantText],
timestamp: timestamp,
transcriptMessageID: transcriptMessageID)
}
@Test func `maps sessions changed event to authoritative refresh signal`() {
let payload = AnyCodable([
"sessionKey": AnyCodable("agent:main:main"),
@@ -138,6 +138,22 @@ public enum OpenClawChatGatewayPayloadCodec {
payload,
as: OpenClawSessionMessageEventPayload.self)
else { return nil }
if var canonicalMessage = message.message,
canonicalMessage.transcriptMessageID?
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty != false,
let messageID = message.messageId?.trimmingCharacters(in: .whitespacesAndNewlines),
!messageID.isEmpty
{
// Live events carry durable transcript identity on their envelope.
// Preserve it on the row so history cannot replay the same message.
canonicalMessage.transcriptMessageID = messageID
return .sessionMessage(OpenClawSessionMessageEventPayload(
sessionKey: message.sessionKey,
agentId: message.agentId,
message: canonicalMessage,
messageId: message.messageId,
messageSeq: message.messageSeq))
}
return .sessionMessage(message)
case "agent":
guard let payload = frame.payload,
@@ -92,6 +92,9 @@ extension OpenClawChatViewModel {
if let idempotencyKey = Self.normalizedIdempotencyKey(message.idempotencyKey) {
return [role, "idempotency", idempotencyKey].joined(separator: "|")
}
if let transcriptMessageID = Self.normalizedTranscriptMessageID(message.transcriptMessageID) {
return [role, "transcript", transcriptMessageID].joined(separator: "|")
}
let timestamp: String = {
guard let value = message.timestamp, value.isFinite else { return "" }
@@ -146,6 +149,11 @@ extension OpenClawChatViewModel {
return trimmed.isEmpty ? nil : trimmed
}
private static func normalizedTranscriptMessageID(_ id: String?) -> String? {
let trimmed = id?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
}
static func adoptingCanonicalMessage(
_ incoming: OpenClawChatMessage,
over existing: OpenClawChatMessage) -> OpenClawChatMessage
@@ -157,6 +165,7 @@ extension OpenClawChatViewModel {
in: incoming.content,
from: existing.content),
timestamp: incoming.timestamp ?? existing.timestamp,
transcriptMessageID: incoming.transcriptMessageID ?? existing.transcriptMessageID,
idempotencyKey: incoming.idempotencyKey,
toolCallId: incoming.toolCallId,
toolName: incoming.toolName,
@@ -696,6 +705,9 @@ extension OpenClawChatViewModel {
if let idempotencyKey = normalizedIdempotencyKey(message.idempotencyKey) {
return "\(message.role)|idempotency|\(idempotencyKey)"
}
if let transcriptMessageID = normalizedTranscriptMessageID(message.transcriptMessageID) {
return "\(message.role)|transcript|\(transcriptMessageID)"
}
guard let timestamp = message.timestamp else { return nil }
let text = message.content.compactMap(\.text).joined(separator: "\n")
.trimmingCharacters(in: .whitespacesAndNewlines)