Files
openclaw/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelAttachmentTests.swift
Peter Steinberger f95ca90378 fix(ios): persist voice notes through offline delivery (#101236)
* fix(ios): persist voice note delivery state

* fix(ios): pin chats during attachment staging

* fix(ios): keep voice notes on captured chat

* fix(ios): keep voice notes durable through staging

* fix(ios): harden voice note lifecycle

* test(ios): satisfy strict concurrency in voice note proof

* fix(ios): keep legacy attachment sends live

* fix(ios): retain legacy drafts during outbox restore

* fix(ios): pin voice note recipient presentation

* fix(ios): preserve confirmed voice note duration

* chore(i18n): refresh voice note source inventory

* chore: leave release notes to release automation
2026-07-07 01:21:16 +01:00

745 lines
29 KiB
Swift

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<Void, Never>?
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 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<OpenClawChatTransportEvent> {
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)
}
}