mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(ios): prevent duplicate native chat replies after tool runs (#115678)
This commit is contained in:
committed by
GitHub
parent
138675aeeb
commit
844329284e
@@ -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,
|
||||
|
||||
+12
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user