import CoreGraphics import Foundation import ImageIO import OpenClawKit import UniformTypeIdentifiers import XCTest @testable import OpenClawChatUI private actor AttachmentSendCapture { private(set) var attachments: [OpenClawChatAttachmentPayload] = [] func store(_ attachments: [OpenClawChatAttachmentPayload]) { self.attachments = attachments } func count() -> Int { self.attachments.count } func first() -> OpenClawChatAttachmentPayload? { self.attachments.first } } private actor AttachmentHealthGate { private var entered = false private var released = false private var continuation: CheckedContinuation? func wait() async { self.entered = true guard !self.released else { return } await withCheckedContinuation { continuation in self.continuation = continuation } } func hasEntered() -> Bool { self.entered } func release() { self.released = true self.continuation?.resume() self.continuation = nil } } @MainActor private final class AttachmentOwnerActivity { var isActive = true } private struct AttachmentProcessingTransport: OpenClawChatTransport { let capture: AttachmentSendCapture? let healthGate: AttachmentHealthGate? let failsAmbiguously: Bool let responseStatus: String let returnsEmptyHistory: Bool let durableOutboxAvailable: Bool init( capture: AttachmentSendCapture? = nil, healthGate: AttachmentHealthGate? = nil, failsAmbiguously: Bool = false, responseStatus: String = "started", returnsEmptyHistory: Bool = false, durableOutboxAvailable: Bool = true) { self.capture = capture self.healthGate = healthGate self.failsAmbiguously = failsAmbiguously self.responseStatus = responseStatus self.returnsEmptyHistory = returnsEmptyHistory self.durableOutboxAvailable = durableOutboxAvailable } func requestHistory(sessionKey _: String) async throws -> OpenClawChatHistoryPayload { if self.returnsEmptyHistory { return OpenClawChatHistoryPayload( sessionKey: "main", sessionId: "session-main", messages: [], thinkingLevel: "off") } throw NSError(domain: "ChatViewModelAttachmentTests", code: 1) } func sendMessage( sessionKey _: String, message _: String, thinking _: String, idempotencyKey: String, attachments: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse { await self.capture?.store(attachments) if self.failsAmbiguously { throw NSError( domain: "ChatViewModelAttachmentTests", code: 9, userInfo: [NSLocalizedDescriptionKey: "Connection lost"]) } return OpenClawChatSendResponse(runId: idempotencyKey, status: self.responseStatus) } func requestHealth(timeoutMs _: Int) async throws -> Bool { await self.healthGate?.wait() return true } func listSessions( limit _: Int?, search _: String?, archived _: Bool) async throws -> OpenClawChatSessionsListResponse { OpenClawChatSessionsListResponse(ts: nil, path: nil, count: 0, defaults: nil, sessions: []) } func acquireOutboxRouteLease() async -> OpenClawChatTransportRouteLeaseResult { guard self.durableOutboxAvailable else { return .unavailable(reason: OpenClawChatTransportUpgradeMessage.routingContract) } let transport = self return .available(OpenClawChatTransportRouteLease( sendMessage: { sessionKey, message, thinking, idempotencyKey, attachments in try await transport.sendMessage( sessionKey: sessionKey, message: message, thinking: thinking, idempotencyKey: idempotencyKey, attachments: attachments) }, requestHistory: { sessionKey in try await transport.requestHistory(sessionKey: sessionKey) })) } func events() -> AsyncStream { AsyncStream { _ in } } } private func makeAttachmentOutbox() -> OpenClawChatSQLiteTranscriptCache { OpenClawChatSQLiteTranscriptCache( databaseURL: FileManager.default.temporaryDirectory .appendingPathComponent("attachment-outbox-\(UUID().uuidString).sqlite"), gatewayID: "attachment-tests") } @MainActor private func makeDurableAttachmentViewModel( transport: AttachmentProcessingTransport, outbox: OpenClawChatSQLiteTranscriptCache) -> OpenClawChatViewModel { OpenClawChatViewModel( sessionKey: "main", transport: transport, transcriptCache: outbox, outbox: outbox) } private func makeChatAttachmentJPEG(width: Int, height: Int) throws -> Data { guard let context = CGContext( data: nil, width: width, height: height, bitsPerComponent: 8, bytesPerRow: width * 4, space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else { throw NSError(domain: "ChatViewModelAttachmentTests", code: 3) } context.setFillColor(CGColor(red: 0.2, green: 0.4, blue: 0.8, alpha: 1)) context.fill(CGRect(x: 0, y: 0, width: width, height: height)) context.setFillColor(CGColor(red: 0.9, green: 0.5, blue: 0.1, alpha: 1)) context.fill(CGRect(x: 0, y: 0, width: width / 2, height: height / 2)) guard let image = context.makeImage() else { throw NSError(domain: "ChatViewModelAttachmentTests", code: 4) } let data = NSMutableData() guard let destination = CGImageDestinationCreateWithData(data, UTType.jpeg.identifier as CFString, 1, nil) else { throw NSError(domain: "ChatViewModelAttachmentTests", code: 5) } CGImageDestinationAddImage(destination, image, [kCGImageDestinationLossyCompressionQuality: 0.95] as CFDictionary) guard CGImageDestinationFinalize(destination) else { throw NSError(domain: "ChatViewModelAttachmentTests", code: 6) } return data as Data } private func chatAttachmentDimensions(for data: Data) -> (width: Int, height: Int)? { guard let source = CGImageSourceCreateWithData(data as CFData, nil), let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any], let width = properties[kCGImagePropertyPixelWidth] as? NSNumber, let height = properties[kCGImagePropertyPixelHeight] as? NSNumber else { return nil } return (width.intValue, height.intValue) } final class ChatViewModelAttachmentTests: XCTestCase { func testImageAttachmentsAreProcessedBeforeStaging() async throws { let imageData = try makeChatAttachmentJPEG(width: 3000, height: 4000) let viewModel = await MainActor.run { OpenClawChatViewModel(sessionKey: "main", transport: AttachmentProcessingTransport()) } await MainActor.run { viewModel.addImageAttachment(data: imageData, fileName: "camera.heic", mimeType: "image/jpeg") } try await waitUntil("attachment processed") { await MainActor.run { !viewModel.attachments.isEmpty || viewModel.errorText != nil } } let attachment = try await MainActor.run { guard let attachment = viewModel.attachments.first else { throw NSError(domain: "ChatViewModelAttachmentTests", code: 7) } return (attachment.fileName, attachment.mimeType, attachment.data) } let dimensions = try XCTUnwrap(chatAttachmentDimensions(for: attachment.2)) XCTAssertEqual(attachment.0, "camera.jpg") XCTAssertEqual(attachment.1, "image/jpeg") XCTAssertLessThanOrEqual(attachment.2.count, ChatImageProcessor.maxPayloadBytes) XCTAssertLessThanOrEqual(max(dimensions.width, dimensions.height), ChatImageProcessor.maxLongEdgePx) let errorText = await MainActor.run { viewModel.errorText } XCTAssertNil(errorText) } func testVoiceNoteAttachmentStagesAudioAndDeletesTemporaryFile() async throws { let fileURL = FileManager.default.temporaryDirectory .appendingPathComponent("voice-note-20260706-120000.m4a") let data = Data("voice-note-data".utf8) try data.write(to: fileURL) let viewModel = await MainActor.run { OpenClawChatViewModel(sessionKey: "main", transport: AttachmentProcessingTransport()) } await viewModel.addVoiceNoteAttachment(fileURL: fileURL, durationSeconds: 8.4) let attachment = try await MainActor.run { () throws -> (Data, String, String, String, Double?, Bool) in let attachment = try XCTUnwrap(viewModel.attachments.first) return ( attachment.data, attachment.fileName, attachment.mimeType, attachment.type, attachment.durationSeconds, attachment.preview == nil) } XCTAssertEqual(attachment.0, data) XCTAssertEqual(attachment.1, "voice-note-20260706-120000.m4a") XCTAssertEqual(attachment.2, "audio/mp4") XCTAssertEqual(attachment.3, "file") XCTAssertEqual(attachment.4, 8.4) XCTAssertTrue(attachment.5) XCTAssertFalse(FileManager.default.fileExists(atPath: fileURL.path)) } func testOversizeVoiceNoteIsRejectedAndDeleted() async throws { let fileURL = FileManager.default.temporaryDirectory .appendingPathComponent("voice-note-oversize.m4a") try Data(repeating: 0x41, count: 5_000_001).write(to: fileURL) let viewModel = await MainActor.run { OpenClawChatViewModel(sessionKey: "main", transport: AttachmentProcessingTransport()) } await viewModel.addVoiceNoteAttachment(fileURL: fileURL, durationSeconds: 180) let result = await MainActor.run { (viewModel.attachments.count, viewModel.errorText) } XCTAssertEqual(result.0, 0) XCTAssertEqual(result.1, "Voice note exceeds the 5 MB attachment limit") XCTAssertFalse(FileManager.default.fileExists(atPath: fileURL.path)) } func testMalformedVoiceNoteDurationIsNormalized() async throws { let fileURL = FileManager.default.temporaryDirectory .appendingPathComponent("voice-note-malformed-duration.m4a") try Data("voice-note".utf8).write(to: fileURL) let viewModel = await MainActor.run { OpenClawChatViewModel(sessionKey: "main", transport: AttachmentProcessingTransport()) } await viewModel.addVoiceNoteAttachment(fileURL: fileURL, durationSeconds: .infinity) let duration = await MainActor.run { viewModel.attachments.first?.durationSeconds } XCTAssertEqual(duration, 0) } @MainActor func testPartialIdentitySyncPreservesTheOtherDeferredComponent() { let oldContract = "per-sender|main|main" let newContract = "per-sender|work-main|main" let contractViewModel = OpenClawChatViewModel( sessionKey: "main", transport: AttachmentProcessingTransport(), activeAgentId: "main", sessionRoutingContract: oldContract) let contractAttachment = OpenClawPendingAttachment( url: nil, data: Data("contract".utf8), fileName: "contract.m4a", mimeType: "audio/mp4", preview: nil) contractViewModel.attachments = [contractAttachment] contractViewModel.syncSessionRoutingContract(newContract) contractViewModel.syncActiveAgentId("main") contractViewModel.removeAttachment(contractAttachment.id) XCTAssertEqual(contractViewModel.activeAgentId, "main") XCTAssertEqual(contractViewModel.sessionRoutingContract, newContract) let agentViewModel = OpenClawChatViewModel( sessionKey: "main", transport: AttachmentProcessingTransport(), activeAgentId: "main", sessionRoutingContract: oldContract) let agentAttachment = OpenClawPendingAttachment( url: nil, data: Data("agent".utf8), fileName: "agent.m4a", mimeType: "audio/mp4", preview: nil) agentViewModel.attachments = [agentAttachment] agentViewModel.syncActiveAgentId("work") agentViewModel.syncSessionRoutingContract(oldContract) agentViewModel.removeAttachment(agentAttachment.id) XCTAssertEqual(agentViewModel.activeAgentId, "work") XCTAssertEqual(agentViewModel.sessionRoutingContract, oldContract) } @MainActor func testAttachmentStagingPinsSessionAndIdentityUntilItFinishes() { let viewModel = OpenClawChatViewModel( sessionKey: "main", transport: AttachmentProcessingTransport(), activeAgentId: "main", sessionRoutingContract: "per-sender|main|main") viewModel.beginAttachmentStaging() viewModel.syncSession(to: "agent:work:main") viewModel.syncDeliveryIdentity( activeAgentId: "work", sessionRoutingContract: "per-sender|main|work") XCTAssertTrue(viewModel.isAttachmentOwnerPinned) XCTAssertEqual(viewModel.sessionKey, "main") XCTAssertEqual(viewModel.activeAgentId, "main") XCTAssertEqual(viewModel.sessionRoutingContract, "per-sender|main|main") viewModel.endAttachmentStaging() XCTAssertFalse(viewModel.isAttachmentOwnerPinned) XCTAssertEqual(viewModel.sessionKey, "agent:work:main") XCTAssertEqual(viewModel.activeAgentId, "work") XCTAssertEqual(viewModel.sessionRoutingContract, "per-sender|main|work") } @MainActor func testRecordingPinsSessionAndIdentityUntilItEnds() { let ownerActivity = AttachmentOwnerActivity() let viewModel = OpenClawChatViewModel( sessionKey: "main", transport: AttachmentProcessingTransport(), activeAgentId: "main", sessionRoutingContract: "per-sender|main|main", attachmentOwnerIsActive: { ownerActivity.isActive }) viewModel.syncSession(to: "agent:work:main") viewModel.syncDeliveryIdentity( activeAgentId: "work", sessionRoutingContract: "per-sender|main|work") XCTAssertTrue(viewModel.isAttachmentOwnerPinned) XCTAssertEqual(viewModel.sessionKey, "main") XCTAssertEqual(viewModel.activeAgentId, "main") XCTAssertEqual(viewModel.sessionRoutingContract, "per-sender|main|main") ownerActivity.isActive = false viewModel.attachmentOwnerActivityChanged() XCTAssertFalse(viewModel.isAttachmentOwnerPinned) XCTAssertEqual(viewModel.sessionKey, "agent:work:main") XCTAssertEqual(viewModel.activeAgentId, "work") XCTAssertEqual(viewModel.sessionRoutingContract, "per-sender|main|work") } func testAttachmentSendWithoutOutboxUsesLiveTransport() async throws { let capture = AttachmentSendCapture() let viewModel = await MainActor.run { let viewModel = OpenClawChatViewModel( sessionKey: "main", transport: AttachmentProcessingTransport(capture: capture)) viewModel.attachments = [ OpenClawPendingAttachment( url: nil, data: Data("fixture-voice-note".utf8), fileName: "fixture.m4a", mimeType: "audio/mp4", preview: nil, durationSeconds: 4), ] return viewModel } await MainActor.run { viewModel.send() } try await waitUntil("live attachment sent without outbox") { await capture.count() == 1 } let capturedPayload = await capture.first() let payload = try XCTUnwrap(capturedPayload) XCTAssertEqual(payload.content, Data("fixture-voice-note".utf8).base64EncodedString()) let state = await MainActor.run { ( viewModel.attachments.isEmpty, viewModel.errorText, viewModel.messages.last?.content.first { $0.mimeType == "audio/mp4" }?.durationSeconds) } XCTAssertTrue(state.0) XCTAssertNil(state.1) XCTAssertEqual(state.2, 4) } func testHealthyLegacyGatewayUsesLiveAttachmentPath() async throws { let capture = AttachmentSendCapture() let outbox = makeAttachmentOutbox() let viewModel = await MainActor.run { makeDurableAttachmentViewModel( transport: AttachmentProcessingTransport( capture: capture, returnsEmptyHistory: true, durableOutboxAvailable: false), outbox: outbox) } await MainActor.run { viewModel.load() } try await waitUntil("legacy gateway bootstrap completed") { await MainActor.run { viewModel.healthOK && !viewModel.isLoading } } await MainActor.run { viewModel.attachments = [ OpenClawPendingAttachment( url: nil, data: Data("legacy-voice-note".utf8), fileName: "legacy.m4a", mimeType: "audio/mp4", preview: nil, durationSeconds: 3), ] viewModel.send() } try await waitUntil("legacy attachment sent live") { await capture.count() == 1 } let commands = await outbox.loadCommands() XCTAssertTrue(commands.isEmpty) } func testLegacyGatewayRetainsAttachmentUntilOutboxRestoreCompletes() async throws { let capture = AttachmentSendCapture() let outbox = makeAttachmentOutbox() let viewModel = await MainActor.run { let viewModel = makeDurableAttachmentViewModel( transport: AttachmentProcessingTransport( capture: capture, durableOutboxAvailable: false), outbox: outbox) viewModel.attachments = [ OpenClawPendingAttachment( url: nil, data: Data("restore-race".utf8), fileName: "restore-race.m4a", mimeType: "audio/mp4", preview: nil, durationSeconds: 2), ] return viewModel } await MainActor.run { viewModel.send() } try await waitUntil("legacy draft held during restore") { await MainActor.run { viewModel.errorText?.contains("Restoring queued messages") == true } } let state = await MainActor.run { (viewModel.attachments.count, viewModel.input) } let sendCount = await capture.count() let commands = await outbox.loadCommands() XCTAssertEqual(state.0, 1) XCTAssertEqual(state.1, "") XCTAssertEqual(sendCount, 0) XCTAssertTrue(commands.isEmpty) } func testFailedAttachmentSendWithoutOutboxRestoresDraft() async throws { let capture = AttachmentSendCapture() let attachmentData = Data("retry-voice-note".utf8) let viewModel = await MainActor.run { OpenClawChatViewModel( sessionKey: "main", transport: AttachmentProcessingTransport( capture: capture, failsAmbiguously: true)) } let attachmentID = await MainActor.run { let attachment = OpenClawPendingAttachment( url: nil, data: attachmentData, fileName: "retry.m4a", mimeType: "audio/mp4", preview: nil, durationSeconds: 5) viewModel.input = "retry caption" viewModel.attachments = [attachment] return attachment.id } await MainActor.run { viewModel.send() } try await waitUntil("failed live attachment restores draft") { await MainActor.run { viewModel.errorText == "Connection lost" } } let state = await MainActor.run { ( viewModel.input, viewModel.attachments.map(\.id), viewModel.attachments.first?.data, viewModel.messages.contains { $0.idempotencyKey?.hasSuffix(":user") == true }) } XCTAssertEqual(state.0, "retry caption") XCTAssertEqual(state.1, [attachmentID]) XCTAssertEqual(state.2, attachmentData) XCTAssertFalse(state.3) } func testVoiceNoteSendUsesExistingAttachmentPayloadAndOptimisticDuration() async throws { let capture = AttachmentSendCapture() let transport = AttachmentProcessingTransport(capture: capture) let fileURL = FileManager.default.temporaryDirectory .appendingPathComponent("voice-note-20260706-120001.m4a") let data = Data("encoded-voice-note".utf8) try data.write(to: fileURL) let outbox = makeAttachmentOutbox() let viewModel = await MainActor.run { makeDurableAttachmentViewModel(transport: transport, outbox: outbox) } await viewModel.addVoiceNoteAttachment(fileURL: fileURL, durationSeconds: 21.2) await MainActor.run { viewModel.send() } try await waitUntil("voice note sent") { await capture.count() == 1 } let capturedPayload = await capture.first() let payload = try XCTUnwrap(capturedPayload) XCTAssertEqual(payload.type, "file") XCTAssertEqual(payload.mimeType, "audio/mp4") XCTAssertEqual(payload.fileName, "voice-note-20260706-120001.m4a") XCTAssertEqual(payload.content, data.base64EncodedString()) let optimisticAudio = await MainActor.run { viewModel.messages.last?.content.first { $0.mimeType == "audio/mp4" } } XCTAssertEqual(optimisticAudio?.type, "file") XCTAssertEqual(optimisticAudio?.mimeType, "audio/mp4") XCTAssertEqual(optimisticAudio?.durationSeconds, 21.2) } func testVoiceNoteSendKeepsCapturedDurationWhenDraftChangesDuringHealthCheck() async throws { let capture = AttachmentSendCapture() let healthGate = AttachmentHealthGate() let transport = AttachmentProcessingTransport(capture: capture, healthGate: healthGate) let (viewModel, draftAttachmentID) = await MainActor.run { let viewModel = OpenClawChatViewModel(sessionKey: "main", transport: transport) let draftAttachment = OpenClawPendingAttachment( url: nil, data: Data("draft-audio".utf8), fileName: "draft.m4a", mimeType: "audio/mp4", preview: nil, durationSeconds: 21.2) viewModel.attachments = [draftAttachment] return (viewModel, draftAttachment.id) } await MainActor.run { viewModel.send() } try await waitUntil("health check started") { await healthGate.hasEntered() } await MainActor.run { viewModel.removeAttachment(draftAttachmentID) viewModel.attachments.append( OpenClawPendingAttachment( url: nil, data: Data("replacement-audio".utf8), fileName: "replacement.m4a", mimeType: "audio/mp4", preview: nil, durationSeconds: 99)) } await healthGate.release() try await waitUntil("voice note sent") { await capture.count() == 1 } let optimisticAudio = await MainActor.run { viewModel.messages.last?.content.first { $0.mimeType == "audio/mp4" } } XCTAssertEqual(optimisticAudio?.fileName, "draft.m4a") XCTAssertEqual(optimisticAudio?.durationSeconds, 21.2) } func testAmbiguousVoiceNoteSurvivesViewModelRecreation() async throws { let outbox = makeAttachmentOutbox() var firstViewModel: OpenClawChatViewModel? = await MainActor.run { let viewModel = makeDurableAttachmentViewModel( transport: AttachmentProcessingTransport(failsAmbiguously: true), outbox: outbox) viewModel.attachments = [ OpenClawPendingAttachment( url: nil, data: Data("durable-voice-note".utf8), fileName: "durable.m4a", mimeType: "audio/mp4", preview: nil, durationSeconds: 42), ] return viewModel } await MainActor.run { firstViewModel?.send() } try await waitUntil("ambiguous voice note is durably parked") { let command = await outbox.loadCommands().first return command?.status == .failed && command?.lastError == OpenClawChatSQLiteTranscriptCache.outboxUnconfirmedError } let persistedCommands = await outbox.loadCommands() let persisted = try XCTUnwrap(persistedCommands.first) XCTAssertEqual(persisted.attachments.first?.data, Data("durable-voice-note".utf8)) XCTAssertEqual(persisted.attachments.first?.durationSeconds, 42) await MainActor.run { firstViewModel = nil } let restoredViewModel = await MainActor.run { makeDurableAttachmentViewModel( transport: AttachmentProcessingTransport(returnsEmptyHistory: true), outbox: outbox) } await MainActor.run { restoredViewModel.load() } try await waitUntil("durable voice note bubble is restored") { await MainActor.run { restoredViewModel.messages.contains { message in message.content.contains { $0.mimeType == "audio/mp4" } } } } let restored = try await MainActor.run { () throws -> (String?, Double?, Bool) in let message = try XCTUnwrap(restoredViewModel.messages.first) let audio = try XCTUnwrap(message.content.first { $0.mimeType == "audio/mp4" }) return ( audio.content?.value as? String, audio.durationSeconds, restoredViewModel.outboxState(for: message.id)?.isFailed == true) } XCTAssertEqual(restored.0, Data("durable-voice-note".utf8).base64EncodedString()) XCTAssertEqual(restored.1, 42) XCTAssertTrue(restored.2) } func testCanonicalVoiceNoteConfirmationPreservesDurationAndDeletesDurableBytes() async throws { let outbox = makeAttachmentOutbox() let viewModel = await MainActor.run { let viewModel = makeDurableAttachmentViewModel( transport: AttachmentProcessingTransport(), outbox: outbox) viewModel.attachments = [ OpenClawPendingAttachment( url: nil, data: Data("confirmed-voice-note".utf8), fileName: "confirmed.m4a", mimeType: "audio/mp4", preview: nil, durationSeconds: 7), ] return viewModel } await MainActor.run { viewModel.send() } try await waitUntil("voice note awaits canonical confirmation") { await outbox.loadCommands().first?.status == .awaitingConfirmation } let awaitingCommands = await outbox.loadCommands() let command = try XCTUnwrap(awaitingCommands.first) let canonical = try JSONDecoder().decode( OpenClawChatMessage.self, from: Data( """ {"role":"user","content":"See attached.","__openclaw":{"idempotencyKey":"\(command .id):user"},"MediaPaths":["media/inbound/media-1.m4a"],"MediaTypes":["audio/mp4"]} """.utf8)) await viewModel.confirmOutboxCommandsNow(in: [canonical]) let remainingCommands = await outbox.loadCommands() XCTAssertTrue(remainingCommands.isEmpty) let cached = await outbox.loadTranscript(sessionKey: "main", agentID: nil) let cachedMessage = try XCTUnwrap(cached.first) let cachedAudio = try XCTUnwrap(cachedMessage.content.first { $0.mimeType == "audio/mp4" }) XCTAssertEqual(cachedAudio.fileName, "media-1.m4a") XCTAssertNil(cachedAudio.content) XCTAssertEqual(cachedAudio.durationSeconds, 7) } @MainActor func testCanonicalVoiceNotePreservesOptimisticDuration() throws { let localAudio = OpenClawChatMessageContent( type: "file", text: nil, mimeType: "audio/mp4", fileName: "voice-note-local.m4a", durationSeconds: 14.6, content: AnyCodable("local")) let existing = OpenClawChatMessage( role: "user", content: [localAudio], timestamp: nil, idempotencyKey: "run:user") let incoming = try JSONDecoder().decode( OpenClawChatMessage.self, from: Data( #"{"role":"user","content":"See attached.","__openclaw":{"idempotencyKey":"run:user"},"MediaPaths":["media/inbound/media-1.m4a"],"MediaTypes":["audio/mp4"]}"# .utf8)) let adopted = OpenClawChatViewModel.adoptingCanonicalMessage(incoming, over: existing) let audio = try XCTUnwrap(adopted.content.first { $0.mimeType == "audio/mp4" }) XCTAssertEqual(audio.fileName, "media-1.m4a") XCTAssertNil(audio.content) XCTAssertEqual(audio.durationSeconds, 14.6) } }